Compare commits

...
21 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
109 changed files with 7648 additions and 544 deletions
+3
View File
@@ -31,6 +31,9 @@ yarn-error.log*
.DS_Store .DS_Store
Thumbs.db Thumbs.db
# this install's PM2 config (made from ecosystem.config.example.js; deploys never touch it)
/ecosystem.config.js
# misc scratch / generated files # misc scratch / generated files
temp/ temp/
backend/public/uploads/ backend/public/uploads/
+78
View File
@@ -7,6 +7,84 @@ and this project follows [Semantic Versioning](https://semver.org/).
## [Unreleased] ## [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 ## [1.37.0] - 2026-10-23
### Added ### Added
+87 -9
View File
@@ -30,10 +30,11 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
14. [Reports](#reports) 14. [Reports](#reports)
15. [Site settings & setup wizard](#site-settings--setup-wizard) 15. [Site settings & setup wizard](#site-settings--setup-wizard)
16. [Accounts & security](#accounts--security) 16. [Accounts & security](#accounts--security)
17. [Help system](#help-system) 17. [Privacy (POPIA)](#privacy-popia)
18. [Background jobs](#background-jobs) 18. [Help system](#help-system)
19. [Operations](#operations) 19. [Background jobs](#background-jobs)
20. [Page map](#page-map) 20. [Operations](#operations)
21. [Page map](#page-map)
--- ---
@@ -206,7 +207,11 @@ Feedback → Attendance**.
- **PayFast** — the second online provider (card, instant EFT…), with sandbox and live - **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 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 payment of a different amount from the checkout is held for review instead of being
recorded. 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 - **Payment links** — supervisors can generate and send a Yoco link for any
amount (including partial). amount (including partial).
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff. - **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
@@ -339,6 +344,8 @@ Every user picks a **notification preference**: Email, WhatsApp, or Both.
outstanding balance). outstanding balance).
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to - **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
one event. 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 - **Automations** — schedule a sequence of lifecycle emails for an event
(pre-event, final reminder, thank-you, promo for another event). (pre-event, final reminder, thank-you, promo for another event).
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends - **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
@@ -440,6 +447,8 @@ logout controls. Disconnected sessions are recovered automatically.
- **Manage users** — server-side search and filters (role, active), paging, - **Manage users** — server-side search and filters (role, active), paging,
edit role and notification preference, revoke sessions, delete or edit role and notification preference, revoke sessions, delete or
**anonymise** personal data. **anonymise** personal data.
- **Data requests** — POPIA correction and deletion requests (see
[Privacy](#privacy-popia)).
- **Registrations** — all registrations, with filters (event, status, text), - **Registrations** — all registrations, with filters (event, status, text),
status counts, revenue and outstanding totals, and per-registration options, status counts, revenue and outstanding totals, and per-registration options,
form answers and payments (with who recorded each one). form answers and payments (with who recorded each one).
@@ -532,13 +541,78 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
(`TRUST_PROXY` to read real IPs behind a proxy). (`TRUST_PROXY` to read real IPs behind a proxy).
- **Security event log** — logins, password changes and resets, with device - **Security event log** — logins, password changes and resets, with device
and IP. and IP.
- **Account closure** — deactivate, or anonymise personal data while keeping - **Account closure** — deactivate, or close and ask for the personal data to be
financial records. deleted (see [Privacy](#privacy-popia)).
- **Upload hardening** — server-generated filenames, and uploads served with a - **Upload hardening** — server-generated filenames, and uploads served with a
sandbox CSP and `nosniff`. 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 ## Help system
A floating **"Need help?"** button on every page opens a page-specific guide A floating **"Need help?"** button on every page opens a page-specific guide
@@ -562,9 +636,12 @@ can't start, the same jobs run on in-process timers.
| Scheduled email/WhatsApp worker | Polls every 30 s (`SCHEDULED_EMAILS_INTERVAL_MS`) and purges sent jobs after 24 h (not on the queue) | | 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) | | `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`) | | `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`) | | `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`) | | `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`) | | `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`) | | `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`) | | `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 | | `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
@@ -611,9 +688,10 @@ can't start, the same jobs run on in-process timers.
| `/consent/[token]` | Parent/guardian | Give or decline parental consent | | `/consent/[token]` | Parent/guardian | Give or decline parental consent |
| `/waitlist/claim/[token]` | Public | Claim a waitlist place | | `/waitlist/claim/[token]` | Public | Claim a waitlist place |
| `/certificates/verify/[code]` | Public | Check a completion certificate | | `/certificates/verify/[code]` | Public | Check a completion certificate |
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
| `/self-service` | Supervisor-unlocked | Kiosk | | `/self-service` | Supervisor-unlocked | Kiosk |
| `/set-banner` | Supervisor | Site banner | | `/set-banner` | Supervisor | Site banner |
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/reset-password`) | User | My Events | | `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/privacy`, `/reset-password`) | User | My Events |
| `/dashboard/staff` (+ `/ticket-scanning`, `/event-tickets`) | Staff | Staff tools | | `/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/supervisor` (+ `/events`, `/event-options`, `/at-the-door`, `/manual`, `/manual-registration`, `/payments`, `/coupons`, `/waitlist`, `/attendance`, `/consent`, `/offline-scans`, `/allocations`, `/email-attendees`, `/whatsapp-attendees`, `/forms`, `/reports`) | Supervisor | Supervisor tools |
| `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/settings`) | Admin | Admin tools | | `/dashboard/admin` (+ `/users`, `/registrations`, `/forms`, `/cashup`, `/cashup/[id]`, `/audit-log`, `/data-requests`, `/privacy`, `/settings`) | Admin | Admin tools |
+12 -2
View File
@@ -98,8 +98,18 @@ Documentation: see Frontend README for environment and scripts.
## Deployment ## Deployment
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server To update a running install, on the server in the repo folder:
(backs up the database, migrates, builds, reloads PM2).
- `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 ### Backend Deployment
+16 -1
View File
@@ -79,7 +79,7 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| `legal_io_name` | `"[not yet configured]"` | Same reasoning as `legal_operator_name`. | | `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_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_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. | | `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. | | `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. | | `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. |
@@ -89,6 +89,21 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
| Setting | Default when unset | Notes | | 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_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 ## Backups
+7
View File
@@ -69,6 +69,13 @@ CONSENT_JOBS_ENABLED=true
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default) # Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
WAITLIST_JOB_ENABLED=true WAITLIST_JOB_ENABLED=true
# WAITLIST_JOB_INTERVAL_MS=600000 # 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). # 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). # Retention count is set via Admin → Site Settings → Backups (default 14).
BACKUP_ENABLED=true BACKUP_ENABLED=true
+58 -3
View File
@@ -18,6 +18,7 @@ Tokens are issued by `POST /api/users/login`.
- [Event Options & Variants](#event-options--variants) - [Event Options & Variants](#event-options--variants)
- [Event Attachments](#event-attachments) - [Event Attachments](#event-attachments)
- [Registrations](#registrations) - [Registrations](#registrations)
- [Privacy (POPIA)](#privacy-popia)
- [Coupons](#coupons) - [Coupons](#coupons)
- [Parental consent](#parental-consent) - [Parental consent](#parental-consent)
- [Waitlist](#waitlist) - [Waitlist](#waitlist)
@@ -550,6 +551,55 @@ Check-in rules:
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given. - `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`. - 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 ## Coupons
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
@@ -581,6 +631,8 @@ Options and variants also accept an optional `staffPrice`, charged automatically
| GET | `/api/payments/providers/settings` | admin | Every provider with `enabled`, `configured` and `capabilities` (`refund`, `getTransaction`, `listTransactions`) | | 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 | | 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 | | 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`) | | 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/mypayments` | user+ | Own payments |
| GET | `/api/payments/:id` | user+ | Payment by ID | | GET | `/api/payments/:id` | user+ | Payment by ID |
@@ -654,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}}` 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 ## WhatsApp Broadcasts
@@ -769,7 +823,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
| Method | Path | Auth | Description | | 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 | | POST | `/api/whatsapp/config` | admin | Save WAWP credentials to DB |
| GET | `/api/whatsapp/status` | admin | WAWP session status | | GET | `/api/whatsapp/status` | admin | WAWP session status |
| GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking | | GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking |
@@ -788,7 +842,8 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|--------|------|------|-------------| |--------|------|------|-------------|
| 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/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/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/webhooks/whatsapp` | — | WAWP inbound event webhook | | 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` |
--- ---
@@ -810,7 +865,7 @@ For what each individual setting key defaults to when it's never been saved, see
Managed from Admin → Site Settings → Payments. 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`). 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. 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.37.0", "version": "1.40.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.37.0", "version": "1.40.0",
"hasInstallScript": true, "hasInstallScript": true,
"dependencies": { "dependencies": {
"@prisma/adapter-pg": "^7.10.1", "@prisma/adapter-pg": "^7.10.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "event-management-backend", "name": "event-management-backend",
"version": "1.37.0", "version": "1.40.0",
"description": "Event Management System Backend", "description": "Event Management System Backend",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
@@ -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";
+209
View File
@@ -75,6 +75,19 @@ model User {
notificationPreference NotificationPreference @default(email) notificationPreference NotificationPreference @default(email)
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
twoFactorMethod TwoFactorMethod? twoFactorMethod TwoFactorMethod?
// POPIA: opted in to "News & announcements" (marketing) bulk messages, per channel.
// Event updates to registrants don't depend on these. History in CommunicationConsent.
newsEmail Boolean @default(false)
newsWhatsApp Boolean @default(false)
// When the person last answered the news opt-in question. Null = ask at next login.
newsAskedAt DateTime?
// The privacy notice version they last accepted (0 = the built-in notice). Null = never
// recorded. Asked again when a version that needs re-acceptance is newer.
noticeVersionAccepted Int?
// Retention: when they last logged in (kept here because login history itself expires),
// and when they were warned that their inactive account is about to be deleted.
lastLoginAt DateTime?
inactivityWarnedAt DateTime?
registrations Registration[] registrations Registration[]
payments Payment[] payments Payment[]
paymentsRecorded Payment[] @relation("PaymentRecordedBy") paymentsRecorded Payment[] @relation("PaymentRecordedBy")
@@ -121,6 +134,13 @@ model User {
trustedDevices TrustedDevice[] trustedDevices TrustedDevice[]
certificates Certificate[] certificates Certificate[]
certificatesIssued Certificate[] @relation("CertificateIssuedBy") certificatesIssued Certificate[] @relation("CertificateIssuedBy")
communicationConsents CommunicationConsent[]
dataRequests DataRequest[]
dataRequestsHandled DataRequest[] @relation("DataRequestHandledBy")
noticesPublished PrivacyNotice[]
noticeAcceptances NoticeAcceptance[]
dataAccessLogs DataAccessLog[]
breachesRecorded SecurityBreach[]
} }
model Event { model Event {
@@ -571,6 +591,17 @@ enum AdminAuditAction {
certificate_issued certificate_issued
certificate_revoked certificate_revoked
job_run_manual job_run_manual
data_request_completed
data_request_rejected
user_anonymised
privacy_notice_published
operator_created
operator_updated
operator_deleted
retention_policies_updated
retention_run
breach_recorded
breach_updated
} }
// Append-only audit trail for admin/supervisor-initiated actions with money or // Append-only audit trail for admin/supervisor-initiated actions with money or
@@ -1567,3 +1598,181 @@ model Certificate {
@@index([ticketId]) @@index([ticketId])
@@index([issuedById]) @@index([issuedById])
} }
// ─── POPIA ────────────────────────────────────────────────────────────────────
// Every change to someone's "News & announcements" opt-in: who, which channel, on or off,
// when and how. Append-only — it's the proof of consent. `address` is set instead of
// userId for people without an account (an ad-hoc broadcast address that unsubscribed).
model CommunicationConsent {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
address String?
channel String // email | whatsapp
granted Boolean
// signup | privacy_centre | prompt | unsubscribe_link | whatsapp_stop | existing_account | admin
source String
ip String?
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([address])
}
// Addresses (email, or phone as 27…) that unsubscribed from news without an account,
// e.g. ad-hoc broadcast recipients. News broadcasts skip them.
model MessagingOptOut {
id String @id @default(uuid())
channel String // email | whatsapp
address String
source String
createdAt DateTime @default(now())
@@unique([channel, address])
}
enum DataRequestType {
access
correction
deletion
}
enum DataRequestStatus {
open
completed
rejected
cancelled
}
// A POPIA request from a data subject. Name and email are copied in so the request
// still shows who made it after the account is anonymised.
model DataRequest {
id String @id @default(uuid())
type DataRequestType
status DataRequestStatus @default(open)
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
name String
email String
details String?
// The admin's answer, sent to the requester when the request is closed.
response String?
// Made and fulfilled by the person themselves (e.g. "Download my data").
selfService Boolean @default(false)
dueAt DateTime
closedAt DateTime?
handledById String?
handledBy User? @relation("DataRequestHandledBy", fields: [handledById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, dueAt])
@@index([userId])
}
// A published version of the privacy notice (versions start at 1). With none published,
// the built-in notice from utils/privacyNotice.js is shown as version 0. Section bodies
// keep their {placeholders} (org name, Information Officer, operators…), filled in when
// shown, so the notice follows Site settings and the operator register.
model PrivacyNotice {
id String @id @default(uuid())
version Int @unique
sections Json // [{ id, title, body }]
changeSummary String?
// Everyone is asked to accept this version again after logging in.
requiresAcceptance Boolean @default(false)
publishedAt DateTime @default(now())
publishedById String?
publishedBy User? @relation(fields: [publishedById], references: [id], onDelete: SetNull)
}
// Someone accepted a version of the privacy notice. Append-only proof, like
// CommunicationConsent.
model NoticeAcceptance {
id String @id @default(uuid())
userId String?
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
version Int
source String // signup | prompt
ip String?
createdAt DateTime @default(now())
@@index([userId, createdAt])
@@index([version])
}
// A third party that processes personal information for the church (POPIA "operator"):
// payment providers, WhatsApp and email delivery, hosting… Active ones are listed in the
// privacy notice.
model Operator {
id String @id @default(uuid())
name String
purpose String
dataShared String
location String?
website String?
active Boolean @default(true)
order Int @default(0)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
// Someone exported or viewed personal information in bulk: report exports, the attendance
// register, certificate downloads, group lists, form answers, the consent and medical lists,
// backups, and viewing the site as another user.
model DataAccessLog {
id String @id @default(uuid())
actorId String?
actor User? @relation(fields: [actorId], references: [id], onDelete: SetNull)
actorRole String
// report_pdf | report_excel | report_email | report_whatsapp | attendance_export |
// certificates_download | group_lists | form_responses | consent_list | medical_list |
// backup_download | impersonation
action String
what String
eventId String?
records Int?
// Children's or special personal information (youth events, medical details).
sensitive Boolean @default(false)
ip String?
createdAt DateTime @default(now())
@@index([createdAt])
@@index([actorId, createdAt])
@@index([action, createdAt])
}
enum BreachStatus {
open
contained
closed
}
// POPIA s22: a security compromise — what happened, whose information, what was done, and
// when the Information Regulator and the people affected were told.
model SecurityBreach {
id String @id @default(uuid())
title String
status BreachStatus @default(open)
discoveredAt DateTime
occurredAt DateTime?
description String
dataAffected String
peopleAffected Int?
// Free text: who (groups of people, events…), since a breach can involve people without accounts.
whoAffected String?
consequences String?
actionsTaken String?
adviceToPeople String?
// Who caused it, if known (POPIA asks for this when notifying).
causedBy String?
regulatorNotifiedAt DateTime?
peopleNotifiedAt DateTime?
createdById String?
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([status, discoveredAt])
}
@@ -287,6 +287,10 @@ const getSetLists = async (req, res) => {
if (!set) throw allocationError('Allocation set not found', 404); if (!set) throw allocationError('Allocation set not found', 404);
const withMedical = req.query.medical === 'true'; const withMedical = req.query.medical === 'true';
const members = await groupMembers(set.groups.map(g => g.id), { withMedical }); 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) { 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) }); 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) });
} }
@@ -175,6 +175,10 @@ const exportRegister = async (req, res) => {
try { try {
const reg = await buildRegister(req.params.eventId); const reg = await buildRegister(req.params.eventId);
const cols = reg.columns.filter(c => !c.cancelled); 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') { if (req.query.format === 'pdf') {
const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 }); const doc = new PDFDocument({ size: 'A4', layout: 'landscape', margin: 36 });
res.setHeader('Content-Type', 'application/pdf'); res.setHeader('Content-Type', 'application/pdf');
@@ -320,6 +324,7 @@ const downloadEventCertificates = async (req, res) => {
const certs = await prisma.certificate.findMany({ where: { eventId: event.id, revokedAt: null }, orderBy: { name: 'asc' } }); 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.'); if (certs.length === 0) throw badRequest('No certificates have been issued for this event yet.');
const pdf = await certificates.renderCertificatesPdf(certs, event); 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-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`); res.setHeader('Content-Disposition', `attachment; filename="${`Certificates - ${event.title}`.replace(/[^a-z0-9 -]/gi, '').slice(0, 80)}.pdf"`);
res.send(pdf); res.send(pdf);
@@ -44,6 +44,7 @@ const downloadBackup = async (req, res) => {
res.status(404); res.status(404);
throw new Error('Backup not found'); throw new Error('Backup not found');
} }
require('../utils/accessLog').logDataAccess(req, { action: 'backup_download', what: `Database backup ${filename}` });
res.download(filePath, filename); res.download(filePath, filename);
} catch (e) { } catch (e) {
res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(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 { replacePlaceholders } = require('../utils/placeholders');
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
function parseFreeformEmails(lines) { function parseFreeformEmails(lines) {
// Supports formats: // Supports formats:
@@ -103,16 +104,16 @@ const previewBroadcast = async (req, res) => {
if (!email) continue; if (!email) continue;
if (!map.has(email)) map.set(email, { email, name: r.name || '' }); 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 // Optionally resolve event info for link/title placeholder
let event = null; let event = null;
if (eventId && typeof eventId === 'string') { if (eventId && typeof eventId === 'string') {
event = await prisma.event.findUnique({ where: { id: eventId } }); 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) { } catch (error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
@@ -147,9 +148,12 @@ const sendBroadcast = async (req, res) => {
if (!email) continue; if (!email) continue;
if (!map.has(email)) map.set(email, { email, name: r.name || '' }); 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) { 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 // Resolve event
@@ -177,9 +181,10 @@ const sendBroadcast = async (req, res) => {
eventLinkHtml: eventCtx.eventLinkHtml, eventLinkHtml: eventCtx.eventLinkHtml,
} : ctxBase; } : ctxBase;
const finalSubject = await replacePlaceholders(subject, ctxBase); const finalSubject = await replacePlaceholders(subject, ctxBase);
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined; const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: eventCtx.eventTitle });
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined); const finalHtml = html ? (await replacePlaceholders(html, ctxForHtml)) + footer.html : undefined;
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText }); 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; 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) { } catch (error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
@@ -200,13 +205,13 @@ const sendBroadcast = async (req, res) => {
// @access Private/Supervisor or Admin // @access Private/Supervisor or Admin
const scheduleBroadcast = async (req, res) => { const scheduleBroadcast = async (req, res) => {
try { 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' }); if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
const when = new Date(scheduledAt); const when = new Date(scheduledAt);
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' }); 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' }); 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) }); const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
@@ -156,6 +156,7 @@ const listEventConsent = async (req, res) => {
const { event, rows } = await consentRows(req.params.eventId); const { event, rows } = await consentRows(req.params.eventId);
if (!event) { res.status(404); throw new Error('Event not found'); } 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; }, {}); 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({ res.json({
event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion }, event: { id: event.id, title: event.title, startDate: event.startDate, requiresParentalConsent: event.requiresParentalConsent, consentTextVersion: event.consentTextVersion },
counts, counts,
@@ -255,6 +256,7 @@ const medicalList = async (req, res) => {
targetType: 'Event', targetId: event.id, metadata: { attendees: list.length }, targetType: 'Event', targetId: event.id, metadata: { attendees: list.length },
ip: getClientIp(req), 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 }); res.json({ event: { id: event.id, title: event.title, startDate: event.startDate }, rows: list });
} catch (error) { } catch (error) {
fail(res, error); fail(res, error);
+25 -11
View File
@@ -1586,8 +1586,16 @@ const emailEventAttendees = async (req, res) => {
} }
} }
// 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) { 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'); const { sendMail } = require('../utils/email');
@@ -1730,10 +1738,11 @@ const emailEventAttendees = async (req, res) => {
// Always perform placeholder replacement on whatever we have // Always perform placeholder replacement on whatever we have
const finalSubject = await replacePlaceholders(subj || '', ctxBase); const finalSubject = await replacePlaceholders(subj || '', ctxBase);
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined; const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: event.title });
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined); 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) => { results.forEach((r, i) => {
if (r.status === 'rejected') { if (r.status === 'rejected') {
@@ -1824,10 +1833,15 @@ const whatsappEventAttendees = async (req, res) => {
uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id }); 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) { 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'); const { sendText } = require('../utils/whatsapp');
@@ -1927,7 +1941,7 @@ const whatsappEventAttendees = async (req, res) => {
} }
const finalMessage = await replacePlaceholders(msg || '', ctx); 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) => { results.forEach((r, i) => {
if (r.status === 'rejected') { if (r.status === 'rejected') {
@@ -1955,7 +1969,7 @@ function describeAttendeeFilter(filter) {
const scheduleEmailEventAttendees = async (req, res) => { const scheduleEmailEventAttendees = async (req, res) => {
try { try {
const eventId = req.params.id; 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 // Validate event
const event = await prisma.event.findUnique({ where: { id: eventId } }); const event = await prisma.event.findUnique({ where: { id: eventId } });
@@ -1973,7 +1987,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
} }
// Build payload matching emailEventAttendees body // 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 { addJob } = require('../utils/scheduledEmails');
const created = addJob({ const created = addJob({
@@ -1996,7 +2010,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
const scheduleWhatsappEventAttendees = async (req, res) => { const scheduleWhatsappEventAttendees = async (req, res) => {
try { try {
const eventId = req.params.id; 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 } }); const event = await prisma.event.findUnique({ where: { id: eventId } });
if (!event) { res.status(404); throw new Error('Event not found'); } if (!event) { res.status(404); throw new Error('Event not found'); }
@@ -2010,7 +2024,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
throw new Error('message is required unless template is "tickets"'); 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 { addJob } = require('../utils/scheduledEmails');
const created = addJob({ const created = addJob({
+11
View File
@@ -43,6 +43,17 @@ const listFormResponses = async (req, res) => {
const nextCursor = responses.length === take ? responses[responses.length - 1].id : null; 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 }); res.json({ items: responses, nextCursor });
} catch (error) { } catch (error) {
res.status(400).json({ message: error.message }); res.status(400).json({ message: error.message });
@@ -30,6 +30,7 @@ const startImpersonation = async (req, res) => {
actorId: req.user.id, actorRole: req.user.role, action: 'impersonation_started', targetType: 'User', targetId: target.id, 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), 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). // Optional: tell the user (Site Settings → Security).
const { getSettingSync } = require('../utils/settingsCache'); const { getSettingSync } = require('../utils/settingsCache');
+15 -1
View File
@@ -80,4 +80,18 @@ const updateConfig = async (req, res) => {
} }
}; };
module.exports = { getConfig, updateConfig }; // @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 };
@@ -1,5 +1,6 @@
const prisma = require('../config/db'); const prisma = require('../config/db');
const payments = require('../payments'); const payments = require('../payments');
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
const { safeErrorMessage } = require('../utils/errorUtils'); const { safeErrorMessage } = require('../utils/errorUtils');
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache'); const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
const { logAdminAction } = require('../utils/adminAudit'); const { logAdminAction } = require('../utils/adminAudit');
@@ -62,15 +63,7 @@ const updatePaymentProviderSettings = async (req, res) => {
async function loadCheckout(id) { async function loadCheckout(id) {
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } }); const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
if (!checkout) return null; if (!checkout) return null;
const refs = [checkout.id, checkout.externalId].filter(Boolean); return { checkout, paid: await checkoutPaid(checkout) };
const paidTx = await prisma.providerTransaction.findFirst({ where: { checkoutId: { in: refs }, paymentId: { not: null } }, select: { id: true } });
let paid = !!paidTx;
const registrationId = checkout.metadata?.registrationId;
if (!paid && registrationId) {
const reg = await prisma.registration.findUnique({ where: { id: String(registrationId) }, select: { status: true } });
paid = reg?.status === 'paid';
}
return { checkout, paid };
} }
const providerList = (list) => list.map(p => ({ id: p.id, label: p.label })); const providerList = (list) => list.map(p => ({ id: p.id, label: p.label }));
@@ -126,4 +119,59 @@ const choosePayProvider = async (req, res) => {
} }
}; };
module.exports = { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider }; 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,
};
@@ -397,6 +397,13 @@ function writeTableRows(ws, startRow, columns, rows) {
return { nextRow: r, dataStartRow }; 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 // POST /api/reports/pdf
// body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? } // body: { title, subtitle?, kind: 'table'|'layered', table?, layered?, stats?, chart?, note?, orientation? }
const generatePdf = async (req, res) => { const generatePdf = async (req, res) => {
@@ -410,6 +417,7 @@ const generatePdf = async (req, res) => {
doc.pipe(res); doc.pipe(res);
drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables }); drawReportPdf(doc, { title, subtitle, kind, table, layered, stats, chart, note, extraTables });
doc.end(); doc.end();
logReport(req, 'report_pdf');
} catch (e) { } catch (e) {
res.status(400).json({ message: e.message }); res.status(400).json({ message: e.message });
} }
@@ -426,6 +434,7 @@ const generateExcel = async (req, res) => {
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`); res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
await wb.xlsx.write(res); await wb.xlsx.write(res);
res.end(); res.end();
logReport(req, 'report_excel');
} catch (e) { } catch (e) {
res.status(400).json({ message: e.message }); res.status(400).json({ message: e.message });
} }
@@ -462,6 +471,7 @@ const emailPdf = async (req, res) => {
try { fs.unlinkSync(filePath); } catch {} try { fs.unlinkSync(filePath); } catch {}
logReport(req, 'report_email');
res.json({ message: `Report emailed to ${user.email}` }); res.json({ message: `Report emailed to ${user.email}` });
} catch (e) { } catch (e) {
res.status(400).json({ message: e.message }); 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`; const filename = `${(title || 'report').replace(/[^a-z0-9]/gi, '_').toLowerCase()}.pdf`;
await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report'); await sendPdf(user.phoneNumber, filePath, filename, caption || title || 'Report');
logReport(req, 'report_whatsapp');
res.json({ message: `Report sent to your WhatsApp` }); res.json({ message: `Report sent to your WhatsApp` });
} catch (e) { } catch (e) {
res.status(400).json({ message: e.message }); res.status(400).json({ message: e.message });
+54 -49
View File
@@ -139,7 +139,8 @@ async function sendWelcomeEmail(user) {
// Fields the login flow needs once the password (and any 2FA code) has checked out. // Fields the login flow needs once the password (and any 2FA code) has checked out.
const LOGIN_USER_SELECT = { const LOGIN_USER_SELECT = {
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true, id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: 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 // Errors from utils/twoFactor carry their own HTTP status; everything else follows
@@ -150,7 +151,11 @@ function errorStatus(error, res) {
// Final step of a successful login (after the password, and the 2FA code when // Final step of a successful login (after the password, and the 2FA code when
// required): notify, record the activity, and issue the session token. // required): notify, record the activity, and issue the session token.
function completeLogin(user, req, res, extra = {}) { 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 // Send login notification in the background
sendLoginNotification(user, req).catch(() => {}); sendLoginNotification(user, req).catch(() => {});
logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {}); logSecurityEvent({ userId: user.id, type: 'login', ip: getClientIp(req), userAgent: req.headers['user-agent'] }).catch(() => {});
@@ -161,6 +166,10 @@ function completeLogin(user, req, res, extra = {}) {
email: user.email, email: user.email,
role: user.role, role: user.role,
token: generateToken(user.id, user.role, user.tokenVersion), 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 ...extra
}); });
} }
@@ -170,7 +179,7 @@ function completeLogin(user, req, res, extra = {}) {
// @access Public // @access Public
const registerUser = async (req, res) => { const registerUser = async (req, res) => {
try { try {
const { name, email, password, phoneNumber, notificationPreference } = req.body; const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
// Normalize phone using SA-aware normalisation // Normalize phone using SA-aware normalisation
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp'); const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
@@ -215,6 +224,18 @@ const registerUser = async (req, res) => {
}); });
if (user) { 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 // Send welcome email in the background — don't block the response
sendWelcomeEmail(user).catch(() => {}); sendWelcomeEmail(user).catch(() => {});
@@ -340,7 +361,7 @@ const loginUser = async (req, res) => {
} }
} }
completeLogin(updated, req, res); await completeLogin(updated, req, res);
} catch (error) { } catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
} }
@@ -375,7 +396,7 @@ const verifyLoginTwoFactor = async (req, res) => {
if (rememberDevice === true) { if (rememberDevice === true) {
extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']); extra.deviceToken = await issueTrustedDevice(user.id, req.headers['user-agent']);
} }
completeLogin(user, req, res, extra); await completeLogin(user, req, res, extra);
} catch (error) { } catch (error) {
res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) }); res.status(errorStatus(error, res)).json({ message: safeErrorMessage(error) });
} }
@@ -515,12 +536,18 @@ const getUserProfile = async (req, res) => {
twoFactorMethod: true, twoFactorMethod: true,
createdAt: true, createdAt: true,
updatedAt: true, updatedAt: true,
isActive: true isActive: true,
newsAskedAt: true,
noticeVersionAccepted: true
} }
}); });
if (user) { if (user) {
res.json({ ...user, twoFactorRequired: requiresTwoFactor(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 { } else {
res.status(404); res.status(404);
throw new Error('User not found'); throw new Error('User not found');
@@ -874,21 +901,22 @@ const anonymizeUser = async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } }); const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) { res.status(404); throw new Error('User not found'); } if (!user) { res.status(404); throw new Error('User not found'); }
await prisma.user.update({ const { deletionBlockers, anonymiseUser } = require('../utils/privacy');
where: { id: req.params.id }, const blockers = await deletionBlockers(user.id);
data: { if (blockers.length) {
name: 'Deleted User', res.status(409);
email: `deleted-${req.params.id}@deleted.local`, throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
phoneNumber: null, }
isActive: false, const counts = await anonymiseUser(user.id);
twoFactorEnabled: false, // An open deletion request from this person is now done.
twoFactorMethod: null, await prisma.dataRequest.updateMany({
tokenVersion: { increment: 1 }, where: { userId: user.id, type: 'deletion', status: 'open' },
updatedAt: new Date() 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),
}); });
await clearTrustedDevices(req.params.id);
await require('../utils/consent').clearConsentDataForUser(req.params.id);
res.json({ message: 'User data deleted' }); res.json({ message: 'User data deleted' });
} catch (error) { } catch (error) {
@@ -1134,34 +1162,11 @@ const closeAccount = async (req, res) => {
const realName = user.name; const realName = user.name;
if (deleteData) { if (deleteData) {
// Anonymise: wipe all personal data while keeping the row intact for referential integrity // POPIA: deleting personal data is a request an admin approves (see privacyController);
await prisma.user.update({ // the account is closed straight away.
where: { id: req.user.id }, const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type: 'deletion', status: 'open' } });
data: { if (!open) await require('./privacyController').requestAccountDeletion(user);
name: 'Deleted 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.' });
email: `deleted-${uuidv4()}@deleted.invalid`,
password: '',
phoneNumber: null,
isActive: false,
twoFactorEnabled: false,
twoFactorMethod: null,
tokenVersion: { increment: 1 },
updatedAt: new Date(),
},
});
await clearTrustedDevices(req.user.id);
await require('../utils/consent').clearConsentDataForUser(req.user.id);
// 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.' });
} else { } else {
// Soft-deactivate only // Soft-deactivate only
await prisma.user.update({ await prisma.user.update({
@@ -1,4 +1,5 @@
const prisma = require('../config/db'); const prisma = require('../config/db');
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
function fmtDate(d) { function fmtDate(d) {
try { return new Date(d).toLocaleString(); } catch { return String(d); } try { return new Date(d).toLocaleString(); } catch { return String(d); }
@@ -82,9 +83,10 @@ const previewWhatsAppBroadcast = async (req, res) => {
if (!phone) continue; if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' }); 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) { } catch (error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
@@ -123,9 +125,12 @@ const sendWhatsAppBroadcast = async (req, res) => {
if (!phone) continue; if (!phone) continue;
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' }); 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) { 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; let event = null;
@@ -139,7 +144,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
const results = await Promise.allSettled(recipients.map(async rcpt => { const results = await Promise.allSettled(recipients.map(async rcpt => {
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink }; const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
const finalMessage = replacePlaceholders(message, ctx); 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; 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) { } catch (error) {
return res.status(400).json({ message: error.message }); return res.status(400).json({ message: error.message });
} }
@@ -160,13 +165,13 @@ const sendWhatsAppBroadcast = async (req, res) => {
// @access Private/Supervisor or Admin // @access Private/Supervisor or Admin
const scheduleWhatsAppBroadcast = async (req, res) => { const scheduleWhatsAppBroadcast = async (req, res) => {
try { 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' }); if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
const when = new Date(scheduledAt); const when = new Date(scheduledAt);
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' }); 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' }); 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 }); const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
@@ -25,6 +25,7 @@ const getConfigHandler = async (req, res) => {
hasToken: !!token, hasToken: !!token,
hasInstance: !!instanceId, hasInstance: !!instanceId,
configured: !!(token && instanceId), configured: !!(token && instanceId),
webhookUrl: await require('../utils/whatsapp').webhookUrl(),
}); });
} catch (e) { } catch (e) {
res.status(500).json({ message: e?.message || 'Failed to load config' }); res.status(500).json({ message: e?.message || 'Failed to load config' });
@@ -162,6 +163,7 @@ const handleWebhook = async (req, res) => {
try { try {
const { event, session } = req.body || {}; const { event, session } = req.body || {};
if (event === 'message') return await handleIncomingMessage(req);
if (event !== 'session.status') return; if (event !== 'session.status') return;
const status = session?.status; 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 = { module.exports = {
getConfigHandler, getConfigHandler,
saveConfigHandler, saveConfigHandler,
+67 -1
View File
@@ -148,6 +148,7 @@ const adminRoutes = require('./routes/adminRoutes');
const backupRoutes = require('./routes/backupRoutes'); const backupRoutes = require('./routes/backupRoutes');
const yocoRoutes = require('./routes/yocoRoutes'); const yocoRoutes = require('./routes/yocoRoutes');
const payfastRoutes = require('./routes/payfastRoutes'); const payfastRoutes = require('./routes/payfastRoutes');
const privacyRoutes = require('./routes/privacyRoutes');
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream // Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
app.use('/api/webhooks', webhookRoutes); app.use('/api/webhooks', webhookRoutes);
@@ -173,6 +174,7 @@ app.use('/api/scheduled-emails', scheduledEmailRoutes);
app.use('/api/sections', sectionRoutes); app.use('/api/sections', sectionRoutes);
app.use('/api/banner', bannerRoutes); app.use('/api/banner', bannerRoutes);
app.use('/api/whatsapp', whatsappRoutes); app.use('/api/whatsapp', whatsappRoutes);
app.use('/api/privacy', privacyRoutes);
app.use('/api/settings', settingsRoutes); app.use('/api/settings', settingsRoutes);
app.use('/api/setup', setupRoutes); app.use('/api/setup', setupRoutes);
app.use('/api/stats', statsRoutes); app.use('/api/stats', statsRoutes);
@@ -511,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', { method:'POST', path:'/api/users/:id/revoke-sessions', auth:'admin', desc:'Revoke all sessions for another user',
pathParams:{ ':id':'User UUID' }, pathParams:{ ':id':'User UUID' },
responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]}, 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' }, pathParams:{ ':id':'User UUID' },
responses:[ responses:[
{ status:200, desc:'Anonymised', body:{ message:'User data deleted' }}, { status:200, desc:'Anonymised', body:{ message:'User data deleted' }},
@@ -805,6 +807,8 @@ app.get('/docs', async (req, res) => {
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]}, 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:'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:'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:'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.', { 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 }] }}}, request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
@@ -882,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 }]}]}, 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: [ { 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', { 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-...' }}, request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn <john@example.com>', eventId:'ev-uuid-...' }},
@@ -1087,6 +1152,7 @@ app.get('/docs', async (req, res) => {
{ title: 'PayFast', base: '/api/payfast', endpoints: [ { 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:'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:'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: [ { 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.', { 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.',
+12
View File
@@ -29,6 +29,10 @@ const JOBS = [
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED', name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
run: () => require('../utils/waitlist').runWaitlistJob(), 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', name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
run: () => require('../utils/feedback').runFeedbackJob(), run: () => require('../utils/feedback').runFeedbackJob(),
@@ -48,6 +52,14 @@ const JOBS = [
return { reminded: await sendConsentReminders(new Date()), purged: await purgeConsentMedicalData(new Date()) }; 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', 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() }), run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
+113 -5
View File
@@ -12,8 +12,9 @@
* it came from a PayFast host, and — by posting it back to /eng/query/validate — that PayFast * 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. * says it's VALID. The amount is checked against the checkout in ../webhook.js.
* *
* Refunds and transaction lookups aren't done through PayFast's API here: refunds are made in * Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's
* the PayFast dashboard and recorded with the manual refund flow. * 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 crypto = require('crypto');
@@ -21,7 +22,8 @@ const dns = require('dns').promises;
const axios = require('axios'); const axios = require('axios');
const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast'); const { getPayfastConfig, payfastHost, payfastNotifyUrl } = require('../../utils/payfast');
const capabilities = { refund: false, getTransaction: false, listTransactions: false }; // 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. // Hosts PayFast sends ITNs from.
const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za']; const VALID_HOSTS = ['www.payfast.co.za', 'w1w.payfast.co.za', 'w2w.payfast.co.za', 'sandbox.payfast.co.za'];
@@ -187,9 +189,115 @@ function parseWebhook(body) {
}; };
} }
// ─── 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 = { module.exports = {
id: 'payfast', label: 'PayFast', capabilities, id: 'payfast', label: 'PayFast', capabilities,
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection,
// for tests // for tests
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, apiSignature, parseCsv,
}; };
+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 };
+21 -9
View File
@@ -95,15 +95,11 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
}); });
} }
// Paid a different amount from the checkout (e.g. changed on the way): don't record it const settled = await settlePayment(provider, event, body, checkout);
if (checkout && tx.amountCents !== checkout.amountCents) { if (settled.needsReview) {
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 res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } }); 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;
const processedData = await recordSucceededPayment(provider.id, event, body);
return res.status(200).json({ return res.status(200).json({
success: true, success: true,
message: `Successfully processed ${event.type} webhook`, message: `Successfully processed ${event.type} webhook`,
@@ -115,7 +111,23 @@ const handleProviderWebhook = (providerId) => async (req, res) => {
} }
}; };
const REG_INCLUDE = { event: { select: { id: true, title: true } }, user: { select: { id: true, name: true, email: true } } }; /**
* 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. */ /** Records a successful payment from a webhook. Idempotent on the provider's payment id. */
async function recordSucceededPayment(providerId, event, body) { async function recordSucceededPayment(providerId, event, body) {
@@ -286,4 +298,4 @@ async function saveProviderTransaction(providerId, event, body, updates = {}) {
} }
} }
module.exports = { handleProviderWebhook, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction }; module.exports = { handleProviderWebhook, settlePayment, recordSucceededPayment, updateRegistrationStatus, saveProviderTransaction };
+2 -1
View File
@@ -1,9 +1,10 @@
const express = require('express'); const express = require('express');
const router = express.Router(); const router = express.Router();
const { getConfig, updateConfig } = require('../controllers/payfastController'); const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
const { protect, admin } = require('../middleware/authMiddleware'); const { protect, admin } = require('../middleware/authMiddleware');
router.get('/config', protect, admin, getConfig); router.get('/config', protect, admin, getConfig);
router.put('/config', protect, admin, updateConfig); router.put('/config', protect, admin, updateConfig);
router.post('/test', protect, admin, testConnection);
module.exports = router; module.exports = router;
+3 -1
View File
@@ -15,7 +15,7 @@ const {
getPaymentStats, getPaymentStats,
sendReceipt, sendReceipt,
} = require('../controllers/paymentController'); } = require('../controllers/paymentController');
const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider } = require('../controllers/paymentProvidersController'); const { listPaymentProviders, getPaymentProviderSettings, updatePaymentProviderSettings, getPayPage, choosePayProvider, listOpenCheckouts, checkCheckoutStatus } = require('../controllers/paymentProvidersController');
const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware'); const { protect, supervisor, staff, admin} = require('../middleware/authMiddleware');
// Protected routes // Protected routes
@@ -23,6 +23,8 @@ router.post('/', protect, supervisor, createPayment);
router.get('/providers', listPaymentProviders); router.get('/providers', listPaymentProviders);
router.get('/pay/:id', getPayPage); router.get('/pay/:id', getPayPage);
router.post('/pay/:id', choosePayProvider); router.post('/pay/:id', choosePayProvider);
router.get('/checkouts/open', protect, supervisor, listOpenCheckouts);
router.post('/checkouts/check', protect, supervisor, checkCheckoutStatus);
router.get('/providers/settings', protect, admin, getPaymentProviderSettings); router.get('/providers/settings', protect, admin, getPaymentProviderSettings);
router.put('/providers/settings', protect, admin, updatePaymentProviderSettings); router.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
router.post('/checkout', protect, createCheckout); router.post('/checkout', protect, createCheckout);
+85
View File
@@ -0,0 +1,85 @@
const express = require('express');
const rateLimit = require('express-rate-limit');
const router = express.Router();
const {
getMyPrivacy,
updateMyNews,
exportMyData,
createMyRequest,
getUnsubscribe,
postUnsubscribe,
oneClickUnsubscribe,
oneClickRedirect,
listRequests,
completeRequest,
rejectRequest,
} = require('../controllers/privacyController');
const {
getNotice,
acceptMyNotice,
getNoticeDraft,
saveNoticeDraft,
discardNoticeDraft,
publishNotice,
listOperators,
createOperator,
updateOperator,
deleteOperator,
consentLog,
accessLog,
getReadiness,
getRetention,
saveRetention,
runRetentionNow,
listBreaches,
createBreach,
updateBreach,
} = require('../controllers/privacyAdminController');
const { protect, admin } = require('../middleware/authMiddleware');
// Exports are heavy-ish (every record the person has) — a few per 10 minutes is plenty.
const exportLimiter = rateLimit({ windowMs: 10 * 60 * 1000, max: 10, standardHeaders: true, legacyHeaders: false });
// The person themselves
router.get('/me', protect, getMyPrivacy);
router.put('/me/news', protect, updateMyNews);
router.get('/me/export', protect, exportLimiter, exportMyData);
router.post('/me/requests', protect, createMyRequest);
// Unsubscribe links (signed tokens, no login)
router.get('/unsubscribe/:token', getUnsubscribe);
router.post('/unsubscribe/:token', postUnsubscribe);
router.get('/unsubscribe/:token/one-click', oneClickRedirect);
router.post('/unsubscribe/:token/one-click', express.urlencoded({ extended: false }), oneClickUnsubscribe);
// Admin: data requests inbox
router.get('/requests', protect, admin, listRequests);
router.post('/requests/:id/complete', protect, admin, completeRequest);
router.post('/requests/:id/reject', protect, admin, rejectRequest);
// Privacy notice: public reading, acceptance, and the admin editor
router.get('/notice', getNotice);
router.post('/me/notice/accept', protect, acceptMyNotice);
router.get('/notice/draft', protect, admin, getNoticeDraft);
router.put('/notice/draft', protect, admin, saveNoticeDraft);
router.delete('/notice/draft', protect, admin, discardNoticeDraft);
router.post('/notice/publish', protect, admin, publishNotice);
// Admin: operator register, consent log, access log
router.get('/operators', protect, admin, listOperators);
router.post('/operators', protect, admin, createOperator);
router.put('/operators/:id', protect, admin, updateOperator);
router.delete('/operators/:id', protect, admin, deleteOperator);
router.get('/consent-log', protect, admin, consentLog);
router.get('/access-log', protect, admin, accessLog);
// Admin: readiness, retention, breach register
router.get('/readiness', protect, admin, getReadiness);
router.get('/retention', protect, admin, getRetention);
router.put('/retention', protect, admin, saveRetention);
router.post('/retention/run', protect, admin, runRetentionNow);
router.get('/breaches', protect, admin, listBreaches);
router.post('/breaches', protect, admin, createBreach);
router.put('/breaches/:id', protect, admin, updateBreach);
module.exports = router;
+59
View File
@@ -0,0 +1,59 @@
const prisma = require('../config/db');
const { getClientIp } = require('./requestUtils');
// What each DataAccessLog action means, for the admin page.
const ACCESS_ACTIONS = {
report_pdf: 'Report downloaded (PDF)',
report_excel: 'Report downloaded (Excel)',
report_email: 'Report emailed',
report_whatsapp: 'Report sent by WhatsApp',
attendance_export: 'Attendance register exported',
certificates_download: 'Certificates downloaded',
group_lists: 'Room/group lists opened',
form_responses: 'Attendee form answers viewed',
consent_list: 'Parental consent list viewed',
medical_list: 'Medical list viewed',
backup_download: 'Database backup downloaded',
impersonation: 'Viewed the site as another user',
};
/**
* POPIA access log: someone exported or viewed personal information in bulk. Fire-and-
* forget like logAdminAction — a logging failure never blocks the export itself.
*/
async function logDataAccess(req, { action, what, eventId = null, records = null, sensitive }) {
try {
// Children's or special information: medical lists always; otherwise anything about a
// youth event (one that asks for parental consent).
let isSensitive = sensitive ?? (action === 'medical_list');
if (!isSensitive && eventId) {
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { requiresParentalConsent: true } });
isSensitive = !!event?.requiresParentalConsent;
}
await prisma.dataAccessLog.create({
data: {
actorId: req.user?.id || null,
actorRole: req.user?.role || 'unknown',
action,
what: String(what || ACCESS_ACTIONS[action] || action).slice(0, 500),
eventId: eventId || null,
records: Number.isFinite(records) ? records : null,
sensitive: isSensitive,
ip: getClientIp(req),
},
});
} catch (e) {
console.error('Failed to log data access:', e?.message);
}
}
/** Rows in a report payload (main table or layered sections, plus extra tables). */
function reportRowCount(payload) {
const p = payload || {};
let n = Array.isArray(p.table?.rows) ? p.table.rows.length : 0;
if (Array.isArray(p.layered?.sections)) n += p.layered.sections.reduce((s, sec) => s + (Array.isArray(sec.items) ? sec.items.length : 0), 0);
if (Array.isArray(p.extraTables)) n += p.extraTables.reduce((s, t) => s + (Array.isArray(t.rows) ? t.rows.length : 0), 0);
return n;
}
module.exports = { ACCESS_ACTIONS, logDataAccess, reportRowCount };
+19 -2
View File
@@ -54,7 +54,7 @@ function isSmtpConfigured() {
return !!_smtpConfig().host; return !!_smtpConfig().host;
} }
async function sendMail({ to, subject, html, text, attachments }) { async function sendMail({ to, subject, html, text, attachments, headers }) {
// Never send to anonymised/deleted accounts // Never send to anonymised/deleted accounts
if (!to || String(to).endsWith('@deleted.invalid')) { if (!to || String(to).endsWith('@deleted.invalid')) {
console.info('[email] Skipping send to deleted account:', to); console.info('[email] Skipping send to deleted account:', to);
@@ -62,7 +62,7 @@ async function sendMail({ to, subject, html, text, attachments }) {
} }
const { transporter, cfg } = _getTransporter(); const { transporter, cfg } = _getTransporter();
const from = cfg.from || 'no-reply@crosscode.local'; const from = cfg.from || 'no-reply@crosscode.local';
const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}) }); const info = await transporter.sendMail({ from, to, subject, html, text, ...(attachments ? { attachments } : {}), ...(headers ? { headers } : {}) });
if (transporter.options && transporter.options.jsonTransport) { if (transporter.options && transporter.options.jsonTransport) {
try { try {
@@ -420,7 +420,24 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
return { text, html: emailWrapper(body, { preheader }) }; return { text, html: emailWrapper(body, { preheader }) };
} }
/**
* A plain message about a privacy (POPIA) request: a heading and paragraphs. Paragraphs
* are text (escaped here), so an admin's typed answer can go straight in.
*/
function buildPrivacyEmail({ name, heading, paragraphs }) {
const org = getOrg();
const esc = s => String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br/>');
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 24px 0;letter-spacing:-0.3px">${esc(heading)}</p>
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${esc(name || 'there')}</strong>,</p>
${paragraphs.map(p => `<p style="margin:0 0 16px 0;color:#374151">${esc(p)}</p>`).join('')}
<p style="margin:0;color:#374151">Questions? Contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.</p>`;
const text = `Hi ${name || 'there'},\n\n${paragraphs.join('\n\n')}\n\nQuestions? Contact us at ${org.email}.\n\n${org.name}`;
return { text, html: emailWrapper(body, { preheader: heading }) };
}
module.exports = { module.exports = {
buildPrivacyEmail,
sendMail, sendMail,
isSmtpConfigured, isSmtpConfigured,
getOrg, getOrg,
+641
View File
@@ -0,0 +1,641 @@
/**
* POPIA toolkit, the data subject side: news opt-in and its consent log, unsubscribe
* links, the opt-out filter every bulk send goes through, anonymising an account, and
* "Download my data".
*
* Bulk messages come in two kinds:
* - 'update' — about an event the person registered for (venue change, reminder). Sent
* regardless of the news opt-in; the email links to the preferences.
* - 'news' — News & announcements (marketing). Only to people who opted in on that
* channel, never to an address that unsubscribed, with a one-click unsubscribe.
*/
const crypto = require('crypto');
const prisma = require('../config/db');
const CHANNELS = ['email', 'whatsapp'];
const MESSAGE_KINDS = ['update', 'news'];
// POPIA gives a responsible party 30 days to respond to a request.
const REQUEST_DUE_DAYS = 30;
const DELETED_NAME = 'Deleted User';
const DELETED_ATTENDEE_NAME = 'Deleted attendee';
function frontendUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
function backendUrl() {
return String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
}
function normaliseKind(kind) {
return MESSAGE_KINDS.includes(kind) ? kind : null;
}
function normaliseEmail(email) {
return String(email || '').trim().toLowerCase();
}
function addressFor(channel, raw) {
if (channel === 'whatsapp') return require('./whatsapp').normalizeZAPhone(raw) || null;
return normaliseEmail(raw) || null;
}
// ─── News opt-in ──────────────────────────────────────────────────────────────
const NEWS_FIELD = { email: 'newsEmail', whatsapp: 'newsWhatsApp' };
/**
* Set someone's news opt-in. `prefs` is { email?, whatsapp? } (booleans; missing = leave).
* Logs one CommunicationConsent row per channel that actually changed — or every channel
* given when `logAll` (the first answer to the opt-in question is worth recording even
* when it keeps the current value). WhatsApp can't be switched on without a valid phone.
*/
async function setNewsPreferences(userId, prefs, { source, ip = null, answered = false, logAll = false } = {}, db = prisma) {
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, phoneNumber: true, newsEmail: true, newsWhatsApp: true } });
if (!user) return null;
const { isValidZAPhone } = require('./whatsapp');
const data = {};
const log = [];
for (const channel of CHANNELS) {
if (typeof prefs?.[channel] !== 'boolean') continue;
let value = prefs[channel];
if (channel === 'whatsapp' && value && !isValidZAPhone(user.phoneNumber)) value = false;
const field = NEWS_FIELD[channel];
if (value !== user[field]) data[field] = value;
if (value !== user[field] || logAll) log.push({ userId, channel, granted: value, source, ip });
}
if (answered) data.newsAskedAt = new Date();
const updated = Object.keys(data).length
? await db.user.update({ where: { id: userId }, data, select: { newsEmail: true, newsWhatsApp: true, newsAskedAt: true } })
: await db.user.findUnique({ where: { id: userId }, select: { newsEmail: true, newsWhatsApp: true, newsAskedAt: true } });
if (log.length) await db.communicationConsent.createMany({ data: log });
return updated;
}
/** The consent history for one person, newest first. */
function consentHistory(userId, db = prisma) {
return db.communicationConsent.findMany({
where: { userId },
orderBy: { createdAt: 'desc' },
select: { id: true, channel: true, granted: true, source: true, createdAt: true },
});
}
// ─── Unsubscribe links ────────────────────────────────────────────────────────
// A token names an account (u) or, for people without one, an address on a channel (a, c).
// Signed with JWT_SECRET and never expires: an unsubscribe link must keep working.
function sign(payload) {
return crypto.createHmac('sha256', process.env.JWT_SECRET || '').update(`unsubscribe:${payload}`).digest('base64url').slice(0, 32);
}
function unsubscribeToken(target) {
const body = target.userId ? { u: target.userId } : { a: target.address, c: target.channel };
const payload = Buffer.from(JSON.stringify(body)).toString('base64url');
return `${payload}.${sign(payload)}`;
}
/** { userId } or { address, channel } for a valid token, else null. */
function readUnsubscribeToken(token) {
const [payload, sig] = String(token || '').split('.');
if (!payload || !sig) return null;
const expected = sign(payload);
if (sig.length !== expected.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) return null;
try {
const body = JSON.parse(Buffer.from(payload, 'base64url').toString('utf8'));
if (typeof body.u === 'string' && body.u) return { userId: body.u };
if (typeof body.a === 'string' && body.a && CHANNELS.includes(body.c)) return { address: body.a, channel: body.c };
} catch { /* fall through */ }
return null;
}
/** The page people land on from an unsubscribe link. */
function unsubscribePageUrl(target) {
return `${frontendUrl()}/unsubscribe?t=${encodeURIComponent(unsubscribeToken(target))}`;
}
/** RFC 8058 one-click endpoint, for the List-Unsubscribe header (mail apps POST to it). */
function oneClickUrl(target) {
return `${backendUrl()}/api/privacy/unsubscribe/${encodeURIComponent(unsubscribeToken(target))}/one-click`;
}
/** What an unsubscribe link refers to, for the landing page. */
async function describeUnsubscribe(token) {
const target = readUnsubscribeToken(token);
if (!target) return null;
if (target.userId) {
const user = await prisma.user.findUnique({ where: { id: target.userId }, select: { email: true, phoneNumber: true, newsEmail: true, newsWhatsApp: true, isActive: true } });
if (!user) return null;
return {
kind: 'account',
email: maskEmail(user.email),
hasPhone: !!user.phoneNumber,
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp },
};
}
const optedOut = !!(await prisma.messagingOptOut.findUnique({ where: { channel_address: { channel: target.channel, address: target.address } } }));
return {
kind: 'address',
channel: target.channel,
address: target.channel === 'email' ? maskEmail(target.address) : maskPhone(target.address),
optedOut,
};
}
/**
* Unsubscribe from news. For an account: the given channels (default both). For an
* address: that address on its channel. Returns the new state, or null for a bad token.
*/
async function unsubscribe(token, { channels = CHANNELS, source = 'unsubscribe_link', ip = null } = {}) {
const target = readUnsubscribeToken(token);
if (!target) return null;
if (target.userId) {
const prefs = {};
for (const c of channels) if (CHANNELS.includes(c)) prefs[c] = false;
return setNewsPreferences(target.userId, prefs, { source, ip });
}
await optOutAddress(target.channel, target.address, { source, ip });
return { optedOut: true };
}
/** Record that an address without an account wants no more news on a channel. */
async function optOutAddress(channel, rawAddress, { source, ip = null } = {}, db = prisma) {
const address = addressFor(channel, rawAddress);
if (!address) return false;
const existing = await db.messagingOptOut.findUnique({ where: { channel_address: { channel, address } } });
if (existing) return true;
await db.messagingOptOut.create({ data: { channel, address, source } });
await db.communicationConsent.create({ data: { address, channel, granted: false, source, ip } });
return true;
}
function maskEmail(email) {
const [local, domain] = String(email || '').split('@');
if (!domain) return '';
return `${local.slice(0, 2)}${'*'.repeat(Math.max(1, local.length - 2))}@${domain}`;
}
function maskPhone(phone) {
const s = String(phone || '');
return s.length > 4 ? `${'*'.repeat(s.length - 4)}${s.slice(-4)}` : s;
}
// ─── Bulk sends ───────────────────────────────────────────────────────────────
/**
* Drop recipients a news message mustn't go to. `recipients` are { email } (channel
* 'email') or { phone } (channel 'whatsapp'), optionally with userId. Removed: accounts on
* that address without the news opt-in for the channel, and addresses that unsubscribed.
* Each kept recipient gets `userId` filled in when the address belongs to an account, so
* the email footer can link to the account's preferences. Update messages pass through
* untouched (apart from the userId lookup). Returns { recipients, skipped }.
*/
async function filterRecipients(recipients, { channel, kind }, db = prisma) {
const key = channel === 'whatsapp' ? 'phone' : 'email';
const addressOf = r => addressFor(channel, r[key]);
const addresses = [...new Set(recipients.map(addressOf).filter(Boolean))];
if (!addresses.length) return { recipients: [], skipped: recipients.length };
const users = await db.user.findMany({
where: channel === 'whatsapp'
? { phoneNumber: { in: addresses } }
: { OR: addresses.map(a => ({ email: { equals: a, mode: 'insensitive' } })) },
select: { id: true, email: true, phoneNumber: true, newsEmail: true, newsWhatsApp: true },
});
const byAddress = new Map(users.map(u => [addressFor(channel, channel === 'whatsapp' ? u.phoneNumber : u.email), u]));
let optedOut = new Set();
if (kind === 'news') {
const rows = await db.messagingOptOut.findMany({ where: { channel, address: { in: addresses } }, select: { address: true } });
optedOut = new Set(rows.map(r => r.address));
}
const kept = [];
for (const r of recipients) {
const address = addressOf(r);
const user = address ? byAddress.get(address) : null;
if (kind === 'news') {
if (!address || optedOut.has(address)) continue;
if (user && !user[NEWS_FIELD[channel]]) continue;
}
kept.push({ ...r, userId: r.userId || user?.id || null });
}
return { recipients: kept, skipped: recipients.length - kept.length };
}
function escapeHtml(s) {
return String(s || '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* The footer and headers a bulk email gets. News: unsubscribe link + List-Unsubscribe
* (one click in Gmail/Apple Mail). Updates: why they got it, and where to manage
* preferences. Returns { html, text, headers }.
*/
function bulkEmailFooter({ kind, recipient, eventTitle = '' }) {
const prefsUrl = `${frontendUrl()}/dashboard/user/privacy`;
const style = 'color:#94a3b8;font-size:12px;line-height:1.6;margin:24px 0 0 0;border-top:1px solid #e2e8f0;padding-top:12px';
if (kind === 'news') {
const target = recipient.userId ? { userId: recipient.userId } : { address: normaliseEmail(recipient.email), channel: 'email' };
const url = unsubscribePageUrl(target);
return {
html: `<p style="${style}">You're receiving this because you chose to get news and announcements from us. <a href="${url}" style="color:#64748b">Unsubscribe</a>${recipient.userId ? ` &middot; <a href="${prefsUrl}" style="color:#64748b">Manage preferences</a>` : ''}</p>`,
text: `\n\n—\nYou're receiving this because you chose to get news and announcements from us.\nUnsubscribe: ${url}`,
headers: {
'List-Unsubscribe': `<${oneClickUrl(target)}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
};
}
const why = eventTitle ? `you registered for ${eventTitle}` : 'you have a registration or account with us';
return {
html: `<p style="${style}">You're receiving this because ${escapeHtml(why)}. <a href="${prefsUrl}" style="color:#64748b">Manage your communication preferences</a></p>`,
text: `\n\n—\nYou're receiving this because ${why}.\nManage your communication preferences: ${prefsUrl}`,
headers: {},
};
}
/** The line a bulk news WhatsApp ends with. */
function whatsAppNewsFooter(recipient) {
const target = recipient.userId ? { userId: recipient.userId } : { address: addressFor('whatsapp', recipient.phone), channel: 'whatsapp' };
return `\n\n_Reply STOP to stop news messages, or unsubscribe: ${unsubscribePageUrl(target)}_`;
}
/** Recognises an opt-out reply ("STOP", "stop.", "Unsubscribe"). */
function isStopKeyword(text) {
return /^\s*(stop|unsubscribe|stop all|opt out|optout)\s*[.!]?\s*$/i.test(String(text || ''));
}
/**
* Someone replied STOP on WhatsApp: no more news on WhatsApp for that number — the
* account's opt-in if the number belongs to one, otherwise the number itself.
* Returns 'account' | 'address' | null (number not usable).
*/
async function whatsAppStop(rawPhone) {
const phone = addressFor('whatsapp', rawPhone);
if (!phone) return null;
const user = await prisma.user.findFirst({ where: { phoneNumber: phone }, select: { id: true } });
if (user) {
await setNewsPreferences(user.id, { whatsapp: false }, { source: 'whatsapp_stop' });
return 'account';
}
await optOutAddress('whatsapp', phone, { source: 'whatsapp_stop' });
return 'address';
}
// ─── Data requests ────────────────────────────────────────────────────────────
function dueDate(from = new Date()) {
return new Date(from.getTime() + REQUEST_DUE_DAYS * 24 * 60 * 60 * 1000);
}
async function createDataRequest({ user, type, details = null, selfService = false }, db = prisma) {
const now = new Date();
return db.dataRequest.create({
data: {
type,
userId: user.id,
name: user.name,
email: user.email,
details: details ? String(details).slice(0, 5000) : null,
selfService,
status: selfService ? 'completed' : 'open',
closedAt: selfService ? now : null,
dueAt: dueDate(now),
},
});
}
/** Who hears about privacy requests: the Information Officer (when set) and every admin. */
async function privacyContacts(db = prisma) {
const { getSettingSync } = require('./settingsCache');
const admins = await db.user.findMany({ where: { role: 'admin', isActive: true }, select: { email: true } });
const io = getSettingSync('legal_io_email', '');
return [...new Set([...(io && !io.endsWith('@example.com') ? [io] : []), ...admins.map(a => a.email)])];
}
/**
* Daily: email the Information Officer and admins a list of open requests due within 7 days
* or overdue. Nothing is sent when there are none.
*/
async function remindDueRequests(now = new Date(), db = prisma) {
const soon = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const due = await db.dataRequest.findMany({
where: { status: 'open', selfService: false, dueAt: { lte: soon } },
orderBy: { dueAt: 'asc' },
select: { type: true, name: true, dueAt: true },
});
if (!due.length) return { due: 0, notified: 0 };
const { sendMail, buildPrivacyEmail } = require('./email');
const to = await privacyContacts(db);
const fmt = d => d.toLocaleDateString('en-ZA', { timeZone: 'Africa/Johannesburg' });
const content = buildPrivacyEmail({
name: '',
heading: 'Privacy requests need an answer',
paragraphs: [
`${due.length} privacy request${due.length === 1 ? ' is' : 's are'} due within a week or overdue. POPIA asks for an answer within 30 days.`,
due.map(r => `${r.dueAt < now ? 'OVERDUE' : `Due ${fmt(r.dueAt)}`} — ${r.type} request from ${r.name}`).join('\n'),
`Handle them on the Data requests page: ${frontendUrl()}/dashboard/admin/data-requests`,
],
});
await Promise.allSettled(to.map(email => sendMail({ to: email, subject: 'Privacy requests need an answer', ...content })));
return { due: due.length, notified: to.length };
}
/**
* Why an account can't be anonymised yet: registrations (not cancelled) for events that
* haven't ended. The person may still need the ticket, or be owed or owe money.
*/
async function deletionBlockers(userId, db = prisma) {
const regs = await db.registration.findMany({
where: { userId, status: { not: 'cancelled' }, event: { endDate: { gte: new Date() } } },
select: { id: true, status: true, event: { select: { id: true, title: true, endDate: true } } },
orderBy: { createdAt: 'asc' },
});
return regs.map(r => ({ registrationId: r.id, status: r.status, eventId: r.event.id, eventTitle: r.event.title, endDate: r.event.endDate }));
}
// ─── Anonymising ──────────────────────────────────────────────────────────────
// Keys in a provider's raw webhook payload that can hold the payer's details (PayFast:
// name_first, name_last, email_address, cell_number; Yoco: customer metadata).
const PERSONAL_KEY = /(^|_)(name|first|last|email|phone|cell|mobile|address|customer|surname)/i;
function scrubPersonal(value) {
if (Array.isArray(value)) return value.map(scrubPersonal);
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
out[k] = PERSONAL_KEY.test(k) && (typeof v === 'string' || typeof v === 'number') ? '[deleted]' : scrubPersonal(v);
}
return out;
}
return value;
}
/**
* Remove a person's personal information while keeping the records the church must keep
* (payments, registrations, tickets, check-ins — amounts and dates) for its accounts and
* historic reports, where they show as "Deleted User". Also covers the attendees on their
* registrations (people they booked for) and their place on other people's bookings.
* Irreversible. Returns counts for the audit log.
*/
async function anonymiseUser(userId, db = prisma) {
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!user) return null;
const { clearTrustedDevices } = require('./twoFactor');
const counts = await db.$transaction(async tx => {
await tx.user.update({
where: { id: userId },
data: {
name: DELETED_NAME,
email: `deleted-${crypto.randomUUID()}@deleted.invalid`,
password: '',
phoneNumber: null,
isActive: false,
twoFactorEnabled: false,
twoFactorMethod: null,
tokenVersion: { increment: 1 },
newsEmail: false,
newsWhatsApp: false,
},
});
const attendees = await tx.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
const attendeeIds = attendees.map(a => a.id);
if (attendeeIds.length) {
await tx.attendee.updateMany({
where: { id: { in: attendeeIds } },
data: { name: DELETED_ATTENDEE_NAME, email: null, phone: null, dateOfBirth: null, gender: null, parentName: null, parentEmail: null, parentPhone: null },
});
await tx.consentRequest.updateMany({
where: { attendeeId: { in: attendeeIds } },
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, signedName: null, ip: null, device: null, medicalPurgedAt: new Date() },
});
}
// Form answers on their registrations (and for their attendees elsewhere) hold
// whatever the form asked — delete the answers, keep that a form was filled in.
const answers = await tx.formAnswer.deleteMany({
where: { response: { OR: [{ registration: { userId } }, ...(attendeeIds.length ? [{ attendeeId: { in: attendeeIds } }] : [])] } },
});
await tx.formDraft.deleteMany({ where: { userId } });
// Feedback stays (it's about the event) but no longer says who gave it.
await tx.feedbackResponse.updateMany({ where: { userId }, data: { userId: null } });
await tx.feedbackInvite.deleteMany({ where: { userId } });
await tx.waitlistEntry.updateMany({ where: { userId, status: { in: ['waiting', 'offered'] } }, data: { status: 'left', offerTokenHash: null } });
await tx.ticketTransferRequest.deleteMany({ where: { requestedById: userId } });
await tx.ticketTransfer.updateMany({ where: { fromUserId: userId }, data: { fromHolderName: DELETED_NAME } });
await tx.ticketTransfer.updateMany({ where: { toUserId: userId }, data: { toName: DELETED_NAME } });
await tx.certificate.updateMany({ where: { userId }, data: { name: DELETED_NAME } });
await tx.payment.updateMany({ where: { userId }, data: { sponsorName: null, sponsorMessage: null } });
const payments = await tx.payment.findMany({ where: { userId }, select: { id: true } });
const txns = payments.length
? await tx.providerTransaction.findMany({ where: { paymentId: { in: payments.map(p => p.id) }, raw: { not: null } }, select: { id: true, raw: true } })
: [];
for (const t of txns) await tx.providerTransaction.update({ where: { id: t.id }, data: { raw: scrubPersonal(t.raw) } });
const checkouts = await tx.providerCheckout.findMany({ where: { metadata: { path: ['userId'], equals: userId } }, select: { id: true, request: true } });
for (const c of checkouts) await tx.providerCheckout.update({ where: { id: c.id }, data: { request: scrubPersonal(c.request) } });
// Consent and notice records stay as proof, without the IP address they came from.
await tx.communicationConsent.updateMany({ where: { userId }, data: { ip: null } });
await tx.noticeAcceptance.updateMany({ where: { userId }, data: { ip: null } });
await tx.securityEvent.deleteMany({ where: { userId } });
await tx.passwordReset.deleteMany({ where: { userId } });
await tx.twoFactorChallenge.deleteMany({ where: { userId } });
return { attendees: attendeeIds.length, formAnswers: answers.count, providerRecords: txns.length + checkouts.length };
});
await clearTrustedDevices(userId);
return counts;
}
// ─── Download my data ─────────────────────────────────────────────────────────
/** Everything held about a person, as plain JSON. */
async function exportUserData(userId, db = prisma) {
const { openJson } = require('./consent');
const user = await db.user.findUnique({
where: { id: userId },
select: {
id: true, name: true, email: true, phoneNumber: true, role: true, createdAt: true, updatedAt: true,
notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
newsEmail: true, newsWhatsApp: true, newsAskedAt: true,
},
});
if (!user) return null;
const [registrations, payments, tickets, waitlist, certificates, feedback, consents, security, requests, notices] = await Promise.all([
db.registration.findMany({
where: { userId },
orderBy: { createdAt: 'asc' },
include: {
event: { select: { title: true, startDate: true, endDate: true } },
registrationOptions: { include: { eventOption: { select: { name: true } }, variant: { select: { name: true } } } },
attendees: { include: { consentRequests: { orderBy: { createdAt: 'desc' }, take: 1 } } },
formResponses: { include: { answers: { include: { field: { select: { label: true } } } } } },
},
}),
db.payment.findMany({ where: { userId }, orderBy: { createdAt: 'asc' }, include: { event: { select: { title: true } } } }),
db.ticket.findMany({
where: { OR: [{ userId }, { holderUserId: userId }] },
orderBy: { createdAt: 'asc' },
include: { event: { select: { title: true } }, usages: { select: { scannedAt: true, sessionId: true } } },
}),
db.waitlistEntry.findMany({ where: { userId }, include: { event: { select: { title: true } } } }),
db.certificate.findMany({ where: { userId }, include: { event: { select: { title: true } } } }),
db.feedbackResponse.findMany({ where: { userId }, include: { form: { include: { event: { select: { title: true } } } }, answers: { include: { field: { select: { label: true } } } } } }),
consentHistory(userId, db),
db.securityEvent.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, take: 100, select: { type: true, ip: true, device: true, createdAt: true } }),
db.dataRequest.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { type: true, status: true, details: true, response: true, createdAt: true, closedAt: true } }),
db.noticeAcceptance.findMany({ where: { userId }, orderBy: { createdAt: 'desc' }, select: { version: true, source: true, createdAt: true } }),
]);
return {
exportedAt: new Date().toISOString(),
account: user,
communication: {
notificationChannel: user.notificationPreference,
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp, answeredAt: user.newsAskedAt },
history: consents.map(c => ({ channel: c.channel, granted: c.granted, source: c.source, at: c.createdAt })),
},
privacyNoticeAccepted: notices.map(n => ({ version: n.version, how: n.source, at: n.createdAt })),
registrations: registrations.map(r => ({
id: r.id,
event: r.event?.title,
eventStart: r.event?.startDate,
status: r.status,
createdAt: r.createdAt,
options: r.registrationOptions.map(o => ({ option: o.eventOption?.name, variant: o.variant?.name || null, quantity: o.quantity, price: o.priceSnapshot })),
attendees: r.attendees.map(a => {
const consent = a.consentRequests[0];
return {
name: a.name, email: a.email, phone: a.phone, dateOfBirth: a.dateOfBirth, gender: a.gender,
parentName: a.parentName, parentEmail: a.parentEmail, parentPhone: a.parentPhone,
parentalConsent: consent ? {
status: consent.status, signedName: consent.signedName, respondedAt: consent.respondedAt,
photoConsent: consent.photoConsent, medical: openJson(consent.medical), emergency: openJson(consent.emergency),
} : null,
};
}),
formAnswers: r.formResponses.flatMap(f => f.answers.map(a => ({ question: a.field?.label, answer: a.value }))),
})),
payments: payments.map(p => ({
amount: p.amount, method: p.method, provider: p.provider, status: p.status, event: p.event?.title || null,
donation: p.isDonation, sponsorName: p.sponsorName, sponsorMessage: p.sponsorMessage, at: p.createdAt,
})),
tickets: tickets.map(t => ({ event: t.event?.title, quantity: t.quantity, checkIns: t.usages.map(u => u.scannedAt), createdAt: t.createdAt })),
waitlist: waitlist.map(w => ({ event: w.event?.title, quantity: w.quantity, status: w.status, joinedAt: w.createdAt })),
certificates: certificates.map(c => ({ event: c.event?.title, name: c.name, code: c.code, issuedAt: c.issuedAt, revoked: !!c.revokedAt })),
feedback: feedback.map(f => ({ event: f.form?.event?.title, at: f.createdAt, answers: f.answers.map(a => ({ question: a.field?.label, answer: a.value })) })),
accountActivity: security.map(s => ({ type: s.type, ip: s.ip, device: s.device, at: s.createdAt })),
dataRequests: requests,
};
}
/** A readable PDF of the export (the JSON is the complete copy). Resolves to a Buffer. */
async function dataExportPdf(data) {
const PDFDocument = require('pdfkit');
const { getBranding } = require('./pdfDocs');
const org = await getBranding();
const doc = new PDFDocument({ size: 'A4', margin: 48 });
const chunks = [];
doc.on('data', c => chunks.push(c));
const done = new Promise(resolve => doc.on('end', () => resolve(Buffer.concat(chunks))));
const fmt = d => (d ? new Date(d).toLocaleString('en-ZA', { timeZone: 'Africa/Johannesburg' }) : '—');
const heading = text => {
if (doc.y > doc.page.height - 120) doc.addPage();
doc.moveDown(0.8).fillColor(org.brandColor).font('Helvetica-Bold').fontSize(13).text(text).moveDown(0.3).fillColor('#1e293b');
};
const line = (label, value) => doc.font('Helvetica-Bold').fontSize(10).text(`${label}: `, { continued: true }).font('Helvetica').text(value == null || value === '' ? '—' : String(value));
const para = text => doc.font('Helvetica').fontSize(10).text(text);
doc.fillColor(org.brandColor).font('Helvetica-Bold').fontSize(20).text('Your personal information');
doc.fillColor('#64748b').font('Helvetica').fontSize(10).text(`${org.name} · exported ${fmt(data.exportedAt)}`);
doc.moveDown(0.4).text('This is a readable summary. The JSON file you can download alongside it is the complete copy.');
doc.fillColor('#1e293b');
const a = data.account;
heading('Account');
line('Name', a.name); line('Email', a.email); line('Phone', a.phoneNumber); line('Role', a.role);
line('Created', fmt(a.createdAt)); line('Two-factor', a.twoFactorEnabled ? `on (${a.twoFactorMethod})` : 'off');
heading('Communication');
line('Notifications by', a.notificationPreference);
line('News by email', data.communication.news.email ? 'yes' : 'no');
line('News by WhatsApp', data.communication.news.whatsapp ? 'yes' : 'no');
for (const h of data.communication.history) para(`${fmt(h.at)} — ${h.channel} news ${h.granted ? 'on' : 'off'} (${h.source.replace(/_/g, ' ')})`);
heading(`Registrations (${data.registrations.length})`);
for (const r of data.registrations) {
doc.font('Helvetica-Bold').fontSize(10).text(`${r.event} — ${r.status}`).font('Helvetica');
para(`Registered ${fmt(r.createdAt)}; ${r.options.map(o => `${o.quantity} × ${o.option}${o.variant ? ` (${o.variant})` : ''}`).join(', ')}`);
for (const p of r.attendees) para(`Attendee: ${p.name}${p.dateOfBirth ? `, born ${new Date(p.dateOfBirth).toLocaleDateString('en-ZA')}` : ''}${p.parentalConsent ? `, parental consent ${p.parentalConsent.status}` : ''}`);
for (const f of r.formAnswers) para(`${f.question}: ${f.answer}`);
doc.moveDown(0.3);
}
heading(`Payments (${data.payments.length})`);
for (const p of data.payments) para(`${fmt(p.at)} — R${Number(p.amount).toFixed(2)} ${p.method}${p.event ? `, ${p.event}` : ''}${p.donation ? ' (donation)' : ''}`);
heading(`Tickets (${data.tickets.length})`);
for (const t of data.tickets) para(`${t.event} — ${t.quantity} place${t.quantity === 1 ? '' : 's'}, ${t.checkIns.length} check-in${t.checkIns.length === 1 ? '' : 's'}`);
if (data.certificates.length) {
heading('Certificates');
for (const c of data.certificates) para(`${c.event} — ${c.code}${c.revoked ? ' (withdrawn)' : ''}`);
}
if (data.feedback.length) {
heading('Feedback you gave');
for (const f of data.feedback) {
doc.font('Helvetica-Bold').fontSize(10).text(f.event || 'Event').font('Helvetica');
for (const ans of f.answers) para(`${ans.question}: ${ans.answer}`);
}
}
heading('Recent account activity');
for (const s of data.accountActivity.slice(0, 20)) para(`${fmt(s.at)} — ${s.type.replace(/_/g, ' ')}${s.device ? `, ${s.device}` : ''}`);
doc.end();
return done;
}
module.exports = {
CHANNELS,
MESSAGE_KINDS,
REQUEST_DUE_DAYS,
DELETED_NAME,
normaliseKind,
setNewsPreferences,
consentHistory,
unsubscribeToken,
readUnsubscribeToken,
unsubscribePageUrl,
describeUnsubscribe,
unsubscribe,
optOutAddress,
filterRecipients,
bulkEmailFooter,
whatsAppNewsFooter,
isStopKeyword,
whatsAppStop,
createDataRequest,
privacyContacts,
remindDueRequests,
deletionBlockers,
scrubPersonal,
anonymiseUser,
exportUserData,
dataExportPdf,
};
+443
View File
@@ -0,0 +1,443 @@
/**
* The privacy notice: the built-in text (version 0), published versions (PrivacyNotice),
* the admin's draft, and who has accepted which version.
*
* A notice is a list of sections { id, title, body }. A body is plain text, one paragraph
* per line; a line starting with "• " is a bullet. Placeholders are filled in when the
* notice is shown, so it follows Site settings and the operator register:
* {org} {orgEmail} {website} {ioName} {ioEmail} {medicalRetentionDays}
* {operators} — on a line of its own: one bullet per active operator.
* {retention} — on a line of its own: one bullet per retention period (utils/retention.js).
*/
const prisma = require('../config/db');
const PLACEHOLDERS = ['org', 'orgEmail', 'website', 'ioName', 'ioEmail', 'medicalRetentionDays', 'operators', 'retention'];
const MAX_SECTIONS = 40;
const MAX_TITLE = 200;
const MAX_BODY = 20000;
const DRAFT_KEY = 'privacy_notice_draft';
const DEFAULT_SECTIONS = [
{
id: 'summary',
title: 'Plain English Summary',
body: [
"We respect your privacy — here's what you need to know:",
'• We only collect info needed to run events: registrations, payments, tickets and check-in.',
'• We store your info safely on South African servers.',
"• We don't sell your data, and only share it with the services listed in section 9, which process payments and deliver messages for us.",
'• If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.',
'• For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.',
'• Online payments go through Yoco or PayFast; we never see your card details.',
'• We send tickets, updates and security alerts by email and/or WhatsApp. News and announcements are sent only if you ask for them, and you can unsubscribe at any time.',
'• Visit statistics are only collected if you click "Allow" on the cookie notice — no third-party analytics.',
'• On the Privacy page in your account you can download your data, ask for corrections, and delete your account.',
].join('\n'),
},
{
id: 'introduction',
title: '1. Introduction',
body: [
"{org} ('we', 'us', 'our') values your privacy and is committed to protecting your personal information in accordance with the Protection of Personal Information Act (POPIA).",
'This notice explains what data we collect, how we use it, and your rights.',
].join('\n'),
},
{
id: 'information',
title: '2. Information We Collect',
body: [
'We collect the following information when you interact with our platform:',
'• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.',
'• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.',
'• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.',
'• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).',
'• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.',
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
'• Your choices: whether you want news and announcements, which version of this notice you accepted, and when you made each choice.',
'• Communication data: emails or messages exchanged with our team.',
'• Visit statistics: only if you allow them on the cookie notice (see section 11).',
'We do not collect or store credit card numbers or bank details.',
'Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.',
].join('\n'),
},
{
id: 'others',
title: '3. People You Register or Transfer Tickets To',
body: [
'You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.',
'• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.',
'• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.',
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
'By entering someone else\'s details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).',
].join('\n'),
},
{
id: 'use',
title: '4. How We Use Your Information',
body: [
'We use your information to:',
'• Process event registrations, payments, discounts and refunds.',
'• Send updates about events you registered for — confirmations, reminders and changes — by email and/or WhatsApp (based on your preference).',
'• Send news and announcements about upcoming events, only if you asked for them. Every such email has an unsubscribe link, and you can reply STOP on WhatsApp.',
'• Send security notifications for new logins, password changes, and account actions on your account.',
'• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).',
'• Check tickets in at the door, including with scanners that keep working without an internet connection.',
'• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.',
'• Send you an optional feedback form after an event, with one reminder.',
'• Manage user accounts and event attendance records, and give support when you ask for help.',
'• Improve our events and website (using anonymous statistics, and only with your permission).',
'• Comply with legal obligations.',
].join('\n'),
},
{
id: 'whatsapp',
title: '5. WhatsApp Communications',
body: [
"If you choose 'WhatsApp' or 'Both' as your notification preference, we will use your South African mobile number to send you messages via WhatsApp. These messages may include:",
'• Event registration confirmations and updates.',
'• Payment receipts.',
'• Your event tickets (as a PDF document).',
'• Security alerts such as new login notifications, password changes, and account closure confirmations.',
'• News and announcements, only if you asked for them on WhatsApp. Reply STOP to any of these to stop them.',
'WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages.',
'Security-critical messages (login alerts, password resets, account closures) are always sent via email regardless of your preference, and additionally sent via WhatsApp if your preference includes it.',
"You may change your notification preference at any time from the Profile & Security page in your account. Selecting 'Email only' will stop WhatsApp messages from being sent to you.",
'For accounts registered without a valid email address (e.g. walk-in or at-the-door registrations), WhatsApp will be used as the primary delivery channel for payment confirmations and ticket delivery, regardless of notification preference, since email is unavailable.',
'If your account requires activation and no email address is on file, the activation link will be sent to your WhatsApp number instead.',
"WhatsApp is a product of Meta Platforms, Inc. By receiving WhatsApp messages from us, you are subject to WhatsApp's own Terms of Service and Privacy Policy.",
].join('\n'),
},
{
id: 'storage',
title: '6. Where We Store Your Data',
body: [
'All data is securely stored on cloud servers hosted in South Africa.',
'We use appropriate technical and organisational measures to protect your information from unauthorised access, alteration, or disclosure.',
].join('\n'),
},
{
id: 'retention',
title: '7. Data Retention',
body: [
'We keep personal information only as long as we need it:',
'{retention}',
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
].join('\n'),
},
{
id: 'children',
title: '8. Children, Parental Consent and Health Information',
body: [
'Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.',
'We do not knowingly collect or process personal information of minors without such consent.',
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
'A parent or guardian may also say whether photos of the young person may be used.',
].join('\n'),
},
{
id: 'sharing',
title: '9. Who Can See Your Information',
body: [
'We do not sell or rent your personal information. Outside our organisation, it is shared only with these service providers ("operators"), each only for its purpose:',
'{operators}',
'• Anyone we are required to share it with under South African law.',
'Within our organisation, people see only what they need:',
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
'• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.',
'• Supervisors and administrators manage registrations, payments and communication for events. Every export of personal information (reports, attendance registers, lists) is recorded.',
'• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.',
'• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.',
'• Nobody else can see whether you have an account.',
].join('\n'),
},
{
id: 'rights',
title: '10. Your Rights (under POPIA)',
body: [
'Under POPIA, you have the right to:',
'• Access your personal information.',
'• Request correction or deletion of your data.',
'• Withdraw consent for processing, including for news and announcements.',
'• Object to direct marketing.',
'• Lodge a complaint with the Information Regulator of South Africa.',
'The Privacy page in your account lets you download your data, ask for a correction, delete your account and choose which messages you receive. We answer requests within 30 days.',
'To contact the Information Regulator:',
'• Website: www.inforeg.org.za',
'• Email: enquiries@inforeg.org.za',
'To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.',
].join('\n'),
},
{
id: 'cookies',
title: '11. Cookie Policy (Cookies and Browser Storage)',
body: [
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
"Strictly necessary — always used, because the site can't work without them:",
'• hope_events_token — keeps you signed in.',
'• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.',
'• hope_events_device — only if you tick "Trust this device" when entering a two-factor code, so you aren\'t asked for a code every time.',
'• hope_events_cookie_consent — remembers your choice on the cookie notice.',
'• banner_dismissed_… — remembers notices you have closed.',
'Staff and administrators only:',
'• hope_events_scan_device, hope_events_offline_lock and the "hope-events-offline-scan" database — on a staff member\'s scanner in offline mode: the device\'s id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.',
'• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.',
'Optional — only if you click "Allow" on the cookie notice:',
'• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose "No thanks", nothing is counted and no visit id is created.',
'Third parties:',
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open the payment provider's secure checkout page (Yoco or PayFast), which uses its own cookies on the provider's site.",
'You can change your cookie choice at any time with "Cookie settings" at the bottom of every page, or with the button above. You can also clear your browser\'s storage in your browser settings; this signs you out.',
].join('\n'),
},
{
id: 'security',
title: '12. Security',
body: [
'Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.',
'Passwords are encrypted and cannot be viewed by anyone, including administrators.',
'Two-factor authentication is available to everyone and is required for supervisors and administrators.',
'Links we send for consent, feedback, ticket transfers and unsubscribing are personal. Only a secure fingerprint of most links is stored, not the link itself.',
'In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.',
].join('\n'),
},
{
id: 'changes',
title: '13. Changes to This Notice',
body: [
'We may update this notice from time to time. Each update is published as a new version with its date, and earlier versions stay available on this page.',
'If a change is significant, we ask you to accept the new version the next time you log in.',
].join('\n'),
},
{
id: 'officer',
title: '14. Information Officer',
body: [
'The Information Officer responsible for overseeing compliance with POPIA on behalf of {org} is:',
'• Name: {ioName}',
'• Email: {ioEmail}',
'You may contact the Information Officer for any POPIA-related requests, including access to your personal data, requests for correction or deletion, and formal complaints.',
].join('\n'),
},
{
id: 'contact',
title: '15. Contact',
body: [
'For general privacy-related questions or requests, you can also contact us at:',
'📧 {orgEmail}',
].join('\n'),
},
];
function defaultSections() {
return DEFAULT_SECTIONS.map(s => ({ ...s }));
}
function slugify(text) {
return String(text || '').toLowerCase().replace(/^\d+\.\s*/, '').replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40) || 'section';
}
/** Checks and tidies sections from the editor. Throws a message fit for the admin. */
function cleanSections(input) {
if (!Array.isArray(input) || input.length === 0) throw new Error('The notice needs at least one section.');
if (input.length > MAX_SECTIONS) throw new Error(`At most ${MAX_SECTIONS} sections.`);
const seen = new Set();
return input.map((s, i) => {
const title = String(s?.title || '').trim();
const body = String(s?.body || '').replace(/\r\n/g, '\n').trim();
if (!title) throw new Error(`Section ${i + 1} needs a title.`);
if (title.length > MAX_TITLE) throw new Error(`The title of "${title.slice(0, 40)}…" is too long.`);
if (body.length > MAX_BODY) throw new Error(`"${title}" is too long.`);
let id = slugify(s?.id || title);
while (seen.has(id)) id = `${id}-${i + 1}`;
seen.add(id);
return { id, title, body };
});
}
/** Settings and operators the placeholders are filled from. */
async function noticeContext(db = prisma) {
const { getSetting } = require('./settingsCache');
const [org, orgEmail, website, ioName, ioEmail, medicalDays, operators, retention] = await Promise.all([
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
getSetting('org_email', ''),
getSetting('legal_website_url', ''),
getSetting('legal_io_name', ''),
getSetting('legal_io_email', ''),
getSetting('consent_medical_retention_days', ''),
db.operator.findMany({ where: { active: true }, orderBy: [{ order: 'asc' }, { name: 'asc' }] }),
require('./retention').noticeLines(db),
]);
// Unset legal fields show an obviously-unfinished placeholder, never someone's real details.
return {
org,
orgEmail: orgEmail || 'privacy@example.com',
website: website || 'example.com',
ioName: ioName || '[not yet configured]',
ioEmail: ioEmail || 'io@example.com',
medicalRetentionDays: String(parseInt(medicalDays, 10) || 30),
operators,
retention,
};
}
// Placeholders that stand on a line of their own and become several bullet lines.
const LIST_PLACEHOLDERS = {
operators: ctx => (ctx.operators.length ? ctx.operators.map(operatorLine) : ['• (No service providers listed.)']),
retention: ctx => ctx.retention || [],
};
function operatorLine(o) {
return `• ${o.name}${o.location ? ` (${o.location})` : ''} — ${o.purpose}. ${o.dataShared ? `Receives: ${o.dataShared}.` : ''}`.trim();
}
/** Sections with their placeholders filled in. */
function renderSections(sections, ctx) {
return sections.map(s => {
const body = String(s.body || '')
.split('\n')
.flatMap(line => {
const list = line.trim().match(/^\{(\w+)\}$/)?.[1];
return list && LIST_PLACEHOLDERS[list] ? LIST_PLACEHOLDERS[list](ctx) : [line];
})
.map(line => line.replace(/\{(\w+)\}/g, (m, key) => (!LIST_PLACEHOLDERS[key] && PLACEHOLDERS.includes(key) ? ctx[key] : m)))
.join('\n');
return { id: s.id, title: s.title, body };
});
}
async function latestNotice(db = prisma) {
return db.privacyNotice.findFirst({ orderBy: { version: 'desc' } });
}
/** The newest version people must have accepted, or null when none asks for it. */
async function requiredVersion(db = prisma) {
const row = await db.privacyNotice.findFirst({ where: { requiresAcceptance: true }, orderBy: { version: 'desc' }, select: { version: true } });
return row ? row.version : null;
}
/** Whether someone must (re-)accept the notice before carrying on. */
async function noticePromptPending(user, db = prisma) {
const required = await requiredVersion(db);
if (required == null) return false;
return (user.noticeVersionAccepted ?? -1) < required;
}
/**
* A notice for the public page: the given version (or the current one), rendered, with
* the list of versions. Version 0 is the built-in notice.
*/
async function publicNotice(version, db = prisma) {
const versions = await db.privacyNotice.findMany({
orderBy: { version: 'desc' },
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true },
});
const ctx = await noticeContext(db);
const wanted = version == null ? versions[0]?.version ?? 0 : version;
if (wanted === 0) {
if (version != null && versions.length) return null; // the built-in text isn't a past version
const { getSetting } = require('./settingsCache');
return {
version: 0,
builtIn: true,
current: true,
publishedAt: null,
effectiveDate: (await getSetting('legal_effective_date', '')) || '[not yet set]',
changeSummary: null,
sections: renderSections(DEFAULT_SECTIONS, ctx),
versions,
};
}
const notice = await db.privacyNotice.findUnique({ where: { version: wanted } });
if (!notice) return null;
return {
version: notice.version,
builtIn: false,
current: notice.version === versions[0]?.version,
publishedAt: notice.publishedAt,
effectiveDate: notice.publishedAt.toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Africa/Johannesburg' }),
changeSummary: notice.changeSummary,
sections: renderSections(notice.sections, ctx),
versions,
};
}
/** The version a new acceptance is for: the latest published, or 0 (built-in). */
async function currentVersion(db = prisma) {
return (await latestNotice(db))?.version ?? 0;
}
/** Record that someone accepted the current notice. */
async function acceptNotice(userId, { source, ip = null } = {}, db = prisma) {
const version = await currentVersion(db);
await db.user.update({ where: { id: userId }, data: { noticeVersionAccepted: version } });
await db.noticeAcceptance.create({ data: { userId, version, source, ip } });
return version;
}
// ─── Draft (admin) ────────────────────────────────────────────────────────────
/** The draft being edited, or — when there is none — the current notice's sections. */
async function getDraft(db = prisma) {
const row = await db.appSetting.findUnique({ where: { key: DRAFT_KEY } });
if (row?.value) {
try {
const draft = JSON.parse(row.value);
if (Array.isArray(draft.sections)) return { sections: draft.sections, savedAt: draft.savedAt || null, isDraft: true };
} catch { /* fall through to the current notice */ }
}
const latest = await latestNotice(db);
return { sections: latest ? latest.sections : defaultSections(), savedAt: null, isDraft: false };
}
async function saveDraft(sections, db = prisma) {
const clean = cleanSections(sections);
const value = JSON.stringify({ sections: clean, savedAt: new Date().toISOString() });
await db.appSetting.upsert({ where: { key: DRAFT_KEY }, update: { value }, create: { key: DRAFT_KEY, value } });
return clean;
}
async function discardDraft(db = prisma) {
await db.appSetting.deleteMany({ where: { key: DRAFT_KEY } });
}
/** Publish the given sections as the next version, and clear the draft. */
async function publish({ sections, changeSummary, requiresAcceptance, publishedById }, db = prisma) {
const clean = cleanSections(sections);
const notice = await db.$transaction(async tx => {
const latest = await tx.privacyNotice.findFirst({ orderBy: { version: 'desc' }, select: { version: true } });
return tx.privacyNotice.create({
data: {
version: (latest?.version ?? 0) + 1,
sections: clean,
changeSummary: changeSummary ? String(changeSummary).trim().slice(0, 2000) : null,
requiresAcceptance: !!requiresAcceptance,
publishedById: publishedById || null,
},
});
});
await discardDraft(db);
return notice;
}
module.exports = {
PLACEHOLDERS,
defaultSections,
cleanSections,
noticeContext,
renderSections,
operatorLine,
requiredVersion,
noticePromptPending,
publicNotice,
currentVersion,
acceptNotice,
getDraft,
saveDraft,
discardDraft,
publish,
};
+95
View File
@@ -0,0 +1,95 @@
/**
* The POPIA readiness checklist for Admin → Privacy: what's set up, what isn't, and where to
* fix it. Checks that don't apply to this install (e.g. WhatsApp STOP without WhatsApp) are
* left out of the score.
*/
const prisma = require('../config/db');
const DAY = 24 * 60 * 60 * 1000;
// Generic names the operator register starts with, which should be replaced with the real provider.
const GENERIC_OPERATORS = ['Email delivery provider', 'Hosting provider'];
async function readiness(now = new Date(), db = prisma) {
const { getSetting } = require('./settingsCache');
const setting = key => getSetting(key, '');
const [ioName, ioEmail, operatorName, website, smtpHost] = await Promise.all([
setting('legal_io_name'), setting('legal_io_email'), setting('legal_operator_name'), setting('legal_website_url'), setting('smtp_host'),
]);
const retention = require('./retention');
const { isEnabled, JOBS } = require('../jobs');
const [notices, operators, policies, lastRun, overdue, openBreaches, webhookSeen, whatsappConfigured, backups] = await Promise.all([
db.privacyNotice.count(),
db.operator.findMany({ where: { active: true }, select: { name: true, location: true } }),
retention.getPolicies(db),
retention.lastRun(db),
db.dataRequest.count({ where: { status: 'open', selfService: false, dueAt: { lt: now } } }),
db.securityBreach.findMany({ where: { status: { not: 'closed' } }, select: { title: true, discoveredAt: true, regulatorNotifiedAt: true } }),
db.appSetting.findUnique({ where: { key: 'wawp_webhook_last_seen' } }),
require('./whatsapp').isConfigured().catch(() => false),
require('./backupUtils').listBackups().catch(() => []),
]);
const checks = [];
const add = (key, label, ok, detail, href, applies = true) => checks.push({ key, label, ok: !!ok, detail, href, applies });
add('io', 'Information Officer named', ioName && ioEmail,
ioName && ioEmail ? `${ioName} (${ioEmail})` : 'Set the Information Officer\'s name and email address — they appear on the privacy notice and contact page.',
'/dashboard/admin/settings?tab=legal');
add('legal', 'Responsible party and website filled in', operatorName && website,
operatorName && website ? `${operatorName} · ${website}` : 'Fill in the operator/responsible party and website for the legal pages.',
'/dashboard/admin/settings?tab=legal');
add('notice', 'Privacy notice published', notices > 0,
notices > 0 ? `${notices} version${notices === 1 ? '' : 's'} published` : 'Review the privacy notice and publish your own version (the built-in text is a starting point).',
'/dashboard/admin/privacy?tab=notice');
const vague = operators.filter(o => !o.location || GENERIC_OPERATORS.includes(o.name)).map(o => o.name);
add('operators', 'Operators listed with their details', operators.length && !vague.length,
!operators.length ? 'List the services that process personal information for you.' : vague.length ? `Check: ${vague.join(', ')} (name the real provider and where it processes data).` : `${operators.length} operator${operators.length === 1 ? '' : 's'} listed`,
'/dashboard/admin/privacy?tab=operators');
add('retention_reviewed', 'Retention periods reviewed', policies.reviewedAt,
policies.reviewedAt ? `Saved ${new Date(policies.reviewedAt).toLocaleDateString('en-ZA')}` : 'Check the retention periods suit your organisation, and save them.',
'/dashboard/admin/privacy?tab=retention');
const retentionJob = JOBS.find(j => j.name === 'retention');
const ranRecently = lastRun?.at && now - new Date(lastRun.at) < 2 * DAY;
add('retention_running', 'Retention job running', isEnabled(retentionJob) && ranRecently,
!isEnabled(retentionJob) ? 'The retention job is switched off (RETENTION_JOBS_ENABLED or JOBS_ENABLED).' : ranRecently ? `Last ran ${new Date(lastRun.at).toLocaleString('en-ZA')}` : 'It hasn\'t run in the last two days — check Background jobs.',
'/dashboard/admin/settings?tab=jobs');
add('requests', 'Privacy requests answered on time', overdue === 0,
overdue ? `${overdue} request${overdue === 1 ? ' is' : 's are'} overdue.` : 'None overdue',
'/dashboard/admin/data-requests');
const unreported = openBreaches.filter(b => !b.regulatorNotifiedAt && now - new Date(b.discoveredAt) > 3 * DAY);
add('breaches', 'Security compromises reported', unreported.length === 0,
unreported.length ? `Not yet reported to the Information Regulator: ${unreported.map(b => b.title).join(', ')}.` : openBreaches.length ? `${openBreaches.length} open, all reported` : 'Nothing open',
'/dashboard/admin/privacy?tab=breaches');
const backendUrl = process.env.BACKEND_URL || '';
// The .env.example value ("https://your-backend-domain.com") doesn't count.
const realHttps = /^https:\/\//i.test(backendUrl) && !/your-|example\./i.test(backendUrl);
add('https', 'Unsubscribe links work in mail apps', realHttps,
realHttps ? backendUrl : 'Set BACKEND_URL on the server to the site\'s public https:// address (used by one-click unsubscribe and the WhatsApp webhook).',
null);
add('smtp', 'Email sending set up', smtpHost || process.env.SMTP_HOST || process.env.EMAIL_HOST,
smtpHost || process.env.SMTP_HOST || process.env.EMAIL_HOST ? 'SMTP configured' : 'Without email, people don\'t get unsubscribe links, request confirmations or inactivity warnings.',
'/dashboard/admin/settings?tab=email');
const seen = webhookSeen?.value ? new Date(webhookSeen.value) : null;
add('whatsapp_stop', 'WhatsApp STOP replies reach the site', seen && now - seen < 30 * DAY,
seen ? `Last message from WAWP ${seen.toLocaleString('en-ZA')}${now - seen >= 30 * DAY ? ' — over a month ago, check the webhook' : ''}` : 'Paste the webhook URL from Site settings → WhatsApp into WAWP (events: message, session.status).',
'/dashboard/admin/settings?tab=whatsapp', whatsappConfigured);
const twoFactorOn = String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() !== 'false';
add('two_factor', 'Two-factor login required for admins and supervisors', twoFactorOn,
twoFactorOn ? 'Required' : 'TWO_FACTOR_REQUIRED_ROLES=false on the server — only for emergencies.', null);
const lastBackup = backups[0]?.createdAt;
add('backups', 'Backups running', lastBackup && now - new Date(lastBackup) < 2 * DAY,
lastBackup ? `Last backup ${new Date(lastBackup).toLocaleString('en-ZA')}` : 'No backups found on this server.',
'/dashboard/admin/settings?tab=backups');
const applicable = checks.filter(c => c.applies);
const passed = applicable.filter(c => c.ok).length;
return { score: applicable.length ? Math.round((passed / applicable.length) * 100) : 0, passed, total: applicable.length, checks };
}
module.exports = { readiness, GENERIC_OPERATORS };
+319
View File
@@ -0,0 +1,319 @@
/**
* POPIA retention: how long each kind of personal information is kept, and the nightly
* `retention` job that deletes or anonymises what's past its time.
*
* Policies live in AppSetting `retention_policies` (JSON) on top of the defaults below.
* Medical details (consent_medical_retention_days), visit statistics (90 days, fixed) and
* backups (backup_retain_count) have their own settings and jobs; they're shown alongside.
*/
const prisma = require('../config/db');
const SETTING_KEY = 'retention_policies';
const LAST_RUN_KEY = 'retention_last_run';
const WARNING_DAYS = 30;
const DAY = 24 * 60 * 60 * 1000;
// Spread a first big run over several nights instead of emailing everyone at once.
const MAX_WARNINGS_PER_RUN = 300;
const MAX_ANONYMISED_PER_RUN = 200;
const BATCH = 500;
const POLICIES = {
inactive_accounts: {
label: 'Inactive accounts',
description: 'Accounts with no login, registration or payment in this time are anonymised, after a warning 30 days before. Staff, supervisor and admin accounts are never touched, nor anyone with a registration for an upcoming event.',
defaults: { enabled: true, months: 60 },
minMonths: 12,
},
form_answers: {
label: 'Attendee form answers',
description: 'Answers to event forms are deleted this long after the event ends.',
defaults: { enabled: true, months: 12 },
minMonths: 1,
},
login_history: {
label: 'Login history',
description: 'Logins, password changes and similar account events (with IP address and device).',
defaults: { enabled: true, months: 24 },
minMonths: 3,
},
payment_details: {
label: 'Payer details in payment notifications',
description: 'Names and email addresses that Yoco and PayFast send with a payment are removed. The payment itself (amount, date, method) is always kept for the accounts.',
defaults: { enabled: true, months: 24 },
minMonths: 3,
},
audit_logs: {
label: 'Audit and access logs',
description: 'The audit log, the access log and "view as" sessions.',
defaults: { enabled: true, months: 60 },
minMonths: 12,
},
};
const MAX_MONTHS = 240;
function monthsAgo(months, now = new Date()) {
const d = new Date(now);
d.setMonth(d.getMonth() - months);
return d;
}
function describeMonths(months) {
return months % 12 === 0 ? `${months / 12} year${months === 12 ? '' : 's'}` : `${months} month${months === 1 ? '' : 's'}`;
}
/** The policies in force (defaults overlaid with what the admin saved), and when they were last reviewed. */
async function getPolicies(db = prisma) {
const row = await db.appSetting.findUnique({ where: { key: SETTING_KEY } });
let saved = {};
try { saved = row?.value ? JSON.parse(row.value) : {}; } catch { saved = {}; }
const policies = {};
for (const [key, def] of Object.entries(POLICIES)) {
const s = saved.policies?.[key] || {};
policies[key] = {
enabled: typeof s.enabled === 'boolean' ? s.enabled : def.defaults.enabled,
months: Number.isInteger(s.months) ? s.months : def.defaults.months,
};
}
return { policies, reviewedAt: saved.reviewedAt || null, reviewedBy: saved.reviewedBy || null };
}
/** Save the admin's policies (validated). Throws a message fit for the admin. */
async function savePolicies(input, { reviewedBy } = {}, db = prisma) {
const policies = {};
for (const [key, def] of Object.entries(POLICIES)) {
const p = input?.[key] || {};
const months = parseInt(p.months, 10);
if (!Number.isInteger(months) || months < def.minMonths || months > MAX_MONTHS) {
throw new Error(`${def.label}: choose between ${def.minMonths} and ${MAX_MONTHS} months.`);
}
policies[key] = { enabled: p.enabled !== false, months };
}
const value = JSON.stringify({ policies, reviewedAt: new Date().toISOString(), reviewedBy: reviewedBy || null });
await db.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value }, create: { key: SETTING_KEY, value } });
return getPolicies(db);
}
// ─── Inactive accounts ────────────────────────────────────────────────────────
/**
* Ordinary accounts (role user, not already anonymised) whose last sign of life — account
* created, last login, a registration (or the end of the event it was for), being named as
* an attendee, a payment — is before `before`.
* warned: false — not warned since that last activity (a warning before it no longer counts);
* otherwise — warned since their last activity, on or before `warnedBefore`.
*/
async function inactiveAccounts(before, { warned, warnedBefore, limit }, db = prisma) {
const rows = await db.$queryRawUnsafe(`
SELECT "id" FROM (
SELECT u."id", u."createdAt", u."inactivityWarnedAt" AS warned_at, GREATEST(
u."createdAt",
COALESCE(u."lastLoginAt", 'epoch'),
COALESCE((SELECT MAX(GREATEST(r."createdAt", e."endDate")) FROM "Registration" r JOIN "Event" e ON e."id" = r."eventId" WHERE r."userId" = u."id"), 'epoch'),
COALESCE((SELECT MAX(a."createdAt") FROM "Attendee" a WHERE a."userId" = u."id"), 'epoch'),
COALESCE((SELECT MAX(p."createdAt") FROM "Payment" p WHERE p."userId" = u."id"), 'epoch')
) AS last_active
FROM "User" u
WHERE u."role" = 'user'
AND u."email" NOT LIKE '%@deleted.invalid' AND u."email" NOT LIKE '%@deleted.local'
) x
WHERE last_active < $1
AND ${warned === false ? '(warned_at IS NULL OR warned_at < last_active)' : 'warned_at >= last_active AND warned_at <= $2'}
ORDER BY "createdAt"
LIMIT ${Math.max(1, Math.min(limit || BATCH, 5000))}`,
before, ...(warned === false ? [] : [warnedBefore]));
return rows.map(r => r.id);
}
async function warnInactive(userId, deleteOn, months, db = prisma) {
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, phoneNumber: true } });
if (!user) return false;
const { getSettingSync } = require('./settingsCache');
const org = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
const url = String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const when = deleteOn.toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Africa/Johannesburg' });
const text = `Your ${org} account hasn't been used for ${describeMonths(months)}. To protect your personal information, we'll delete it on ${when}. If you'd like to keep your account, just log in before then: ${url}/login`;
const realEmail = user.email && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(user.email);
if (realEmail) {
const { sendMail, buildPrivacyEmail } = require('./email');
await sendMail({
to: user.email,
subject: `Your ${org} account will be deleted on ${when}`,
...buildPrivacyEmail({ name: user.name, heading: 'Your account will be deleted', paragraphs: [text, 'If you do nothing, your account is closed and your personal information deleted. Payment records are kept without your name, as the law requires.'] }),
}).catch(() => {});
} else {
const { waTextAny } = require('./notify');
await waTextAny(user, text);
}
await db.user.update({ where: { id: userId }, data: { inactivityWarnedAt: new Date() } });
return true;
}
async function runInactiveAccounts(policy, now, db = prisma) {
const cutoff = monthsAgo(policy.months, now);
const deleteOn = new Date(now.getTime() + WARNING_DAYS * DAY);
const { anonymiseUser, deletionBlockers } = require('./privacy');
// Warn those who'll pass the limit within the warning period.
const toWarn = await inactiveAccounts(new Date(cutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: MAX_WARNINGS_PER_RUN }, db);
let warned = 0;
for (const id of toWarn) if (await warnInactive(id, deleteOn, policy.months, db)) warned++;
// Anonymise those past the limit who were warned at least 30 days ago and still haven't come back.
const due = await inactiveAccounts(cutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: MAX_ANONYMISED_PER_RUN }, db);
let anonymised = 0;
let waiting = 0;
for (const id of due) {
if ((await deletionBlockers(id, db)).length) { waiting++; continue; }
await anonymiseUser(id, db);
await db.dataRequest.updateMany({ where: { userId: id, type: 'deletion', status: 'open' }, data: { status: 'completed', closedAt: now, response: 'Deleted after a long period without use.' } });
anonymised++;
}
return { warned, anonymised, waiting };
}
// ─── The other policies ───────────────────────────────────────────────────────
async function runFormAnswers(policy, now, db = prisma) {
const cutoff = monthsAgo(policy.months, now);
const where = { response: { registration: { event: { endDate: { lt: cutoff } } } } };
const answers = await db.formAnswer.deleteMany({ where });
const drafts = await db.formDraft.deleteMany({ where: { registration: { event: { endDate: { lt: cutoff } } } } });
return { answers: answers.count, drafts: drafts.count };
}
async function runLoginHistory(policy, now, db = prisma) {
const res = await db.securityEvent.deleteMany({ where: { createdAt: { lt: monthsAgo(policy.months, now) } } });
return { deleted: res.count };
}
// Rows not scrubbed yet carry no "_scrubbed" key; scrubbed ones get it so they're skipped next time.
async function scrubJsonColumn(table, column, cutoff, db) {
const { scrubPersonal } = require('./privacy');
let total = 0;
for (;;) {
const rows = await db.$queryRawUnsafe(
`SELECT "id", "${column}" AS data FROM "${table}" WHERE "createdAt" < $1 AND "${column}" IS NOT NULL AND jsonb_typeof("${column}") = 'object' AND NOT ("${column}" ? '_scrubbed') LIMIT ${BATCH}`,
cutoff,
);
if (!rows.length) break;
for (const r of rows) {
const model = table === 'ProviderTransaction' ? db.providerTransaction : db.providerCheckout;
await model.update({ where: { id: r.id }, data: { [column]: { ...scrubPersonal(r.data), _scrubbed: true } } });
}
total += rows.length;
if (rows.length < BATCH) break;
}
return total;
}
async function runPaymentDetails(policy, now, db = prisma) {
const cutoff = monthsAgo(policy.months, now);
return {
notifications: await scrubJsonColumn('ProviderTransaction', 'raw', cutoff, db),
checkouts: await scrubJsonColumn('ProviderCheckout', 'request', cutoff, db),
};
}
async function runAuditLogs(policy, now, db = prisma) {
const cutoff = monthsAgo(policy.months, now);
const [audit, access, sessions] = await Promise.all([
db.adminAuditLog.deleteMany({ where: { createdAt: { lt: cutoff } } }),
db.dataAccessLog.deleteMany({ where: { createdAt: { lt: cutoff } } }),
db.impersonationSession.deleteMany({ where: { startedAt: { lt: cutoff } } }),
]);
return { audit: audit.count, access: access.count, viewAsSessions: sessions.count };
}
const RUNNERS = {
inactive_accounts: runInactiveAccounts,
form_answers: runFormAnswers,
login_history: runLoginHistory,
payment_details: runPaymentDetails,
audit_logs: runAuditLogs,
};
/** The nightly job: apply every switched-on policy. */
async function runRetention(now = new Date(), db = prisma) {
const { policies } = await getPolicies(db);
const result = {};
for (const [key, policy] of Object.entries(policies)) {
if (!policy.enabled) continue;
try {
result[key] = await RUNNERS[key](policy, now, db);
} catch (e) {
result[key] = { error: e?.message || String(e) };
console.warn(`[retention] ${key} failed:`, e?.message || e);
}
}
const value = JSON.stringify({ at: now.toISOString(), result });
await db.appSetting.upsert({ where: { key: LAST_RUN_KEY }, update: { value }, create: { key: LAST_RUN_KEY, value } });
const { isNoop } = require('../jobs');
if (!isNoop(result)) {
require('./adminAudit').logAdminAction({ actorId: null, actorRole: 'system', action: 'retention_run', targetType: 'retention', metadata: result });
}
return result;
}
async function lastRun(db = prisma) {
const row = await db.appSetting.findUnique({ where: { key: LAST_RUN_KEY } });
try { return row?.value ? JSON.parse(row.value) : null; } catch { return null; }
}
/** How much each policy would affect if the job ran now — for the admin page. */
async function preview(policies, now = new Date(), db = prisma) {
const out = {};
const p = policies;
const inactiveCutoff = monthsAgo(p.inactive_accounts.months, now);
const [toWarn, warnedDue, formAnswers, logins, audit] = await Promise.all([
inactiveAccounts(new Date(inactiveCutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: 5000 }, db),
inactiveAccounts(inactiveCutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: 5000 }, db),
db.formAnswer.count({ where: { response: { registration: { event: { endDate: { lt: monthsAgo(p.form_answers.months, now) } } } } } }),
db.securityEvent.count({ where: { createdAt: { lt: monthsAgo(p.login_history.months, now) } } }),
db.adminAuditLog.count({ where: { createdAt: { lt: monthsAgo(p.audit_logs.months, now) } } }),
]);
const payments = await db.$queryRawUnsafe(
`SELECT COUNT(*)::int AS n FROM "ProviderTransaction" WHERE "createdAt" < $1 AND "raw" IS NOT NULL AND jsonb_typeof("raw") = 'object' AND NOT ("raw" ? '_scrubbed')`,
monthsAgo(p.payment_details.months, now),
);
out.inactive_accounts = { toWarn: toWarn.length, toAnonymise: warnedDue.length };
out.form_answers = { count: formAnswers };
out.login_history = { count: logins };
out.payment_details = { count: payments[0]?.n || 0 };
out.audit_logs = { count: audit };
return out;
}
/** One line per policy for the privacy notice's {retention} placeholder. */
async function noticeLines(db = prisma) {
const { policies } = await getPolicies(db);
const { getSetting } = require('./settingsCache');
const [medical, backups] = await Promise.all([getSetting('consent_medical_retention_days', ''), getSetting('backup_retain_count', '')]);
const line = (key, on, off) => (policies[key].enabled ? on(describeMonths(policies[key].months)) : off);
return [
line('inactive_accounts', t => `• Accounts with no login, registration or payment for ${t} are deleted, after a warning 30 days before.`, '• Accounts are kept until you ask us to delete them.'),
line('form_answers', t => `• Answers to event forms are deleted ${t} after the event.`, '• Answers to event forms are kept until you ask us to delete them.'),
`• Medical and emergency-contact details from parental consent forms are deleted ${parseInt(medical, 10) || 30} days after the event. The record of who gave consent, when and to which wording is kept.`,
line('login_history', t => `• Login history (time, IP address, device) is kept for ${t}.`, '• Login history is kept until you ask us to delete it.'),
line('payment_details', t => `• Payer names and email addresses received with payments are removed after ${t}. Payment records themselves (amount, date, method) are kept as tax law requires.`, '• Payment records are kept as tax law requires.'),
line('audit_logs', t => `• Audit and access records (who did what in the system) are kept for ${t}.`, '• Audit and access records are kept for accountability.'),
'• Detailed visit statistics become anonymous daily totals after 90 days.',
`• Database backups are kept for about ${parseInt(backups, 10) || 14} days, so deleted information stays in them until they are replaced.`,
];
}
module.exports = {
POLICIES,
WARNING_DAYS,
getPolicies,
savePolicies,
inactiveAccounts,
runRetention,
lastRun,
preview,
noticeLines,
describeMonths,
monthsAgo,
};
+29
View File
@@ -297,7 +297,36 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000); setTimeout(() => { try { fs.unlinkSync(tempPath); } catch {} }, 5 * 60 * 1000);
} }
/**
* WAWP doesn't sign webhooks, so the webhook URL carries a secret (?secret=…) and incoming
* messages are only acted on when it matches. Made on first use and kept in AppSetting.
*/
async function getWebhookSecret() {
const prisma = require('../config/db');
const row = await prisma.appSetting.findUnique({ where: { key: 'WAWP_WEBHOOK_SECRET' } });
if (row?.value) return row.value;
const secret = require('crypto').randomBytes(24).toString('base64url');
await prisma.appSetting.upsert({ where: { key: 'WAWP_WEBHOOK_SECRET' }, update: {}, create: { key: 'WAWP_WEBHOOK_SECRET', value: secret } });
const saved = await prisma.appSetting.findUnique({ where: { key: 'WAWP_WEBHOOK_SECRET' } });
return saved.value;
}
/** The URL to paste into WAWP's webhook settings (events: message, session.status). */
async function webhookUrl() {
const base = String(process.env.BACKEND_URL || process.env.API_URL || 'http://localhost:5000').replace(/\/$/, '');
return `${base}/api/whatsapp/webhook?secret=${encodeURIComponent(await getWebhookSecret())}`;
}
/** The sender's phone (27…) from a WAWP chat id, or null for groups, channels and LIDs. */
function phoneFromChatId(chatId) {
const m = String(chatId || '').match(/^(\d+)@c\.us$/);
return m ? normalizeZAPhone(m[1]) : null;
}
module.exports = { module.exports = {
getWebhookSecret,
webhookUrl,
phoneFromChatId,
getConfig, getConfig,
setConfig, setConfig,
normalizeZAPhone, normalizeZAPhone,
+136
View File
@@ -0,0 +1,136 @@
jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() },
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
payment: { findFirst: jest.fn(), create: jest.fn() },
providerTransaction: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { findMany: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn() },
ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() },
}));
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
jest.mock('../src/controllers/ticketController', () => ({ emailTickets: jest.fn() }));
const crypto = require('crypto');
const prisma = require('../src/config/db');
const settingsCache = require('../src/utils/settingsCache');
const { computeRegistrationTotalDue } = require('../src/utils/pricing');
const payfast = require('../src/payments/providers/payfast');
const status = require('../src/payments/status');
const PF = { payfast_merchant_id: '10000100', payfast_merchant_key: 'abcdefghij123', payfast_passphrase: 'Salt pass', payfast_mode: 'sandbox' };
function settings(values) {
prisma.appSetting.findMany.mockResolvedValue(Object.entries(values).map(([key, value]) => ({ key, value })));
settingsCache.invalidate();
}
// As PayFast's sandbox returned it (CRLF lines, quoted amounts with thousands separators)
const CSV = [
'Date,Type,Sign,Party,Name,Description,Currency,"Funding Type",Gross,Fee,Net,Balance,"M Payment ID","PF Payment ID","custom str1"',
'"2026-10-23 14:19:24",FUNDS_RECEIVED,CREDIT,"Test User 01","Family Fun Day",Adult,ZAR,WALLET,80.00,-2.30,77.70,"22,574.02",chk_a,3471967,',
'"2026-10-23 15:00:00",FUNDS_RECEIVED,CREDIT,"Test User 01","Big ""Camp""",,ZAR,CC,"1,250.00",-20.00,"1,230.00",,chk_b,3471999,',
'"2026-10-23 16:00:00",REFUND,DEBIT,"Test User 01",x,,ZAR,CC,-80.00,0,-80.00,,chk_c,3472000,',
].join('\r\n');
beforeEach(() => {
jest.clearAllMocks();
settings(PF);
});
describe('PayFast API', () => {
test('requests are signed over the alphabetically sorted variables and passphrase', () => {
const md5 = (s) => crypto.createHash('md5').update(s).digest('hex');
expect(payfast.apiSignature({ version: 'v1', 'merchant-id': '10000100', timestamp: '2026-10-23T10:00:00+00:00', passphrase: 'Salt pass', from: '' }))
.toBe(md5('merchant-id=10000100&passphrase=Salt+pass&timestamp=2026-10-23T10%3A00%3A00%2B00%3A00&version=v1'));
});
test('reads PayFast CSV, quoted commas and quotes included', () => {
const rows = payfast.parseCsv(CSV);
expect(rows).toHaveLength(3);
expect(rows[0]).toMatchObject({ Type: 'FUNDS_RECEIVED', Gross: '80.00', Balance: '22,574.02', 'M Payment ID': 'chk_a', 'PF Payment ID': '3471967' });
expect(rows[1]).toMatchObject({ Name: 'Big "Camp"', Gross: '1,250.00' });
});
test('findPayments matches history rows on our checkout id (money received only)', async () => {
const get = jest.fn().mockResolvedValue({ data: CSV });
const list = ['chk_a', 'chk_b', 'chk_c'].map(id => ({ id, createdAt: new Date() }));
const found = await payfast.findPayments(list, { get });
expect([...found.keys()]).toEqual(['chk_a', 'chk_b']);
expect(found.get('chk_a')).toMatchObject({ externalId: '3471967', amountCents: 8000, checkoutId: 'chk_a', status: 'succeeded' });
expect(found.get('chk_a').createdDate.toISOString()).toBe('2026-10-23T12:19:24.000Z');
expect(found.get('chk_b').amountCents).toBe(125000);
const [url, opts] = get.mock.calls[0];
expect(url).toBe('https://api.payfast.co.za/transactions/history');
expect(opts.params).toMatchObject({ testing: 'true', limit: 1000, offset: 0 });
expect(opts.headers).toMatchObject({ 'merchant-id': '10000100', version: 'v1', signature: expect.any(String) });
});
test('live mode leaves out testing=true', async () => {
settings({ ...PF, payfast_mode: 'live' });
const get = jest.fn().mockResolvedValue({ data: CSV });
await payfast.findPayments([{ id: 'chk_a', createdAt: new Date() }], { get });
expect(get.mock.calls[0][1].params.testing).toBeUndefined();
});
test('a refused passphrase reads clearly', async () => {
const get = jest.fn().mockRejectedValue({ response: { status: 401, data: '{"data":{"response":"Merchant authorization failed."}}' } });
await expect(payfast.testConnection({ get })).rejects.toThrow(/refused the merchant ID or passphrase/);
});
});
describe('checkCheckouts', () => {
const checkout = (id, provider, cents) => ({ id, provider, externalId: null, amountCents: cents, metadata: { registrationId: 'r9', userId: 'u9' }, createdAt: new Date() });
let spy;
beforeEach(() => {
prisma.providerTransaction.findFirst.mockResolvedValue(null);
prisma.registration.findUnique.mockResolvedValue({ id: 'r9', status: 'pending', registrationOptions: [], payments: [{ amount: 80 }] });
prisma.registration.findFirst.mockResolvedValue({ id: 'r9', userId: 'u9', eventId: 'e9', status: 'pending', event: { id: 'e9', title: 'Fun Day' }, user: { id: 'u9', name: 'T' } });
prisma.registration.update.mockImplementation(({ data }) => Promise.resolve({ id: 'r9', ...data }));
computeRegistrationTotalDue.mockReturnValue(80);
prisma.payment.findFirst.mockResolvedValue(null);
prisma.payment.create.mockImplementation(({ data }) => Promise.resolve({ createdAt: new Date(), ...data }));
prisma.providerTransaction.findUnique.mockResolvedValue(null);
prisma.providerTransaction.create.mockImplementation(({ data }) => Promise.resolve({ id: 'ptx', ...data }));
prisma.ticket.findMany.mockResolvedValue([]);
spy = jest.spyOn(payfast, 'findPayments');
});
afterEach(() => spy.mockRestore());
test('a payment PayFast received is recorded as its ITN would have been', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '3471967', amountCents: 8000, currency: 'ZAR', createdDate: new Date(), checkoutId: 'chk_a', methodType: null, metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000)]);
expect(out.get('chk_a')).toEqual({ status: 'recorded' });
expect(prisma.payment.create.mock.calls[0][0].data).toMatchObject({ amount: 80, provider: 'payfast', externalId: '3471967', registrationId: 'r9', status: 'completed' });
expect(prisma.registration.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { checkoutId: { in: ['chk_a'] } } }));
expect(prisma.providerTransaction.create).toHaveBeenCalledWith({ data: expect.objectContaining({ provider: 'payfast', externalId: '3471967', reconciled: true }) });
});
test('a different amount is held for review; nothing found is not paid', async () => {
spy.mockResolvedValue(new Map([['chk_a', { externalId: '1', amountCents: 2000, checkoutId: 'chk_a', metadata: {}, status: 'succeeded' }]]));
const out = await status.checkCheckouts([checkout('chk_a', 'payfast', 8000), checkout('chk_n', 'payfast', 5000)]);
expect(out.get('chk_a')).toMatchObject({ status: 'needs_review', note: expect.stringMatching(/Paid R20.00/) });
expect(out.get('chk_n')).toEqual({ status: 'not_paid' });
expect(prisma.payment.create).not.toHaveBeenCalled();
});
test('already paid checkouts are skipped; Yoco can\'t be asked', async () => {
prisma.providerTransaction.findFirst.mockImplementation(({ where }) => Promise.resolve(where.checkoutId.in.includes('chk_paid') && where.paymentId ? { id: 'x' } : null));
spy.mockResolvedValue(new Map());
const out = await status.checkCheckouts([checkout('chk_paid', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
expect(out.get('chk_paid')).toEqual({ status: 'paid' });
expect(out.get('chk_y')).toEqual({ status: 'cant_check' });
expect(spy).not.toHaveBeenCalled();
});
test('the job checks checkouts between 3 days and 10 minutes old, with providers that can be asked', async () => {
const now = new Date('2026-10-23T12:00:00Z');
prisma.providerCheckout.findMany.mockResolvedValue([checkout('chk_a', 'payfast', 8000), checkout('chk_y', 'yoco', 8000)]);
spy.mockResolvedValue(new Map());
await expect(status.runPaymentStatusJob(now)).resolves.toEqual({ checked: 1, recorded: 0, needsReview: 0 });
expect(prisma.providerCheckout.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: { provider: { not: null }, createdAt: { gte: new Date('2026-10-20T12:00:00Z'), lte: new Date('2026-10-23T11:50:00Z') } },
}));
expect(spy).toHaveBeenCalledWith([expect.objectContaining({ id: 'chk_a' })]);
});
});
+3 -3
View File
@@ -2,12 +2,12 @@ jest.mock('../src/config/db', () => ({
appSetting: { findMany: jest.fn() }, appSetting: { findMany: jest.fn() },
registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() }, registration: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn() },
payment: { findFirst: jest.fn(), create: jest.fn() }, payment: { findFirst: jest.fn(), create: jest.fn() },
providerTransaction: { findUnique: jest.fn(), create: jest.fn(), update: jest.fn() }, providerTransaction: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn() }, providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: jest.fn(), findMany: jest.fn() },
ticket: { findMany: jest.fn() }, ticket: { findMany: jest.fn() },
user: { findFirst: jest.fn() }, user: { findFirst: jest.fn() },
})); }));
jest.mock('axios', () => { const fn = jest.fn(); fn.post = jest.fn(); return fn; }); jest.mock('axios', () => { const fn = jest.fn(); fn.post = jest.fn(); fn.get = jest.fn(); return fn; });
jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() })); jest.mock('../src/utils/pricing', () => ({ computeRegistrationTotalDue: jest.fn(), refreshPricingForRegistration: jest.fn() }));
jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) })); jest.mock('../src/utils/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() })); jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
+161
View File
@@ -0,0 +1,161 @@
jest.mock('../src/config/db', () => ({
user: { findUnique: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
communicationConsent: { createMany: jest.fn(), create: jest.fn() },
messagingOptOut: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn() },
registration: { findMany: jest.fn() },
}));
process.env.JWT_SECRET = 'test-secret';
process.env.FRONTEND_URL = 'https://events.example.org';
process.env.BACKEND_URL = 'https://api.example.org';
const prisma = require('../src/config/db');
const privacy = require('../src/utils/privacy');
const { phoneFromChatId } = require('../src/utils/whatsapp');
beforeEach(() => {
jest.clearAllMocks();
prisma.messagingOptOut.findMany.mockResolvedValue([]);
});
describe('unsubscribe tokens', () => {
test('round-trip an account and an address', () => {
expect(privacy.readUnsubscribeToken(privacy.unsubscribeToken({ userId: 'u1' }))).toEqual({ userId: 'u1' });
const t = privacy.unsubscribeToken({ address: 'a@b.co', channel: 'email' });
expect(privacy.readUnsubscribeToken(t)).toEqual({ address: 'a@b.co', channel: 'email' });
});
test('a tampered token is refused', () => {
const [, sig] = privacy.unsubscribeToken({ userId: 'u1' }).split('.');
const forged = `${Buffer.from(JSON.stringify({ u: 'someone-else' })).toString('base64url')}.${sig}`;
expect(privacy.readUnsubscribeToken(forged)).toBeNull();
expect(privacy.readUnsubscribeToken('garbage')).toBeNull();
expect(privacy.readUnsubscribeToken('')).toBeNull();
});
test('an address token needs a known channel', () => {
const payload = Buffer.from(JSON.stringify({ a: 'x@y.z', c: 'sms' })).toString('base64url');
const real = privacy.unsubscribeToken({ address: 'x@y.z', channel: 'email' });
expect(privacy.readUnsubscribeToken(`${payload}.${real.split('.')[1]}`)).toBeNull();
});
});
describe('filterRecipients', () => {
const users = [
{ id: 'in', email: 'in@x.co', phoneNumber: '27820000001', newsEmail: true, newsWhatsApp: true },
{ id: 'out', email: 'Out@x.co', phoneNumber: '27820000002', newsEmail: false, newsWhatsApp: false },
];
test('updates go to everyone, with the account filled in', async () => {
prisma.user.findMany.mockResolvedValue(users);
const res = await privacy.filterRecipients([{ email: 'in@x.co' }, { email: 'out@x.co' }, { email: 'guest@x.co' }], { channel: 'email', kind: 'update' });
expect(res.skipped).toBe(0);
expect(res.recipients.map(r => r.userId)).toEqual(['in', 'out', null]);
expect(prisma.messagingOptOut.findMany).not.toHaveBeenCalled();
});
test('news skips accounts without the opt-in (any letter case) and unsubscribed addresses', async () => {
prisma.user.findMany.mockResolvedValue(users);
prisma.messagingOptOut.findMany.mockResolvedValue([{ address: 'gone@x.co' }]);
const res = await privacy.filterRecipients(
[{ email: 'in@x.co' }, { email: 'OUT@x.co' }, { email: 'gone@x.co' }, { email: 'new@x.co' }],
{ channel: 'email', kind: 'news' },
);
expect(res.recipients.map(r => r.email)).toEqual(['in@x.co', 'new@x.co']);
expect(res.skipped).toBe(2);
});
test('WhatsApp news uses the WhatsApp opt-in and normalised numbers', async () => {
prisma.user.findMany.mockResolvedValue(users);
const res = await privacy.filterRecipients([{ phone: '082 000 0001' }, { phone: '0820000002' }], { channel: 'whatsapp', kind: 'news' });
expect(res.recipients).toHaveLength(1);
expect(res.recipients[0].userId).toBe('in');
});
});
describe('setNewsPreferences', () => {
test('logs only the channels that changed, and WhatsApp needs a phone', async () => {
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: null, newsEmail: true, newsWhatsApp: false });
prisma.user.update.mockResolvedValue({ newsEmail: false, newsWhatsApp: false, newsAskedAt: null });
await privacy.setNewsPreferences('u1', { email: false, whatsapp: true }, { source: 'privacy_centre' });
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({ data: { newsEmail: false } }));
expect(prisma.communicationConsent.createMany).toHaveBeenCalledWith({
data: [{ userId: 'u1', channel: 'email', granted: false, source: 'privacy_centre', ip: null }],
});
});
test('the first answer is logged even when nothing changes', async () => {
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: '27820000001', newsEmail: true, newsWhatsApp: true });
prisma.user.update.mockResolvedValue({ newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() });
await privacy.setNewsPreferences('u1', { email: true, whatsapp: true }, { source: 'prompt', answered: true, logAll: true });
expect(prisma.user.update.mock.calls[0][0].data.newsAskedAt).toBeInstanceOf(Date);
expect(prisma.communicationConsent.createMany.mock.calls[0][0].data).toHaveLength(2);
});
});
describe('footers', () => {
test('news emails carry an unsubscribe link and one-click headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'news', recipient: { email: 'a@b.co', userId: 'u1' } });
expect(f.html).toContain('https://events.example.org/unsubscribe?t=');
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/privacy\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
});
test('update emails explain why and link to the preferences, without unsubscribe headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'update', recipient: { email: 'a@b.co' }, eventTitle: 'Camp <2026>' });
expect(f.html).toContain('you registered for Camp &lt;2026&gt;');
expect(f.html).toContain('/dashboard/user/privacy');
expect(f.headers).toEqual({});
});
test('news WhatsApps mention STOP', () => {
expect(privacy.whatsAppNewsFooter({ phone: '0820000001' })).toMatch(/Reply STOP/);
});
});
describe('WhatsApp STOP', () => {
test.each(['STOP', 'stop', ' Stop. ', 'unsubscribe', 'Opt out'])('%p is an opt-out', text => {
expect(privacy.isStopKeyword(text)).toBe(true);
});
test.each(['please stop by the office', 'STOPPED', '', null])('%p is not', text => {
expect(privacy.isStopKeyword(text)).toBe(false);
});
test('phone numbers only from personal chats', () => {
expect(phoneFromChatId('27821234567@c.us')).toBe('27821234567');
expect(phoneFromChatId('1203630@g.us')).toBeNull();
expect(phoneFromChatId('99887766@lid')).toBeNull();
});
test('an account number turns off WhatsApp news; an unknown number is opted out', async () => {
prisma.user.findFirst.mockResolvedValueOnce({ id: 'u1' });
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: '27821234567', newsEmail: true, newsWhatsApp: true });
prisma.user.update.mockResolvedValue({});
expect(await privacy.whatsAppStop('27821234567')).toBe('account');
expect(prisma.user.update.mock.calls[0][0].data).toEqual({ newsWhatsApp: false });
prisma.user.findFirst.mockResolvedValueOnce(null);
prisma.messagingOptOut.findUnique.mockResolvedValueOnce(null);
expect(await privacy.whatsAppStop('0829999999')).toBe('address');
expect(prisma.messagingOptOut.create).toHaveBeenCalledWith({ data: { channel: 'whatsapp', address: '27829999999', source: 'whatsapp_stop' } });
});
});
describe('anonymising helpers', () => {
test('scrubPersonal removes payer details from provider payloads, keeps amounts and ids', () => {
const raw = { m_payment_id: 'chk_1', pf_payment_id: '123', amount_gross: '80.00', name_first: 'Ann', name_last: 'Lee', email_address: 'ann@x.co', nested: { customer: 'Ann Lee', id: 'x' } };
expect(privacy.scrubPersonal(raw)).toEqual({
m_payment_id: 'chk_1', pf_payment_id: '123', amount_gross: '80.00',
name_first: '[deleted]', name_last: '[deleted]', email_address: '[deleted]', nested: { customer: '[deleted]', id: 'x' },
});
});
test('deletion is blocked by registrations for events that have not ended', async () => {
prisma.registration.findMany.mockResolvedValue([{ id: 'r1', status: 'paid', event: { id: 'e1', title: 'Camp', endDate: new Date('2099-01-01') } }]);
const blockers = await privacy.deletionBlockers('u1');
expect(blockers).toEqual([{ registrationId: 'r1', status: 'paid', eventId: 'e1', eventTitle: 'Camp', endDate: new Date('2099-01-01') }]);
const where = prisma.registration.findMany.mock.calls[0][0].where;
expect(where.status).toEqual({ not: 'cancelled' });
expect(where.event.endDate.gte).toBeInstanceOf(Date);
});
});
+109
View File
@@ -0,0 +1,109 @@
jest.mock('../src/config/db', () => ({
privacyNotice: { findFirst: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
operator: { findMany: jest.fn() },
appSetting: { findMany: jest.fn().mockResolvedValue([]), findUnique: jest.fn() },
}));
const prisma = require('../src/config/db');
const notice = require('../src/utils/privacyNotice');
const { reportRowCount } = require('../src/utils/accessLog');
const ctx = {
org: 'Hope Church', orgEmail: 'office@hope.test', website: 'hope.test', ioName: 'Ann IO', ioEmail: 'io@hope.test', medicalRetentionDays: '30',
operators: [
{ name: 'PayFast', location: 'South Africa', purpose: 'Online payments', dataShared: 'Name, email address' },
{ name: 'WAWP', location: null, purpose: 'Delivering WhatsApp messages', dataShared: 'Mobile number' },
],
};
beforeEach(() => jest.clearAllMocks());
describe('renderSections', () => {
test('fills placeholders and expands {operators} into one bullet each', () => {
const [s] = notice.renderSections([{ id: 'a', title: 'A', body: 'Run by {org} — ask {ioName} ({ioEmail}).\n{operators}\nUnknown {nope} stays.' }], ctx);
expect(s.body.split('\n')).toEqual([
'Run by Hope Church — ask Ann IO (io@hope.test).',
'• PayFast (South Africa) — Online payments. Receives: Name, email address.',
'• WAWP — Delivering WhatsApp messages. Receives: Mobile number.',
'Unknown {nope} stays.',
]);
});
test('{operators} inside a sentence is left alone', () => {
const [s] = notice.renderSections([{ id: 'a', title: 'A', body: 'See {operators} below' }], ctx);
expect(s.body).toBe('See {operators} below');
});
test('the built-in notice has no unfilled placeholders', () => {
const rendered = notice.renderSections(notice.defaultSections(), ctx);
for (const s of rendered) expect(s.body).not.toMatch(/\{\w+\}/);
expect(rendered.find(s => s.id === 'sharing').body).toContain('• PayFast (South Africa)');
});
});
describe('cleanSections', () => {
test('trims, makes ids from titles and keeps them unique', () => {
const out = notice.cleanSections([
{ title: ' 1. Introduction ', body: 'Hi\r\nthere ' },
{ title: 'Introduction', body: '' },
]);
expect(out).toEqual([
{ id: 'introduction', title: '1. Introduction', body: 'Hi\nthere' },
{ id: 'introduction-2', title: 'Introduction', body: '' },
]);
});
test('refuses an empty notice or a section without a title', () => {
expect(() => notice.cleanSections([])).toThrow(/at least one section/);
expect(() => notice.cleanSections([{ title: ' ', body: 'x' }])).toThrow(/needs a title/);
});
});
describe('noticePromptPending', () => {
test('nobody is asked while no version needs re-acceptance', async () => {
prisma.privacyNotice.findFirst.mockResolvedValue(null);
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(false);
});
test('asked when they accepted an older version than the newest one that needs it', async () => {
prisma.privacyNotice.findFirst.mockResolvedValue({ version: 3 });
expect(await notice.noticePromptPending({ noticeVersionAccepted: 2 })).toBe(true);
expect(await notice.noticePromptPending({ noticeVersionAccepted: null })).toBe(true);
expect(await notice.noticePromptPending({ noticeVersionAccepted: 3 })).toBe(false);
expect(await notice.noticePromptPending({ noticeVersionAccepted: 5 })).toBe(false);
expect(prisma.privacyNotice.findFirst.mock.calls[0][0].where).toEqual({ requiresAcceptance: true });
});
});
describe('publicNotice', () => {
beforeEach(() => prisma.operator.findMany.mockResolvedValue([]));
test('the built-in notice is version 0 while nothing is published', async () => {
prisma.privacyNotice.findMany.mockResolvedValue([]);
const n = await notice.publicNotice(null);
expect(n.version).toBe(0);
expect(n.builtIn).toBe(true);
expect(n.sections.length).toBeGreaterThan(10);
});
test('once published, version 0 is no longer available as a past version', async () => {
prisma.privacyNotice.findMany.mockResolvedValue([{ version: 1, publishedAt: new Date() }]);
expect(await notice.publicNotice(0)).toBeNull();
});
test('a published version, marked current only when it is the newest', async () => {
prisma.privacyNotice.findMany.mockResolvedValue([{ version: 2 }, { version: 1 }]);
prisma.privacyNotice.findUnique.mockResolvedValue({ version: 1, publishedAt: new Date('2026-10-01T10:00:00Z'), changeSummary: 'x', sections: [{ id: 'a', title: 'A', body: 'Hello' }] });
const n = await notice.publicNotice(1);
expect(n).toMatchObject({ version: 1, builtIn: false, current: false, changeSummary: 'x' });
expect(n.sections[0].body).toBe('Hello');
});
});
describe('reportRowCount', () => {
test('counts table rows, layered items and extra tables', () => {
expect(reportRowCount({ table: { rows: [[1], [2]] }, extraTables: [{ rows: [[1]] }] })).toBe(3);
expect(reportRowCount({ layered: { sections: [{ items: ['a', 'b'] }, { items: ['c'] }] } })).toBe(3);
expect(reportRowCount(null)).toBe(0);
});
});
+85
View File
@@ -0,0 +1,85 @@
jest.mock('../src/config/db', () => ({
appSetting: { findUnique: jest.fn(), upsert: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
$queryRawUnsafe: jest.fn(),
}));
const prisma = require('../src/config/db');
const retention = require('../src/utils/retention');
function saved(value) {
prisma.appSetting.findUnique.mockResolvedValue(value === undefined ? null : { value: JSON.stringify(value) });
}
beforeEach(() => jest.clearAllMocks());
describe('policies', () => {
test('defaults: inactive accounts after 5 years, on', async () => {
saved(undefined);
const { policies, reviewedAt } = await retention.getPolicies();
expect(policies.inactive_accounts).toEqual({ enabled: true, months: 60 });
expect(policies.form_answers).toEqual({ enabled: true, months: 12 });
expect(reviewedAt).toBeNull();
});
test('saved values override the defaults, one policy at a time', async () => {
saved({ policies: { form_answers: { enabled: false, months: 6 } }, reviewedAt: '2026-10-23T00:00:00.000Z' });
const { policies, reviewedAt } = await retention.getPolicies();
expect(policies.form_answers).toEqual({ enabled: false, months: 6 });
expect(policies.login_history).toEqual({ enabled: true, months: 24 });
expect(reviewedAt).toBe('2026-10-23T00:00:00.000Z');
});
test('saving refuses periods below the minimum or silly ones', async () => {
const ok = Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { ...d.defaults }]));
await expect(retention.savePolicies({ ...ok, inactive_accounts: { enabled: true, months: 6 } })).rejects.toThrow(/Inactive accounts: choose between 12 and 240/);
await expect(retention.savePolicies({ ...ok, form_answers: { enabled: true, months: 'x' } })).rejects.toThrow(/Attendee form answers/);
saved(undefined);
await retention.savePolicies(ok, { reviewedBy: 'Ada' });
const value = JSON.parse(prisma.appSetting.upsert.mock.calls[0][0].create.value);
expect(value.reviewedBy).toBe('Ada');
expect(value.policies.audit_logs).toEqual({ enabled: true, months: 60 });
});
});
describe('describing periods', () => {
test('whole years read as years', () => {
expect(retention.describeMonths(60)).toBe('5 years');
expect(retention.describeMonths(12)).toBe('1 year');
expect(retention.describeMonths(18)).toBe('18 months');
expect(retention.describeMonths(1)).toBe('1 month');
});
test('monthsAgo goes back calendar months', () => {
expect(retention.monthsAgo(24, new Date('2026-10-23T12:00:00Z')).toISOString()).toBe('2024-10-23T12:00:00.000Z');
});
test('the notice lines follow the policies, and say so when one is off', async () => {
saved({ policies: { inactive_accounts: { enabled: false, months: 60 }, form_answers: { enabled: true, months: 24 } } });
const lines = await retention.noticeLines();
expect(lines[0]).toBe('• Accounts are kept until you ask us to delete them.');
expect(lines[1]).toBe('• Answers to event forms are deleted 2 years after the event.');
expect(lines.every(l => l.startsWith('• '))).toBe(true);
});
});
describe('inactive accounts query', () => {
test('not-yet-warned: a warning older than the last activity no longer counts', async () => {
prisma.$queryRawUnsafe.mockResolvedValue([{ id: 'u1' }]);
const before = new Date('2021-10-23');
expect(await retention.inactiveAccounts(before, { warned: false, limit: 10 })).toEqual(['u1']);
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
expect(sql).toMatch(/warned_at IS NULL OR warned_at < last_active/);
expect(sql).toMatch(/u\."role" = 'user'/);
expect(params).toEqual([before]);
});
test('due for deletion: warned since the last activity, long enough ago', async () => {
prisma.$queryRawUnsafe.mockResolvedValue([]);
const before = new Date('2021-10-23');
const warnedBefore = new Date('2026-09-23');
await retention.inactiveAccounts(before, { warnedBefore });
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
expect(sql).toMatch(/warned_at >= last_active AND warned_at <= \$2/);
expect(params).toEqual([before, warnedBefore]);
});
});
+66 -12
View File
@@ -1,6 +1,6 @@
# Handover — where things stand and how to carry on # Handover — where things stand and how to carry on
Last updated: 2026-10-23, after **1.37.0**. The plan itself is in Last updated: 2026-10-23, after **1.40.0**. The plan itself is in
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of [ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md). features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
@@ -8,27 +8,24 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
| Version | What | | Version | What |
|---------|------| |---------|------|
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
| 1.32.0 | Early-bird ribbon on event cards | | 1.32.0 | Early-bird ribbon on event cards |
| 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) | | 1.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` | | 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) | | 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
| 1.37.4 | Deploy script: no tag = latest main; per-install `ecosystem.config.js` out of git (`ecosystem.config.example.js`) |
| 1.37.3 | Fix: the production build failed on unescaped quotes in a help text |
| 1.37.2 | Payment status checks (PayFast transaction history; job every 15 min; Payments → Reconcile → Waiting for payment), PayFast Test connection |
| 1.37.1 | "Pay online" hidden when no provider is on or under R15; SnapScan/Zapper dropped from the roadmap |
| 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider | | 1.37.0 | PayFast (signed form, ITN checks), `ProviderCheckout` with amount checks, the "Pay online" page (`/pay/<id>`) where payers choose a provider |
| 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum | | 1.36.0 | Payment provider interface (`backend/src/payments`) with Yoco behind it, `ProviderTransaction`, `Payment.provider`, Site settings → Payments (on/off and order per provider), R15 minimum |
| 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) | | 1.35.1 | Waitlist per session, session picker at the door, "All sessions" tickets let people tick sessions at one price; fixes (per-session stock limits, offline service worker serving stale scripts) |
## Next up ## Next up
1. **1.38.0 SnapScan → 1.39.0 Zapper** (one release each), each as a provider in 1. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
`backend/src/payments` (see "Where the payment code lives" below). Get the current API
documentation links from Joshua before building each one, and don't guess at an API —
sandbox/test credentials needed. They're QR/app payments: the `/pay/<id>` page is where the
QR shows (desktop) or the app deep link opens (mobile), polling until the webhook lands.
Still open from the roadmap: hide "pay online" buttons below R15 / when no provider is on
(today the server refuses with a clear message), and a "check payment status" /
reconcile job for webhooks that never arrive. Decisions: fees are **never** deducted or
passed on (people handle fees at cashup); minimum online payment **R15** for all providers.
2. **1.40.0–1.41.0 — POPIA toolkit** (spec §3 in the roadmap).
3. **1.42.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
`joshua/hope-events-hub`). `joshua/hope-events-hub`).
## Working on this repo ## Working on this repo
@@ -38,6 +35,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
`## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff` `## [X.Y.Z] - date` heading under `[Unreleased]` in CHANGELOG.md → `git merge --no-ff`
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
version per feature. version per feature.
- **Before tagging:** run `npx eslint src` in `frontend` over the **whole** app (the deploy runs `next build`, which fails on any lint error, e.g. an unescaped ' or " in JSX text). If a dev server is running, a full check build needs its own folder: `NEXT_DIST_DIR=.next-buildcheck npx next build` (then delete it and `git checkout frontend/tsconfig.json`).
- **Changelog:** add entries to `[Unreleased]` as you go, in plain language. - **Changelog:** add entries to `[Unreleased]` as you go, in plain language.
- **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md, - **Docs to keep in step:** CHANGELOG.md, FEATURES.md, README.md, SETTINGS.md,
backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and backend/API_DOCUMENTATION.md, the `/docs` route list in `backend/src/index.js`, and
@@ -49,6 +47,11 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
need a migration. need a migration.
- **Server Node version:** the deployment notes install Node 20, so dependencies must - **Server Node version:** the deployment notes install Node 20, so dependencies must
support Node 20 (that's why pg-boss is pinned to 10.x). support Node 20 (that's why pg-boss is pinned to 10.x).
- **Deploying:** `bash scripts/deploy.sh [tag]` on the server (no tag = latest main). Each install
keeps its own `ecosystem.config.js` (PM2 app names, port), which is not in git. The script
runs from a temp copy of itself and rolls back on failure before the PM2 reload. It was
tested by running it against a throwaway clone with npm/pm2 stubbed: across the tracked →
untracked change for the PM2 file, deploying main, failed builds and a bad tag.
- **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale - **Pushing:** pushing from some shells fails with "Failed to authenticate user" (a stale
saved credential); pushing from your own terminal works. saved credential); pushing from your own terminal works.
@@ -100,6 +103,10 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
- `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`, - `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`,
donation split, registration status, tickets, emails, `ProviderTransaction`). donation split, registration status, tickets, emails, `ProviderTransaction`).
- `backend/src/payments/checkouts.js` — registration checkouts (balance, part, + donation). - `backend/src/payments/checkouts.js` — registration checkouts (balance, part, + donation).
- `backend/src/payments/status.js` — status checks for notifications that never arrived
(`payment-status` job, Payments → Reconcile → "Waiting for payment"). PayFast looks payments
up in its transaction history (`findPayments`); Yoco's Checkout API documents no lookup, so
Yoco checkouts are "can't check". Tests: `backend/tests/paymentStatus.test.js`.
- **Adding a provider:** write `providers/<id>.js` with the same interface, add it to - **Adding a provider:** write `providers/<id>.js` with the same interface, add it to
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>` `PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
(`routes/webhookRoutes.js`), and give it a settings card in (`routes/webhookRoutes.js`), and give it a settings card in
@@ -107,8 +114,55 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
- `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it; - `Payment.method` stays the wallet type (card, apple_pay…) because cashups bucket on it;
the provider is `Payment.provider`. the provider is `Payment.provider`.
## Where the privacy (POPIA) code lives
- `backend/src/utils/privacy.js` — news opt-in (`setNewsPreferences`, logged in
`CommunicationConsent`), unsubscribe tokens (HMAC with `JWT_SECRET`, never expire),
`filterRecipients` (every bulk send goes through it: `kind` 'update' passes, 'news' only to
opted-in accounts and not to `MessagingOptOut` addresses), email/WhatsApp footers, STOP
handling, data requests, `deletionBlockers`, `anonymiseUser`, `exportUserData` + PDF.
Tests: `backend/tests/privacy.test.js`; e2e: `e2e/privacy.spec.ts`.
- `controllers/privacyController.js` + `routes/privacyRoutes.js` (`/api/privacy`).
- Bulk sends: `broadcastController`, `whatsappBroadcastController`, and `emailEventAttendees` /
`whatsappEventAttendees` in `eventController` take `kind`; scheduled payloads carry it.
- WhatsApp STOP: `handleIncomingMessage` in `whatsappController.js` (WAWP `message` event on
`/api/whatsapp/webhook?secret=…`; secret in AppSetting `WAWP_WEBHOOK_SECRET`).
- Frontend: `app/dashboard/user/privacy`, `app/unsubscribe`, `app/dashboard/admin/data-requests`,
`components/shared/NewsPrompt.tsx` (dashboard layout), `MessageKindPicker.tsx`.
- **Anything new that stores personal data** must be added to `anonymiseUser` and
`exportUserData`.
- Privacy notice: `utils/privacyNotice.js` (built-in sections, placeholders, versions, draft in
AppSetting `privacy_notice_draft`, `noticePromptPending`); public page `app/legal/privacy`
(renders whatever `/api/privacy/notice` returns), admin `app/dashboard/admin/privacy` +
`components/admin/privacy/*`, prompt `components/shared/NoticePrompt.tsx` (via
`PrivacyPrompts.tsx`: notice first, then the news question). **Changing the built-in text
changes the live notice only for installs that haven't published a version yet.**
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
**Any new bulk export or list of personal data should call it** and get a label in
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
- Retention: `utils/retention.js` (policies in AppSetting `retention_policies`, `runRetention`,
`preview`, `noticeLines` for `{retention}`), job `retention` (03:30). **Anything new that
stores personal data should get a retention rule** (a new entry in `POLICIES` + runner).
Inactive = latest of `User.createdAt`, `lastLoginAt` (set on login), registrations/event end,
attendee rows, payments. Readiness: `utils/readiness.js`. Breaches: `SecurityBreach`.
Admin panels: `components/admin/privacy/{Readiness,Retention,Breaches}Panel.tsx`.
- e2e: `privacy-retention.spec.ts` runs the retention job on seeded six-year-old accounts
(`prepare-db.js` → `retention`); `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
seeded users in `afterAll` so later spec files don't meet the prompt.
## Known gaps / follow-ups ## Known gaps / follow-ups
- WhatsApp STOP only works once someone pastes the webhook URL from Site settings → WhatsApp
into WAWP (events `message` + `session.status`), and only for senders WhatsApp identifies by
phone number — a sender known only by a "LID" is logged and ignored (they can still use the
link in the message).
- `/api/webhooks/whatsapp` (`webhookController.handleWhatsappWebhook`) is an old empty stub; the
real WAWP webhook is `/api/whatsapp/webhook`. Remove the stub in a later release.
- There's no log of individual messages sent, so "Download my data" can't include "messages
sent to you" yet.
- The audit-log page's action filter list was already missing newer actions before 1.38.0
(only the three privacy ones were added).
- At the door, per-session tickets are booked on new registrations only; editing an - At the door, per-session tickets are booked on new registrations only; editing an
existing registration leaves its booked sessions as they are (the backend refuses existing registration leaves its booked sessions as they are (the backend refuses
changes to picked lines). "Add & charge" for a session at the scanner isn't built. changes to picked lines). "Add & charge" for a session at the scanner isn't built.
+189 -40
View File
@@ -8,7 +8,8 @@ previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.m
**Theme for 2.0:** turn a feature-rich church app into a product that can be **Theme for 2.0:** turn a feature-rich church app into a product that can be
sold — trustworthy (POPIA), flexible (multi-day events, several payment sold — trustworthy (POPIA), flexible (multi-day events, several payment
providers), and maintainable (tests, CI, typed backend, versioned API). providers), maintainable (tests, CI, typed backend, versioned API), and with
**AI helpers** as the headline feature (§15).
**Status (2026-10-23):** all features in this document are approved. Shipped **Status (2026-10-23):** all features in this document are approved. Shipped
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
@@ -17,8 +18,11 @@ reminders, certificates); 1.34.0 job queue (pg-boss), Playwright E2E tests, Gite
workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B workflow (waiting for a runner) and deploy script; 1.35.0 multi-day events phase B
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session (pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page. (Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
**Next: 1.38.0 SnapScan, then Zapper.** 1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
**Next: 1.41.0 install/update tooling + the Hope Events Hub (§13).**
**Repositories:** **Repositories:**
@@ -32,7 +36,7 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
## Contents ## Contents
1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first** 1. [Multi-day events (sessions)](#1-multi-day-events-sessions) ← **first**
2. [Payment providers: PayFast, SnapScan, Zapper](#2-payment-providers-payfast-snapscan-zapper) 2. [Payment providers: PayFast](#2-payment-providers-payfast)
3. [POPIA toolkit](#3-popia-toolkit) 3. [POPIA toolkit](#3-popia-toolkit)
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels) 4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters) 5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
@@ -45,8 +49,10 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church) 12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub) 13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
14. [Foundations (platform work)](#14-foundations-platform-work) 14. [Foundations (platform work)](#14-foundations-platform-work)
15. [Suggested build order](#suggested-build-order) 15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
16. [Decisions](#decisions-2026-10-22) 16. [AI: later stages](#16-ai-later-stages)
17. [Suggested build order](#suggested-build-order)
18. [Decisions](#decisions-2026-10-22)
--- ---
@@ -267,11 +273,12 @@ it's not used for check-in decisions; "checked in" is per session
--- ---
## 2. Payment providers: PayFast, SnapScan, Zapper ## 2. Payment providers: PayFast
Today Yoco is the only online provider. Add **PayFast**, **SnapScan** and Add **PayFast** next to Yoco, and let the admin **enable one or both** in Site
**Zapper**, and let the admin **enable one or more** in Site settings. At settings. At checkout the user picks from the enabled ones. (Shipped in
checkout the user picks from the enabled ones. 1.36.0–1.37.2. SnapScan and Zapper were planned too, but dropped on 2026-10-23:
two providers are enough.)
**Who:** Admins configure providers. Users choose at payment. Supervisors **Who:** Admins configure providers. Users choose at payment. Supervisors
reconcile. reconcile.
@@ -280,10 +287,10 @@ reconcile.
| Setting | Options | | Setting | Options |
|---------|---------| |---------|---------|
| Providers | Yoco · PayFast · SnapScan · Zapper — each with an **Enabled** toggle | | Providers | Yoco · PayFast — each with an **Enabled** toggle |
| Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) | | Credentials | Per provider (merchant id/key, API key, passphrase, webhook secret…), stored encrypted (`utils/encryption.js`, same as WhatsApp) |
| Mode | Live / Sandbox per provider (where supported) | | Mode | Live / Sandbox per provider (where supported) |
| Display order / default | Which one is shown first. Default order: Yoco · PayFast · SnapScan · Zapper | | Display order / default | Which one is shown first. Default order: Yoco · PayFast |
| Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) | | Minimum amount | **R15 for all providers** (one site-wide minimum for online payments) |
**Fees are not handled automatically.** Payments are recorded at the full **Fees are not handled automatically.** Payments are recorded at the full
@@ -300,10 +307,7 @@ total. Fees are dealt with by people during **cashup**, as today. There is no
`getTransaction`, `refund` (if the provider supports it via API), `getTransaction`, `refund` (if the provider supports it via API),
`listTransactions` (for reconciliation). `listTransactions` (for reconciliation).
- Yoco is moved behind the same interface first (no behaviour change), then - Yoco is moved behind the same interface first (no behaviour change), then
PayFast, SnapScan and Zapper are added one release each. PayFast is added.
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
deep-link into the app; the page polls/waits for the webhook and moves on
automatically when payment lands.
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to - `Payment.method` gets the provider name; `YocoTransaction` is generalised to
`ProviderTransaction` (`provider` column) so the existing reconciliation page `ProviderTransaction` (`provider` column) so the existing reconciliation page
works for all providers. works for all providers.
@@ -606,8 +610,10 @@ whether they're healthy, basic usage stats, and their **licence** status.
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
protocol version, so churches on older versions keep reporting while they protocol version, so churches on older versions keep reporting while they
wait to update. wait to update.
- Runs on its own server/domain (e.g. `hub.<your-domain>`). If the Hub is - Runs on its own server at **`events-hub.crosscode.co.za`**. If the Hub is
down, **no church install is affected**. down, **no church install is affected**.
- **Licence keys are generated by the Hub:** Joshua clicks "Generate" on a
customer and the Hub creates the key (not typed by hand).
- Hope Family Church's own site is the first registered install. - Hope Family Church's own site is the first registered install.
### Registration (setup → Hub) ### Registration (setup → Hub)
@@ -634,7 +640,10 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
- **Basic stats (aggregate counts only):** users, active/upcoming events, - **Basic stats (aggregate counts only):** users, active/upcoming events,
registrations and tickets (30 days / all time), check-ins, WhatsApp/email registrations and tickets (30 days / all time), check-ins, WhatsApp/email
sent, enabled payment providers, campuses on/off. sent, enabled payment providers, campuses on/off.
- **Optional (off by default, customer chooses):** payment totals. - **Income in ranges, never exact amounts:** total payments received (30
days / all time) reported as a band — R0–R1 000, R1 001–R5 000,
R5 001–R20 000, R20 001–R50 000, R50 001–R100 000, over R100 000. The
band is worked out on the install; the exact figure never leaves it.
- **Install → Hub only.** The install pushes; the Hub never needs to reach - **Install → Hub only.** The install pushes; the Hub never needs to reach
into the install, so it works behind firewalls and the Hub has no access into the install, so it works behind firewalls and the Hub has no access
to church data. to church data.
@@ -672,8 +681,9 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
- **Never a hard kill:** a church must always be able to scan tickets for - **Never a hard kill:** a church must always be able to scan tickets for
events people already paid for, and get its own data out (POPIA access events people already paid for, and get its own data out (POPIA access
rights). Suspension blocks *new* activity only. rights). Suspension blocks *new* activity only.
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in - **One plan for now.** Plan limits (e.g. number of campuses, WhatsApp) can
the token. be carried in the token later. The first per-licence limit comes with 2.0: the
**monthly AI cap in rand** (§15), kept and enforced on the Hub, since the Hub meters AI.
### Things to be aware of ### Things to be aware of
@@ -697,6 +707,8 @@ model Install { id, customerId, licenceId, name, url, secretHash, version,
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json } model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
// rolled up to daily after 30 days // rolled up to daily after 30 days
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
// 2.0, AI (§15): Licence gets aiMonthlyCapCents; usage per call, no prompt/response text
model AiUsage { id, installId, helper, model, inputTokens, outputTokens, costCents, createdAt }
``` ```
--- ---
@@ -718,6 +730,134 @@ keep adding to.
--- ---
## 15. AI helpers (2.0 headline)
Small, one-button AI helpers in the places people already work. **No chat** in 2.0:
each helper is one request with a predictable cost, and a person always reviews
the result before anything is saved or sent.
**Who:** Supervisors and admins (the people who create events and read reports).
### The helpers
| Helper | Where | What it does |
|--------|-------|--------------|
| **Draft an event** | Event wizard, first step: "Describe your event" | *"Youth camp 14–16 March at Camp Wartburg, R450, 60 places, kids need consent"* → fills in title, description, dates/sessions, location, ticket options and prices, capacity, consent settings. The supervisor checks every field and saves as usual |
| **Summarise this report** | Every report (Reports page) | A few plain-language insights under the report: trends, outliers, comparisons with the previous period |
| **Draft a message** | Email / WhatsApp attendees, broadcasts | Turns a short note (*"remind them to bring a sleeping bag, bus leaves 7:30"*) into a friendly message in the right tone and length (shorter for WhatsApp), with placeholders like `{{name}}` |
| **Summarise feedback** | Post-event feedback results | Themes, what people liked, what to improve, notable comments |
| **Improve this text** | Event description, banner, message boxes | Rewrite for clarity, fix spelling, shorten |
Every helper shows **"AI suggestion — check before saving"**, can be undone, and
never saves, sends or publishes by itself.
### Provider and model
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
current model ($0.10 per million input tokens, $0.50 per million output, for
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
summarising; a helper can move to a bigger model later if quality needs it.
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
prompt-cached, which makes repeat calls cheaper still.
- Structured output (a JSON schema) for "Draft an event", so the reply maps straight
onto the event form and is validated before it's shown.
### Key, caps and billing — through the Hub
Joshua provides the API key; churches don't need their own. The key must never sit on
an install (one leaked install would leak it for everyone), so:
- **Installs call AI through the Hub:** `POST /api/ai/complete` on the Hub, signed with
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
the result. Still **install → Hub only** — the Hub never reaches into an install.
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
install's admins get a warning email; at 100% the helpers are switched off until the next
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
### POPIA
- Anthropic is added to the **operator register** (and Joshua's Hub, which relays the
requests) and to the privacy notice: processing outside South Africa (POPIA s72),
for drafting and summarising only.
- **Send as little personal information as possible:** reports and feedback are sent as
counts, totals and anonymised comments — no names, contact details, medical or
children's information. Drafting an event or a message needs none.
- The Hub stores **no prompt or response text** — only the install, helper, tokens, cost
and time. Check Anthropic's commercial terms on retention and training before launch.
- Uses that include personal information (e.g. feedback comments) go in the access log.
### Plumbing
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
has no AI or the Hub isn't connected.
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
usage view for Joshua (alongside the existing install detail page).
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
looks like) to check quality when changing the prompt or model.
- Help content, audit log entries (AI settings changed), CHANGELOG, FEATURES.md, SETTINGS.md.
---
## 16. AI: later stages
Ideas for after 2.0, in rough order. **Not tied to versions** — each is decided when it's
picked up, and all of them reuse the Hub proxy, metering and caps from §15.
### Ask AI (read-only assistant)
A chat panel for supervisors and admins: *"How many paid for Camp compared with last
year?"*, *"Which events this month are under half full?"*, *"Who hasn't paid yet for
Men's Breakfast?"*.
- The AI can only call **tools the app defines** — the existing stats and report queries,
"find an event", "registrations by week" — never its own SQL. It runs with the
logged-in person's permissions, so it sees nothing they couldn't.
- Answers show the numbers they're based on and link to the matching report.
- Costs more per question than a button (several tool calls), so it counts against the same
cap; may need a bigger model for harder questions.
### Help and navigation assistant
*"How do I add a coupon?"*, *"Where do I see who checked in?"* → a short answer from the
in-app help content (`frontend/src/content/help/`) with a link straight to the right page.
No personal information involved, so it's cheap and simple — could also be offered to
ordinary users.
### Actions with confirmation
The Ask assistant can also *do* things — create an event draft, schedule a reminder, offer
waitlist places, record a cash payment — always as **"here's what I'll do → Confirm"**, with
each confirmed action in the audit log.
### More helpers
Smaller one-button additions as they come up: suggest an event's price tiers from past
events, write a post-event thank-you, draft certificate wording, translate a message into
Afrikaans (ties in with §10), flag unusual payments for the cashup.
### Self-hosted AI (long term)
Run an open-weight model on Joshua's own GPU server instead of a paid API, once usage and
income justify the hardware:
- Because every install already talks to the **Hub's** AI endpoint, moving to self-hosting
is a change on the Hub only — the Hub gets a provider adapter (Claude now, self-hosted
later, or both: simple helpers self-hosted, harder questions on Claude).
- **POPIA bonus:** the data can stay in South Africa — no cross-border transfer.
- **When it makes sense:** compare the monthly API bill with the cost of a GPU server
(hardware or cloud rental, power, upkeep), and check the self-hosted model against the
eval set from §15 before switching any helper over.
---
## Suggested build order ## Suggested build order
| Phase | Version | Features | Why | | Phase | Version | Features | Why |
@@ -726,15 +866,15 @@ keep adding to.
| 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work | | 1 | 1.33.0 ✅ | **Multi-day events — phase A** (sessions, whole-course tickets, late joining + late pricing, per-session check-in with whole-day window, attendance register, optional reminders, certificates) | Requested first; touches registration/tickets/scanning, so it shapes later work |
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue | | 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage | | 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
| 4 | 1.36.0 ✅ – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this | | 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue | | 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 | | 6 | 1.41.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first | | 7 | 1.42.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses | | 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins | | 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release | | 10 | 1.48.0 – 1.50.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork | | 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release | | 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
Each phase should add matching help content (`frontend/src/content/help/`), Each phase should add matching help content (`frontend/src/content/help/`),
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
@@ -756,21 +896,30 @@ feature.
| — | Pro-rata rounding | Nearest R10 | | — | Pro-rata rounding | Nearest R10 |
| — | Multi-campus | In scope (§12), inside one install | | — | Multi-campus | In scope (§12), inside one install |
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally | | — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links | | 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" | | — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
| — | Minimum online payment | R15 for all providers | | — | Minimum online payment | R15 for all providers |
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights | | — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one | | — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
| — | Hub domain | `events-hub.crosscode.co.za` (2026-10-23) |
| — | Licence keys | Generated by the Hub when Joshua clicks "Generate" on a customer (2026-10-23) |
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
| — | Licence plans | One plan for now (2026-10-23) |
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
| — | POPIA part 2 split | 1.39.0: privacy notice editor + versions + re-acceptance, operator register, Information Officer on the contact page, consent log, access log. 1.40.0: retention policies + nightly job, breach register, children's-data flags, readiness page. Hub moves to 1.41.0 (2026-10-23) |
| — | POPIA: privacy notice | Today's notice becomes the starting template, one editable box per section; org name, Information Officer, retention periods and the operator list fill in automatically. Publishing saves a numbered version (2026-10-23) |
| — | POPIA: re-accepting the notice | The admin decides per version ("significant change — ask everyone to accept again"); people are asked once after login (2026-10-23) |
| — | POPIA: inactive accounts | Anonymised after 5 years without a login or registration, **on by default**, with a warning email 30 days before; staff/supervisor/admin accounts never (2026-10-23) |
| — | POPIA: WhatsApp STOP | In 1.38.0, via WAWP's incoming `message` webhook with a secret in the URL (WAWP doesn't sign webhooks) (2026-10-23) |
## Still open ## Still open
Hub (needed before phase 6, not before 1.33.0): Nothing for now.
1. Hub domain and server, and where licence keys come from (created by hand
in the Hub per customer?).
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
still scan, data export still works) — agreed?
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
counts only?
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
now?
+3
View File
@@ -7,6 +7,9 @@ export type Seed = {
users: Record<Role, { id: string; name: string; email: string; token: string }>; users: Record<Role, { id: string; name: string; email: string; token: string }>;
events: { free: string; paid: string; course: string; dropIn: string; conference: string }; events: { free: string; paid: string; course: string; dropIn: string; conference: string };
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string }; course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
unsubscribeToken: string;
newsPromptUser: { id: string; token: string };
retention: { toWarn: string; toAnonymise: string };
}; };
export const API = "http://localhost:5100"; export const API = "http://localhost:5100";
+9
View File
@@ -43,6 +43,15 @@ test.describe("PayFast", () => {
await page.waitForURL(/sandbox\.payfast\.co\.za/, { timeout: 30_000 }); await page.waitForURL(/sandbox\.payfast\.co\.za/, { timeout: 30_000 });
await expect(page.locator("body")).not.toContainText(/signature/i); await expect(page.locator("body")).not.toContainText(/signature/i);
await expect(page.locator("body")).toContainText(/150[.,]00/); await expect(page.locator("body")).toContainText(/150[.,]00/);
// Not paid at PayFast: the status check asks PayFast's API (signed) and says so
await loginAs(page, "supervisor");
await page.goto("/dashboard/supervisor/payments");
await page.getByText("Reconcile", { exact: true }).click();
const row = page.getByRole("row").filter({ hasText: "PayFast" }).filter({ hasText: "R 150.00" }).first();
await row.getByRole("button", { name: "Check status" }).click();
await expect(row.getByText("Not paid (yet)")).toBeVisible({ timeout: 30_000 });
await expect(page.getByText("No new payments found.")).toBeVisible();
} finally { } finally {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] }); await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
await api(page, "admin", "PUT", "/api/payfast/config", { merchantId: "", merchantKey: "", passphrase: "" }); await api(page, "admin", "PUT", "/api/payfast/config", { merchantId: "", merchantKey: "", passphrase: "" });
+30 -1
View File
@@ -1,4 +1,4 @@
import { test, expect, loginAs, api } from "./fixtures"; import { test, expect, seed, loginAs, api } from "./fixtures";
type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean }; type ProviderSetting = { id: string; label: string; enabled: boolean; configured: boolean };
@@ -34,4 +34,33 @@ test.describe("payment providers", () => {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }] }); await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }] });
} }
}); });
test("with no provider on, nothing offers to pay online", async ({ page }) => {
type EventWithOptions = { eventOptions: { id: string; isMainTicket: boolean }[] };
const event = await api<EventWithOptions>(page, "admin", "GET", `/api/events/${seed().events.paid}`);
const option = event.eventOptions.find((o) => o.isMainTicket)!;
const reg = await api<{ id: string }>(page, "admin", "POST", "/api/registrations", { eventId: seed().events.paid, options: [{ eventOptionId: option.id, quantity: 1 }] });
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: false }, { id: "payfast", enabled: false }] });
try {
await loginAs(page, "admin");
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByText(/Online payment isn.t available/)).toBeVisible();
await expect(page.getByRole("button", { name: "Pay online" })).toHaveCount(0);
await page.goto("/dashboard/user");
await expect(page.getByRole("button", { name: "Make donation" })).toHaveCount(0);
await page.goto("/dashboard/user/donate");
await expect(page.getByText(/Online donations aren.t available/)).toBeVisible();
} finally {
await api(page, "admin", "PUT", "/api/payments/providers/settings", { providers: [{ id: "yoco", enabled: true }, { id: "payfast", enabled: false }] });
}
// Back on (when Yoco is set up in this environment), the button returns
const on = await api<{ providers: unknown[] }>(page, "admin", "GET", "/api/payments/providers");
if (on.providers.length > 0) {
await page.goto(`/registration/success?registrationId=${reg.id}`);
await expect(page.getByRole("button", { name: "Pay online" })).toBeVisible();
}
});
}); });
+91
View File
@@ -0,0 +1,91 @@
import { test, expect, seed, loginAs, api, API, type Role } from "./fixtures";
type Section = { id: string; title: string; body: string };
type Draft = { sections: Section[]; versions: { version: number; acceptances: number }[] };
type Notice = { version: number; builtIn: boolean; sections: Section[] };
type Operator = { id: string; name: string; active: boolean };
type AccessLog = { rows: { action: string; what: string; records: number | null; actor: string }[] };
type ConsentLog = { rows: { version?: number; source: string; email: string | null }[] };
test.describe("privacy notice, operators and access log (POPIA)", () => {
// A version that needs accepting is published below; accept it for every seeded account
// afterwards so later test files don't meet the prompt.
test.afterAll(async ({ request }) => {
const tokens = [...(["admin", "supervisor", "staff", "user"] as Role[]).map(r => seed().users[r].token), seed().newsPromptUser.token];
for (const token of tokens) {
await request.post(`${API}/api/privacy/me/notice/accept`, { headers: { Authorization: `Bearer ${token}` } });
}
});
test("the built-in notice is shown until a version is published, with the operator register filled in", async ({ page }) => {
const notice = await (await page.request.get(`${API}/api/privacy/notice`)).json() as Notice;
expect(notice.version).toBe(0);
expect(notice.sections.find(s => s.id === "sharing")?.body).toContain("• PayFast (South Africa)");
await page.goto("/legal/privacy");
await expect(page.getByRole("heading", { name: "Privacy Policy" })).toBeVisible();
await expect(page.getByText("• PayFast (South Africa)", { exact: false })).toBeVisible();
});
test("an admin edits and publishes; operators feed the notice", async ({ page }) => {
// A new operator appears in the notice's {operators} list.
const op = await api<Operator>(page, "admin", "POST", "/api/privacy/operators", { name: "E2E Mailer", purpose: "Sending emails", dataShared: "Email address", location: "Ireland" });
await loginAs(page, "admin");
await page.goto("/dashboard/admin/privacy?tab=notice");
const intro = page.getByLabel("1. Introduction text");
await expect(intro).toBeVisible();
await intro.fill("{org} looks after your information carefully. (E2E edit)");
await page.getByRole("button", { name: "Save draft" }).click();
await expect(page.getByText("Draft saved.")).toBeVisible();
// Nothing public changes until it's published.
expect(((await (await page.request.get(`${API}/api/privacy/notice`)).json()) as Notice).version).toBe(0);
await page.getByRole("button", { name: "Publish…" }).click();
await page.getByPlaceholder("e.g. We now use PayFast for online payments.").fill("Clearer introduction.");
await page.getByRole("button", { name: "Publish", exact: true }).click();
await expect(page.getByText("Version 1 is published.")).toBeVisible();
await page.goto("/legal/privacy");
await expect(page.getByText("E2E Church looks after your information carefully. (E2E edit)")).toBeVisible();
await expect(page.getByText("• E2E Mailer (Ireland) — Sending emails.", { exact: false })).toBeVisible();
await expect(page.getByText(/Version 1/).first()).toBeVisible();
await api(page, "admin", "DELETE", `/api/privacy/operators/${op.id}`);
});
test("a significant change is accepted once, after login", async ({ page }) => {
const draft = await api<Draft>(page, "admin", "GET", "/api/privacy/notice/draft");
// Re-acceptance needs a summary of what changed.
await expect(api(page, "admin", "POST", "/api/privacy/notice/publish", { sections: draft.sections, requiresAcceptance: true })).rejects.toThrow(/400/);
const published = await api<{ version: number }>(page, "admin", "POST", "/api/privacy/notice/publish", { sections: draft.sections, requiresAcceptance: true, changeSummary: "We added WhatsApp news." });
await loginAs(page, "user");
await page.goto("/dashboard/user");
const dialog = page.getByRole("dialog", { name: "We've updated our privacy notice" });
await expect(dialog).toBeVisible();
await expect(dialog.getByText("We added WhatsApp news.")).toBeVisible();
await dialog.getByRole("button", { name: "I accept" }).click();
await expect(dialog).toBeHidden();
await page.reload();
await expect(page.getByText("My Events").first()).toBeVisible();
await expect(page.getByRole("dialog", { name: "We've updated our privacy notice" })).toHaveCount(0);
const log = await api<ConsentLog>(page, "admin", "GET", `/api/privacy/consent-log?type=notice&q=${encodeURIComponent(seed().users.user.email)}`);
expect(log.rows[0]).toMatchObject({ version: published.version, source: "prompt" });
});
test("bulk exports are in the access log", async ({ page }) => {
const res = await page.request.post(`${API}/api/reports/pdf`, {
headers: { Authorization: `Bearer ${seed().users.supervisor.token}`, "Content-Type": "application/json" },
data: JSON.stringify({ title: "E2E attendee list", kind: "table", table: { columns: ["Name"], rows: [["A"], ["B"], ["C"]] } }),
});
expect(res.ok()).toBe(true);
await api(page, "supervisor", "GET", `/api/consent/events/${seed().events.free}`);
const log = await api<AccessLog>(page, "admin", "GET", "/api/privacy/access-log");
expect(log.rows.find(r => r.action === "report_pdf")).toMatchObject({ what: "E2E attendee list", records: 3, actor: "Sam Supervisor" });
expect(log.rows.some(r => r.action === "consent_list")).toBe(true);
});
});
+64
View File
@@ -0,0 +1,64 @@
import { test, expect, seed, loginAs, api } from "./fixtures";
type RunResult = { result: Record<string, Record<string, number>> };
type UserRow = { name: string; email: string; isActive: boolean };
type Readiness = { score: number; checks: { key: string; ok: boolean; applies: boolean }[] };
type Breach = { id: string; status: string; regulatorNotifiedAt: string | null };
test.describe("retention, breaches and readiness (POPIA)", () => {
test("the retention job warns inactive accounts first, and anonymises them 30 days after the warning", async ({ page }) => {
const { result } = await api<RunResult>(page, "admin", "POST", "/api/privacy/retention/run");
expect(result.inactive_accounts.warned).toBeGreaterThanOrEqual(1);
expect(result.inactive_accounts.anonymised).toBeGreaterThanOrEqual(1);
expect(result.login_history.deleted).toBeGreaterThanOrEqual(1);
const warned = await api<UserRow>(page, "admin", "GET", `/api/users/${seed().retention.toWarn}`);
expect(warned.email).toBe("dormant@e2e.test");
const gone = await api<UserRow>(page, "admin", "GET", `/api/users/${seed().retention.toAnonymise}`);
expect(gone.name).toBe("Deleted User");
expect(gone.email).toMatch(/@deleted\.invalid$/);
// Seeded accounts (staff roles, and the recently active user) are untouched.
expect((await api<UserRow>(page, "admin", "GET", `/api/users/${seed().users.user.id}`)).email).toBe("user@e2e.test");
});
test("retention periods are validated and saved; the notice follows them", async ({ page }) => {
const state = await api<{ policies: Record<string, { enabled: boolean; months: number }> }>(page, "admin", "GET", "/api/privacy/retention");
await expect(api(page, "admin", "PUT", "/api/privacy/retention", { policies: { ...state.policies, inactive_accounts: { enabled: true, months: 3 } } })).rejects.toThrow(/400/);
await api(page, "admin", "PUT", "/api/privacy/retention", { policies: { ...state.policies, form_answers: { enabled: true, months: 18 } } });
await page.goto("/legal/privacy");
await expect(page.getByText("Answers to event forms are deleted 18 months after the event.", { exact: false })).toBeVisible();
});
test("a breach is recorded, reported, and drafts are ready to copy", async ({ page }) => {
const breach = await api<Breach>(page, "admin", "POST", "/api/privacy/breaches", {
title: "Phone with the attendee list lost", discoveredAt: new Date().toISOString(),
description: "A leader's phone was stolen at the camp site.", dataAffected: "Names and phone numbers of 40 campers",
});
expect(breach.status).toBe("open");
await loginAs(page, "admin");
await page.goto("/dashboard/admin/privacy?tab=breaches");
await page.getByRole("button", { name: /Phone with the attendee list lost/ }).click();
await expect(page.getByText("Draft message to the people affected")).toBeVisible();
await expect(page.getByText("Personal information affected: Names and phone numbers of 40 campers")).toBeVisible();
await page.getByRole("button", { name: "Regulator told today" }).click();
await expect(page.getByRole("button", { name: "Regulator told today" })).toHaveCount(0);
const list = await api<Breach[]>(page, "admin", "GET", "/api/privacy/breaches");
expect(list.find(b => b.id === breach.id)?.regulatorNotifiedAt).toBeTruthy();
});
test("the readiness page scores the install", async ({ page }) => {
const r = await api<Readiness>(page, "admin", "GET", "/api/privacy/readiness");
expect(r.score).toBeGreaterThan(0);
expect(r.checks.find(c => c.key === "retention_reviewed")?.ok).toBe(true);
expect(r.checks.find(c => c.key === "retention_running")?.ok).toBe(false); // jobs are off in e2e
await loginAs(page, "admin");
await page.goto("/dashboard/admin/privacy");
await expect(page.getByText("POPIA readiness")).toBeVisible();
await expect(page.getByText(`${r.score}%`)).toBeVisible();
});
});
+111
View File
@@ -0,0 +1,111 @@
import type { Page } from "@playwright/test";
import { test, expect, seed, loginAs, api, API } from "./fixtures";
type Account = { id: string; token: string; email: string };
type PrivacyInfo = { news: { email: boolean; whatsapp: boolean }; history: { source: string }[]; requests: { type: string; status: string }[] };
type Preview = { kind: string; matched: number; skipped: number };
type Requests = { requests: { id: string; type: string; userId: string; blockers: unknown[] }[] };
/** A brand-new account made through sign-up, so tests can change it freely. */
async function signUp(page: Page, newsOptIn: boolean): Promise<Account> {
const email = `privacy-${Date.now()}-${Math.floor(Math.random() * 1e6)}@e2e.test`;
const res = await page.request.post(`${API}/api/users`, {
data: { name: "Pat Privacy", email, password: "E2e-password-123", newsOptIn },
});
expect(res.ok()).toBe(true);
const body = await res.json();
return { id: body.id, token: body.token, email };
}
async function asAccount<T>(page: Page, account: Account, method: string, url: string, body?: unknown) {
const res = await page.request.fetch(`${API}${url}`, {
method,
headers: { Authorization: `Bearer ${account.token}`, "Content-Type": "application/json" },
data: body === undefined ? undefined : JSON.stringify(body),
});
return { status: res.status(), body: (await res.json().catch(() => null)) as T };
}
test.describe("privacy (POPIA)", () => {
test("sign-up records the news choice; the privacy page changes it and downloads my data", async ({ page }) => {
const me = await signUp(page, false);
const before = await asAccount<PrivacyInfo>(page, me, "GET", "/api/privacy/me");
expect(before.body.news.email).toBe(false);
expect(before.body.history.map((h) => h.source)).toContain("signup");
await page.addInitScript((token) => localStorage.setItem("hope_events_token", token), me.token);
await page.goto("/dashboard/user/privacy");
const byEmail = page.getByRole("checkbox", { name: "By email" });
await expect(byEmail).not.toBeChecked();
await byEmail.check();
await expect(page.getByText("You're subscribed.")).toBeVisible();
await expect(byEmail).toBeChecked();
const exported = await asAccount<{ account: { email: string }; communication: { news: { email: boolean } } }>(page, me, "GET", "/api/privacy/me/export");
expect(exported.status).toBe(200);
expect(exported.body.account.email).toBe(me.email);
expect(exported.body.communication.news.email).toBe(true);
});
test("news broadcasts skip people who aren't opted in; event updates don't", async ({ page }) => {
const out = await signUp(page, false);
const news = await api<Preview>(page, "supervisor", "POST", "/api/broadcasts/preview", { userIds: [out.id, seed().users.admin.id], emails: "", kind: "news" });
expect(news.matched).toBe(1);
expect(news.skipped).toBe(1);
const update = await api<Preview>(page, "supervisor", "POST", "/api/broadcasts/preview", { userIds: [out.id, seed().users.admin.id], emails: "", kind: "update" });
expect(update.matched).toBe(2);
expect(update.skipped).toBe(0);
});
test("the unsubscribe link turns news off", async ({ page }) => {
await page.goto(`/unsubscribe?t=${encodeURIComponent(seed().unsubscribeToken)}`);
await page.getByRole("button", { name: "Unsubscribe from all news" }).click();
await expect(page.getByText(/You're unsubscribed from news and announcements/)).toBeVisible();
const after = await api<PrivacyInfo>(page, "user", "GET", "/api/privacy/me");
expect(after.news).toEqual({ email: false, whatsapp: false });
expect(after.history.some((h) => h.source === "unsubscribe_link")).toBe(true);
const bad = await page.request.get(`${API}/api/privacy/unsubscribe/not-a-real-token`);
expect(bad.status()).toBe(404);
});
test("accounts that never answered are asked once after logging in", async ({ page }) => {
await page.addInitScript((token) => localStorage.setItem("hope_events_token", token), seed().newsPromptUser.token);
await page.goto("/dashboard/user");
const dialog = page.getByRole("dialog", { name: "Hear about upcoming events?" });
await expect(dialog).toBeVisible();
await dialog.getByRole("button", { name: "No thanks" }).click();
await expect(dialog).toBeHidden();
await page.reload();
await expect(page.getByText("My Events").first()).toBeVisible();
await expect(page.getByRole("dialog", { name: "Hear about upcoming events?" })).toHaveCount(0);
});
test("delete my account: closed at once, anonymised when an admin completes the request", async ({ page }) => {
const me = await signUp(page, true);
const wrong = await asAccount(page, me, "POST", "/api/privacy/me/requests", { type: "deletion", password: "nope" });
expect(wrong.status).toBe(400);
const made = await asAccount(page, me, "POST", "/api/privacy/me/requests", { type: "deletion", password: "E2e-password-123" });
expect(made.status).toBe(201);
// The account is closed: the old session no longer works, and logging in is refused.
expect((await asAccount(page, me, "GET", "/api/privacy/me")).status).toBe(401);
const login = await page.request.post(`${API}/api/users/login`, { data: { email: me.email, password: "E2e-password-123" } });
expect(login.ok()).toBe(false);
const inbox = await api<Requests>(page, "admin", "GET", "/api/privacy/requests?status=open");
const request = inbox.requests.find((r) => r.userId === me.id);
expect(request?.type).toBe("deletion");
expect(request?.blockers).toEqual([]);
// The admin page shows it; completing it removes the personal information.
await loginAs(page, "admin");
await page.goto("/dashboard/admin/data-requests");
await expect(page.getByText(me.email)).toBeVisible();
await api(page, "admin", "POST", `/api/privacy/requests/${request!.id}/complete`, {});
const user = await api<{ name: string; email: string }>(page, "admin", "GET", `/api/users/${me.id}`);
expect(user.name).toBe("Deleted User");
expect(user.email).toMatch(/@deleted\.invalid$/);
});
});
+20 -1
View File
@@ -48,8 +48,10 @@ async function seed(url) {
}); });
const password = await hashPassword('E2e-password-123'); const password = await hashPassword('E2e-password-123');
// Opted in to news and already asked (newsAskedAt), so the one-time news question
// doesn't cover the dashboards in every test.
const mk = (role, name, email, phoneNumber) => prisma.user.create({ const mk = (role, name, email, phoneNumber) => prisma.user.create({
data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date() }, data: { name, email, phoneNumber, role, password, isActive: true, updatedAt: new Date(), newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() },
}); });
const users = { const users = {
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'), admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
@@ -155,6 +157,23 @@ async function seed(url) {
users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])), users: Object.fromEntries(Object.entries(users).map(([k, u]) => [k, { id: u.id, name: u.name, email: u.email, token: generateToken(u.id, u.role, u.tokenVersion || 0) }])),
events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id }, events: { free: free.id, paid: paid.id, course: course.id, dropIn: dropIn.id, conference: conference.id },
course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode }, course: { sessions: course.sessions.map(s => ({ id: s.id, label: s.label })), ticketId: ticket.id, qrCode: ticket.qrCode },
// An account that has never answered the news question (asked once after login).
newsPromptUser: await (async () => {
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true } });
return { id: u.id, token: generateToken(u.id, u.role, 0) };
})(),
// Retention: two accounts untouched for six years — one not yet warned, one warned 40 days
// ago (so the next run anonymises it) — and three-year-old login history.
retention: await (async () => {
const longAgo = new Date(Date.now() - 6 * 365 * 24 * 60 * 60 * 1000);
const oldUser = (email, warnedAt) => prisma.user.create({ data: { name: `Old ${email}`, email, role: 'user', password, isActive: true, createdAt: longAgo, updatedAt: longAgo, inactivityWarnedAt: warnedAt } });
const toWarn = await oldUser('dormant@e2e.test', null);
const toAnonymise = await oldUser('gone-quiet@e2e.test', new Date(Date.now() - 40 * 24 * 60 * 60 * 1000));
await prisma.securityEvent.create({ data: { userId: toWarn.id, type: 'login', createdAt: new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000) } });
return { toWarn: toWarn.id, toAnonymise: toAnonymise.id };
})(),
// The link a news email to the seeded user would carry (signed with JWT_SECRET).
unsubscribeToken: require(path.join(backend, 'src/utils/privacy')).unsubscribeToken({ userId: users.user.id }),
}; };
fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true }); fs.mkdirSync(path.join(root, 'e2e', '.data'), { recursive: true });
fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2)); fs.writeFileSync(path.join(root, 'e2e', '.data', 'seed.json'), JSON.stringify(out, null, 2));
@@ -1,3 +1,6 @@
// PM2 config template. Each install keeps its own copy as ecosystem.config.js (not in git,
// so deploys never change it); scripts/deploy.sh creates it from this file the first time.
// Change the app names (unique per install on a server) and the frontend port to suit.
module.exports = { module.exports = {
apps: [ apps: [
{ {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.37.0", "version": "1.40.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.37.0", "version": "1.40.0",
"dependencies": { "dependencies": {
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@hookform/resolvers": "^5.2.1", "@hookform/resolvers": "^5.2.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "hope-events-frontend", "name": "hope-events-frontend",
"version": "1.37.0", "version": "1.40.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev --turbopack", "dev": "next dev --turbopack",
+22 -1
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import { Mail, Phone, MapPin } from "lucide-react"; import Link from "next/link";
import { Mail, Phone, MapPin, ShieldCheck } from "lucide-react";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
@@ -13,6 +14,8 @@ export default function ContactPage() {
const email = settings.org_email || ""; const email = settings.org_email || "";
const phone = settings.org_phone || ""; const phone = settings.org_phone || "";
const address = settings.org_address || ""; const address = settings.org_address || "";
const ioName = settings.legal_io_name || "";
const ioEmail = settings.legal_io_email || "";
const hasAny = !!(email || phone || address); const hasAny = !!(email || phone || address);
return ( return (
@@ -75,6 +78,24 @@ export default function ContactPage() {
<LocationMap address={address} /> <LocationMap address={address} />
</div> </div>
)} )}
{/* POPIA: the Information Officer, once one is set in Site Settings → Legal. */}
{ioName && (
<div className="mt-8 flex items-start gap-3 border rounded-xl p-5 bg-white shadow-sm">
<div className="w-10 h-10 rounded-full bg-brand-50 flex items-center justify-center shrink-0">
<ShieldCheck className="w-5 h-5 text-brand-600" />
</div>
<div className="text-sm text-gray-600">
<div className="font-semibold text-gray-900">Privacy and your personal information</div>
<p className="mt-1">
Our Information Officer is <strong>{ioName}</strong>
{ioEmail && <> — <a href={`mailto:${ioEmail}`} className="text-brand-600 hover:underline break-all">{ioEmail}</a></>}.
Contact them about the information we hold about you, or see our{" "}
<Link href="/legal/privacy" className="text-brand-600 hover:underline">privacy notice</Link>.
</p>
</div>
</div>
)}
</main> </main>
<Footer /> <Footer />
</div> </div>
@@ -40,6 +40,17 @@ const ACTIONS = [
"yoco_config_updated", "yoco_config_updated",
"yoco_webhook_registered", "yoco_webhook_registered",
"yoco_webhook_deleted", "yoco_webhook_deleted",
"data_request_completed",
"data_request_rejected",
"user_anonymised",
"privacy_notice_published",
"operator_created",
"operator_updated",
"operator_deleted",
"retention_policies_updated",
"retention_run",
"breach_recorded",
"breach_updated",
] as const; ] as const;
const ACTION_LABELS: Record<string, string> = { const ACTION_LABELS: Record<string, string> = {
@@ -72,6 +83,17 @@ const ACTION_LABELS: Record<string, string> = {
yoco_config_updated: "Yoco settings changed", yoco_config_updated: "Yoco settings changed",
yoco_webhook_registered: "Yoco webhook registered", yoco_webhook_registered: "Yoco webhook registered",
yoco_webhook_deleted: "Yoco webhook deleted", yoco_webhook_deleted: "Yoco webhook deleted",
data_request_completed: "Privacy request completed",
data_request_rejected: "Privacy request rejected/cancelled",
user_anonymised: "User's personal data deleted",
privacy_notice_published: "Privacy notice published",
operator_created: "Operator added",
operator_updated: "Operator changed",
operator_deleted: "Operator removed",
retention_policies_updated: "Retention periods changed",
retention_run: "Retention job deleted/anonymised data",
breach_recorded: "Security compromise recorded",
breach_updated: "Security compromise updated",
}; };
interface AuditLogEntry { interface AuditLogEntry {
@@ -0,0 +1,201 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Inbox, AlertTriangle } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type RequestType = "access" | "correction" | "deletion";
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
type Blocker = { registrationId: string; status: string; eventId: string; eventTitle: string; endDate: string };
type DataRequest = {
id: string; type: RequestType; status: RequestStatus; name: string; email: string; details: string | null; response: string | null;
dueAt: string; closedAt: string | null; createdAt: string; userId: string | null; handledBy: string | null; overdue: boolean; blockers: Blocker[];
};
type Msg = { type: "ok" | "err"; text: string } | null;
const TYPE_LABEL: Record<RequestType, string> = { access: "Access", correction: "Correction", deletion: "Delete account" };
const STATUS_TONE: Record<RequestStatus, string> = {
open: "bg-amber-50 text-amber-700",
completed: "bg-green-50 text-green-700",
rejected: "bg-red-50 text-red-700",
cancelled: "bg-gray-100 text-gray-600",
};
const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" });
/** One open request with its actions: complete (deletion: anonymise), reject or cancel. */
function OpenRequest({ request, token, onDone }: { request: DataRequest; token: string; onDone: (msg: Msg) => void }) {
const [mode, setMode] = useState<"idle" | "complete" | "reject">("idle");
const [response, setResponse] = useState("");
const [cancelled, setCancelled] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const isDeletion = request.type === "deletion";
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (mode === "complete" && isDeletion && !confirm(`Delete ${request.name}'s personal information? This can't be undone.`)) return;
setBusy(true);
setError(null);
try {
await apiFetch(`/api/privacy/requests/${request.id}/${mode}`, {
method: "POST",
authToken: token,
body: { response: response || undefined, cancelled: mode === "reject" ? cancelled : undefined },
});
onDone({ type: "ok", text: mode === "complete" ? (isDeletion ? `${request.name}'s personal information was deleted.` : "Request completed.") : "Request closed." });
} catch (e) {
setError(errorMessage(e) || "Something went wrong.");
} finally {
setBusy(false);
}
};
return (
<li className={`border rounded-xl p-4 bg-white shadow-sm ${request.overdue ? "border-red-300" : ""}`}>
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-gray-900">{TYPE_LABEL[request.type]}</span>
<span className="text-sm text-gray-700">— {request.name}</span>
<a href={`mailto:${request.email}`} className="text-sm text-brand-600 hover:underline">{request.email}</a>
<span className={`ml-auto text-xs px-2 py-0.5 rounded-full ${request.overdue ? "bg-red-50 text-red-700" : "bg-gray-100 text-gray-600"}`}>
{request.overdue ? "Overdue — was due " : "Due "}{fmtDate(request.dueAt)}
</span>
</div>
<p className="text-xs text-gray-400 mt-1">Received {fmtDate(request.createdAt)}</p>
{request.details && <p className="text-sm text-gray-700 mt-2 whitespace-pre-line">{request.details}</p>}
{isDeletion && (
request.blockers.length > 0 ? (
<div className="mt-3 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-900">
<div className="flex items-center gap-2 font-medium"><AlertTriangle className="w-4 h-4" /> Waiting on registrations for upcoming events</div>
<ul className="mt-1 list-disc pl-5">
{request.blockers.map(b => (
<li key={b.registrationId}>{b.eventTitle} ({b.status}, ends {fmtDate(b.endDate)})</li>
))}
</ul>
<p className="mt-1 text-xs">
Cancel (and refund where needed) on <Link href="/dashboard/admin/registrations" className="text-brand-600 hover:underline">Registrations</Link> —
search for {request.email} — or wait until the event is over. The account is already closed.
</p>
</div>
) : (
<p className="mt-2 text-xs text-gray-500">The account is closed. Completing deletes their name, contact details, attendees, form answers and payer details; payments and registrations stay as &quot;Deleted User&quot;.</p>
)
)}
{mode === "idle" ? (
<div className="flex flex-wrap gap-2 mt-3">
<button onClick={() => setMode("complete")} disabled={isDeletion && request.blockers.length > 0} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{isDeletion ? "Delete personal data…" : "Mark as done…"}
</button>
<button onClick={() => setMode("reject")} className="px-3 py-1.5 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50">
Reject or cancel…
</button>
</div>
) : (
<form onSubmit={submit} className="mt-3 space-y-3">
<label className="block text-sm font-medium text-gray-700">
{mode === "reject" ? "Reason (emailed to them)" : request.type === "correction" ? "What was corrected (emailed to them)" : "Message to them (optional)"}
</label>
<textarea
className="w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500"
rows={3}
maxLength={5000}
value={response}
onChange={e => setResponse(e.target.value)}
required={mode === "reject" || request.type === "correction"}
/>
{mode === "reject" && (
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={cancelled} onChange={e => setCancelled(e.target.checked)} />
They withdrew it (cancelled, not rejected)
</label>
)}
{mode === "reject" && isDeletion && <p className="text-xs text-gray-500">Their account is opened again so they can log in.</p>}
<div className="flex gap-2">
<button type="button" onClick={() => { setMode("idle"); setResponse(""); setError(null); }} className="px-3 py-1.5 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">Back</button>
<button type="submit" disabled={busy} className={`px-3 py-1.5 rounded-lg text-white text-sm font-medium disabled:opacity-50 ${mode === "complete" && isDeletion ? "bg-red-600 hover:bg-red-700" : "bg-brand-600 hover:bg-brand-700"}`}>
{busy ? "Saving…" : mode === "complete" ? (isDeletion ? "Delete personal data" : "Complete") : cancelled ? "Cancel request" : "Reject request"}
</button>
</div>
{error && <p className="text-sm text-red-600">{error}</p>}
</form>
)}
</li>
);
}
export default function DataRequestsPage() {
const { token } = useAuth();
const [view, setView] = useState<"open" | "closed">("open");
const [requests, setRequests] = useState<DataRequest[] | null>(null);
const [openCount, setOpenCount] = useState(0);
const [msg, setMsg] = useDismissingState<Msg>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ requests: DataRequest[]; openCount: number }>(`/api/privacy/requests?status=${view}`, { authToken: token });
setRequests(res.requests);
setOpenCount(res.openCount);
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load requests." });
}
}, [token, view, setMsg]);
useEffect(() => { setRequests(null); load(); }, [load]);
return (
<div className="max-w-4xl mx-auto w-full">
<div className="mb-6">
<BackLink href="/dashboard/admin" />
<div className="flex items-center gap-2 mt-0.5">
<Inbox className="w-6 h-6 text-brand-600" />
<h1 className="text-2xl font-semibold text-gray-900">Data requests</h1>
</div>
<p className="text-sm text-gray-500 mt-1">
POPIA requests from people about their personal information. Answer within 30 days. Downloads people make
themselves from their Privacy page aren&apos;t listed here.
</p>
</div>
<div className="flex gap-2 mb-4">
{(["open", "closed"] as const).map(v => (
<button key={v} onClick={() => setView(v)} className={`px-3 py-1.5 rounded-lg text-sm font-medium ${view === v ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
{v === "open" ? `Open${openCount ? ` (${openCount})` : ""}` : "Closed"}
</button>
))}
</div>
{msg && <div className={`mb-4 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{requests === null ? (
<p className="text-sm text-gray-400">Loading…</p>
) : requests.length === 0 ? (
<p className="text-sm text-gray-500">{view === "open" ? "No open requests." : "No closed requests yet."}</p>
) : view === "open" ? (
<ul className="space-y-3">
{requests.map(r => <OpenRequest key={r.id} request={r} token={token!} onDone={m => { setMsg(m); load(); }} />)}
</ul>
) : (
<ul className="space-y-2">
{requests.map(r => (
<li key={r.id} className="border rounded-xl p-3 bg-white text-sm">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium text-gray-900">{TYPE_LABEL[r.type]}</span>
<span className="text-gray-700">— {r.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
<span className="ml-auto text-xs text-gray-400">{r.closedAt ? `Closed ${fmtDate(r.closedAt)}` : ""}{r.handledBy ? ` by ${r.handledBy}` : ""}</span>
</div>
{r.response && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.response}</p>}
</li>
))}
</ul>
)}
</div>
);
}
+3 -1
View File
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
import { useVisiblePolling } from "@/hooks/useVisiblePolling"; import { useVisiblePolling } from "@/hooks/useVisiblePolling";
import { import {
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList, Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
} from "lucide-react"; } from "lucide-react";
import { StatCard, StatCardRow } from "@/components/shared/StatCard"; import { StatCard, StatCardRow } from "@/components/shared/StatCard";
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile"; import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
@@ -42,6 +42,8 @@ const QUICK_ACTIONS = [
{ href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle }, { href: "/dashboard/supervisor/whatsapp-attendees", label: "WhatsApp attendees", description: "Send a WhatsApp message to event attendees", icon: MessageCircle },
{ href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet }, { href: "/dashboard/admin/cashup", label: "Post-event Cashup", description: "Set costs, reconcile takings, and close out events", icon: Wallet },
{ href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History }, { href: "/dashboard/admin/audit-log", label: "Audit log", description: "Review refunds, manual registrations, event and settings changes", icon: History },
{ href: "/dashboard/admin/data-requests", label: "Data requests", description: "POPIA: people asking to correct or delete their information", icon: Inbox },
{ href: "/dashboard/admin/privacy", label: "Privacy (POPIA)", description: "Privacy notice, operators, consent and access logs", icon: ShieldCheck },
] as const; ] as const;
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null }; type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
@@ -0,0 +1,81 @@
"use client";
import React, { Suspense } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ShieldCheck, ScrollText, Building2, ListChecks, Eye, Inbox, Gauge, Timer, AlertTriangle } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { BackLink } from "@/components/shared/BackLink";
import { NoticeEditor } from "@/components/admin/privacy/NoticeEditor";
import { OperatorsPanel } from "@/components/admin/privacy/OperatorsPanel";
import { ConsentLogPanel, AccessLogPanel } from "@/components/admin/privacy/LogPanels";
import { ReadinessPanel } from "@/components/admin/privacy/ReadinessPanel";
import { RetentionPanel } from "@/components/admin/privacy/RetentionPanel";
import { BreachesPanel } from "@/components/admin/privacy/BreachesPanel";
const TABS = [
{ key: "readiness", label: "Readiness", icon: Gauge },
{ key: "notice", label: "Privacy notice", icon: ScrollText },
{ key: "operators", label: "Operators", icon: Building2 },
{ key: "retention", label: "Retention", icon: Timer },
{ key: "consent", label: "Consent log", icon: ListChecks },
{ key: "access", label: "Access log", icon: Eye },
{ key: "breaches", label: "Breaches", icon: AlertTriangle },
] as const;
type TabKey = (typeof TABS)[number]["key"];
function PrivacyAdmin() {
const { token } = useAuth();
const router = useRouter();
const params = useSearchParams();
const tab = (TABS.find(t => t.key === params.get("tab"))?.key || "readiness") as TabKey;
return (
<div className="max-w-5xl mx-auto w-full">
<div className="mb-6">
<BackLink href="/dashboard/admin" />
<div className="flex items-center gap-2 mt-0.5">
<ShieldCheck className="w-6 h-6 text-brand-600" />
<h1 className="text-2xl font-semibold text-gray-900">Privacy (POPIA)</h1>
</div>
<p className="text-sm text-gray-500 mt-1">
Your privacy notice, the services that process personal information for you, and the records that show how it&apos;s handled.
People&apos;s requests are on <Link href="/dashboard/admin/data-requests" className="text-brand-600 hover:underline inline-flex items-center gap-1"><Inbox className="w-3.5 h-3.5" />Data requests</Link>;
the Information Officer is set in <Link href="/dashboard/admin/settings" className="text-brand-600 hover:underline">Site settings → Legal</Link>.
</p>
</div>
<div className="flex flex-wrap gap-2 mb-5" role="tablist">
{TABS.map(t => {
const Icon = t.icon;
return (
<button key={t.key} role="tab" aria-selected={tab === t.key} onClick={() => router.replace(`/dashboard/admin/privacy?tab=${t.key}`)}
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium ${tab === t.key ? "bg-brand-600 text-white" : "bg-white border text-gray-700 hover:bg-gray-50"}`}>
<Icon className="w-4 h-4" /> {t.label}
</button>
);
})}
</div>
{token && (
<div className="border rounded-xl p-5 bg-white shadow-sm">
{tab === "readiness" && <ReadinessPanel token={token} />}
{tab === "notice" && <NoticeEditor token={token} />}
{tab === "operators" && <OperatorsPanel token={token} />}
{tab === "retention" && <RetentionPanel token={token} />}
{tab === "consent" && <ConsentLogPanel token={token} />}
{tab === "access" && <AccessLogPanel token={token} />}
{tab === "breaches" && <BreachesPanel token={token} />}
</div>
)}
</div>
);
}
export default function PrivacyAdminPage() {
return (
<Suspense fallback={<p className="text-sm text-gray-400">Loading…</p>}>
<PrivacyAdmin />
</Suspense>
);
}
@@ -3,6 +3,7 @@
import React, { Suspense, useCallback, useEffect, useRef, useState } from "react"; import React, { Suspense, useCallback, useEffect, useRef, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
import Link from "next/link";
import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api"; import { apiFetch, ApiError, API_BASE, resolveToApiOrigin } from "@/lib/api";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { useDismissingState } from "@/hooks/useDismissingState"; import { useDismissingState } from "@/hooks/useDismissingState";
@@ -649,7 +650,9 @@ function SiteSettingsPageInner() {
<div className="space-y-4"> <div className="space-y-4">
<h2 className="text-lg font-semibold text-gray-900">Legal</h2> <h2 className="text-lg font-semibold text-gray-900">Legal</h2>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">
These values populate the Terms of Use and Privacy Policy pages automatically. These values populate the Terms of Use and Privacy Policy pages automatically. Edit the privacy notice&apos;s
text, and publish new versions, in <Link href="/dashboard/admin/privacy" className="text-brand-600 hover:underline">Privacy (POPIA)</Link>.
The effective date below is only used until you publish your first version.
</p> </p>
<Field label="Operator / responsible party" <Field label="Operator / responsible party"
@@ -746,6 +749,8 @@ interface ConfigResponse {
hasToken: boolean; hasToken: boolean;
hasInstance: boolean; hasInstance: boolean;
configured: boolean; configured: boolean;
// Where WAWP should send events; carries the secret incoming messages are checked against.
webhookUrl?: string;
} }
interface StatusResponse { interface StatusResponse {
@@ -1182,6 +1187,22 @@ function WhatsAppTab({ active }: { active: boolean }) {
</section> </section>
)} )}
{cfg?.webhookUrl && (
<section className="border rounded-xl p-5 bg-gray-50 space-y-3">
<h3 className="text-base font-semibold">Webhook (STOP replies)</h3>
<p className="text-sm text-gray-600">
So people can reply <strong>STOP</strong> to stop news on WhatsApp: in the WAWP dashboard, click <em>Webhook</em> on
your instance, paste this URL and tick the <code>message</code> and <code>session.status</code> events. Keep it
private — the secret at the end is what proves a message came from WAWP.
</p>
<div className="flex gap-2">
<input readOnly value={cfg.webhookUrl} className="flex-1 min-w-0 border rounded-lg px-3 py-2 text-xs font-mono bg-white" onFocus={e => e.target.select()} />
<button type="button" className="px-3 py-2 rounded-lg border text-sm bg-white hover:bg-gray-50" onClick={() => navigator.clipboard?.writeText(cfg.webhookUrl || "")}>Copy</button>
</div>
<p className="text-xs text-gray-500">The URL must be reachable from the internet over HTTPS (set <code>BACKEND_URL</code> on the server).</p>
</section>
)}
<section className="border rounded-xl p-5 bg-gray-50 space-y-4"> <section className="border rounded-xl p-5 bg-gray-50 space-y-4">
<h3 className="text-base font-semibold">Session Controls</h3> <h3 className="text-base font-semibold">Session Controls</h3>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
+4
View File
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar"; import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
import { PrivacyPrompts } from "@/components/shared/PrivacyPrompts";
// The sidebar shows only on these exact routes — each role's dashboard root, // The sidebar shows only on these exact routes — each role's dashboard root,
// plus Profile & Security and Site Settings (matching the redesign // plus Profile & Security and Site Settings (matching the redesign
@@ -17,6 +18,7 @@ const SIDEBAR_ROUTES = [
"/dashboard/staff", "/dashboard/staff",
"/dashboard/user", "/dashboard/user",
"/dashboard/user/profile", "/dashboard/user/profile",
"/dashboard/user/privacy",
"/dashboard/admin/settings", "/dashboard/admin/settings",
]; ];
@@ -85,6 +87,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return ( return (
<div className="h-screen flex flex-col overflow-hidden"> <div className="h-screen flex flex-col overflow-hidden">
<Navbar /> <Navbar />
<PrivacyPrompts />
<MobileSidebar /> <MobileSidebar />
<div className="flex-1 flex min-h-0"> <div className="flex-1 flex min-h-0">
<Sidebar /> <Sidebar />
@@ -100,6 +103,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
return ( return (
<div className="min-h-screen flex flex-col"> <div className="min-h-screen flex flex-col">
<Navbar /> <Navbar />
<PrivacyPrompts />
<main className="flex-1 p-6 bg-gray-50">{children}</main> <main className="flex-1 p-6 bg-gray-50">{children}</main>
<Footer /> <Footer />
</div> </div>
@@ -10,6 +10,7 @@ import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types"; import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink"; import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
type Attendee = { id: string; name: string; email: string; pref: string }; type Attendee = { id: string; name: string; email: string; pref: string };
type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets'; type TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
@@ -243,6 +244,7 @@ function EmailAttendeesPageInner() {
}, [preselectEventId]); }, [preselectEventId]);
const [templateKey, setTemplateKey] = useState<TemplateKey>('custom'); const [templateKey, setTemplateKey] = useState<TemplateKey>('custom');
const [messageKind, setMessageKind] = useState<MessageKind>('update');
const [showInfo, setShowInfo] = useState(false); const [showInfo, setShowInfo] = useState(false);
// Broadcasts state // Broadcasts state
@@ -256,7 +258,9 @@ function EmailAttendeesPageInner() {
const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number|null>(null); const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number|null>(null);
const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{email:string;name?:string}[]|null>(null); const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{email:string;name?:string}[]|null>(null);
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>(""); const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
const [broadcastKind, setBroadcastKind] = useState<MessageKind>('news');
const resetBroadcastForm = () => { const resetBroadcastForm = () => {
setBroadcastKind('news');
setSelectedUserIds([]); setSelectedUserIds([]);
setBroadcastEventId(""); setBroadcastEventId("");
setBroadcastSubject(""); setBroadcastSubject("");
@@ -423,11 +427,12 @@ function EmailAttendeesPageInner() {
if (!subject.trim()) { setError("Subject is required"); return; } if (!subject.trim()) { setError("Subject is required"); return; }
if (!body.trim()) { setError("Message is required"); return; } if (!body.trim()) { setError("Message is required"); return; }
} }
const payload: { subject: string; filter: MessageFilter; dryRun: boolean; template: TemplateKey } & EmailContent = { const payload: { subject: string; filter: MessageFilter; dryRun: boolean; template: TemplateKey; kind: MessageKind } & EmailContent = {
subject: subject || "(no subject)", subject: subject || "(no subject)",
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined }, filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
dryRun: true, dryRun: true,
template: templateKey, template: templateKey,
kind: messageKind,
}; };
if (templateKey !== 'tickets') { if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body; if (body.trim().startsWith('<')) payload.html = body; else payload.text = body;
@@ -435,7 +440,7 @@ function EmailAttendeesPageInner() {
const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload }); const res = await apiFetch<EmailSendResult>(`/api/events/${encodeURIComponent(eventId)}/email-attendees`, { method: 'POST', authToken: token, body: payload });
setPreviewCount(res?.matched ?? 0); setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null); setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`); setInfo(matchedText(res));
} catch (e) { } catch (e) {
setError(errorMessage(e) || 'Failed to preview recipients'); setError(errorMessage(e) || 'Failed to preview recipients');
} }
@@ -443,6 +448,7 @@ function EmailAttendeesPageInner() {
const resetAttendeesForm = () => { const resetAttendeesForm = () => {
setTemplateKey('custom'); setTemplateKey('custom');
setMessageKind('update');
setSubject(''); setBody(''); setSubject(''); setBody('');
setSubjectDirty(false); setBodyDirty(false); setSubjectDirty(false); setBodyDirty(false);
setStatus('any'); setStatus('any');
@@ -463,10 +469,11 @@ function EmailAttendeesPageInner() {
if (!body.trim()) { setError("Message is required"); return; } if (!body.trim()) { setError("Message is required"); return; }
} }
setSending(true); setSending(true);
const payload: { subject: string; filter: MessageFilter; template: TemplateKey } & EmailContent = { const payload: { subject: string; filter: MessageFilter; template: TemplateKey; kind: MessageKind } & EmailContent = {
subject, subject,
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined }, filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, template: templateKey,
kind: messageKind,
}; };
if (templateKey !== 'tickets') { if (templateKey !== 'tickets') {
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n'); if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
@@ -571,6 +578,10 @@ function EmailAttendeesPageInner() {
</div> </div>
</div> </div>
{templateKey !== 'tickets' && (
<MessageKindPicker name="attendeesKind" value={messageKind} onChange={setMessageKind} />
)}
{showInfo && ( {showInfo && (
<div className="fixed inset-0 z-20"> <div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} /> <div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} />
@@ -663,10 +674,11 @@ function EmailAttendeesPageInner() {
if (!body.trim()) { setError('Message is required'); return; } if (!body.trim()) { setError('Message is required'); return; }
} }
const whenIso = new Date(scheduledAtLocal).toISOString(); const whenIso = new Date(scheduledAtLocal).toISOString();
const payload: { subject: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } & EmailContent = { const payload: { subject: string; filter: MessageFilter; template: TemplateKey; kind: MessageKind; scheduledAt: string } & EmailContent = {
subject, subject,
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined }, filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, template: templateKey,
kind: messageKind,
scheduledAt: whenIso, scheduledAt: whenIso,
}; };
if (templateKey !== 'tickets') { if (templateKey !== 'tickets') {
@@ -739,6 +751,8 @@ function EmailAttendeesPageInner() {
<PrefWarning attendees={users} selectedIds={selectedUserIds} channel="email" /> <PrefWarning attendees={users} selectedIds={selectedUserIds} channel="email" />
)} )}
<MessageKindPicker name="broadcastKind" value={broadcastKind} onChange={setBroadcastKind} />
<div> <div>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="block text-xs text-gray-600 mb-1">Subject</label> <label className="block text-xs text-gray-600 mb-1">Subject</label>
@@ -782,11 +796,11 @@ Jane Doe <jane@example.com>
if (!token) { setError('Not authenticated'); return; } if (!token) { setError('Not authenticated'); return; }
if (!broadcastSubject.trim()) { setError('Subject is required'); return; } if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; } if (!broadcastBody.trim()) { setError('Message is required'); return; }
const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined }; const body = { userIds: selectedUserIds, emails: broadcastEmails, eventId: broadcastEventId || undefined, kind: broadcastKind };
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body }); const res = await apiFetch<EmailSendResult>(`/api/broadcasts/preview`, { method: 'POST', authToken: token, body });
setBroadcastPreviewCount(res?.matched ?? 0); setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null); setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`); setInfo(matchedText(res));
} catch (e) { } catch (e) {
setError(errorMessage(e) || 'Failed to preview recipients'); setError(errorMessage(e) || 'Failed to preview recipients');
} }
@@ -798,10 +812,10 @@ Jane Doe <jane@example.com>
if (!token) { setError('Not authenticated'); return; } if (!token) { setError('Not authenticated'); return; }
if (!broadcastSubject.trim()) { setError('Subject is required'); return; } if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; } if (!broadcastBody.trim()) { setError('Message is required'); return; }
const payload: { subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails }; const payload: { subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody; if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload }); const res = await apiFetch<EmailSendResult>(`/api/broadcasts/send`, { method: 'POST', authToken: token, body: payload });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`); setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ''}`);
// Reset broadcast form // Reset broadcast form
resetBroadcastForm(); resetBroadcastForm();
} catch (e) { } catch (e) {
@@ -816,7 +830,7 @@ Jane Doe <jane@example.com>
if (!broadcastSubject.trim()) { setError('Subject is required'); return; } if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
if (!broadcastBody.trim()) { setError('Message is required'); return; } if (!broadcastBody.trim()) { setError('Message is required'); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString(); const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails }; const payload: { scheduledAt: string; subject: string; eventId?: string; userIds: string[]; emails: string; kind: MessageKind } & EmailContent = { scheduledAt: whenIso, subject: broadcastSubject, eventId: broadcastEventId || undefined, userIds: selectedUserIds, emails: broadcastEmails, kind: broadcastKind };
if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody; if (broadcastBody.trim().startsWith('<')) payload.html = broadcastBody; else payload.text = broadcastBody;
const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload }); const res = await apiFetch<EmailSendResult>(`/api/broadcasts/schedule`, { method: 'POST', authToken: token, body: payload });
if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.'); if (res?.job?.id) setInfo('Broadcast scheduled. It will be sent around the specified time.'); else setInfo('Scheduled.');
@@ -11,6 +11,8 @@ import { errorMessage } from "@/lib/utils";
import { registrationOptionUnitPrice } from "@/lib/pricing"; import { registrationOptionUnitPrice } from "@/lib/pricing";
import type { Event, Payment, Registration, User } from "@/types"; import type { Event, Payment, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink"; import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { WaitingForPayment } from "@/components/payments/WaitingForPayment";
// A donation is never mutated once created — assigning it to a registration creates a separate // A donation is never mutated once created — assigning it to a registration creates a separate
// "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead. // "leg" Payment row (isDonation:false, originalPaymentId -> the donation, amount > 0) instead.
@@ -145,6 +147,7 @@ function UserSearchField({ allUsers, value, onChange, placeholder = "Search by n
function PaymentsContent() { function PaymentsContent() {
const { user, loading, token } = useAuth(); const { user, loading, token } = useAuth();
const online = useOnlinePayments();
const router = useRouter(); const router = useRouter();
const search = useSearchParams(); const search = useSearchParams();
@@ -541,16 +544,23 @@ function PaymentsContent() {
<ScanLine className="w-4 h-4" /> <ScanLine className="w-4 h-4" />
Reconcile Reconcile
</label> </label>
{/* Payment links are paid online, so only while a payment provider is on */}
{online.available && (
<label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}> <label className={`inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded border ${mode === 'link' ? 'bg-brand-600 text-white border-brand-600' : 'bg-white text-gray-800 border-gray-200'} cursor-pointer`}>
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} /> <input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
<Link2 className="w-4 h-4" /> <Link2 className="w-4 h-4" />
Payment Link Payment Link
</label> </label>
)}
</div> </div>
<div className="grid lg:grid-cols-3 gap-6"> <div className="grid lg:grid-cols-3 gap-6">
<div className="lg:col-span-2 space-y-6"> <div className="lg:col-span-2 space-y-6">
{mode === 'reconcile' && (
<WaitingForPayment token={token} onRecorded={() => { loadUnreconciled(); loadPayments(); loadRegistrations(); }} />
)}
{/* Online payment reconciliation panel */} {/* Online payment reconciliation panel */}
{mode === 'reconcile' && ( {mode === 'reconcile' && (
<div className="border rounded-xl p-4 bg-white shadow-sm"> <div className="border rounded-xl p-4 bg-white shadow-sm">
@@ -758,7 +768,7 @@ function PaymentsContent() {
</> </>
)} )}
{mode === 'link' && ( {mode === 'link' && online.available && (
<div className="border rounded-xl p-4 bg-white shadow-sm"> <div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="text-lg font-semibold mb-3">Create payment link</div> <div className="text-lg font-semibold mb-3">Create payment link</div>
<div className="grid sm:grid-cols-2 gap-3"> <div className="grid sm:grid-cols-2 gap-3">
@@ -10,6 +10,7 @@ import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import type { Event, MessageSendResult, Registration, User } from "@/types"; import type { Event, MessageSendResult, Registration, User } from "@/types";
import { BackLink } from "@/components/shared/BackLink"; import { BackLink } from "@/components/shared/BackLink";
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
// Attendee with preference info // Attendee with preference info
type Attendee = { id: string; name: string; phone: string; pref: string }; type Attendee = { id: string; name: string; phone: string; pref: string };
@@ -404,6 +405,7 @@ function WhatsAppAttendeesPageInner() {
const [eventId, setEventId] = useState<string>(preselectEventId); const [eventId, setEventId] = useState<string>(preselectEventId);
useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]); useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]);
const [templateKey, setTemplateKey] = useState<TemplateKey>("custom"); const [templateKey, setTemplateKey] = useState<TemplateKey>("custom");
const [messageKind, setMessageKind] = useState<MessageKind>("update");
const [message, setMessage] = useState(""); const [message, setMessage] = useState("");
// Set once the supervisor edits the message, so picking a template doesn't overwrite it. // Set once the supervisor edits the message, so picking a template doesn't overwrite it.
// A ref, not state: it only gates the template effect below and is never rendered. // A ref, not state: it only gates the template effect below and is never rendered.
@@ -456,9 +458,10 @@ function WhatsAppAttendeesPageInner() {
}, [eventId, token]); }, [eventId, token]);
const buildPayload = (dryRun?: boolean) => { const buildPayload = (dryRun?: boolean) => {
const payload: { filter: MessageFilter; template: TemplateKey; dryRun?: boolean; message?: string } = { const payload: { filter: MessageFilter; template: TemplateKey; kind: MessageKind; dryRun?: boolean; message?: string } = {
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined }, filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, template: templateKey,
kind: messageKind,
dryRun: dryRun || undefined, dryRun: dryRun || undefined,
}; };
if (templateKey !== "tickets") payload.message = message; if (templateKey !== "tickets") payload.message = message;
@@ -474,12 +477,12 @@ function WhatsAppAttendeesPageInner() {
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) }); const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees`, { method: "POST", authToken: token, body: buildPayload(true) });
setPreviewCount(res?.matched ?? 0); setPreviewCount(res?.matched ?? 0);
setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null); setPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s) with a phone number.`); setInfo(matchedText(res));
} catch (e) { setError(errorMessage(e) || "Failed to preview"); } } catch (e) { setError(errorMessage(e) || "Failed to preview"); }
}; };
const resetAttendeesForm = () => { const resetAttendeesForm = () => {
setTemplateKey("custom"); setMessage(""); messageDirtyRef.current = false; setTemplateKey("custom"); setMessageKind("update"); setMessage(""); messageDirtyRef.current = false;
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null); setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
setSelectedAttendeeIds(attendees.map((a) => a.id)); setSelectedAttendeeIds(attendees.map((a) => a.id));
}; };
@@ -505,10 +508,10 @@ function WhatsAppAttendeesPageInner() {
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; } if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
if (!scheduledAtLocal) { setError("Scheduled time is required"); return; } if (!scheduledAtLocal) { setError("Scheduled time is required"); return; }
const whenIso = new Date(scheduledAtLocal).toISOString(); const whenIso = new Date(scheduledAtLocal).toISOString();
const payload: { message?: string; filter: MessageFilter; template: TemplateKey; scheduledAt: string } = { const payload: { message?: string; filter: MessageFilter; template: TemplateKey; kind: MessageKind; scheduledAt: string } = {
message: message || undefined, message: message || undefined,
filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined }, filter: { status: status !== "any" ? status : undefined, attendeeIds: selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
template: templateKey, scheduledAt: whenIso, template: templateKey, kind: messageKind, scheduledAt: whenIso,
}; };
const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload }); const res = await apiFetch<MessageSendResult>(`/api/events/${encodeURIComponent(eventId)}/whatsapp-attendees/schedule`, { method: "POST", authToken: token, body: payload });
setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled."); setInfo(res?.job?.id ? "WhatsApp scheduled. It will be sent around the specified time." : "Scheduled.");
@@ -526,6 +529,7 @@ function WhatsAppAttendeesPageInner() {
const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number | null>(null); const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number | null>(null);
const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null); const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>(""); const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
const [broadcastKind, setBroadcastKind] = useState<MessageKind>("news");
useEffect(() => { useEffect(() => {
const run = async () => { const run = async () => {
@@ -546,7 +550,7 @@ function WhatsAppAttendeesPageInner() {
const resetBroadcastForm = () => { const resetBroadcastForm = () => {
setSelectedUserIds([]); setBroadcastEventId(""); setBroadcastMessage(""); setSelectedUserIds([]); setBroadcastEventId(""); setBroadcastMessage("");
setBroadcastPhones(""); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null); setBroadcastPhones(""); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
setBroadcastScheduledAtLocal(""); setBroadcastScheduledAtLocal(""); setBroadcastKind("news");
}; };
// ── Scheduled tab ────────────────────────────────────────────────────────── // ── Scheduled tab ──────────────────────────────────────────────────────────
@@ -683,6 +687,10 @@ function WhatsAppAttendeesPageInner() {
</div> </div>
</div> </div>
{templateKey !== "tickets" && (
<MessageKindPicker name="waAttendeesKind" value={messageKind} onChange={setMessageKind} />
)}
{showInfo && ( {showInfo && (
<div className="fixed inset-0 z-20"> <div className="fixed inset-0 z-20">
<div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} /> <div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} />
@@ -817,6 +825,8 @@ function WhatsAppAttendeesPageInner() {
<PrefWarning attendees={users} selectedIds={selectedUserIds} channel="whatsapp" /> <PrefWarning attendees={users} selectedIds={selectedUserIds} channel="whatsapp" />
)} )}
<MessageKindPicker name="waBroadcastKind" value={broadcastKind} onChange={setBroadcastKind} />
<div> <div>
<label className="block text-xs text-gray-600 mb-1">Message</label> <label className="block text-xs text-gray-600 mb-1">Message</label>
<textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={broadcastMessage} <textarea className="w-full border rounded px-3 py-2 text-sm" rows={8} value={broadcastMessage}
@@ -843,10 +853,10 @@ function WhatsAppAttendeesPageInner() {
try { try {
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null); setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
if (!token) { setError("Not authenticated"); return; } if (!token) { setError("Not authenticated"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } }); const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/preview`, { method: "POST", authToken: token, body: { userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
setBroadcastPreviewCount(res?.matched ?? 0); setBroadcastPreviewCount(res?.matched ?? 0);
setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null); setBroadcastPreviewSample(Array.isArray(res?.recipients) ? res.recipients : null);
setInfo(`Matched ${res?.matched ?? 0} recipient(s).`); setInfo(matchedText(res));
} catch (e) { setError(errorMessage(e) || "Failed to preview"); } } catch (e) { setError(errorMessage(e) || "Failed to preview"); }
}}>Preview recipients</button> }}>Preview recipients</button>
@@ -855,8 +865,8 @@ function WhatsAppAttendeesPageInner() {
setError(null); setInfo(null); setError(null); setInfo(null);
if (!token) { setError("Not authenticated"); return; } if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; } if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } }); const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/send`, { method: "POST", authToken: token, body: { message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`); setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ""}`);
resetBroadcastForm(); resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to send"); } } catch (e) { setError(errorMessage(e) || "Failed to send"); }
}}>Send now</button> }}>Send now</button>
@@ -867,7 +877,7 @@ function WhatsAppAttendeesPageInner() {
if (!token) { setError("Not authenticated"); return; } if (!token) { setError("Not authenticated"); return; }
if (!broadcastMessage.trim()) { setError("Message is required"); return; } if (!broadcastMessage.trim()) { setError("Message is required"); return; }
const whenIso = new Date(broadcastScheduledAtLocal).toISOString(); const whenIso = new Date(broadcastScheduledAtLocal).toISOString();
const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined } }); const res = await apiFetch<MessageSendResult>(`/api/whatsapp-broadcasts/schedule`, { method: "POST", authToken: token, body: { scheduledAt: whenIso, message: broadcastMessage, userIds: selectedUserIds, phones: broadcastPhones, eventId: broadcastEventId || undefined, kind: broadcastKind } });
if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled."); if (res?.job?.id) setInfo("WhatsApp broadcast scheduled."); else setInfo("Scheduled.");
resetBroadcastForm(); resetBroadcastForm();
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); } } catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
@@ -7,9 +7,11 @@ import { HandHeart } from "lucide-react";
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import type { Event } from "@/types"; import type { Event } from "@/types";
import { BackLink } from "@/components/shared/BackLink"; import { BackLink } from "@/components/shared/BackLink";
import { useOnlinePayments } from "@/lib/onlinePayments";
export default function DonatePage() { export default function DonatePage() {
const { token } = useAuth(); const { token } = useAuth();
const online = useOnlinePayments();
const [events, setEvents] = useState<Event[]>([]); const [events, setEvents] = useState<Event[]>([]);
const [eventId, setEventId] = useState<string>(""); const [eventId, setEventId] = useState<string>("");
const [amount, setAmount] = useState<string>(""); const [amount, setAmount] = useState<string>("");
@@ -72,6 +74,11 @@ export default function DonatePage() {
</div> </div>
</div> </div>
{online.ready && !online.available ? (
<div className="border rounded-xl p-5 bg-white shadow-sm text-sm text-gray-700">
Online donations aren&apos;t available at the moment. You can give at an event, or contact the organiser.
</div>
) : (
<div className="border rounded-xl p-5 bg-white shadow-sm"> <div className="border rounded-xl p-5 bg-white shadow-sm">
{error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>} {error && <p className="text-red-600 text-sm mb-3 bg-red-50 border border-red-100 rounded-lg p-2.5">{error}</p>}
{info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>} {info && <p className="text-green-700 text-sm mb-3 bg-green-50 border border-green-100 rounded-lg p-2.5">{info}</p>}
@@ -99,6 +106,7 @@ export default function DonatePage() {
className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors" className="w-full bg-brand-600 hover:bg-brand-700 text-white px-4 py-2.5 rounded-lg disabled:opacity-60 font-medium transition-colors"
>{loading?"Starting checkout...":"Donate"}</button> >{loading?"Starting checkout...":"Donate"}</button>
</div> </div>
)}
</div> </div>
); );
} }
+13 -5
View File
@@ -2,6 +2,7 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch, createFullPaymentCheckout } from "@/lib/api"; import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { formatDate } from "@/lib/date"; import { formatDate } from "@/lib/date";
import { printTicketCards } from "@/lib/ticketPrint"; import { printTicketCards } from "@/lib/ticketPrint";
@@ -81,6 +82,7 @@ function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null
export default function UserDashboardPage() { export default function UserDashboardPage() {
const { token, user } = useAuth(); const { token, user } = useAuth();
const online = useOnlinePayments();
const [registrations, setRegistrations] = useState<Registration[]>([]); const [registrations, setRegistrations] = useState<Registration[]>([]);
const [tickets, setTickets] = useState<TicketRecord[]>([]); const [tickets, setTickets] = useState<TicketRecord[]>([]);
const [error, setError] = useDismissingState<string | null>(null); const [error, setError] = useDismissingState<string | null>(null);
@@ -654,10 +656,12 @@ export default function UserDashboardPage() {
className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" className="px-2.5 py-1 text-xs rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
onClick={() => router.push("/dashboard/user/payments")} onClick={() => router.push("/dashboard/user/payments")}
>Payment history</button> >Payment history</button>
<button {online.available && (
className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1" <button
onClick={() => router.push("/dashboard/user/donate")} className="px-3 py-1.5 text-sm rounded bg-brand-600 text-white hover:bg-brand-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-brand-500 focus:ring-offset-1"
>Make donation</button> onClick={() => router.push("/dashboard/user/donate")}
>Make donation</button>
)}
</div> </div>
</div> </div>
{error && <p className="text-red-600 text-sm mb-3">{error}</p>} {error && <p className="text-red-600 text-sm mb-3">{error}</p>}
@@ -1194,7 +1198,7 @@ export default function UserDashboardPage() {
className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1" className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800 shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1"
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })} onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
>Invoice</button> >Invoice</button>
{canModifyActive && activeBill && activeBill.outstanding > 0 && ( {canModifyActive && activeBill && online.canPay(activeBill.outstanding) && (
<button <button
className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1" className="px-3 py-1.5 text-sm bg-green-600 text-white rounded hover:bg-green-700 shadow-sm focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-1"
onClick={() => payNow(activeReg.id)} onClick={() => payNow(activeReg.id)}
@@ -1207,6 +1211,10 @@ export default function UserDashboardPage() {
}}>Print all tickets</button> }}>Print all tickets</button>
</div> </div>
{canModifyActive && activeBill && activeBill.outstanding > 0 && online.reason(activeBill.outstanding) && (
<p className="text-xs text-gray-500">{online.reason(activeBill.outstanding)}</p>
)}
{/* Cancel registration — only shown when no payments have been made and the event still permits changes */} {/* Cancel registration — only shown when no payments have been made and the event still permits changes */}
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && ( {canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
<div className="pt-3 border-t mt-1"> <div className="pt-3 border-t mt-1">
@@ -0,0 +1,342 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch, API_BASE } from "@/lib/api";
import { useDismissingState } from "@/hooks/useDismissingState";
import { Megaphone, Download, PencilLine, Trash2, ListChecks, type LucideIcon } from "lucide-react";
import { errorMessage } from "@/lib/utils";
import { BackLink } from "@/components/shared/BackLink";
type RequestType = "access" | "correction" | "deletion";
type RequestStatus = "open" | "completed" | "rejected" | "cancelled";
type DataRequest = { id: string; type: RequestType; status: RequestStatus; details: string | null; response: string | null; createdAt: string; closedAt: string | null };
type ConsentEntry = { id: string; channel: "email" | "whatsapp"; granted: boolean; source: string; createdAt: string };
type PrivacyInfo = {
news: { email: boolean; whatsapp: boolean };
canWhatsApp: boolean;
notificationPreference: "email" | "whatsapp" | "both";
history: ConsentEntry[];
requests: DataRequest[];
};
type Msg = { type: "ok" | "err"; text: string } | null;
const SOURCE_LABEL: Record<string, string> = {
signup: "when you signed up",
privacy_centre: "in your privacy settings",
prompt: "when we asked after logging in",
unsubscribe_link: "with an unsubscribe link",
whatsapp_stop: "by replying STOP on WhatsApp",
existing_account: "your account existed before we asked",
admin: "by an administrator",
};
const TYPE_LABEL: Record<RequestType, string> = { access: "Access", correction: "Correction", deletion: "Account deletion" };
const STATUS_TONE: Record<RequestStatus, string> = {
open: "bg-amber-50 text-amber-700",
completed: "bg-green-50 text-green-700",
rejected: "bg-red-50 text-red-700",
cancelled: "bg-gray-100 text-gray-600",
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
function Alert({ msg }: { msg: NonNullable<Msg> }) {
return (
<div className={`mt-3 p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>
{msg.text}
</div>
);
}
function SectionHeader({ icon: Icon, title, tone = "brand" }: { icon: LucideIcon; title: string; tone?: "brand" | "red" }) {
return (
<div className="flex items-center gap-2 mb-4">
<div className={`w-9 h-9 rounded-lg flex items-center justify-center shrink-0 ${tone === "red" ? "bg-red-50 text-red-600" : "bg-brand-50 text-brand-600"}`}>
<Icon className="w-5 h-5" />
</div>
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
</div>
);
}
function Toggle({ label, hint, checked, disabled, onChange }: { label: string; hint?: string; checked: boolean; disabled?: boolean; onChange: (v: boolean) => void }) {
return (
<label className={`flex items-start gap-3 ${disabled ? "opacity-60" : "cursor-pointer"}`}>
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-600" checked={checked} disabled={disabled} onChange={e => onChange(e.target.checked)} />
<span className="text-sm text-gray-700">
{label}
{hint && <span className="block text-xs text-gray-500">{hint}</span>}
</span>
</label>
);
}
export default function PrivacyCentrePage() {
const { token, logout } = useAuth();
const [info, setInfo] = useState<PrivacyInfo | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
setInfo(await apiFetch<PrivacyInfo>("/api/privacy/me", { authToken: token }));
setLoadError(null);
} catch (e) {
setLoadError(errorMessage(e) || "Could not load your privacy settings.");
}
}, [token]);
useEffect(() => { load(); }, [load]);
// ── News & announcements ──────────────────────────────────────────────────
const [newsMsg, setNewsMsg] = useDismissingState<Msg>(null);
const [savingNews, setSavingNews] = useState(false);
const setNews = async (channel: "email" | "whatsapp", value: boolean) => {
if (!token || !info) return;
setSavingNews(true);
setNewsMsg(null);
// Show the new state straight away; load() puts back the saved one if it fails.
setInfo({ ...info, news: { ...info.news, [channel]: value } });
try {
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { [channel]: value } });
await load();
setNewsMsg({ type: "ok", text: value ? "You're subscribed." : "You're unsubscribed." });
} catch (e) {
await load();
setNewsMsg({ type: "err", text: errorMessage(e) || "Could not save." });
} finally {
setSavingNews(false);
}
};
// ── Download my data ──────────────────────────────────────────────────────
const [downloadMsg, setDownloadMsg] = useDismissingState<Msg>(null);
const [downloading, setDownloading] = useState<"json" | "pdf" | null>(null);
const download = async (format: "json" | "pdf") => {
if (!token) return;
setDownloading(format);
setDownloadMsg(null);
try {
const res = await fetch(`${API_BASE}/api/privacy/me/export${format === "pdf" ? "?format=pdf" : ""}`, { headers: { Authorization: `Bearer ${token}` } });
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(body?.message || `Download failed (${res.status})`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `my-data-${new Date().toISOString().slice(0, 10)}.${format}`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 2000);
} catch (e) {
setDownloadMsg({ type: "err", text: errorMessage(e) || "Download failed." });
} finally {
setDownloading(null);
}
};
// ── Correction request ────────────────────────────────────────────────────
const [correction, setCorrection] = useState("");
const [correctionMsg, setCorrectionMsg] = useDismissingState<Msg>(null);
const [sendingCorrection, setSendingCorrection] = useState(false);
const requestCorrection = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setSendingCorrection(true);
setCorrectionMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", { method: "POST", authToken: token, body: { type: "correction", details: correction } });
setCorrection("");
setCorrectionMsg({ type: "ok", text: res?.message || "Request sent." });
load();
} catch (e) {
setCorrectionMsg({ type: "err", text: errorMessage(e) || "Could not send the request." });
} finally {
setSendingCorrection(false);
}
};
// ── Delete my account ─────────────────────────────────────────────────────
const [deleteStep, setDeleteStep] = useState<"idle" | "confirm">("idle");
const [deletePassword, setDeletePassword] = useState("");
const [deleteReason, setDeleteReason] = useState("");
const [deleteMsg, setDeleteMsg] = useDismissingState<Msg>(null);
const [deleting, setDeleting] = useState(false);
const requestDeletion = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
setDeleting(true);
setDeleteMsg(null);
try {
const res = await apiFetch<{ message?: string }>("/api/privacy/me/requests", {
method: "POST",
authToken: token,
body: { type: "deletion", password: deletePassword, details: deleteReason || undefined },
});
setDeleteMsg({ type: "ok", text: res?.message || "Your account is closed." });
setTimeout(() => logout(), 3000);
} catch (e) {
setDeleteMsg({ type: "err", text: errorMessage(e) || "Could not send the request." });
} finally {
setDeleting(false);
}
};
const openCorrection = info?.requests.some(r => r.type === "correction" && r.status === "open");
return (
<div className="max-w-4xl mx-auto w-full">
<div className="mb-6">
<BackLink href="/dashboard" />
<h1 className="text-2xl font-semibold text-gray-900 mt-0.5">Privacy</h1>
<p className="text-sm text-gray-500 mt-1">
What we send you, a copy of the information we hold about you, and your rights under POPIA.
Read the <Link href="/legal/privacy" className="text-brand-600 hover:underline">Privacy Policy</Link>.
</p>
</div>
{loadError && <Alert msg={{ type: "err", text: loadError }} />}
<div className="grid lg:grid-cols-2 gap-6 items-start">
<div className="space-y-6">
{/* ── News & announcements ───────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Megaphone} title="News & announcements" />
<p className="text-sm text-gray-500 mb-4">
Occasional messages about upcoming events. Messages about events you&apos;ve registered for — tickets,
reminders, changes — are always sent{info ? ` (by ${info.notificationPreference === "both" ? "email and WhatsApp" : info.notificationPreference === "whatsapp" ? "WhatsApp" : "email"}; change this in ` : " (change how in "}
<Link href="/dashboard/user/profile" className="text-brand-600 hover:underline">Profile &amp; Security</Link>).
</p>
{info ? (
<div className="space-y-3">
<Toggle label="By email" checked={info.news.email} disabled={savingNews} onChange={v => setNews("email", v)} />
<Toggle
label="By WhatsApp"
hint={info.canWhatsApp ? undefined : "Add a valid phone number to your profile first."}
checked={info.news.whatsapp}
disabled={savingNews || (!info.canWhatsApp && !info.news.whatsapp)}
onChange={v => setNews("whatsapp", v)}
/>
</div>
) : !loadError && <p className="text-sm text-gray-400">Loading…</p>}
{newsMsg && <Alert msg={newsMsg} />}
{info && info.history.length > 0 && (
<details className="mt-4">
<summary className="text-xs text-gray-500 cursor-pointer">History of your choices</summary>
<ul className="mt-2 space-y-1">
{info.history.slice(0, 20).map(h => (
<li key={h.id} className="text-xs text-gray-500">
{new Date(h.createdAt).toLocaleString()} — {h.channel === "email" ? "email" : "WhatsApp"} {h.granted ? "on" : "off"}, {SOURCE_LABEL[h.source] || h.source}
</li>
))}
</ul>
</details>
)}
</section>
{/* ── Download my data ───────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Download} title="Download my data" />
<p className="text-sm text-gray-500 mb-4">
A copy of everything we hold about you: your account, registrations, the people you registered,
form answers, payments, tickets and check-ins, certificates, feedback and your communication choices.
</p>
<div className="flex flex-wrap gap-3">
<button onClick={() => download("pdf")} disabled={!!downloading} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{downloading === "pdf" ? "Preparing…" : "Readable PDF"}
</button>
<button onClick={() => download("json")} disabled={!!downloading} className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 text-sm font-medium hover:bg-gray-50 disabled:opacity-50">
{downloading === "json" ? "Preparing…" : "Complete copy (JSON)"}
</button>
</div>
{downloadMsg && <Alert msg={downloadMsg} />}
</section>
{/* ── My requests ────────────────────────────────────────────────── */}
{info && info.requests.length > 0 && (
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={ListChecks} title="My requests" />
<ul className="space-y-3">
{info.requests.map(r => (
<li key={r.id} className="text-sm">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-800">{TYPE_LABEL[r.type]}</span>
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS_TONE[r.status]}`}>{r.status}</span>
<span className="text-xs text-gray-400 ml-auto">{new Date(r.createdAt).toLocaleDateString()}</span>
</div>
{r.details && <p className="text-gray-600 mt-1 whitespace-pre-line">{r.details}</p>}
{r.response && <p className="text-gray-500 mt-1 text-xs whitespace-pre-line">Our answer: {r.response}</p>}
</li>
))}
</ul>
</section>
)}
</div>
<div className="space-y-6">
{/* ── Correct my data ────────────────────────────────────────────── */}
<section className="border rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={PencilLine} title="Correct my data" />
<p className="text-sm text-gray-500 mb-4">
Change your name, email or phone number yourself in{" "}
<Link href="/dashboard/user/profile" className="text-brand-600 hover:underline">Profile &amp; Security</Link>.
For anything you can&apos;t change there — such as details on a past registration, an attendee&apos;s
date of birth or a form answer — tell us what is wrong and we&apos;ll correct it.
</p>
{openCorrection ? (
<p className="text-sm text-gray-600">You have a correction request open — we&apos;ll email you when it&apos;s done.</p>
) : (
<form onSubmit={requestCorrection} className="space-y-3">
<textarea className={inputCls} rows={4} maxLength={5000} value={correction} onChange={e => setCorrection(e.target.value)} placeholder="What needs correcting, and what should it be?" required />
<button type="submit" disabled={sendingCorrection || !correction.trim()} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{sendingCorrection ? "Sending…" : "Send correction request"}
</button>
</form>
)}
{correctionMsg && <Alert msg={correctionMsg} />}
</section>
{/* ── Delete my account ──────────────────────────────────────────── */}
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Trash2} title="Delete my account" tone="red" />
<p className="text-sm text-gray-500 mb-4">
Your account is closed straight away, and your personal information — name, contact details, the people
you registered, form answers — is deleted once we&apos;ve processed the request (within 30 days). If you
have a registration for an upcoming event, that is sorted out first. Payment amounts and dates are kept,
without your name, because the law requires us to keep financial records.
</p>
{deleteStep === "idle" ? (
<button onClick={() => setDeleteStep("confirm")} className="px-4 py-2 rounded-lg border border-red-600 text-red-600 text-sm font-medium hover:bg-red-50">
Delete my account…
</button>
) : (
<form onSubmit={requestDeletion} className="space-y-4">
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-800">
<strong>You won&apos;t be able to log in again.</strong> Download your data first if you want a copy.
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Reason (optional)</label>
<textarea className={inputCls} rows={2} maxLength={1000} value={deleteReason} onChange={e => setDeleteReason(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Confirm with your password</label>
<input type="password" className={inputCls} value={deletePassword} onChange={e => setDeletePassword(e.target.value)} required autoComplete="current-password" />
</div>
<div className="flex gap-3">
<button type="button" onClick={() => { setDeleteStep("idle"); setDeletePassword(""); setDeleteReason(""); setDeleteMsg(null); }} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200">
Cancel
</button>
<button type="submit" disabled={deleting || !deletePassword} className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50">
{deleting ? "Sending…" : "Close account and delete my data"}
</button>
</div>
</form>
)}
{deleteMsg && <Alert msg={deleteMsg} />}
</section>
</div>
</div>
</div>
);
}
@@ -3,6 +3,7 @@
import React, { useCallback, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { isValidZAPhone } from "@/lib/phone"; import { isValidZAPhone } from "@/lib/phone";
import { useDismissingState } from "@/hooks/useDismissingState"; import { useDismissingState } from "@/hooks/useDismissingState";
@@ -534,9 +535,10 @@ export default function UserProfilePage() {
<section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm"> <section className="border border-red-200 rounded-xl p-5 bg-white shadow-sm">
<SectionHeader icon={Trash2} title="Close account" tone="red" /> <SectionHeader icon={Trash2} title="Close account" tone="red" />
<p className="text-sm text-gray-500 mb-4"> <p className="text-sm text-gray-500 mb-4">
Closing your account will deactivate it immediately. You can also request that your personal Closing your account will deactivate it immediately. You can also ask for your personal
information (name, email, phone number) be permanently deleted. Tickets and payment records information to be deleted — we do that once the request is processed (see{" "}
will remain for accounting purposes but will show as &quot;Deleted User&quot;. <Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">Privacy</Link>). Tickets and
payment records remain for accounting purposes but show as &quot;Deleted User&quot;.
</p> </p>
{closeStep === "idle" && ( {closeStep === "idle" && (
@@ -562,8 +564,9 @@ export default function UserProfilePage() {
onChange={e => setDeleteData(e.target.checked)} onChange={e => setDeleteData(e.target.checked)}
/> />
<span className="text-sm text-gray-700"> <span className="text-sm text-gray-700">
<span className="font-medium">Also delete my personal data</span> — your name, email address, <span className="font-medium">Also delete my personal data</span> — your name, contact details,
and phone number will be permanently removed and cannot be recovered. the people you registered and your form answers are permanently removed once your request
is processed (within 30 days).
</span> </span>
</label> </label>
@@ -595,7 +598,7 @@ export default function UserProfilePage() {
disabled={closing || !closePassword} disabled={closing || !closePassword}
className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50" className="px-4 py-2 rounded-lg bg-red-600 text-white text-sm font-medium hover:bg-red-700 disabled:opacity-50"
> >
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"} {closing ? "Processing…" : deleteData ? "Close account & request deletion" : "Close my account"}
</button> </button>
</div> </div>
{closeMsg && <Alert msg={closeMsg} />} {closeMsg && <Alert msg={closeMsg} />}
+103 -291
View File
@@ -1,307 +1,119 @@
"use client"; "use client";
import { Suspense, useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar"; import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer"; import { Footer } from "@/components/layout/Footer";
import { useScrollSpy } from "@/hooks/useScrollSpy"; import { useScrollSpy } from "@/hooks/useScrollSpy";
import { useSiteSettings } from "@/contexts/SiteSettingsContext"; import { useSiteSettings } from "@/contexts/SiteSettingsContext";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton"; import { apiFetch, ApiError } from "@/lib/api";
import { PrivacyNoticeSection, type PublicNotice } from "@/components/shared/PrivacyNoticeSection";
const sections = [ const fmtDate = (d: string) => new Date(d).toLocaleDateString(undefined, { day: "numeric", month: "long", year: "numeric" });
{ id: "summary", title: "Plain English Summary" },
{ id: "introduction",title: "1. Introduction" }, /**
{ id: "information", title: "2. Information We Collect" }, * The privacy notice. Its text comes from the backend: the published version edited in
{ id: "others", title: "3. People You Register or Transfer Tickets To" }, * Admin → Privacy (or the built-in notice before one is published), with the organisation's
{ id: "use", title: "4. How We Use Your Information" }, * details and operator register filled in. ?version=N shows an earlier version.
{ id: "whatsapp", title: "5. WhatsApp Communications" }, */
{ id: "storage", title: "6. Where We Store Your Data" }, function PrivacyNotice() {
{ id: "retention", title: "7. Data Retention" }, const versionParam = useSearchParams().get("version");
{ id: "children", title: "8. Children, Parental Consent and Health Information" }, const { settings } = useSiteSettings();
{ id: "sharing", title: "9. Who Can See Your Information" }, const [notice, setNotice] = useState<PublicNotice | null>(null);
{ id: "rights", title: "10. Your Rights (under POPIA)" }, const [error, setError] = useState<string | null>(null);
{ id: "cookies", title: "11. Cookie Policy (Cookies and Browser Storage)" }, const sectionIds = useMemo(() => (notice?.sections || []).map(s => s.id), [notice]);
{ id: "security", title: "12. Security" }, const activeId = useScrollSpy(sectionIds);
{ id: "changes", title: "13. Changes to This Policy" },
{ id: "officer", title: "14. Information Officer" }, useEffect(() => {
{ id: "contact", title: "15. Contact" }, setNotice(null);
]; setError(null);
apiFetch<PublicNotice>(`/api/privacy/notice${versionParam ? `?version=${encodeURIComponent(versionParam)}` : ""}`)
.then(setNotice)
.catch(e => setError(e instanceof ApiError ? e.message : "The privacy notice couldn't be loaded. Please try again."));
}, [versionParam]);
const orgName = settings.org_name || "Cross Code";
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const past = (notice?.versions || []).filter(v => v.version !== notice?.versions[0]?.version);
return (
<main className="flex-1 max-w-6xl mx-auto px-6 py-12 grid grid-cols-1 lg:grid-cols-[250px_1fr] gap-10 w-full">
<aside className="hidden lg:block text-sm text-gray-600 space-y-2 self-start sticky top-24 h-fit">
<h3 className="font-semibold text-gray-800 mb-3">On this page</h3>
<ul className="space-y-2 border-l border-gray-300 pl-3">
{(notice?.sections || []).map(s => (
<li key={s.id}>
<a href={`#${s.id}`} className={`block hover:text-brand-600 transition-colors ${activeId === s.id ? "text-brand-600 font-semibold" : ""}`}>
{s.title}
</a>
</li>
))}
</ul>
</aside>
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-brand-700 mb-2">Privacy Policy</h1>
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
{!notice && !error && <p className="text-sm text-gray-400">Loading…</p>}
{notice && (
<>
<p className="text-sm text-gray-500 mb-6">
Effective Date: {notice.effectiveDate}
{!notice.builtIn && <> · Version {notice.version}</>}
</p>
{!notice.current && (
<div className="mb-6 p-3 rounded-lg bg-amber-50 border border-amber-200 text-sm text-amber-900">
This is an earlier version of our privacy notice. <Link href="/legal/privacy" className="font-medium underline">Read the current version</Link>.
</div>
)}
<div className="border-l-4 border-brand-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-brand-600 hover:underline">{websiteUrl}</a>
</p>
<p><strong>Responsible Party:</strong> {orgName}</p>
<p>
<strong>Email:</strong>{" "}
<a href={`mailto:${orgEmail}`} className="text-brand-600 hover:underline">{orgEmail}</a>
</p>
</div>
{notice.sections.map((s, i) => <PrivacyNoticeSection key={s.id} section={s} first={i === 0} />)}
{past.length > 0 && (
<section className="mt-12 pt-6 border-t text-sm">
<h3 className="font-semibold text-gray-800 mb-2">Earlier versions</h3>
<ul className="space-y-1">
{notice.versions.map(v => (
<li key={v.version}>
<Link href={v.version === notice.versions[0].version ? "/legal/privacy" : `/legal/privacy?version=${v.version}`} className={`hover:underline ${v.version === notice.version ? "font-semibold text-gray-900" : "text-brand-600"}`}>
Version {v.version}
</Link>{" "}
<span className="text-gray-500">— {fmtDate(v.publishedAt)}{v.changeSummary ? `: ${v.changeSummary}` : ""}</span>
</li>
))}
</ul>
</section>
)}
</>
)}
</article>
</main>
);
}
export default function PrivacyPolicyPage() { export default function PrivacyPolicyPage() {
const activeId = useScrollSpy(sections.map((s) => s.id));
const { settings } = useSiteSettings();
// orgName follows the sitewide org_name default (Cross Code) since org_name is a
// required field at setup — this fallback is practically unreachable in production.
const orgName = settings.org_name || "Cross Code";
// The fields below are all OPTIONAL legal-specific settings that can realistically
// stay unset on a live site. Defaulting them to a real person/org's identity would be
// actively misleading on someone else's deployment, so these are generic placeholders
// that obviously need to be filled in via Admin → Site Settings → Legal, rather than
// silently showing Cross Code's or any other real organisation's details.
const orgEmail = settings.org_email || "privacy@example.com";
const websiteUrl = settings.legal_website_url || "example.com";
const ioName = settings.legal_io_name || "[not yet configured]";
const ioEmail = settings.legal_io_email || "io@example.com";
const effectiveDate = settings.legal_effective_date || "[not yet set]";
return ( return (
<div className="min-h-screen flex flex-col bg-gray-50"> <div className="min-h-screen flex flex-col bg-gray-50">
<Navbar /> <Navbar />
<main className="flex-1 max-w-6xl mx-auto px-6 py-12 grid grid-cols-1 lg:grid-cols-[250px_1fr] gap-10"> <Suspense fallback={<main className="flex-1" />}>
{/* Sidebar */} <PrivacyNotice />
<aside className="hidden lg:block text-sm text-gray-600 space-y-2 self-start sticky top-24 h-fit"> </Suspense>
<h3 className="font-semibold text-gray-800 mb-3">On this page</h3>
<ul className="space-y-2 border-l border-gray-300 pl-3">
{sections.map((section) => (
<li key={section.id}>
<a
href={`#${section.id}`}
className={`block hover:text-brand-600 transition-colors ${
activeId === section.id ? "text-brand-600 font-semibold" : ""
}`}
>
{section.title}
</a>
</li>
))}
</ul>
</aside>
{/* Main Content */}
<article className="bg-white rounded-2xl shadow p-8 text-gray-700 leading-relaxed">
<h1 className="text-4xl font-bold text-brand-700 mb-2">Privacy Policy</h1>
<p className="text-sm text-gray-500 mb-6">Effective Date: {effectiveDate}</p>
<div className="border-l-4 border-brand-600 pl-4 mb-8 text-gray-700">
<p>
<strong>Website:</strong>{" "}
<a href={`https://${websiteUrl}`} className="text-brand-600 hover:underline">
{websiteUrl}
</a>
</p>
<p><strong>Responsible Party:</strong> {orgName}</p>
<p>
<strong>Email:</strong>{" "}
<a href={`mailto:${orgEmail}`} className="text-brand-600 hover:underline">
{orgEmail}
</a>
</p>
</div>
<section id="summary" className="mb-10">
<h2 className="text-2xl font-semibold text-brand-600 mb-4">🟢 Plain English Summary</h2>
<p>We respect your privacy — here&apos;s what you need to know:</p>
<ul className="list-disc ml-6 space-y-1">
<li>We only collect info needed to run events: registrations, payments, tickets and check-in.</li>
<li>We store your info safely on South African servers.</li>
<li>We don&apos;t sell your data, and only share it with the services that process payments and deliver messages.</li>
<li>If you register other people or pass a ticket on, we only use the email or phone number you type to contact them. We never show you whether someone has an account with us.</li>
<li>For youth events, a parent or guardian gives consent. Medical details are encrypted, seen only by the event leaders who need them, and deleted shortly after the event.</li>
<li>Online payments go through Yoco or PayFast; we never see your card details.</li>
<li>We send tickets, updates and security alerts by email and/or WhatsApp (powered by WAWP).</li>
<li>Visit statistics are only collected if you click &quot;Allow&quot; on the cookie notice — no third-party analytics.</li>
<li>You can ask what we hold about you, or delete your account, at any time. We keep data for up to 5 years after your last event registration.</li>
</ul>
</section>
{sections.slice(1).map((section) => (
<Section key={section.id} id={section.id} title={section.title}
orgName={orgName} orgEmail={orgEmail} ioName={ioName} ioEmail={ioEmail} />
))}
</article>
</main>
<Footer /> <Footer />
</div> </div>
); );
} }
function Section({
id, title, orgName, orgEmail, ioName, ioEmail,
}: {
id: string;
title: string;
orgName: string;
orgEmail: string;
ioName: string;
ioEmail: string;
}) {
const content: Record<string, string[]> = {
introduction: [
`${orgName} ('we', 'us', 'our') values your privacy and is committed to protecting your personal information in accordance with the Protection of Personal Information Act (POPIA).`,
"This policy explains what data we collect, how we use it, and your rights.",
],
information: [
"We collect the following information when you interact with our platform:",
"• Account information: name, email address, phone number, notification preference and password (stored only as an encrypted hash). If you use two-factor authentication, we keep which method you use; codes are short-lived and never stored in readable form.",
"• Security records: sign-ins, password changes and similar account events, with the time, IP address and device type, so we can alert you and investigate misuse.",
"• Event information: events you register for, ticket selections, discount codes used, attendee form answers, waitlist requests, ticket check-ins (when your ticket was scanned), and room or group allocations.",
"• Details about other people you register (see section 3), and parental consent details for youth events (see section 8).",
"• Payment information: processed by our payment provider (Yoco or PayFast); we receive only payment status, amount and reference numbers. If you sponsor a spot, we record the amount and, unless you give anonymously, the name and message you choose to add.",
"• Feedback: answers you give to post-event feedback forms. If an event's feedback is anonymous, your answers are not linked to your name.",
"• Communication data: emails or messages exchanged with our team.",
"• Visit statistics: only if you allow them on the cookie notice (see section 11).",
"We do not collect or store credit card numbers or bank details.",
"Accounts may be created by an administrator, or when someone registers you or transfers a ticket to you, with a mobile number or email address only. You can activate such an account at any time.",
],
others: [
"You can register other people (for example family members) and give each one their own ticket, or pass your ticket on to someone else.",
"• You type their name and, optionally, their email address or phone number. There is no search or list of existing users to choose from, and we never tell you whether the details you enter belong to an account.",
"• We use those details only to send that person their ticket, their attendee form, a parental consent request (youth events), or — for a transfer — a link to accept the ticket. A transfer only happens once they accept it; they can also decline.",
"• If the email or phone number matches an existing account, the ticket also appears in that person's own account. Otherwise an account may be created for them, which they can activate if they wish.",
"• Some events ask for each person's date of birth or gender (for example, to allocate rooms at a camp).",
"By entering someone else's details, you confirm that you are allowed to share them with us for this purpose (for a child, that you are their parent or guardian or have their permission).",
],
use: [
"We use your information to:",
"• Process event registrations, payments, discounts and refunds.",
"• Communicate event updates, confirmations, or changes via email and/or WhatsApp (based on your preference).",
"• Send security notifications for new logins, password changes, and account actions on your account.",
"• Deliver your event tickets via email attachment and/or WhatsApp PDF (based on your preference).",
"• Check tickets in at the door, including with scanners that keep working without an internet connection.",
"• Offer places from a waitlist, arrange rooms and groups, and ask for parental consent on youth events.",
"• Send you an optional feedback form after an event, with one reminder.",
"• Manage user accounts and event attendance records, and give support when you ask for help.",
"• Improve our events and website (using anonymous statistics, and only with your permission).",
"• Comply with legal obligations.",
],
whatsapp: [
"If you choose 'WhatsApp' or 'Both' as your notification preference, we will use your South African mobile number to send you messages via WhatsApp. These messages may include:",
"• Event registration confirmations and updates.",
"• Payment receipts.",
"• Your event tickets (as a PDF document).",
"• Security alerts such as new login notifications, password changes, and account closure confirmations.",
"WhatsApp communications are delivered through WAWP (wawp.net), a third-party WhatsApp Business API provider. Your mobile number is transmitted to WAWP solely for the purpose of delivering your messages. WAWP does not retain your data beyond what is necessary for message delivery.",
"Security-critical messages (login alerts, password resets, account closures) are always sent via email regardless of your preference, and additionally sent via WhatsApp if your preference includes it.",
"You may change your notification preference at any time from the Profile & Security page in your account. Selecting 'Email only' will stop WhatsApp messages from being sent to you.",
"For accounts registered without a valid email address (e.g. walk-in or at-the-door registrations), WhatsApp will be used as the primary delivery channel for payment confirmations and ticket delivery, regardless of notification preference, since email is unavailable.",
"If your account requires activation and no email address is on file, the activation link will be sent to your WhatsApp number instead.",
"WhatsApp is a product of Meta Platforms, Inc. By receiving WhatsApp messages from us, you are subject to WhatsApp's own Terms of Service and Privacy Policy.",
],
storage: [
"All data is securely stored on cloud servers hosted in South Africa.",
"We use appropriate technical and organisational measures to protect your information from unauthorised access, alteration, or disclosure.",
],
retention: [
"We retain your personal data for up to 5 years after your last event registration, or until you request deletion of your account — whichever comes first.",
"Medical and emergency-contact details from parental consent forms are deleted a set number of days after the event ends (30 days unless the organisation sets a different period). The record of who gave consent, when, and to which wording is kept.",
"Detailed visit statistics are combined into anonymous daily totals after 90 days.",
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
"Records of support access to accounts (see section 9) and other audit records are kept for accountability.",
"You can delete your account at any time from the Profile & Security page in your account. Deletion anonymises your personal information (name, email address, and phone number are replaced with placeholder values) while retaining event and payment records in anonymised form for financial record-keeping purposes.",
"An administrator may also anonymise your account data on your request. To request this, contact our Information Officer (see section 14).",
],
children: [
"Users under 18 may only create an account or register for events with the consent and supervision of a parent or guardian.",
"We do not knowingly collect or process personal information of minors without such consent.",
"For youth events, each attendee's date of birth is required. For anyone under 18, the parent or guardian named at registration is sent a consent request by email and WhatsApp, where they review the event details and give or decline consent. Their typed name, the time, the wording agreed to, and the IP address and device type used are recorded as proof of consent. A young person can't be checked in until consent is given.",
"The consent form may ask for medical information (such as allergies, medication and conditions) and an emergency contact. This is special personal information under POPIA. We collect it only with the parent's or guardian's consent, for the young person's safety at the event. It is stored encrypted and shown only to supervisors who need it, and each time the medical list is viewed this is recorded. It is deleted after the event as described in section 7.",
"A parent or guardian may also say whether photos of the young person may be used.",
],
sharing: [
"We do not sell or rent your personal information. Outside our organisation, it is shared only with:",
"• Yoco and PayFast (South African payment processors), to process payments securely — your name and email may be passed to PayFast to fill in its payment page.",
"• WAWP (wawp.net), a WhatsApp Business API provider, to deliver WhatsApp messages — only your mobile number and the message content, solely for delivery.",
"• Our email delivery provider, to deliver emails.",
"• Anyone we are required to share it with under South African law.",
"Within our organisation, people see only what they need:",
"• Door staff see a ticket's holder name, ticket type and check-in status when scanning.",
"• Group, room or bus leaders see the names, ages and contact numbers of the people in their group. Medical details are shown to supervisors only.",
"• Supervisors and administrators manage registrations, payments and communication for events.",
"• Support access: to help with a problem (for example, a ticket not showing), an administrator may view the site as you see it for up to 30 minutes. This is strictly read-only — nothing can be changed, paid, registered or sent on your behalf. A reason must be given, and the session and every page viewed are recorded. The organisation may choose to email you whenever this happens.",
"• Sponsors never see who was helped by a sponsorship; the public only sees how many spots were sponsored.",
"• Nobody else can see whether you have an account.",
],
rights: [
"Under POPIA, you have the right to:",
"• Access your personal information.",
"• Request correction or deletion of your data.",
"• Withdraw consent for processing.",
"• Lodge a complaint with the Information Regulator of South Africa.",
"To contact the Information Regulator:",
"• Website: www.inforeg.org.za",
"• Email: enquiries@inforeg.org.za",
"To exercise your rights directly with us, contact our Information Officer using the details in section 14 below.",
],
cookies: [
"Our own site does not set tracking cookies. It keeps a few small items in your browser's storage (localStorage, and on staff scanners IndexedDB and an offline page cache). These work much like cookies, so this policy covers them. We do not use third-party analytics, advertising or tracking pixels.",
"Strictly necessary — always used, because the site can't work without them:",
"• hope_events_token — keeps you signed in.",
"• hope_events_user_cache — your last loaded profile, so pages keep working if your connection drops.",
"• hope_events_device — only if you tick \"Trust this device\" when entering a two-factor code, so you aren't asked for a code every time.",
"• hope_events_cookie_consent — remembers your choice on the cookie notice.",
"• banner_dismissed_… — remembers notices you have closed.",
"Staff and administrators only:",
"• hope_events_scan_device, hope_events_offline_lock and the \"hope-events-offline-scan\" database — on a staff member's scanner in offline mode: the device's id, the ticket list for the section being scanned, and scans waiting to upload. They are deleted when offline mode is ended. A copy of the scanner page is also cached so it opens without a connection.",
"• hope_events_admin_token, hope_events_admin_user_cache and hope_events_impersonation — only while an administrator is using read-only support access (section 9), and removed when it ends.",
"Optional — only if you click \"Allow\" on the cookie notice:",
"• hope_events_analytics_sid — a random, anonymous visit id. With it we count, in our own statistics, how many visitors view an event, start registering, and complete a registration. We record only that step, where the visit came from (for example a short link or campaign link) and the type of device — never your name, IP address or browsing history. Detailed records become anonymous daily totals after 90 days. If you choose \"No thanks\", nothing is counted and no visit id is created.",
"Third parties:",
"• Event pages with a location show an embedded Google Maps map. Google may set its own cookies when the map loads (see Google's privacy policy).",
"• Payments open the payment provider's secure checkout page (Yoco or PayFast), which uses its own cookies on the provider's site.",
"You can change your cookie choice at any time with \"Cookie settings\" at the bottom of every page, or with the button above. You can also clear your browser's storage in your browser settings; this signs you out.",
],
security: [
"Your personal data is encrypted during transmission and stored using industry-standard security protocols. Medical and emergency details are additionally encrypted in our database.",
"Passwords are encrypted and cannot be viewed by anyone, including administrators.",
"Two-factor authentication is available to everyone and is required for supervisors and administrators.",
"Links we send for consent, feedback and ticket transfers are personal and time-limited. Only a secure fingerprint of each link is stored, not the link itself.",
"In the event of a data breach that poses a risk to your rights, we will notify you and the Information Regulator of South Africa as required under POPIA section 22, without unreasonable delay.",
],
changes: [
"We may update this Privacy Policy periodically.",
"Any changes will be posted on this page with an updated effective date.",
],
officer: [
`The Information Officer responsible for overseeing compliance with POPIA on behalf of ${orgName} is:`,
`• Name: ${ioName}`,
`• Email: ${ioEmail}`,
"You may contact the Information Officer for any POPIA-related requests, including access to your personal data, requests for correction or deletion, and formal complaints.",
],
contact: [
"For general privacy-related questions or requests, you can also contact us at:",
`📧 ${orgEmail}`,
],
};
return (
<section id={id} className="mb-10 scroll-mt-24">
<h3 className="text-xl font-semibold text-brand-600 mb-3">{title}</h3>
{id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{content[id]?.map((line, i) => {
// Render email addresses as links
const emailMatch = line.match(/[\w.+-]+@[\w-]+\.[\w.]+/);
if (emailMatch) {
const email = emailMatch[0];
const parts = line.split(email);
return (
<p key={i} className="mb-2">
{parts[0]}
<a href={`mailto:${email}`} className="text-brand-600 hover:underline">
{email}
</a>
{parts[1] || ""}
</p>
);
}
if (line.includes("www.inforeg.org.za")) {
return (
<p key={i} className="mb-2">
{line.replace("www.inforeg.org.za", "")}
<a href="https://www.inforeg.org.za" target="_blank" rel="noopener noreferrer" className="text-brand-600 hover:underline">
www.inforeg.org.za
</a>
</p>
);
}
return (
<p key={i} className="mb-2">
{line}
</p>
);
})}
</section>
);
}
@@ -3,6 +3,7 @@
import React, { useEffect, useMemo, useState } from "react"; import React, { useEffect, useMemo, useState } from "react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField"; import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals"; import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types"; import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
@@ -375,6 +376,7 @@ function DonationPicker({
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) { export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
const { token, user } = useAuth(); const { token, user } = useAuth();
const online = useOnlinePayments();
const eventId = baseEvent.id; const eventId = baseEvent.id;
// A waitlist claim link (?waitlist=<token>): the places held for this person are // A waitlist claim link (?waitlist=<token>): the places held for this person are
@@ -507,7 +509,7 @@ export default function RegisterForm({ event: baseEvent }: { event: RegisterEven
const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" }); const [donationChoice, setDonationChoice] = useState<DonationChoice>({ kind: "none" });
const [customDonation, setCustomDonation] = useState(""); const [customDonation, setCustomDonation] = useState("");
const presets = useMemo(() => donationPresets(total), [total]); const presets = useMemo(() => donationPresets(total), [total]);
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut; const showDonation = isLoggedIn && payable > 0 && !eventSoldOut && online.available;
const donation = !showDonation ? 0 const donation = !showDonation ? 0
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0) : donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100) : donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
+11 -3
View File
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
import { useSearchParams, useRouter } from "next/navigation"; import { useSearchParams, useRouter } from "next/navigation";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { CheckCircle2 } from "lucide-react"; import { CheckCircle2 } from "lucide-react";
import { useOnlinePayments } from "@/lib/onlinePayments";
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import type { Registration } from "@/types"; import type { Registration } from "@/types";
@@ -108,6 +109,11 @@ function RegistrationSuccessContent() {
} }
}; };
const online = useOnlinePayments();
const owed = totalDue > 0 || (!reg && fallbackTotalDue > 0);
const payAmount = (reg ? totalDue : fallbackTotalDue) + donation;
const payOnline = owed && online.canPay(payAmount);
const goDashboard = () => { const goDashboard = () => {
router.push("/dashboard/user"); router.push("/dashboard/user");
}; };
@@ -129,7 +135,7 @@ function RegistrationSuccessContent() {
<div className="font-medium">This event requires attendee details.</div> <div className="font-medium">This event requires attendee details.</div>
<div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div> <div className="text-sm">Please complete one form per main ticket to receive tickets. You can also do this later from your dashboard, but tickets cannot be generated until completed.</div>
</div> </div>
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? ( ) : payOnline ? (
<> <>
{donation > 0 && ( {donation > 0 && (
<p className="text-gray-700 mb-2"> <p className="text-gray-700 mb-2">
@@ -138,10 +144,12 @@ function RegistrationSuccessContent() {
)} )}
<p className="text-gray-700 mb-6">Would you like to pay online now?</p> <p className="text-gray-700 mb-6">Would you like to pay online now?</p>
</> </>
) : owed && online.reason(payAmount) ? (
<p className="text-gray-700 mb-6">{online.reason(payAmount)}</p>
) : null} ) : null}
<div className="flex flex-col sm:flex-row gap-3 mt-4"> <div className="flex flex-col sm:flex-row gap-3 mt-4">
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && ( {payOnline && (
<button <button
onClick={goPay} onClick={goPay}
disabled={!registrationId || payLoading} disabled={!registrationId || payLoading}
@@ -151,7 +159,7 @@ function RegistrationSuccessContent() {
<button <button
onClick={goDashboard} onClick={goDashboard}
className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200" className="px-4 py-2 rounded bg-gray-100 text-gray-800 hover:bg-gray-200"
>{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button> >{payOnline ? "Go to Dashboard (Pay Later)" : "Go to Dashboard"}</button>
</div> </div>
{error && <p className="text-sm text-red-600 mt-3">{error}</p>} {error && <p className="text-sm text-red-600 mt-3">{error}</p>}
{!registrationId && ( {!registrationId && (
+1 -1
View File
@@ -8,7 +8,7 @@ export default function robots(): MetadataRoute.Robots {
rules: { rules: {
userAgent: "*", userAgent: "*",
allow: "/", allow: "/",
disallow: ["/dashboard", "/self-service", "/set-banner", "/lockdown-rules"], disallow: ["/dashboard", "/self-service", "/set-banner", "/lockdown-rules", "/unsubscribe"],
}, },
sitemap: `${base}/sitemap.xml`, sitemap: `${base}/sitemap.xml`,
}; };
+103
View File
@@ -0,0 +1,103 @@
"use client";
import React, { Suspense, useEffect, useState } from "react";
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { Navbar } from "@/components/layout/Navbar";
import { Footer } from "@/components/layout/Footer";
import { apiFetch, ApiError } from "@/lib/api";
import { MailX } from "lucide-react";
type Info =
| { kind: "account"; email: string; hasPhone: boolean; news: { email: boolean; whatsapp: boolean } }
| { kind: "address"; channel: "email" | "whatsapp"; address: string; optedOut: boolean };
/** Landing page for the unsubscribe link in News & announcements emails and WhatsApps. */
function Unsubscribe() {
const token = useSearchParams().get("t") || "";
const [info, setInfo] = useState<Info | null>(null);
const [error, setError] = useState<string | null>(token ? null : "This unsubscribe link is incomplete.");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
useEffect(() => {
if (!token) return;
apiFetch<Info>(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`)
.then(setInfo)
.catch(e => setError(e instanceof ApiError ? e.message : "Couldn't load this link."));
}, [token]);
const unsubscribe = async (channels?: ("email" | "whatsapp")[]) => {
setBusy(true);
try {
await apiFetch(`/api/privacy/unsubscribe/${encodeURIComponent(token)}`, { method: "POST", body: channels ? { channels } : {} });
setDone(true);
} catch (e) {
setError(e instanceof ApiError ? e.message : "Couldn't unsubscribe. Please try again.");
} finally {
setBusy(false);
}
};
const alreadyOff = info && (info.kind === "account" ? !info.news.email && !info.news.whatsapp : info.optedOut);
const btn = "px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50";
return (
<>
{error && <div className="border rounded-xl p-4 bg-red-50 text-red-800 text-sm">{error}</div>}
{!info && !error && <div className="text-sm text-gray-600">Loading…</div>}
{info && (
<div className="border rounded-xl p-5 bg-white shadow-sm space-y-4">
{done || alreadyOff ? (
<p className="text-sm text-gray-700">
You&apos;re unsubscribed from news and announcements{info.kind === "address" ? ` for ${info.address}` : ""}.
You&apos;ll still get messages about events you register for.
</p>
) : (
<>
<p className="text-sm text-gray-700">
Stop news and announcements to <strong>{info.kind === "account" ? info.email : info.address}</strong>?
Messages about events you register for — tickets, reminders, changes — still come through.
</p>
<div className="flex flex-wrap gap-3">
{info.kind === "account" && info.news.email && info.news.whatsapp ? (
<>
<button onClick={() => unsubscribe()} disabled={busy} className={`${btn} bg-brand-600 text-white hover:bg-brand-700`}>Unsubscribe from all news</button>
<button onClick={() => unsubscribe(["email"])} disabled={busy} className={`${btn} border border-gray-300 text-gray-700 hover:bg-gray-50`}>Email only</button>
<button onClick={() => unsubscribe(["whatsapp"])} disabled={busy} className={`${btn} border border-gray-300 text-gray-700 hover:bg-gray-50`}>WhatsApp only</button>
</>
) : (
<button onClick={() => unsubscribe()} disabled={busy} className={`${btn} bg-brand-600 text-white hover:bg-brand-700`}>{busy ? "Unsubscribing…" : "Unsubscribe"}</button>
)}
</div>
</>
)}
{info.kind === "account" && (
<p className="text-xs text-gray-500">
<Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">Log in to manage all your preferences</Link>
</p>
)}
</div>
)}
</>
);
}
export default function UnsubscribePage() {
return (
<div className="min-h-screen flex flex-col">
<Navbar />
<main className="flex-1 px-4 py-10 max-w-xl mx-auto w-full">
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-xl bg-brand-50 flex items-center justify-center shrink-0">
<MailX className="w-5 h-5 text-brand-600" />
</div>
<h1 className="text-2xl font-semibold text-gray-900">Unsubscribe</h1>
</div>
<Suspense fallback={<div className="text-sm text-gray-600">Loading…</div>}>
<Unsubscribe />
</Suspense>
</main>
<Footer />
</div>
);
}
@@ -72,6 +72,21 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
} }
}; };
const [testing, setTesting] = useState(false);
// Checks the saved merchant ID and passphrase with PayFast (a signed API ping).
const test = async () => {
if (!token) return;
setTesting(true);
try {
const res = await apiFetch<{ message: string }>("/api/payfast/test", { method: "POST", authToken: token });
setMessage({ type: "ok", text: res.message });
} catch (err) {
setMessage({ type: "err", text: errorMessage(err) || "PayFast didn't accept the settings" });
} finally {
setTesting(false);
}
};
const removePassphrase = async () => { const removePassphrase = async () => {
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return; if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
try { try {
@@ -134,7 +149,15 @@ export function PayFastSettings({ active, onChanged }: { active: boolean; onChan
Payment notifications go to <span className="font-mono break-all">{cfg.notifyUrl || "(not set)"}</span>. Payment notifications go to <span className="font-mono break-all">{cfg.notifyUrl || "(not set)"}</span>.
{cfg.notifyUrlProblem && <p className="text-amber-700 mt-1">{cfg.notifyUrlProblem}</p>} {cfg.notifyUrlProblem && <p className="text-amber-700 mt-1">{cfg.notifyUrlProblem}</p>}
</div> </div>
<button type="submit" className={primaryBtn} disabled={saving}>{saving ? "Saving…" : "Save PayFast settings"}</button> <div className="flex flex-wrap gap-2">
<button type="submit" className={primaryBtn} disabled={saving}>{saving ? "Saving…" : "Save PayFast settings"}</button>
{cfg.configured && (
<button type="button" className="px-3 py-1.5 text-sm rounded-lg bg-gray-100 hover:bg-gray-200 disabled:opacity-50" disabled={testing} onClick={test}>
{testing ? "Testing…" : "Test connection"}
</button>
)}
</div>
<p className="text-xs text-gray-500">Test connection checks the saved settings — save any changes first.</p>
</form> </form>
)} )}
</div> </div>
@@ -0,0 +1,220 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Copy, Pencil } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
type Status = "open" | "contained" | "closed";
type Breach = {
id: string; title: string; status: Status; discoveredAt: string; occurredAt: string | null; description: string; dataAffected: string;
peopleAffected: number | null; whoAffected: string | null; consequences: string | null; actionsTaken: string | null; adviceToPeople: string | null;
causedBy: string | null; regulatorNotifiedAt: string | null; peopleNotifiedAt: string | null; createdBy: { name: string } | null; createdAt: string;
};
type Form = Record<"title" | "description" | "dataAffected" | "whoAffected" | "consequences" | "actionsTaken" | "adviceToPeople" | "causedBy" | "discoveredAt" | "occurredAt" | "peopleAffected", string>;
const EMPTY: Form = { title: "", description: "", dataAffected: "", whoAffected: "", consequences: "", actionsTaken: "", adviceToPeople: "", causedBy: "", discoveredAt: "", occurredAt: "", peopleAffected: "" };
const STATUS: Record<Status, { label: string; tone: string }> = {
open: { label: "Open", tone: "bg-red-50 text-red-700" },
contained: { label: "Contained", tone: "bg-amber-50 text-amber-800" },
closed: { label: "Closed", tone: "bg-gray-100 text-gray-600" },
};
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
const toLocal = (d: string | null) => {
if (!d) return "";
const x = new Date(d);
return new Date(x.getTime() - x.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
};
function BreachForm({ initial, onSave, onCancel, busy }: { initial: Form; onSave: (f: Form) => void; onCancel: () => void; busy: boolean }) {
const [f, setF] = useState(initial);
const field = (k: keyof Form, label: string, opts: { rows?: number; required?: boolean; type?: string; placeholder?: string } = {}) => (
<label className="block text-sm text-gray-700">
{label}{opts.required && " *"}
{opts.rows ? (
<textarea className={inputCls} rows={opts.rows} value={f[k]} required={opts.required} placeholder={opts.placeholder} onChange={e => setF({ ...f, [k]: e.target.value })} />
) : (
<input className={inputCls} type={opts.type || "text"} value={f[k]} required={opts.required} placeholder={opts.placeholder} onChange={e => setF({ ...f, [k]: e.target.value })} />
)}
</label>
);
return (
<form onSubmit={e => { e.preventDefault(); onSave(f); }} className="border rounded-xl p-4 bg-gray-50 space-y-3">
{field("title", "Short title", { required: true, placeholder: "e.g. Attendee list emailed to the wrong group" })}
<div className="grid sm:grid-cols-3 gap-3">
{field("discoveredAt", "Discovered", { type: "datetime-local", required: true })}
{field("occurredAt", "Happened (if known)", { type: "datetime-local" })}
{field("peopleAffected", "People affected (about)", { type: "number" })}
</div>
{field("description", "What happened", { rows: 3, required: true })}
{field("dataAffected", "What information was affected", { rows: 2, required: true, placeholder: "e.g. names, phone numbers and medical details of 40 camp attendees" })}
{field("whoAffected", "Who was affected", { rows: 2, placeholder: "e.g. everyone registered for Youth Camp 2026" })}
{field("consequences", "Possible consequences for them", { rows: 2 })}
{field("actionsTaken", "What you've done or will do about it", { rows: 2 })}
{field("adviceToPeople", "What affected people should do", { rows: 2, placeholder: "e.g. be careful of messages asking for payment" })}
{field("causedBy", "Who caused it (if known)", {})}
<div className="flex gap-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 rounded-lg bg-white border text-sm hover:bg-gray-100">Cancel</button>
<button type="submit" disabled={busy} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
</div>
</form>
);
}
function CopyBlock({ title, text }: { title: string; text: string }) {
const [copied, setCopied] = useState(false);
return (
<div className="border rounded-lg bg-white">
<div className="flex items-center justify-between px-3 py-2 border-b">
<span className="text-sm font-medium text-gray-800">{title}</span>
<button onClick={() => { navigator.clipboard?.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }} className="inline-flex items-center gap-1 text-xs text-brand-600 hover:underline">
<Copy className="w-3.5 h-3.5" /> {copied ? "Copied" : "Copy"}
</button>
</div>
<pre className="p-3 text-xs text-gray-700 whitespace-pre-wrap font-sans">{text}</pre>
</div>
);
}
/** Admin → Privacy → Breaches: the register of security compromises (POPIA s22). */
export function BreachesPanel({ token }: { token: string }) {
const { settings } = useSiteSettings();
const [list, setList] = useState<Breach[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [open, setOpen] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setList(await apiFetch<Breach[]>("/api/privacy/breaches", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Could not load the register.");
}
}, [token]);
useEffect(() => { load(); }, [load]);
const send = async (id: string | "new", body: Record<string, unknown>) => {
setBusy(true);
setError(null);
try {
if (id === "new") await apiFetch("/api/privacy/breaches", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/breaches/${id}`, { method: "PUT", authToken: token, body });
setEditing(null);
await load();
} catch (e) {
setError(errorMessage(e) || "Could not save.");
} finally {
setBusy(false);
}
};
const save = (f: Form) => send(editing!, {
...f,
discoveredAt: f.discoveredAt ? new Date(f.discoveredAt).toISOString() : "",
occurredAt: f.occurredAt ? new Date(f.occurredAt).toISOString() : null,
peopleAffected: f.peopleAffected === "" ? null : f.peopleAffected,
});
const org = settings.org_name || "We";
const io = [settings.legal_io_name, settings.legal_io_email].filter(Boolean).join(", ") || "our Information Officer";
const peopleText = (b: Breach) => [
`Dear member,`,
``,
`We are writing to tell you about a security incident that affected your personal information held by ${org}.`,
``,
`What happened: ${b.description}`,
`Information involved: ${b.dataAffected}`,
b.consequences ? `What this could mean for you: ${b.consequences}` : "",
b.actionsTaken ? `What we have done: ${b.actionsTaken}` : "",
b.adviceToPeople ? `What you can do: ${b.adviceToPeople}` : "",
b.causedBy ? `Who was responsible: ${b.causedBy}` : "",
``,
`We are sorry this happened. If you have questions, contact ${io}. You may also complain to the Information Regulator (inforegulator.org.za).`,
].filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n");
const regulatorText = (b: Breach) => [
`Responsible party: ${settings.legal_operator_name || org}`,
`Information Officer: ${io}`,
`Discovered: ${fmt(b.discoveredAt)}${b.occurredAt ? ` · Happened: ${fmt(b.occurredAt)}` : ""}`,
`Description: ${b.description}`,
`Personal information affected: ${b.dataAffected}`,
`People affected: ${b.peopleAffected ?? "unknown"}${b.whoAffected ? ` — ${b.whoAffected}` : ""}`,
b.consequences ? `Possible consequences: ${b.consequences}` : "",
b.actionsTaken ? `Measures taken: ${b.actionsTaken}` : "",
b.adviceToPeople ? `Advice given to data subjects: ${b.adviceToPeople}` : "",
b.causedBy ? `Identity of the unauthorised person (if known): ${b.causedBy}` : "",
`Data subjects notified: ${b.peopleNotifiedAt ? fmt(b.peopleNotifiedAt) : "not yet"}`,
].filter(Boolean).join("\n");
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Record any security compromise — a lost phone with the attendee list, an email to the wrong people, a hacked account. POPIA (section 22)
requires you to tell the Information Regulator and the people affected as soon as reasonably possible. Each entry gives you a draft
for both; report to the Regulator on its website (inforegulator.org.za) and record the date here.
</p>
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
{editing === "new" ? (
<BreachForm busy={busy} initial={{ ...EMPTY, discoveredAt: toLocal(new Date().toISOString()) }} onCancel={() => setEditing(null)} onSave={save} />
) : (
<button onClick={() => setEditing("new")} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
<Plus className="w-4 h-4" /> Record a security compromise
</button>
)}
{list === null ? <p className="text-sm text-gray-400">Loading…</p> : list.length === 0 ? <p className="text-sm text-gray-500">Nothing recorded.</p> : (
<ul className="space-y-3">
{list.map(b => editing === b.id ? (
<li key={b.id}>
<BreachForm busy={busy} onCancel={() => setEditing(null)} onSave={save} initial={{
title: b.title, description: b.description, dataAffected: b.dataAffected, whoAffected: b.whoAffected || "", consequences: b.consequences || "",
actionsTaken: b.actionsTaken || "", adviceToPeople: b.adviceToPeople || "", causedBy: b.causedBy || "",
discoveredAt: toLocal(b.discoveredAt), occurredAt: toLocal(b.occurredAt), peopleAffected: b.peopleAffected == null ? "" : String(b.peopleAffected),
}} />
</li>
) : (
<li key={b.id} className="border rounded-xl bg-white">
<button onClick={() => setOpen(open === b.id ? null : b.id)} className="w-full flex flex-wrap items-center gap-2 p-3 text-left">
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS[b.status].tone}`}>{STATUS[b.status].label}</span>
<span className="font-medium text-gray-900">{b.title}</span>
<span className="ml-auto text-xs text-gray-500">
Discovered {fmt(b.discoveredAt)} · Regulator {b.regulatorNotifiedAt ? "told" : <strong className="text-red-700">not told</strong>} · People {b.peopleNotifiedAt ? "told" : "not told"}
</span>
</button>
{open === b.id && (
<div className="border-t p-3 space-y-3 text-sm">
<dl className="grid sm:grid-cols-[10rem_1fr] gap-x-3 gap-y-1 text-gray-700">
<dt className="text-gray-500">What happened</dt><dd className="whitespace-pre-line">{b.description}</dd>
<dt className="text-gray-500">Information</dt><dd className="whitespace-pre-line">{b.dataAffected}</dd>
<dt className="text-gray-500">Who</dt><dd>{b.peopleAffected ?? "?"} people{b.whoAffected ? ` — ${b.whoAffected}` : ""}</dd>
{b.actionsTaken && <><dt className="text-gray-500">Done</dt><dd className="whitespace-pre-line">{b.actionsTaken}</dd></>}
<dt className="text-gray-500">Recorded</dt><dd>{fmt(b.createdAt)}{b.createdBy ? ` by ${b.createdBy.name}` : ""}</dd>
</dl>
<div className="flex flex-wrap gap-2">
<button onClick={() => setEditing(b.id)} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50"><Pencil className="w-3.5 h-3.5" /> Edit</button>
{!b.regulatorNotifiedAt && <button disabled={busy} onClick={() => send(b.id, { regulatorNotifiedAt: new Date().toISOString() })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Regulator told today</button>}
{!b.peopleNotifiedAt && <button disabled={busy} onClick={() => send(b.id, { peopleNotifiedAt: new Date().toISOString() })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">People told today</button>}
{b.status !== "contained" && b.status !== "closed" && <button disabled={busy} onClick={() => send(b.id, { status: "contained" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Mark contained</button>}
{b.status !== "closed" ? (
<button disabled={busy} onClick={() => send(b.id, { status: "closed" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Close</button>
) : (
<button disabled={busy} onClick={() => send(b.id, { status: "open" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Reopen</button>
)}
</div>
<CopyBlock title="Draft message to the people affected" text={peopleText(b)} />
<CopyBlock title="Summary for the Information Regulator's form" text={regulatorText(b)} />
<p className="text-xs text-gray-500">These are drafts — check them with your Information Officer before sending.</p>
</div>
)}
</li>
))}
</ul>
)}
</div>
);
}
@@ -0,0 +1,191 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
const selectCls = "border rounded-lg px-3 py-2 text-sm bg-white";
function Pager({ page, total, pageSize, onPage }: { page: number; total: number; pageSize: number; onPage: (p: number) => void }) {
const pages = Math.max(1, Math.ceil(total / pageSize));
if (pages <= 1) return <p className="text-xs text-gray-500">{total} record{total === 1 ? "" : "s"}</p>;
return (
<div className="flex items-center gap-2 text-sm">
<button onClick={() => onPage(page - 1)} disabled={page <= 1} className="px-2 py-1 rounded border bg-white disabled:opacity-40">Previous</button>
<span className="text-gray-600">Page {page} of {pages} · {total} records</span>
<button onClick={() => onPage(page + 1)} disabled={page >= pages} className="px-2 py-1 rounded border bg-white disabled:opacity-40">Next</button>
</div>
);
}
// ─── Consent log ──────────────────────────────────────────────────────────────
type ConsentType = "news" | "notice" | "parental";
type NewsRow = { id: string; at: string; name: string | null; email: string | null; channel: string; granted: boolean; source: string; ip: string | null };
type NoticeRow = { id: string; at: string; name: string | null; email: string | null; version: number; source: string; ip: string | null };
type ParentalRow = { id: string; at: string | null; child: string; event: string; status: string; signedName: string | null; parentName: string | null; textVersion: number; photoConsent: boolean | null; ip: string | null; medicalDeleted: boolean };
type LogResponse<T> = { rows: T[]; total: number; pageSize: number };
const SOURCE: Record<string, string> = {
signup: "Sign-up", privacy_centre: "Privacy page", prompt: "Asked after login", unsubscribe_link: "Unsubscribe link",
whatsapp_stop: "Replied STOP", existing_account: "Existing account (1.38.0)", admin: "Administrator",
};
/** Admin → Privacy → Consent log: proof of who agreed to what, when and how. */
export function ConsentLogPanel({ token }: { token: string }) {
const [type, setType] = useState<ConsentType>("news");
const [q, setQ] = useState("");
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [data, setData] = useState<LogResponse<NewsRow | NoticeRow | ParentalRow> | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
setData(null);
try {
setData(await apiFetch(`/api/privacy/consent-log?type=${type}&page=${page}${query ? `&q=${encodeURIComponent(query)}` : ""}`, { authToken: token }));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the consent log.");
}
}, [token, type, page, query]);
useEffect(() => { load(); }, [load]);
return (
<div className="space-y-3">
<p className="text-sm text-gray-600">Who agreed to what, when and how. Entries are never changed or removed (after an account is deleted they stay, without the IP address).</p>
<form onSubmit={e => { e.preventDefault(); setPage(1); setQuery(q.trim()); }} className="flex flex-wrap gap-2">
<select className={selectCls} value={type} onChange={e => { setType(e.target.value as ConsentType); setPage(1); }} aria-label="Kind of consent">
<option value="news">News &amp; announcements opt-in</option>
<option value="notice">Privacy notice accepted</option>
<option value="parental">Parental consent</option>
</select>
<input className="border rounded-lg px-3 py-2 text-sm flex-1 min-w-[12rem]" value={q} onChange={e => setQ(e.target.value)} placeholder="Name or email" aria-label="Search" />
<button className="px-3 py-2 rounded-lg bg-brand-600 text-white text-sm">Search</button>
</form>
{error && <p className="text-sm text-red-700">{error}</p>}
{!data ? <p className="text-sm text-gray-400">Loading…</p> : data.rows.length === 0 ? <p className="text-sm text-gray-500">Nothing recorded yet.</p> : (
<div className="overflow-x-auto border rounded-xl bg-white">
<table className="w-full text-sm">
{type === "news" && (
<>
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Person</th><th className="p-2">Channel</th><th className="p-2">Choice</th><th className="p-2">How</th></tr></thead>
<tbody>{(data.rows as NewsRow[]).map(r => (
<tr key={r.id} className="border-t">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.name || <span className="text-gray-400">No account</span>}<div className="text-xs text-gray-500 break-all">{r.email}</div></td>
<td className="p-2">{r.channel === "whatsapp" ? "WhatsApp" : "Email"}</td>
<td className="p-2">{r.granted ? <span className="text-green-700">Opted in</span> : <span className="text-gray-700">Opted out</span>}</td>
<td className="p-2 text-gray-600">{SOURCE[r.source] || r.source}{r.ip ? <div className="text-xs text-gray-400">{r.ip}</div> : null}</td>
</tr>
))}</tbody>
</>
)}
{type === "notice" && (
<>
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Person</th><th className="p-2">Version</th><th className="p-2">How</th></tr></thead>
<tbody>{(data.rows as NoticeRow[]).map(r => (
<tr key={r.id} className="border-t">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.name}<div className="text-xs text-gray-500 break-all">{r.email}</div></td>
<td className="p-2">{r.version === 0 ? "Built-in" : r.version}</td>
<td className="p-2 text-gray-600">{SOURCE[r.source] || r.source}{r.ip ? <div className="text-xs text-gray-400">{r.ip}</div> : null}</td>
</tr>
))}</tbody>
</>
)}
{type === "parental" && (
<>
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Child · event</th><th className="p-2">Answer</th><th className="p-2">Signed by</th><th className="p-2">Wording</th></tr></thead>
<tbody>{(data.rows as ParentalRow[]).map(r => (
<tr key={r.id} className="border-t">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.child}<div className="text-xs text-gray-500">{r.event}</div></td>
<td className="p-2">{r.status === "given" ? <span className="text-green-700">Given</span> : <span className="text-red-700">Declined</span>}{r.photoConsent != null && <div className="text-xs text-gray-500">Photos: {r.photoConsent ? "yes" : "no"}</div>}</td>
<td className="p-2">{r.signedName || "—"}{r.ip ? <div className="text-xs text-gray-400">{r.ip}</div> : null}</td>
<td className="p-2 text-gray-600">v{r.textVersion}{r.medicalDeleted && <div className="text-xs text-gray-400">medical details deleted</div>}</td>
</tr>
))}</tbody>
</>
)}
</table>
</div>
)}
{data && <Pager page={page} total={data.total} pageSize={data.pageSize} onPage={setPage} />}
</div>
);
}
// ─── Access log ───────────────────────────────────────────────────────────────
type AccessRow = { id: string; at: string; actor: string; actorRole: string; action: string; what: string; records: number | null; ip: string | null; sensitive: boolean };
type AccessResponse = LogResponse<AccessRow> & { actions: Record<string, string>; actors: { id: string; name: string }[] };
/** Admin → Privacy → Access log: who took personal information out of the system in bulk. */
export function AccessLogPanel({ token }: { token: string }) {
const [action, setAction] = useState("");
const [actorId, setActorId] = useState("");
const [sensitiveOnly, setSensitiveOnly] = useState(false);
const [page, setPage] = useState(1);
const [data, setData] = useState<AccessResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const params = new URLSearchParams({ page: String(page) });
if (action) params.set("action", action);
if (actorId) params.set("actorId", actorId);
if (sensitiveOnly) params.set("sensitive", "true");
setData(await apiFetch<AccessResponse>(`/api/privacy/access-log?${params}`, { authToken: token }));
setError(null);
} catch (e) {
setError(errorMessage(e) || "Could not load the access log.");
}
}, [token, action, actorId, sensitiveOnly, page]);
useEffect(() => { load(); }, [load]);
return (
<div className="space-y-3">
<p className="text-sm text-gray-600">
Every time someone downloads, emails or opens personal information in bulk — reports, attendance registers, certificates, group lists,
form answers, the consent and medical lists, backups — and every time an admin views the site as someone else.
</p>
<div className="flex flex-wrap gap-2">
<select className={selectCls} value={action} onChange={e => { setAction(e.target.value); setPage(1); }} aria-label="What">
<option value="">Everything</option>
{Object.entries(data?.actions || {}).map(([k, label]) => <option key={k} value={k}>{label}</option>)}
</select>
<select className={selectCls} value={actorId} onChange={e => { setActorId(e.target.value); setPage(1); }} aria-label="Who">
<option value="">Everyone</option>
{(data?.actors || []).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
</select>
<label className="flex items-center gap-2 text-sm text-gray-700">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={sensitiveOnly} onChange={e => { setSensitiveOnly(e.target.checked); setPage(1); }} />
Children&apos;s or medical information only
</label>
</div>
{error && <p className="text-sm text-red-700">{error}</p>}
{!data ? <p className="text-sm text-gray-400">Loading…</p> : data.rows.length === 0 ? <p className="text-sm text-gray-500">Nothing recorded yet.</p> : (
<div className="overflow-x-auto border rounded-xl bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500"><tr><th className="p-2">When</th><th className="p-2">Who</th><th className="p-2">What</th><th className="p-2 text-right">Records</th></tr></thead>
<tbody>{data.rows.map(r => (
<tr key={r.id} className="border-t align-top">
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
<td className="p-2">{r.actor}<div className="text-xs text-gray-500">{r.actorRole}{r.ip ? ` · ${r.ip}` : ""}</div></td>
<td className="p-2">
{data.actions[r.action] || r.action}
{r.sensitive && <span className="ml-2 text-[11px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700" title="Children's or special personal information">children/medical</span>}
<div className="text-xs text-gray-500">{r.what}</div>
</td>
<td className="p-2 text-right">{r.records ?? "—"}</td>
</tr>
))}</tbody>
</table>
</div>
)}
{data && <Pager page={page} total={data.total} pageSize={data.pageSize} onPage={setPage} />}
</div>
);
}
@@ -0,0 +1,231 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { ArrowUp, ArrowDown, Trash2, Plus, RotateCcw, Eye, PencilLine } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import { PrivacyNoticeSection, type NoticeSection } from "@/components/shared/PrivacyNoticeSection";
type Version = { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean; publishedBy: string | null; acceptances: number };
type Draft = { sections: NoticeSection[]; rendered: NoticeSection[]; isDraft: boolean; savedAt: string | null; versions: Version[]; builtIn: NoticeSection[] };
type Msg = { type: "ok" | "err"; text: string } | null;
const PLACEHOLDER_HELP: [string, string][] = [
["{org}", "organisation name"],
["{orgEmail}", "organisation email"],
["{website}", "website (Site settings → Legal)"],
["{ioName}", "Information Officer"],
["{ioEmail}", "Information Officer's email"],
["{medicalRetentionDays}", "days before medical details are deleted"],
["{operators}", "on its own line: one bullet per active operator"],
["{retention}", "on its own line: one bullet per retention period"],
];
const fmt = (d: string) => new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
/** Admin → Privacy → Privacy notice: edit the sections, preview, publish versions. */
export function NoticeEditor({ token }: { token: string }) {
const [draft, setDraft] = useState<Draft | null>(null);
const [sections, setSections] = useState<NoticeSection[]>([]);
const [dirty, setDirty] = useState(false);
const [preview, setPreview] = useState(false);
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const [publishing, setPublishing] = useState(false);
const [changeSummary, setChangeSummary] = useState("");
const [requiresAcceptance, setRequiresAcceptance] = useState(false);
const load = useCallback(async () => {
try {
const d = await apiFetch<Draft>("/api/privacy/notice/draft", { authToken: token });
setDraft(d);
setSections(d.sections);
setDirty(false);
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load the notice." });
}
}, [token]);
useEffect(() => { load(); }, [load]);
const change = (next: NoticeSection[]) => { setSections(next); setDirty(true); };
const update = (i: number, patch: Partial<NoticeSection>) => change(sections.map((s, j) => (j === i ? { ...s, ...patch } : s)));
const move = (i: number, by: number) => {
const next = [...sections];
const [s] = next.splice(i, 1);
next.splice(i + by, 0, s);
change(next);
};
const save = async () => {
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ sections: NoticeSection[]; rendered: NoticeSection[]; savedAt: string }>("/api/privacy/notice/draft", { method: "PUT", authToken: token, body: { sections } });
setDraft(d => (d ? { ...d, sections: res.sections, rendered: res.rendered, isDraft: true, savedAt: res.savedAt } : d));
setSections(res.sections);
setDirty(false);
setMsg({ type: "ok", text: "Draft saved. It isn't public until you publish it." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
} finally {
setBusy(false);
}
};
const discard = async () => {
if (!confirm("Throw away the draft and go back to the published text?")) return;
setBusy(true);
try {
await apiFetch("/api/privacy/notice/draft", { method: "DELETE", authToken: token });
await load();
setMsg({ type: "ok", text: "Draft discarded." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not discard." });
} finally {
setBusy(false);
}
};
const publish = async (e: React.FormEvent) => {
e.preventDefault();
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ version: number }>("/api/privacy/notice/publish", { method: "POST", authToken: token, body: { sections, changeSummary, requiresAcceptance } });
setPublishing(false);
setChangeSummary("");
setRequiresAcceptance(false);
await load();
setMsg({ type: "ok", text: `Version ${res.version} is published.${requiresAcceptance ? " Everyone will be asked to accept it after they next log in." : ""}` });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not publish." });
} finally {
setBusy(false);
}
};
if (!draft) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const builtInById = new Map(draft.builtIn.map(s => [s.id, s]));
const current = draft.versions[0];
return (
<div className="space-y-5">
<div className="text-sm text-gray-600">
{current ? (
<>Published: <strong>version {current.version}</strong> ({fmt(current.publishedAt)}). </>
) : (
<>Nothing published yet — the site shows the built-in notice. </>
)}
{draft.isDraft && <span className="text-amber-700">You have an unpublished draft{draft.savedAt ? ` (saved ${fmt(draft.savedAt)})` : ""}. </span>}
<Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">View the public page</Link>
</div>
<details className="text-xs text-gray-500 border rounded-lg p-3 bg-gray-50">
<summary className="cursor-pointer font-medium text-gray-700">How to write it</summary>
<p className="mt-2">One paragraph per line; start a line with &quot;• &quot; for a bullet. These are filled in when the notice is shown, so they stay up to date:</p>
<ul className="mt-1 grid sm:grid-cols-2 gap-x-4">
{PLACEHOLDER_HELP.map(([p, d]) => <li key={p}><code>{p}</code> — {d}</li>)}
</ul>
<p className="mt-2">This is tooling to help you comply, not legal advice — your Information Officer should check the wording.</p>
</details>
<div className="flex flex-wrap gap-2">
<button onClick={() => setPreview(p => !p)} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
{preview ? <><PencilLine className="w-4 h-4" /> Edit</> : <><Eye className="w-4 h-4" /> Preview</>}
</button>
<button onClick={save} disabled={busy || !dirty} className="px-3 py-1.5 rounded-lg bg-white border border-brand-600 text-brand-700 text-sm font-medium hover:bg-brand-50 disabled:opacity-50">
Save draft
</button>
<button onClick={() => setPublishing(true)} disabled={busy} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
Publish…
</button>
{draft.isDraft && (
<button onClick={discard} disabled={busy} className="px-3 py-1.5 rounded-lg text-sm text-gray-600 hover:bg-gray-100">Discard draft</button>
)}
{dirty && <span className="self-center text-xs text-amber-700">Unsaved changes</span>}
</div>
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
{publishing && (
<form onSubmit={publish} className="border rounded-xl p-4 bg-brand-50/40 space-y-3">
<div className="font-medium text-gray-900">Publish version {(current?.version ?? 0) + 1}</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">What changed</label>
<textarea className={inputCls} rows={2} maxLength={2000} value={changeSummary} onChange={e => setChangeSummary(e.target.value)} placeholder="e.g. We now use PayFast for online payments." />
</div>
<label className="flex items-start gap-2 text-sm text-gray-700">
<input type="checkbox" className="mt-0.5 h-4 w-4 accent-brand-600" checked={requiresAcceptance} onChange={e => setRequiresAcceptance(e.target.checked)} />
<span>
<strong>Significant change</strong> — ask everyone to accept this version the next time they log in.
<span className="block text-xs text-gray-500">Leave unticked for small fixes (wording, typos, contact details).</span>
</span>
</label>
<div className="flex gap-2">
<button type="button" onClick={() => setPublishing(false)} className="px-3 py-1.5 rounded-lg bg-gray-100 text-sm hover:bg-gray-200">Cancel</button>
<button type="submit" disabled={busy || (requiresAcceptance && !changeSummary.trim())} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{busy ? "Publishing…" : "Publish"}
</button>
</div>
</form>
)}
{preview ? (
<div className="border rounded-xl p-6 bg-white text-gray-700 leading-relaxed">
{dirty && <p className="mb-4 text-xs text-amber-700">Showing the last saved draft — save to preview your latest edits.</p>}
{draft.rendered.map((s, i) => <PrivacyNoticeSection key={s.id} section={s} first={i === 0} />)}
</div>
) : (
<div className="space-y-3">
{sections.map((s, i) => {
const original = builtInById.get(s.id);
return (
<div key={`${s.id}-${i}`} className="border rounded-xl p-3 bg-white">
<div className="flex items-center gap-2 mb-2">
<input className={`${inputCls} font-medium`} value={s.title} onChange={e => update(i, { title: e.target.value })} aria-label="Section title" />
<button onClick={() => move(i, -1)} disabled={i === 0} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move up"><ArrowUp className="w-4 h-4" /></button>
<button onClick={() => move(i, 1)} disabled={i === sections.length - 1} className="p-1.5 rounded hover:bg-gray-100 disabled:opacity-30" aria-label="Move down"><ArrowDown className="w-4 h-4" /></button>
{original && (original.body !== s.body || original.title !== s.title) && (
<button onClick={() => update(i, { title: original.title, body: original.body })} className="p-1.5 rounded hover:bg-gray-100" title="Restore the built-in text" aria-label="Restore the built-in text"><RotateCcw className="w-4 h-4" /></button>
)}
<button onClick={() => confirm(`Remove "${s.title}"?`) && change(sections.filter((_, j) => j !== i))} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label="Remove section"><Trash2 className="w-4 h-4" /></button>
</div>
<textarea className={`${inputCls} font-mono text-xs`} rows={Math.min(18, Math.max(3, s.body.split("\n").length + 1))} value={s.body} onChange={e => update(i, { body: e.target.value })} aria-label={`${s.title} text`} />
</div>
);
})}
<button onClick={() => change([...sections, { id: `section-${sections.length + 1}`, title: "New section", body: "" }])} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
<Plus className="w-4 h-4" /> Add section
</button>
</div>
)}
{draft.versions.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Published versions</h3>
<div className="overflow-x-auto border rounded-xl bg-white">
<table className="w-full text-sm">
<thead className="bg-gray-50 text-left text-xs text-gray-500">
<tr><th className="p-2">Version</th><th className="p-2">Published</th><th className="p-2">What changed</th><th className="p-2">Re-accept</th><th className="p-2 text-right">Accepted by</th></tr>
</thead>
<tbody>
{draft.versions.map(v => (
<tr key={v.version} className="border-t">
<td className="p-2"><Link href={`/legal/privacy?version=${v.version}`} target="_blank" className="text-brand-600 hover:underline">{v.version}</Link></td>
<td className="p-2 whitespace-nowrap">{fmt(v.publishedAt)}{v.publishedBy ? ` · ${v.publishedBy}` : ""}</td>
<td className="p-2 text-gray-600">{v.changeSummary || "—"}</td>
<td className="p-2">{v.requiresAcceptance ? "Yes" : "No"}</td>
<td className="p-2 text-right">{v.acceptances}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,120 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { Plus, Pencil, Trash2 } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type Operator = { id: string; name: string; purpose: string; dataShared: string; location: string | null; website: string | null; active: boolean; order: number };
type Form = { name: string; purpose: string; dataShared: string; location: string; website: string; active: boolean };
const EMPTY: Form = { name: "", purpose: "", dataShared: "", location: "", website: "", active: true };
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
function OperatorForm({ initial, onSave, onCancel, busy }: { initial: Form; onSave: (f: Form) => void; onCancel: () => void; busy: boolean }) {
const [f, setF] = useState(initial);
const set = (k: keyof Form) => (e: React.ChangeEvent<HTMLInputElement>) => setF({ ...f, [k]: k === "active" ? e.target.checked : e.target.value });
return (
<form onSubmit={e => { e.preventDefault(); onSave(f); }} className="grid sm:grid-cols-2 gap-3 border rounded-xl p-4 bg-gray-50">
<label className="text-sm">Name<input className={inputCls} value={f.name} onChange={set("name")} required maxLength={200} /></label>
<label className="text-sm">What they do for you<input className={inputCls} value={f.purpose} onChange={set("purpose")} required placeholder="e.g. Online payments" /></label>
<label className="text-sm sm:col-span-2">Personal information they receive<input className={inputCls} value={f.dataShared} onChange={set("dataShared")} placeholder="e.g. Name, email address, payment amount" /></label>
<label className="text-sm">Where it&apos;s processed<input className={inputCls} value={f.location} onChange={set("location")} placeholder="e.g. South Africa" /></label>
<label className="text-sm">Website / privacy policy<input className={inputCls} value={f.website} onChange={set("website")} placeholder="https://…" /></label>
<label className="flex items-center gap-2 text-sm sm:col-span-2">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={f.active} onChange={set("active")} /> In use (listed in the privacy notice)
</label>
<div className="flex gap-2 sm:col-span-2">
<button type="button" onClick={onCancel} className="px-3 py-1.5 rounded-lg bg-white border text-sm hover:bg-gray-100">Cancel</button>
<button type="submit" disabled={busy} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
</div>
</form>
);
}
/** Admin → Privacy → Operators: the third parties that process personal information. */
export function OperatorsPanel({ token }: { token: string }) {
const [ops, setOps] = useState<Operator[] | null>(null);
const [editing, setEditing] = useState<string | "new" | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setOps(await apiFetch<Operator[]>("/api/privacy/operators", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Could not load operators.");
}
}, [token]);
useEffect(() => { load(); }, [load]);
const save = async (f: Form) => {
setBusy(true);
setError(null);
try {
const body = { ...f, location: f.location || null, website: f.website || null };
if (editing === "new") await apiFetch("/api/privacy/operators", { method: "POST", authToken: token, body });
else await apiFetch(`/api/privacy/operators/${editing}`, { method: "PUT", authToken: token, body });
setEditing(null);
await load();
} catch (e) {
setError(errorMessage(e) || "Could not save.");
} finally {
setBusy(false);
}
};
const remove = async (op: Operator) => {
if (!confirm(`Remove ${op.name} from the register? (Untick "In use" instead if you might use them again.)`)) return;
try {
await apiFetch(`/api/privacy/operators/${op.id}`, { method: "DELETE", authToken: token });
await load();
} catch (e) {
setError(errorMessage(e) || "Could not remove.");
}
};
return (
<div className="space-y-4">
<p className="text-sm text-gray-600">
Services outside your organisation that process personal information for you (POPIA calls them &quot;operators&quot;). The ones in
use are listed in the privacy notice wherever it says <code>{"{operators}"}</code>. Check each one&apos;s details — especially
your email and hosting provider — and that you have an agreement with it.
</p>
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
{ops === null ? <p className="text-sm text-gray-400">Loading…</p> : (
<ul className="space-y-2">
{ops.map(op => (
<li key={op.id}>
{editing === op.id ? (
<OperatorForm busy={busy} onCancel={() => setEditing(null)} onSave={save}
initial={{ name: op.name, purpose: op.purpose, dataShared: op.dataShared, location: op.location || "", website: op.website || "", active: op.active }} />
) : (
<div className={`flex items-start gap-3 border rounded-xl p-3 bg-white ${op.active ? "" : "opacity-60"}`}>
<div className="min-w-0 flex-1 text-sm">
<div className="font-medium text-gray-900">
{op.name}{op.location ? <span className="font-normal text-gray-500"> · {op.location}</span> : null}
{!op.active && <span className="ml-2 text-xs px-2 py-0.5 rounded-full bg-gray-100 text-gray-600">not in use</span>}
</div>
<div className="text-gray-600">{op.purpose}</div>
{op.dataShared && <div className="text-xs text-gray-500">Receives: {op.dataShared}</div>}
{op.website && <a href={op.website} target="_blank" rel="noopener noreferrer" className="text-xs text-brand-600 hover:underline break-all">{op.website}</a>}
</div>
<button onClick={() => setEditing(op.id)} className="p-1.5 rounded hover:bg-gray-100" aria-label={`Edit ${op.name}`}><Pencil className="w-4 h-4" /></button>
<button onClick={() => remove(op)} className="p-1.5 rounded hover:bg-red-50 text-red-600" aria-label={`Remove ${op.name}`}><Trash2 className="w-4 h-4" /></button>
</div>
)}
</li>
))}
</ul>
)}
{editing === "new" ? (
<OperatorForm busy={busy} initial={EMPTY} onCancel={() => setEditing(null)} onSave={save} />
) : (
<button onClick={() => setEditing("new")} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border bg-white text-sm hover:bg-gray-50">
<Plus className="w-4 h-4" /> Add operator
</button>
)}
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CheckCircle2, Circle, ArrowRight } from "lucide-react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type Check = { key: string; label: string; ok: boolean; detail: string; href: string | null; applies: boolean };
type Readiness = { score: number; passed: number; total: number; checks: Check[] };
/** Admin → Privacy → Readiness: a checklist of what's set up for POPIA, with a score. */
export function ReadinessPanel({ token }: { token: string }) {
const [data, setData] = useState<Readiness | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
setData(await apiFetch<Readiness>("/api/privacy/readiness", { authToken: token }));
} catch (e) {
setError(errorMessage(e) || "Could not load the checklist.");
}
}, [token]);
useEffect(() => { load(); }, [load]);
if (error) return <p className="text-sm text-red-700">{error}</p>;
if (!data) return <p className="text-sm text-gray-400">Loading…</p>;
const tone = data.score >= 90 ? "text-green-700 bg-green-50 border-green-200" : data.score >= 60 ? "text-amber-800 bg-amber-50 border-amber-200" : "text-red-700 bg-red-50 border-red-200";
const todo = data.checks.filter(c => c.applies && !c.ok);
const done = data.checks.filter(c => c.applies && c.ok);
const na = data.checks.filter(c => !c.applies);
const Row = ({ c }: { c: Check }) => (
<li className="flex items-start gap-3 border rounded-xl p-3 bg-white">
{c.ok ? <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" /> : <Circle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />}
<div className="min-w-0 flex-1 text-sm">
<div className="font-medium text-gray-900">{c.label}</div>
<div className="text-gray-600 break-words">{c.detail}</div>
</div>
{!c.ok && c.href && (
<Link href={c.href} className="shrink-0 inline-flex items-center gap-1 text-sm text-brand-600 hover:underline">
Fix <ArrowRight className="w-3.5 h-3.5" />
</Link>
)}
</li>
);
return (
<div className="space-y-5">
<div className={`flex items-center gap-4 border rounded-xl p-4 ${tone}`}>
<div className="text-4xl font-bold tabular-nums">{data.score}%</div>
<div className="text-sm">
<div className="font-semibold">POPIA readiness</div>
<div>{data.passed} of {data.total} checks done. This helps you comply; it isn&apos;t legal advice — your Information Officer stays responsible.</div>
</div>
</div>
{todo.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">To do</h3>
<ul className="space-y-2">{todo.map(c => <Row key={c.key} c={c} />)}</ul>
</div>
)}
{done.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-900 mb-2">Done</h3>
<ul className="space-y-2">{done.map(c => <Row key={c.key} c={c} />)}</ul>
</div>
)}
{na.length > 0 && <p className="text-xs text-gray-500">Not counted (doesn&apos;t apply here): {na.map(c => c.label).join(", ")}.</p>}
</div>
);
}
@@ -0,0 +1,144 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type Policy = { enabled: boolean; months: number };
type Definition = { label: string; description: string; minMonths: number; defaults: Policy };
type State = {
policies: Record<string, Policy>;
definitions: Record<string, Definition>;
reviewedAt: string | null;
reviewedBy: string | null;
preview: Record<string, Record<string, number>>;
lastRun: { at: string; result: Record<string, Record<string, number | string>> } | null;
related: { medicalDays: number; backupCount: number; analyticsDays: number };
};
type Msg = { type: "ok" | "err"; text: string } | null;
const fmt = (d: string) => new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
function previewText(key: string, p: Record<string, number> | undefined) {
if (!p) return "";
if (key === "inactive_accounts") {
if (!p.toWarn && !p.toAnonymise) return "Nobody is affected right now.";
return `Next run: ${p.toWarn} to warn, ${p.toAnonymise} to anonymise (warned 30+ days ago).`;
}
return p.count ? `Next run affects ${p.count} record${p.count === 1 ? "" : "s"}.` : "Nothing is due right now.";
}
/** Admin → Privacy → Retention: how long personal information is kept. */
export function RetentionPanel({ token }: { token: string }) {
const [state, setState] = useState<State | null>(null);
const [policies, setPolicies] = useState<Record<string, Policy>>({});
const [busy, setBusy] = useState(false);
const [msg, setMsg] = useState<Msg>(null);
const apply = (s: State) => { setState(s); setPolicies(s.policies); };
const load = useCallback(async () => {
try {
apply(await apiFetch<State>("/api/privacy/retention", { authToken: token }));
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not load retention settings." });
}
}, [token]);
useEffect(() => { load(); }, [load]);
const save = async () => {
const inactive = policies.inactive_accounts;
if (state && inactive?.enabled && !state.policies.inactive_accounts.enabled && !confirm("Switch on deleting inactive accounts? People are warned 30 days before; after that it can't be undone.")) return;
setBusy(true);
setMsg(null);
try {
apply(await apiFetch<State>("/api/privacy/retention", { method: "PUT", authToken: token, body: { policies } }));
setMsg({ type: "ok", text: "Saved. The privacy notice's retention section is updated to match." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
} finally {
setBusy(false);
}
};
const runNow = async () => {
if (!confirm("Run the retention job now? It deletes and anonymises everything past its retention period, just like the nightly run.")) return;
setBusy(true);
setMsg(null);
try {
const res = await apiFetch<{ state: State }>("/api/privacy/retention/run", { method: "POST", authToken: token });
apply(res.state);
setMsg({ type: "ok", text: "Done — see \"Last run\" below." });
} catch (e) {
setMsg({ type: "err", text: errorMessage(e) || "The run failed." });
} finally {
setBusy(false);
}
};
if (!state) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
const dirty = JSON.stringify(policies) !== JSON.stringify(state.policies);
return (
<div className="space-y-5">
<p className="text-sm text-gray-600">
POPIA says personal information mustn&apos;t be kept longer than needed. Every night at 03:30 the retention job deletes or anonymises
what&apos;s past these periods. The defaults are a sensible start — check them with your Information Officer and save, even if you change nothing.
{state.reviewedAt ? ` Last saved ${fmt(state.reviewedAt)}${state.reviewedBy ? ` by ${state.reviewedBy}` : ""}.` : " Not reviewed yet."}
</p>
<ul className="space-y-3">
{Object.entries(state.definitions).map(([key, def]) => {
const p = policies[key];
return (
<li key={key} className="border rounded-xl p-4 bg-white">
<div className="flex flex-wrap items-center gap-3">
<label className="flex items-center gap-2 font-medium text-gray-900">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={p.enabled} onChange={e => setPolicies({ ...policies, [key]: { ...p, enabled: e.target.checked } })} />
{def.label}
</label>
<label className="ml-auto flex items-center gap-2 text-sm text-gray-700">
after
<input type="number" min={def.minMonths} max={240} className="w-20 border rounded-lg px-2 py-1 text-sm" value={p.months} disabled={!p.enabled}
onChange={e => setPolicies({ ...policies, [key]: { ...p, months: parseInt(e.target.value, 10) || def.minMonths } })} aria-label={`${def.label} months`} />
months
</label>
</div>
<p className="mt-1 text-sm text-gray-600">{def.description}</p>
<p className="mt-1 text-xs text-gray-500">
{p.enabled ? previewText(key, state.preview[key]) : "Off — kept until someone asks for it to be deleted."}
{(p.months !== def.defaults.months || p.enabled !== def.defaults.enabled) && ` (Default: ${def.defaults.enabled ? `${def.defaults.months} months` : "off"}.)`}
</p>
</li>
);
})}
</ul>
<div className="border rounded-xl p-4 bg-gray-50 text-sm text-gray-600 space-y-1">
<div className="font-medium text-gray-800">Set elsewhere</div>
<div>Medical details from parental consent: deleted {state.related.medicalDays} days after the event — <Link href="/dashboard/admin/settings?tab=legal" className="text-brand-600 hover:underline">Site settings → Legal</Link>.</div>
<div>Database backups: the last {state.related.backupCount} are kept — <Link href="/dashboard/admin/settings?tab=backups" className="text-brand-600 hover:underline">Site settings → Backups</Link>.</div>
<div>Visit statistics: rolled up into anonymous daily totals after {state.related.analyticsDays} days.</div>
<div>Consent records and privacy notice acceptances are kept as proof (without the IP address once an account is deleted).</div>
</div>
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
<div className="flex flex-wrap gap-2">
<button onClick={save} disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{busy ? "Saving…" : dirty ? "Save changes" : "Save (confirm reviewed)"}
</button>
<button onClick={runNow} disabled={busy || dirty} className="px-4 py-2 rounded-lg border bg-white text-sm hover:bg-gray-50 disabled:opacity-50" title={dirty ? "Save first" : undefined}>
Run now
</button>
</div>
{state.lastRun && (
<div className="text-xs text-gray-500">
<span className="font-medium text-gray-700">Last run</span> {fmt(state.lastRun.at)}:{" "}
{Object.entries(state.lastRun.result).length === 0 ? "nothing switched on." : Object.entries(state.lastRun.result).map(([k, r]) => `${state.definitions[k]?.label || k} — ${Object.entries(r).map(([a, b]) => `${a}: ${b}`).join(", ")}`).join("; ")}
</div>
)}
</div>
);
}
@@ -19,6 +19,8 @@ export function RegisterForm() {
const [phoneNumber, setPhoneNumber] = useState(""); const [phoneNumber, setPhoneNumber] = useState("");
const [notificationPreference, setNotificationPreference] = useState<"email" | "whatsapp" | "both">("email"); const [notificationPreference, setNotificationPreference] = useState<"email" | "whatsapp" | "both">("email");
const [accepted, setAccepted] = useState(false); const [accepted, setAccepted] = useState(false);
// POPIA: marketing needs an opt-in, so this starts unticked.
const [newsOptIn, setNewsOptIn] = useState(false);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -53,6 +55,7 @@ export function RegisterForm() {
password, password,
phoneNumber: phoneNumber || undefined, phoneNumber: phoneNumber || undefined,
notificationPreference: hasValidPhone ? notificationPreference : "email", notificationPreference: hasValidPhone ? notificationPreference : "email",
newsOptIn,
}); });
window.location.href = redirectTo; window.location.href = redirectTo;
} catch (err) { } catch (err) {
@@ -150,6 +153,19 @@ export function RegisterForm() {
</label> </label>
</div> </div>
<div className="flex items-start gap-2">
<input
id="news"
type="checkbox"
checked={newsOptIn}
onChange={(e) => setNewsOptIn(e.target.checked)}
className="mt-1 h-4 w-4 border-gray-300 rounded accent-brand-600"
/>
<label htmlFor="news" className="text-sm text-gray-700 leading-snug">
Send me news and announcements about upcoming events (optional — you can unsubscribe at any time).
</label>
</div>
<div className="mt-1"> <div className="mt-1">
<a <a
href={loginHref} href={loginHref}
@@ -5,6 +5,7 @@ import { HeartHandshake, X } from "lucide-react";
import { apiFetch } from "@/lib/api"; import { apiFetch } from "@/lib/api";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { errorMessage } from "@/lib/utils"; import { errorMessage } from "@/lib/utils";
import { useOnlinePayments } from "@/lib/onlinePayments";
type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null }; type Sponsorship = { enabled: boolean; open?: boolean; spotPrice?: number | null; showProgress?: boolean; goalSpots?: number | null; spotsFunded?: number | null; sponsors?: number | null };
@@ -30,6 +31,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [returned, setReturned] = useState<string | null>(null); const [returned, setReturned] = useState<string | null>(null);
const online = useOnlinePayments();
useEffect(() => { useEffect(() => {
apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null)); apiFetch<Sponsorship>(`/api/sponsorships/event/${encodeURIComponent(eventId)}`).then(setData).catch(() => setData(null));
@@ -39,7 +41,8 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
} catch { /* no window */ } } catch { /* no window */ }
}, [eventId]); }, [eventId]);
if (!data?.enabled) return null; // Sponsoring is paid online, so it's offered only when online payment is possible.
if (!data?.enabled || (!online.available && !returned)) return null;
const price = data.spotPrice || null; const price = data.spotPrice || null;
const loggedIn = !!(token && user); const loggedIn = !!(token && user);
const total = mode === "spots" && price ? spots * price : Number(amount) || 0; const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
+2 -1
View File
@@ -2,7 +2,7 @@
import React, { useState } from "react"; import React, { useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, type LucideIcon } from "lucide-react"; import { Calendar, User, LayoutDashboard, ShieldCheck, Globe, Menu, Lock, type LucideIcon } from "lucide-react";
import { useAuth } from "@/hooks/useAuth"; import { useAuth } from "@/hooks/useAuth";
import { RoleBadge, type Role } from "@/components/shared/RoleBadge"; import { RoleBadge, type Role } from "@/components/shared/RoleBadge";
import { UserAvatar } from "@/components/shared/UserAvatar"; import { UserAvatar } from "@/components/shared/UserAvatar";
@@ -12,6 +12,7 @@ import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
const navLinks: { href: string; label: string; icon: LucideIcon; roles?: string[] }[] = [ const navLinks: { href: string; label: string; icon: LucideIcon; roles?: string[] }[] = [
{ href: "/dashboard/user", label: "My Events", icon: Calendar, roles: ["user", "staff", "supervisor", "admin"] }, { href: "/dashboard/user", label: "My Events", icon: Calendar, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/profile", label: "Profile & Security", icon: User, roles: ["user", "staff", "supervisor", "admin"] }, { href: "/dashboard/user/profile", label: "Profile & Security", icon: User, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/user/privacy", label: "Privacy", icon: Lock, roles: ["user", "staff", "supervisor", "admin"] },
{ href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] }, { href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] },
{ href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] }, { href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] },
{ href: "/dashboard/admin", label: "Admin", icon: ShieldCheck, roles: ["admin"] }, { href: "/dashboard/admin", label: "Admin", icon: ShieldCheck, roles: ["admin"] },
@@ -0,0 +1,137 @@
"use client";
import React, { useCallback, useEffect, useState } from "react";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type OpenCheckout = {
id: string;
provider: string;
providerLabel: string;
canCheck: boolean;
amount: number;
description: string;
type: string | null;
registrationId: string | null;
payer: string | null;
createdAt: string;
};
type CheckResult = { status: "paid" | "recorded" | "needs_review" | "not_paid" | "cant_check"; note?: string };
const RESULT_TEXT: Record<CheckResult["status"], string> = {
paid: "Already paid",
recorded: "Payment found and recorded",
needs_review: "Paid a different amount — see Unreconciled below",
not_paid: "Not paid (yet)",
cant_check: "Can't check — look in the provider's portal",
};
/**
* Online checkouts from the last 14 days with no payment yet (Payments → Reconcile). For when a
* provider's notification never arrived: "Check status" asks the provider and records any
* payment it received. A background job does the same every 15 minutes.
*/
export function WaitingForPayment({ token, onRecorded }: { token: string | null; onRecorded: () => void }) {
const [list, setList] = useState<OpenCheckout[] | null>(null);
const [results, setResults] = useState<Record<string, CheckResult>>({});
const [busy, setBusy] = useState<string | null>(null);
const [message, setMessage] = useState<{ ok: boolean; text: string } | null>(null);
const load = useCallback(async () => {
if (!token) return;
try {
const res = await apiFetch<{ checkouts: OpenCheckout[] }>("/api/payments/checkouts/open", { authToken: token });
setList(res.checkouts);
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "Couldn't load waiting payments" });
}
}, [token]);
useEffect(() => { load(); }, [load]);
const check = async (ids?: string[]) => {
if (!token) return;
setBusy(ids?.[0] || "all");
setMessage(null);
try {
const res = await apiFetch<{ results: Record<string, CheckResult>; recorded: number; message: string }>("/api/payments/checkouts/check", {
method: "POST", authToken: token, body: ids ? { ids } : {},
});
setResults(r => ({ ...r, ...res.results }));
setMessage({ ok: true, text: res.message });
if (res.recorded > 0) onRecorded();
} catch (e) {
setMessage({ ok: false, text: errorMessage(e) || "Couldn't check" });
} finally {
setBusy(null);
}
};
const checkable = (list || []).some(c => c.canCheck);
return (
<div className="border rounded-xl p-4 bg-white shadow-sm">
<div className="flex items-center justify-between gap-2 mb-1">
<div className="text-lg font-semibold">Waiting for payment</div>
<div className="flex gap-2">
{checkable && (
<button className="px-3 py-1.5 text-sm rounded bg-brand-600 hover:bg-brand-700 text-white disabled:opacity-50" disabled={!!busy} onClick={() => check()}>
{busy === "all" ? "Checking…" : "Check all"}
</button>
)}
<button className="px-3 py-1.5 text-sm rounded bg-gray-100 hover:bg-gray-200 text-gray-800" onClick={() => { setResults({}); load(); }}>Refresh</button>
</div>
</div>
<p className="text-xs text-gray-500 mb-3">
Online payments started in the last 14 days that haven&apos;t been confirmed. If the provider&apos;s notification didn&apos;t arrive, check the status here — PayFast payments are also checked automatically every 15 minutes.
</p>
{message && <div className={`text-sm mb-3 p-2 rounded ${message.ok ? "bg-green-50 text-green-800" : "bg-red-50 text-red-700"}`}>{message.text}</div>}
{!list ? (
<div className="text-sm text-gray-500">Loading…</div>
) : list.length === 0 ? (
<div className="text-sm text-gray-500">Nothing waiting.</div>
) : (
<div className="overflow-x-auto w-full">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left border-b">
<th className="py-2 pr-3">Started</th>
<th className="py-2 pr-3">Payer</th>
<th className="py-2 pr-3">For</th>
<th className="py-2 pr-3">Amount</th>
<th className="py-2 pr-3">Provider</th>
<th className="py-2">Status</th>
</tr>
</thead>
<tbody>
{list.map(c => {
const result = results[c.id];
return (
<tr key={c.id} className="border-b align-top">
<td className="py-2 pr-3 whitespace-nowrap">{new Date(c.createdAt).toLocaleString()}</td>
<td className="py-2 pr-3">{c.payer || "—"}</td>
<td className="py-2 pr-3">{c.description || (c.type === "donation" ? "Donation" : "—")}</td>
<td className="py-2 pr-3 whitespace-nowrap">R {c.amount.toFixed(2)}</td>
<td className="py-2 pr-3">{c.providerLabel}</td>
<td className="py-2">
{result ? (
<span className={result.status === "recorded" || result.status === "paid" ? "text-green-700" : result.status === "needs_review" ? "text-amber-800" : "text-gray-600"}>
{RESULT_TEXT[result.status]}
</span>
) : c.canCheck ? (
<button className="px-2 py-1 rounded border border-brand-300 text-brand-700 hover:bg-brand-50 disabled:opacity-50" disabled={!!busy} onClick={() => check([c.id])}>
{busy === c.id ? "Checking…" : "Check status"}
</button>
) : (
<span className="text-xs text-gray-500">Can&apos;t check automatically — look in the {c.providerLabel} portal, then use Reconcile</span>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
"use client";
import React from "react";
export type MessageKind = "update" | "news";
const OPTIONS: { value: MessageKind; label: string; description: string }[] = [
{
value: "update",
label: "Event update",
description: "About an event the recipients registered for — a reminder, venue or time change, what to bring. Goes to everyone selected.",
},
{
value: "news",
label: "News & announcements",
description: "Promoting events or sharing news. Only goes to people who opted in, and includes an unsubscribe link.",
},
];
/** POPIA: what kind of bulk message this is — decides who it may go to (see utils/privacy.js). */
export function MessageKindPicker({ value, onChange, name }: { value: MessageKind; onChange: (kind: MessageKind) => void; name: string }) {
return (
<fieldset>
<legend className="block text-xs text-gray-600 mb-1">Message type</legend>
<div className="grid sm:grid-cols-2 gap-2">
{OPTIONS.map(o => (
<label key={o.value} className={`flex items-start gap-2 border rounded-lg p-2.5 cursor-pointer ${value === o.value ? "border-brand-500 bg-brand-50" : "border-gray-200 bg-white hover:bg-gray-50"}`}>
<input type="radio" name={name} className="mt-0.5 accent-brand-600" checked={value === o.value} onChange={() => onChange(o.value)} />
<span>
<span className="block text-sm font-medium text-gray-800">{o.label}</span>
<span className="block text-[11px] text-gray-500">{o.description}</span>
</span>
</label>
))}
</div>
</fieldset>
);
}
/** "Matched 12 recipient(s)." plus how many news skipped. */
export function matchedText(res: { matched?: number; skipped?: number } | null | undefined) {
const skipped = res?.skipped ?? 0;
return `Matched ${res?.matched ?? 0} recipient(s).${skipped ? ` ${skipped} skipped — not opted in to news or unsubscribed.` : ""}`;
}
@@ -0,0 +1,87 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
type News = { email: boolean; whatsapp: boolean };
/**
* POPIA: asks once, after login, whether the person wants News & announcements — for
* accounts that never answered (existing accounts, ones made at the door). Their current
* setting is pre-filled; saving records the answer so it isn't asked again.
*/
export function NewsPrompt() {
const { user, token } = useAuth();
const [news, setNews] = useState<News | null>(null);
const [canWhatsApp, setCanWhatsApp] = useState(false);
const [done, setDone] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const pending = !!user?.newsPromptPending && !done;
useEffect(() => {
if (!pending || !token || news) return;
apiFetch<{ news: News; canWhatsApp: boolean }>("/api/privacy/me", { authToken: token })
.then(res => { setNews(res.news); setCanWhatsApp(res.canWhatsApp); })
.catch(() => setDone(true)); // never block the dashboard over this
}, [pending, token, news]);
if (!pending || !news) return null;
const save = async (choice: News) => {
if (!token) return;
setSaving(true);
setError(null);
try {
await apiFetch("/api/privacy/me/news", { method: "PUT", authToken: token, body: { ...choice, source: "prompt" } });
setDone(true);
} catch (e) {
setError(errorMessage(e) || "Could not save your choice.");
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="news-prompt-title">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-brand-50 text-brand-600 shrink-0">
<Megaphone className="w-5 h-5" />
</div>
<h2 id="news-prompt-title" className="text-lg font-semibold text-gray-900">Hear about upcoming events?</h2>
</div>
<p className="text-sm text-gray-600 mb-4">
We&apos;d like to send you occasional news and announcements. Messages about events you register for are
sent either way. You can change this any time under <Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">Privacy</Link>.
</p>
<div className="space-y-3 mb-5">
<label className="flex items-center gap-3 text-sm text-gray-700 cursor-pointer">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={news.email} onChange={e => setNews({ ...news, email: e.target.checked })} />
By email
</label>
{canWhatsApp && (
<label className="flex items-center gap-3 text-sm text-gray-700 cursor-pointer">
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={news.whatsapp} onChange={e => setNews({ ...news, whatsapp: e.target.checked })} />
By WhatsApp
</label>
)}
</div>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<div className="flex flex-wrap justify-end gap-3">
<button onClick={() => save({ email: false, whatsapp: false })} disabled={saving} className="px-4 py-2 rounded-lg bg-gray-100 text-sm font-medium hover:bg-gray-200 disabled:opacity-50">
No thanks
</button>
<button onClick={() => save({ email: news.email, whatsapp: canWhatsApp && news.whatsapp })} disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : "Save my choice"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,73 @@
"use client";
import React, { useEffect, useState } from "react";
import Link from "next/link";
import { ScrollText } from "lucide-react";
import { useAuth } from "@/hooks/useAuth";
import { apiFetch } from "@/lib/api";
import { errorMessage } from "@/lib/utils";
import type { PublicNotice } from "@/components/shared/PrivacyNoticeSection";
/**
* POPIA: a privacy notice version marked as a significant change has been published, and
* this person accepted an older one. Asked once after login; it can't be waved away, but the
* notice and the Privacy page (to delete the account instead) stay reachable.
*/
export function NoticePrompt({ onDone }: { onDone: () => void }) {
const { token } = useAuth();
const [notice, setNotice] = useState<PublicNotice | null>(null);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
apiFetch<PublicNotice>("/api/privacy/notice").then(setNotice).catch(() => onDone());
}, [onDone]);
if (!notice) return null;
const accept = async () => {
if (!token) return;
setSaving(true);
setError(null);
try {
await apiFetch("/api/privacy/me/notice/accept", { method: "POST", authToken: token });
onDone();
} catch (e) {
setError(errorMessage(e) || "Could not save. Please try again.");
} finally {
setSaving(false);
}
};
// The newest version that needed accepting, for "what changed".
const changes = notice.versions.find(v => v.requiresAcceptance)?.changeSummary || notice.changeSummary;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4" role="dialog" aria-modal="true" aria-labelledby="notice-prompt-title">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl">
<div className="flex items-center gap-2 mb-3">
<div className="w-9 h-9 rounded-lg flex items-center justify-center bg-brand-50 text-brand-600 shrink-0">
<ScrollText className="w-5 h-5" />
</div>
<h2 id="notice-prompt-title" className="text-lg font-semibold text-gray-900">We&apos;ve updated our privacy notice</h2>
</div>
{changes && (
<div className="mb-3 p-3 rounded-lg bg-gray-50 border text-sm text-gray-700 whitespace-pre-line">
<span className="font-medium">What changed: </span>{changes}
</div>
)}
<p className="text-sm text-gray-600 mb-5">
Please read the <Link href="/legal/privacy" target="_blank" className="text-brand-600 hover:underline">privacy notice</Link> and
accept it to carry on using your account. If you don&apos;t agree, you can{" "}
<Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">delete your account</Link> instead.
</p>
{error && <p className="text-sm text-red-600 mb-3">{error}</p>}
<div className="flex justify-end">
<button onClick={accept} disabled={saving} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
{saving ? "Saving…" : "I accept"}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,59 @@
"use client";
import React from "react";
import { CookieSettingsButton } from "@/components/shared/CookieSettingsButton";
export type NoticeSection = { id: string; title: string; body: string };
export type PublicNotice = {
version: number;
builtIn: boolean;
current: boolean;
publishedAt: string | null;
effectiveDate: string;
changeSummary: string | null;
sections: NoticeSection[];
versions: { version: number; publishedAt: string; changeSummary: string | null; requiresAcceptance: boolean }[];
};
// Email addresses, web addresses and bare "www." names in notice text become links.
const LINK_RE = /([\w.+-]+@[\w-]+\.[\w.]+|https?:\/\/[^\s)]+|www\.[\w-]+\.[\w.]+)/g;
function linkify(line: string) {
const parts = line.split(LINK_RE);
return parts.map((part, i) => {
if (i % 2 === 0) return part;
const href = part.includes("@") && !part.startsWith("http") ? `mailto:${part}` : part.startsWith("http") ? part : `https://${part}`;
return (
<a key={i} href={href} className="text-brand-600 hover:underline break-all" {...(href.startsWith("http") ? { target: "_blank", rel: "noopener noreferrer" } : {})}>
{part}
</a>
);
});
}
/** One section of the privacy notice: one paragraph per line, "• " lines as bullets. */
export function PrivacyNoticeSection({ section, first = false }: { section: NoticeSection; first?: boolean }) {
const lines = section.body.split("\n").filter(l => l.trim() !== "");
return (
<section id={section.id} className="mb-10 scroll-mt-24">
{first ? (
<h2 className="text-2xl font-semibold text-brand-600 mb-4">{section.title}</h2>
) : (
<h3 className="text-xl font-semibold text-brand-600 mb-3">{section.title}</h3>
)}
{section.id === "cookies" && (
<CookieSettingsButton className="mb-3 px-3 py-1.5 text-sm rounded-lg border border-brand-300 text-brand-700 hover:bg-brand-50">
Change my cookie choice
</CookieSettingsButton>
)}
{lines.map((line, i) =>
line.startsWith("• ") ? (
<p key={i} className="mb-1.5 pl-5 -indent-3">{"• "}{linkify(line.slice(2))}</p>
) : (
<p key={i} className="mb-2">{linkify(line)}</p>
),
)}
</section>
);
}
@@ -0,0 +1,23 @@
"use client";
import React, { useCallback, useState } from "react";
import { usePathname } from "next/navigation";
import { useAuth } from "@/hooks/useAuth";
import { NoticePrompt } from "@/components/shared/NoticePrompt";
import { NewsPrompt } from "@/components/shared/NewsPrompt";
/**
* The one-time POPIA questions after login, one at a time: accept a new privacy notice
* version first, then the news opt-in. Neither shows on the Privacy page itself, so someone
* can always get to "Delete my account".
*/
export function PrivacyPrompts() {
const { user } = useAuth();
const pathname = usePathname();
const [noticeDone, setNoticeDone] = useState(false);
const finishNotice = useCallback(() => setNoticeDone(true), []);
if (pathname === "/dashboard/user/privacy") return null;
if (user?.noticePromptPending && !noticeDone) return <NoticePrompt onDone={finishNotice} />;
return <NewsPrompt />;
}
+154
View File
@@ -0,0 +1,154 @@
import React from "react";
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types";
/** Help content for /dashboard/user/privacy (the person's own privacy centre). */
export const userPrivacyHelpContent: HelpContent = {
title: "Privacy",
subtitle: "What we send you, your data, and your rights under POPIA.",
tabs: [
{
key: "messages",
label: "Messages",
icon: Megaphone,
content: (
<div className="space-y-4">
<GuideItem icon={Megaphone} title="News & announcements" tone="brand">
Occasional messages about upcoming events. You only get them if you said yes — by email, WhatsApp or both. Untick a box to stop them.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Messages about your events" tone="green">
Tickets, receipts, reminders and changes to events you registered for are always sent, by the channel you chose in Profile &amp; Security.
</GuideItem>
<GuideItem icon={MailX} title="Unsubscribe" tone="amber">
Every news email has an unsubscribe link, and you can reply STOP to a news message on WhatsApp. The history under the boxes shows every choice you made and when.
</GuideItem>
</div>
),
},
{
key: "data",
label: "Your data",
icon: Download,
content: (
<div className="space-y-4">
<GuideItem icon={Download} title="Download my data" tone="brand">
The PDF is easy to read; the JSON file is the complete copy. Both include your registrations, the people you registered, form answers, payments, tickets and check-ins.
</GuideItem>
<GuideItem icon={PencilLine} title="Correct my data" tone="green">
Change your name, email and phone in Profile &amp; Security. For anything else, send a correction request — we answer by email within 30 days.
</GuideItem>
<GuideItem icon={Trash2} title="Delete my account" tone="rose">
Your account is closed straight away. Your personal information is deleted once the request is processed (within 30 days) — after any upcoming event you registered for. Payment amounts and dates are kept without your name, because the law requires it.
</GuideItem>
</div>
),
},
],
};
/** Help content for /dashboard/admin/privacy. */
export const adminPrivacyHelpContent: HelpContent = {
title: "Privacy (POPIA)",
subtitle: "Your privacy notice, operators, retention, breaches and the consent and access records.",
tabs: [
{
key: "readiness",
label: "Readiness & retention",
icon: Gauge,
content: (
<div className="space-y-4">
<GuideItem icon={Gauge} title="Readiness" tone="brand">
A checklist with a score: Information Officer set, notice published, operators described, retention reviewed and running, requests answered on time, breaches reported, unsubscribe and WhatsApp STOP working, backups, two-factor login. Each open item links to where you fix it.
</GuideItem>
<GuideItem icon={Timer} title="Retention" tone="amber">
How long each kind of personal information is kept. Every night at 03:30 the retention job deletes or anonymises what&apos;s older: inactive accounts (after a warning 30 days before — logging in keeps the account), event form answers, login history, payer details from payment notifications, and old audit/access records. The privacy notice&apos;s retention section follows these settings ({"{retention}"}). Review and save them, even if you keep the defaults.
</GuideItem>
<GuideItem icon={AlertTriangle} title="Breaches" tone="rose">
Record any security compromise. POPIA requires telling the Information Regulator and the people affected as soon as reasonably possible; each entry has a draft for both, and you record when you told them. Readiness flags any not reported to the Regulator within 3 days.
</GuideItem>
</div>
),
},
{
key: "notice",
label: "Privacy notice",
icon: ScrollText,
content: (
<div className="space-y-4">
<GuideItem icon={ScrollText} title="Editing" tone="brand">
The notice starts from the built-in text. Change any section, add or remove sections, and save a draft — nothing is public until you publish. Placeholders like {"{org}"}, {"{ioName}"} and {"{operators}"} are filled in from Site settings and the operator register, so they stay up to date.
</GuideItem>
<GuideItem icon={Send} title="Publishing" tone="green">
Each publish is a numbered version with its date, and earlier versions stay readable on the privacy page. Tick &quot;Significant change&quot; and say what changed when people should accept it again — they&apos;re asked once, after they next log in. Leave it unticked for small fixes.
</GuideItem>
<GuideItem icon={ShieldCheck} title="Not legal advice" tone="amber">
The tools help you comply; your Information Officer should check the wording.
</GuideItem>
</div>
),
},
{
key: "records",
label: "Operators & logs",
icon: ListChecks,
content: (
<div className="space-y-4">
<GuideItem icon={Building2} title="Operators" tone="brand">
The services that process personal information for you: payment providers, WhatsApp and email delivery, hosting. Check the details of each — especially your email and hosting provider. Ones marked &quot;in use&quot; are listed in the privacy notice.
</GuideItem>
<GuideItem icon={ListChecks} title="Consent log" tone="green">
Proof of consent: every news opt-in and opt-out (and how — sign-up, unsubscribe link, STOP reply…), every privacy notice acceptance, and every parental consent answer.
</GuideItem>
<GuideItem icon={Eye} title="Access log" tone="violet">
Who downloaded, emailed or opened personal information in bulk — reports, attendance registers, certificates, group lists, form answers, consent and medical lists, backups — and every &quot;view as&quot; session.
</GuideItem>
</div>
),
},
],
};
/** Help content for /dashboard/admin/data-requests. */
export const adminDataRequestsHelpContent: HelpContent = {
title: "Data requests",
subtitle: "POPIA requests from people about their personal information.",
tabs: [
{
key: "how",
label: "How it works",
icon: Inbox,
content: (
<div className="space-y-4">
<GuideItem icon={Inbox} title="Where requests come from" tone="brand">
People send correction and deletion requests from their Privacy page (or by ticking &quot;also delete my data&quot; when closing their account). The Information Officer (Site settings → Legal) and all admins are emailed for each one.
</GuideItem>
<GuideItem icon={Clock} title="30 days" tone="amber">
POPIA asks for an answer within 30 days. Requests due within a week or overdue are emailed to admins every morning, and overdue ones are outlined in red.
</GuideItem>
<GuideItem icon={Download} title="Access requests" tone="green">
People download their own data from their Privacy page, so those aren&apos;t listed here. If someone asks by email or phone, point them to that page. (Downloading is blocked while viewing as someone else, so an admin can&apos;t take a person&apos;s full record that way.)
</GuideItem>
</div>
),
},
{
key: "handle",
label: "Handling a request",
icon: Trash2,
content: (
<div className="space-y-4">
<GuideItem icon={PencilLine} title="Corrections" tone="brand">
Make the change (e.g. on the registration or attendee), then click Mark as done and say what you changed — it&apos;s emailed to them.
</GuideItem>
<GuideItem icon={Trash2} title="Deletions" tone="rose">
The account is already closed. If they have a registration for an upcoming event, cancel it (refund if needed) or wait until the event is over. Then Delete personal data: their name, contact details, attendees, form answers and the payer details kept by Yoco/PayFast notifications are removed. Payments, registrations and check-ins stay as &quot;Deleted User&quot; for the books and reports. It can&apos;t be undone and is logged in the audit log.
</GuideItem>
<GuideItem icon={MessageCircle} title="Reject or cancel" tone="amber">
Give a reason; it&apos;s emailed to them. Tick &quot;They withdrew it&quot; if they changed their mind. Rejecting or cancelling a deletion opens their account again.
</GuideItem>
</div>
),
},
],
};
+16
View File
@@ -26,6 +26,7 @@ import { supervisorBundlesHelpContent } from "./supervisor-bundles";
import { supervisorConsentHelpContent } from "./supervisor-consent"; import { supervisorConsentHelpContent } from "./supervisor-consent";
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist"; import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
import { supervisorAttendanceHelpContent } from "./supervisor-attendance"; import { supervisorAttendanceHelpContent } from "./supervisor-attendance";
import { userPrivacyHelpContent, adminDataRequestsHelpContent, adminPrivacyHelpContent } from "./privacy";
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans"; import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations"; import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees"; import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
@@ -179,6 +180,21 @@ const REGISTRY: HelpRegistryEntry[] = [
matches: pathname => pathname === "/dashboard/supervisor/attendance", matches: pathname => pathname === "/dashboard/supervisor/attendance",
content: supervisorAttendanceHelpContent, content: supervisorAttendanceHelpContent,
}, },
{
slug: "user-privacy",
matches: pathname => pathname === "/dashboard/user/privacy",
content: userPrivacyHelpContent,
},
{
slug: "admin-privacy",
matches: pathname => pathname === "/dashboard/admin/privacy",
content: adminPrivacyHelpContent,
},
{
slug: "admin-data-requests",
matches: pathname => pathname === "/dashboard/admin/data-requests",
content: adminDataRequestsHelpContent,
},
{ {
slug: "supervisor-consent", slug: "supervisor-consent",
matches: pathname => pathname === "/dashboard/supervisor/consent", matches: pathname => pathname === "/dashboard/supervisor/consent",
+1 -1
View File
@@ -111,7 +111,7 @@ export const siteSettingsHelpContent: HelpContent = {
Shown while Yoco is ticked. Paste the secret key, then generate the webhook (or enter its secret) so payments are confirmed automatically. Secrets are stored encrypted and never shown in full. Shown while Yoco is ticked. Paste the secret key, then generate the webhook (or enter its secret) so payments are confirmed automatically. Secrets are stored encrypted and never shown in full.
</GuideItem> </GuideItem>
<GuideItem icon={KeyRound} title="PayFast" tone="green"> <GuideItem icon={KeyRound} title="PayFast" tone="green">
Shown while PayFast is ticked. Enter the merchant ID, merchant key and salt passphrase from your PayFast dashboard (Settings), and choose Sandbox to test or Live for real payments. The passphrase must match the dashboard exactly. When more than one provider is ticked, payers choose how to pay. Shown while PayFast is ticked. Enter the merchant ID, merchant key and salt passphrase from your PayFast dashboard (Settings), and choose Sandbox to test or Live for real payments. The passphrase must match the dashboard exactly — save, then use Test connection to check. When more than one provider is ticked, payers choose how to pay.
</GuideItem> </GuideItem>
<GuideItem icon={CreditCard} title="Minimum and fees" tone="gray"> <GuideItem icon={CreditCard} title="Minimum and fees" tone="gray">
Online payments must be at least R15. Payments are recorded at the full amount paid; provider fees aren&apos;t deducted or added to the payer&apos;s total — deal with them at cashup. Online payments must be at least R15. Payments are recorded at the full amount paid; provider fees aren&apos;t deducted or added to the payer&apos;s total — deal with them at cashup.
@@ -1,5 +1,5 @@
import React from "react"; import React from "react";
import { Mail, Users, Zap, Send, Clock } from "lucide-react"; import { Mail, Users, Zap, Send, Clock, ShieldCheck } from "lucide-react";
import { GuideItem } from "@/components/shared/GuideItem"; import { GuideItem } from "@/components/shared/GuideItem";
import type { HelpContent } from "./types"; import type { HelpContent } from "./types";
@@ -22,6 +22,9 @@ export const supervisorEmailAttendeesHelpContent: HelpContent = {
<GuideItem icon={Send} title="Preview, send, or schedule" tone="green"> <GuideItem icon={Send} title="Preview, send, or schedule" tone="green">
Preview the recipient list before sending, send immediately, or pick a future date and time. Preview the recipient list before sending, send immediately, or pick a future date and time.
</GuideItem> </GuideItem>
<GuideItem icon={ShieldCheck} title="Message type (POPIA)" tone="violet">
An <strong>Event update</strong> is about the event they registered for and goes to everyone selected. <strong>News &amp; announcements</strong> (promoting events) only goes to people who opted in, and gets an unsubscribe link — the preview says how many were skipped. The next-event promo automation is always news.
</GuideItem>
</div> </div>
</div> </div>
), ),
@@ -37,7 +40,7 @@ export const supervisorEmailAttendeesHelpContent: HelpContent = {
Four canned lifecycle emails per event — a pre-event reminder, a final reminder, a thank-you, and a next-event promo — with send times auto-computed from the event&apos;s dates. Four canned lifecycle emails per event — a pre-event reminder, a final reminder, a thank-you, and a next-event promo — with send times auto-computed from the event&apos;s dates.
</GuideItem> </GuideItem>
<GuideItem icon={Mail} title="Broadcasts" tone="violet"> <GuideItem icon={Mail} title="Broadcasts" tone="violet">
Send to any selected users or pasted external addresses, independent of a specific event&apos;s attendee list. Send to any selected users or pasted external addresses, independent of a specific event&apos;s attendee list. Broadcasts are News &amp; announcements unless you choose Event update.
</GuideItem> </GuideItem>
<GuideItem icon={Clock} title="Scheduled" tone="gray"> <GuideItem icon={Clock} title="Scheduled" tone="gray">
Review, edit, or cancel any email that&apos;s scheduled but not yet sent. Review, edit, or cancel any email that&apos;s scheduled but not yet sent.

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