Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40c5c9cc1d | ||
|
|
261bc8a639 | ||
|
|
594cb35a85 | ||
|
|
be5ad620da | ||
|
|
96adf20cf0 | ||
|
|
a9c7a1129b | ||
|
|
d8eb9f22cc | ||
|
|
4cae0fc672 | ||
|
|
cd33ba281e | ||
|
|
220dafb400 | ||
|
|
eb0f390215 | ||
|
|
55a7ee2970 | ||
|
|
4bc7dbc989 | ||
|
|
71b9da2458 |
@@ -31,6 +31,9 @@ yarn-error.log*
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# this install's PM2 config (made from ecosystem.config.example.js; deploys never touch it)
|
||||
/ecosystem.config.js
|
||||
|
||||
# misc scratch / generated files
|
||||
temp/
|
||||
backend/public/uploads/
|
||||
|
||||
@@ -7,6 +7,50 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.38.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **POPIA toolkit, part 1: privacy for the people in the system.**
|
||||
- **Privacy page** (My account → Privacy): choose News & announcements by email and/or WhatsApp, see the history of those choices, **download my data** (a readable PDF, or the complete copy as JSON: account, registrations and the people registered, form answers, parental consent, payments, tickets and check-ins, certificates, feedback, communication choices), **ask for a correction**, and **delete my account**.
|
||||
- **Two kinds of bulk message.** When emailing or WhatsApping attendees, or sending a broadcast, choose **Event update** (about an event the recipients registered for — goes to everyone selected) or **News & announcements** (only to people who opted in, with an unsubscribe link). Attendee messages default to event updates, broadcasts to news, and the next-event promo automation is always news. Previews say how many people were skipped.
|
||||
- **Unsubscribe**: every news email ends with an unsubscribe link and carries one-click unsubscribe headers (Gmail, Apple Mail); every event update says why it was sent and links to the preferences. News WhatsApps end with "Reply STOP" and a link. Unsubscribed addresses without an account are remembered too.
|
||||
- **WhatsApp STOP**: replying STOP (or "unsubscribe") to the church's number stops WhatsApp news and sends a confirmation. Needs WAWP's webhook pointed at the URL shown in Site settings → WhatsApp (it carries a secret, since WAWP doesn't sign webhooks).
|
||||
- **News opt-in**: an unticked "news and announcements" box on sign-up. Existing accounts stay opted in (logged as "existing account") and are asked once, after their next login, to confirm or opt out. Every change is logged with when and how.
|
||||
- **Data requests** (Admin → Data requests): correction and deletion requests with a 30-day due date, emailed to the Information Officer and admins, with a daily reminder for ones due within a week or overdue (`privacy` job, 08:00; `PRIVACY_JOBS_ENABLED=false` to switch off). Completing or rejecting a request emails the person and is in the audit log.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Deleting an account is now a request.** "Delete my account" (and "Also delete my personal data" when closing an account) closes the account straight away; the personal information is deleted when an admin completes the request — after any registration for an upcoming event has been cancelled or the event is over.
|
||||
- **Deleting personal data now goes much further.** Before, it only cleared the account's name, email and phone. Now it also removes the names and details of the people they registered (and their own details on other people's bookings), form answers, parental-consent details, sponsor names, feedback authorship, pending transfer requests, certificate names, login history, and payer names/emails kept from Yoco and PayFast notifications. Payments, registrations, tickets and check-ins are kept, as "Deleted User", for the books and reports. Admin → Users → Anonymise uses the same deletion, and is refused while the person has a registration for an upcoming event.
|
||||
- "Download my data" is blocked while an admin is viewing as someone else.
|
||||
|
||||
## [1.37.4] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Deploy script** (`scripts/deploy.sh`): without a tag it now deploys the latest `main`. The PM2 config is per install: `ecosystem.config.js` is no longer in git (the repo has `ecosystem.config.example.js`), so deploys stop overwriting the install's app names and port; the script keeps the install's copy across checkouts and rollbacks, and creates it from the example on a fresh install. The script also runs from a temporary copy (so checking out a new version of it mid-deploy is safe), and a failure rolls back once.
|
||||
|
||||
## [1.37.3] - 2026-10-23
|
||||
|
||||
### Fixed
|
||||
|
||||
- The production build (and so the deploy) failed on unescaped quotes in the Payments help text.
|
||||
|
||||
## [1.37.2] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **Payment status checks** for when a provider's notification never arrives. Payments → Reconcile has a new "Waiting for payment" list: online checkouts from the last 14 days with no payment yet, each with "Check status" (and "Check all"). For PayFast it looks the payment up in PayFast's transaction history and, if PayFast received it, records it exactly as the notification would have (same amount check, tickets and emails). A background job does this every 15 minutes for checkouts between 10 minutes and 3 days old (`payment-status`, switch off with `PAYMENT_STATUS_JOB_ENABLED=false`). Yoco's API has no way to look a payment up, so Yoco checkouts show "can't check" — find them in the Yoco portal and record them with Reconcile, as before.
|
||||
- **Test connection** on the PayFast settings card: checks the saved merchant ID and passphrase with PayFast.
|
||||
|
||||
## [1.37.1] - 2026-10-23
|
||||
|
||||
### Changed
|
||||
|
||||
- **No "Pay online" when it can't be used.** When no payment provider is switched on, or the amount is under the R15 minimum, the registration success page, My Events ("Make payment" and "Make donation"), the donate page, "Sponsor a spot", the donation box on the register form and the supervisor Payment Link tab no longer offer online payment. Where it helps, they say why and to pay at the event instead.
|
||||
- Roadmap: SnapScan and Zapper are dropped (Yoco and PayFast are enough); later releases move up two versions — POPIA toolkit is next as 1.38.0.
|
||||
|
||||
## [1.37.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+56
-9
@@ -30,10 +30,11 @@ WhatsApp · PM2 for process management · optional Sentry for error monitoring.
|
||||
14. [Reports](#reports)
|
||||
15. [Site settings & setup wizard](#site-settings--setup-wizard)
|
||||
16. [Accounts & security](#accounts--security)
|
||||
17. [Help system](#help-system)
|
||||
18. [Background jobs](#background-jobs)
|
||||
19. [Operations](#operations)
|
||||
20. [Page map](#page-map)
|
||||
17. [Privacy (POPIA)](#privacy-popia)
|
||||
18. [Help system](#help-system)
|
||||
19. [Background jobs](#background-jobs)
|
||||
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
|
||||
modes. When several providers are on, payers choose on the "Pay online" page; a
|
||||
payment of a different amount from the checkout is held for review instead of being
|
||||
recorded.
|
||||
recorded. When no provider is on (or the amount is under R15), nothing offers to pay
|
||||
online.
|
||||
- **Payment status checks** — if a provider's notification never arrives, Payments →
|
||||
Reconcile → "Waiting for payment" checks with the provider (PayFast; Yoco's API can't be
|
||||
asked) and records what it finds. A job checks recent PayFast checkouts every 15 minutes.
|
||||
- **Payment links** — supervisors can generate and send a Yoco link for any
|
||||
amount (including partial).
|
||||
- **Manual payments** — cash, card, EFT, voucher or other, recorded by staff.
|
||||
@@ -339,6 +344,8 @@ Every user picks a **notification preference**: Email, WhatsApp, or Both.
|
||||
outstanding balance).
|
||||
- **Broadcasts** — ad-hoc email/WhatsApp to any users or addresses, not tied to
|
||||
one event.
|
||||
- **Message type** — Event update or News & announcements; news only reaches people
|
||||
who opted in (see [Privacy](#privacy-popia)).
|
||||
- **Automations** — schedule a sequence of lifecycle emails for an event
|
||||
(pre-event, final reminder, thank-you, promo for another event).
|
||||
- **Manage scheduled** — view, edit or delete queued jobs per channel. Sends
|
||||
@@ -440,6 +447,8 @@ logout controls. Disconnected sessions are recovered automatically.
|
||||
- **Manage users** — server-side search and filters (role, active), paging,
|
||||
edit role and notification preference, revoke sessions, delete or
|
||||
**anonymise** personal data.
|
||||
- **Data requests** — POPIA correction and deletion requests (see
|
||||
[Privacy](#privacy-popia)).
|
||||
- **Registrations** — all registrations, with filters (event, status, text),
|
||||
status counts, revenue and outstanding totals, and per-registration options,
|
||||
form answers and payments (with who recorded each one).
|
||||
@@ -532,13 +541,48 @@ See [SETTINGS.md](./SETTINGS.md) for the default of every setting.
|
||||
(`TRUST_PROXY` to read real IPs behind a proxy).
|
||||
- **Security event log** — logins, password changes and resets, with device
|
||||
and IP.
|
||||
- **Account closure** — deactivate, or anonymise personal data while keeping
|
||||
financial records.
|
||||
- **Account closure** — deactivate, or close and ask for the personal data to be
|
||||
deleted (see [Privacy](#privacy-popia)).
|
||||
- **Upload hardening** — server-generated filenames, and uploads served with a
|
||||
sandbox CSP and `nosniff`.
|
||||
|
||||
---
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
Tools that help a church handle personal information the way POPIA expects. They
|
||||
support compliance; wording and retention choices are for the church's Information
|
||||
Officer.
|
||||
|
||||
**For everyone** — the **Privacy** page (`/dashboard/user/privacy`):
|
||||
|
||||
- **News & announcements** opt-in per channel (email, WhatsApp), with the history of
|
||||
every choice: when, and how (sign-up, privacy page, unsubscribe link, STOP reply…).
|
||||
Sign-up has an unticked opt-in box; accounts that never answered are asked once
|
||||
after logging in.
|
||||
- **Download my data** — readable PDF, or the complete copy as JSON.
|
||||
- **Correct my data** — profile fields directly; anything else as a correction request.
|
||||
- **Delete my account** — closes the account at once and files a deletion request.
|
||||
|
||||
**Bulk messages** are either an **Event update** (about an event the recipients
|
||||
registered for; always sent) or **News & announcements** (only to people who opted in
|
||||
on that channel and haven't unsubscribed). News emails carry an unsubscribe link and
|
||||
one-click `List-Unsubscribe` headers; news WhatsApps end with "Reply STOP" and a link.
|
||||
Event updates say why they were sent and link to the preferences. A STOP reply on
|
||||
WhatsApp turns off WhatsApp news (through WAWP's `message` webhook, checked against a
|
||||
secret in the webhook URL).
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
admins, reminded daily when due within a week. Completing a deletion anonymises the
|
||||
person: name, contact details, the people they registered, form answers, consent
|
||||
details, sponsor names, login history and payer details in provider notifications are
|
||||
removed; payments, registrations, tickets and check-ins stay as "Deleted User". It
|
||||
waits while they have a registration for an upcoming event. Everything is in the audit
|
||||
log.
|
||||
|
||||
---
|
||||
|
||||
## Help system
|
||||
|
||||
A floating **"Need help?"** button on every page opens a page-specific guide
|
||||
@@ -562,9 +606,11 @@ 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) |
|
||||
| `daily-summary` — daily event summary email | 07:00 daily (`DAILY_SUMMARY_ENABLED=false` to disable) |
|
||||
| `waitlist` — expire unclaimed offers, offer freed places | Every 10 minutes (`WAITLIST_JOB_ENABLED=false`) |
|
||||
| `payment-status` — look up PayFast checkouts (10 min – 3 days old) whose notification never arrived, and record them | Every 15 minutes (`PAYMENT_STATUS_JOB_ENABLED=false`) |
|
||||
| `feedback` — post-event feedback forms and their 3-day reminder | Every 15 minutes (`FEEDBACK_JOB_ENABLED=false`) |
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
|
||||
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
|
||||
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
|
||||
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
|
||||
@@ -611,9 +657,10 @@ can't start, the same jobs run on in-process timers.
|
||||
| `/consent/[token]` | Parent/guardian | Give or decline parental consent |
|
||||
| `/waitlist/claim/[token]` | Public | Claim a waitlist place |
|
||||
| `/certificates/verify/[code]` | Public | Check a completion certificate |
|
||||
| `/unsubscribe?t=…` | Public | Unsubscribe from news (link in news emails/WhatsApps) |
|
||||
| `/self-service` | Supervisor-unlocked | Kiosk |
|
||||
| `/set-banner` | Supervisor | Site banner |
|
||||
| `/dashboard/user` (+ `/payments`, `/donate`, `/forms`, `/profile`, `/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/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`, `/settings`) | Admin | Admin tools |
|
||||
|
||||
@@ -98,8 +98,18 @@ Documentation: see Frontend README for environment and scripts.
|
||||
|
||||
## Deployment
|
||||
|
||||
To update a running install to a release: `bash scripts/deploy.sh v1.34.0` on the server
|
||||
(backs up the database, migrates, builds, reloads PM2).
|
||||
To update a running install, on the server in the repo folder:
|
||||
|
||||
- `bash scripts/deploy.sh` — the latest `main`
|
||||
- `bash scripts/deploy.sh v1.37.4` — a tagged release
|
||||
|
||||
It backs up the database, checks the version out, installs, migrates, builds and reloads PM2.
|
||||
If anything fails before the reload, it goes back to the previous version (the running app
|
||||
isn't touched).
|
||||
|
||||
PM2 uses the install's own `ecosystem.config.js` (app names, frontend port). It isn't in git,
|
||||
so deploys never change it; the first deploy creates it from `ecosystem.config.example.js` —
|
||||
set names that match your PM2 apps (`pm2 ls`) and the port you use.
|
||||
|
||||
### Backend Deployment
|
||||
|
||||
|
||||
+10
@@ -89,6 +89,16 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `WAWP_ACCESS_TOKEN` / `WAWP_INSTANCE_ID` | `WAWP_ACCESS_TOKEN`/`WAWP_INSTANCE_ID` env vars, else *(blank)* | No hardcoded default — WhatsApp sending is simply unavailable until both are configured (Admin → Site Settings → WhatsApp). |
|
||||
| `WAWP_WEBHOOK_SECRET` | Generated (random) the first time the WhatsApp tab is opened | Part of the webhook URL shown in Site Settings → WhatsApp (`/api/whatsapp/webhook?secret=…`). WAWP doesn't sign webhooks, so incoming messages (STOP replies) are only acted on when the secret matches. Not editable in the UI; delete the row to get a new one (then update the URL in WAWP). The URL uses `BACKEND_URL`. |
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
| Setting | Default when unset | Notes |
|
||||
|---|---|---|
|
||||
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
|
||||
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -69,6 +69,10 @@ CONSENT_JOBS_ENABLED=true
|
||||
# Waitlist: expire unclaimed offers and offer freed places (every 10 minutes by default)
|
||||
WAITLIST_JOB_ENABLED=true
|
||||
# WAITLIST_JOB_INTERVAL_MS=600000
|
||||
# Payments: every 15 minutes, look up PayFast checkouts whose notification never arrived
|
||||
PAYMENT_STATUS_JOB_ENABLED=true
|
||||
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
|
||||
PRIVACY_JOBS_ENABLED=true
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -18,6 +18,7 @@ Tokens are issued by `POST /api/users/login`.
|
||||
- [Event Options & Variants](#event-options--variants)
|
||||
- [Event Attachments](#event-attachments)
|
||||
- [Registrations](#registrations)
|
||||
- [Privacy (POPIA)](#privacy-popia)
|
||||
- [Coupons](#coupons)
|
||||
- [Parental consent](#parental-consent)
|
||||
- [Waitlist](#waitlist)
|
||||
@@ -550,6 +551,28 @@ Check-in rules:
|
||||
- `POST /api/tickets/scan/:qrCode` returns `403 { code: "CONSENT_REQUIRED" }` unless consent is given.
|
||||
- Supervisors can pass `overrideConsent: true`, which is audited as `consent_override`.
|
||||
|
||||
## Privacy (POPIA)
|
||||
|
||||
The data subject's tools, unsubscribe links and the admin data-requests inbox (`src/utils/privacy.js`, `src/controllers/privacyController.js`).
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/privacy/me` | user | `{ news: { email, whatsapp }, newsAskedAt, canWhatsApp, notificationPreference, history, requests }` |
|
||||
| PUT | `/api/privacy/me/news` | user | `{ email?: bool, whatsapp?: bool, source?: "prompt" }` — change the news opt-in (WhatsApp needs a valid phone). Logged in `CommunicationConsent`; `source: "prompt"` is the one-time question after login |
|
||||
| GET | `/api/privacy/me/export` | user | Download my data as JSON (`?format=pdf` for a readable PDF). Recorded as a completed self-service `access` request. 403 while impersonating; rate-limited |
|
||||
| POST | `/api/privacy/me/requests` | user | `{ type: "correction", details }` or `{ type: "deletion", password, details? }`. Deletion closes the account at once (deactivated, sessions revoked, news off). 409 if one of that type is already open. Emails the Information Officer + admins |
|
||||
| GET | `/api/privacy/unsubscribe/:token` | public (signed token) | What the link is for: an account (masked email, news state) or an address |
|
||||
| POST | `/api/privacy/unsubscribe/:token` | public (signed token) | `{ channels?: ["email","whatsapp"] }` — turn news off (default both) |
|
||||
| POST | `/api/privacy/unsubscribe/:token/one-click` | public (signed token) | RFC 8058 one-click (from the `List-Unsubscribe` header) — email news off. GET redirects to `/unsubscribe` |
|
||||
| GET | `/api/privacy/requests?status=open\|closed\|all` | admin | Data requests (not self-service downloads), with `overdue` and, for open deletions, `blockers` (registrations for events that haven't ended) |
|
||||
| POST | `/api/privacy/requests/:id/complete` | admin | `{ response? }` (required for corrections; emailed). Deletion: anonymises the account — 409 while it has blockers. Audited as `data_request_completed` (+ `user_anonymised`) |
|
||||
| POST | `/api/privacy/requests/:id/reject` | admin | `{ response, cancelled?: bool }` — reject (or mark withdrawn); a deletion reopens the account. Audited as `data_request_rejected` |
|
||||
|
||||
Notes:
|
||||
- Requests are due 30 days after they're made. The `privacy` job (08:00, `PRIVACY_JOBS_ENABLED=false` to disable) emails the Information Officer (`legal_io_email`) and admins about open requests due within 7 days or overdue.
|
||||
- **Anonymising** (also `POST /api/users/:id/anonymize`) keeps payments, registrations, tickets and check-ins, and removes: the account's name/email/phone/password, the details of attendees on their registrations and their own attendee rows elsewhere, parental-consent details, form answers and drafts, feedback authorship and invites, waiting waitlist places, pending transfer requests, names on transfers and certificates, sponsor names/messages, login history, password resets and 2FA challenges, and payer fields in provider webhook payloads and checkouts.
|
||||
- Sign-up (`POST /api/users`) takes `newsOptIn: bool`. Login and `GET /api/users/profile` return `newsPromptPending` (never answered the opt-in question).
|
||||
|
||||
## Coupons
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
@@ -581,6 +604,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/pay/:id` | public (checkout id) | The payer's page for a checkout: `{ status: open\|paid, amount, description, provider, providers, cancelUrl, redirectUrl, form }`. `form` (`{ action, fields }`) is set once a form provider (PayFast) is chosen — the page posts it |
|
||||
| POST | `/api/payments/pay/:id` | public (checkout id) | `{ provider }` → starts the checkout with that provider: `{ provider, redirectUrl }` (Yoco) or `{ provider, form }` (PayFast). 409 when already paid |
|
||||
| GET | `/api/payments/checkouts/open` | supervisor+ | Online checkouts from the last 14 days with no payment yet: `{ checkouts: [{ id, provider, providerLabel, canCheck, amount, description, type, registrationId, payer, createdAt }], days }` |
|
||||
| POST | `/api/payments/checkouts/check` | supervisor+ | `{ ids? }` (default: every open one) → asks each provider and records payments it received: `{ results: { [checkoutId]: { status: paid\|recorded\|needs_review\|not_paid\|cant_check, note? } }, recorded, message }`. PayFast: transaction history (API); Yoco: `cant_check` |
|
||||
| PUT | `/api/payments/providers/settings` | admin | `{ providers: [{ id, enabled }] }` in display order. Audit logged (`payment_providers_updated`) |
|
||||
| GET | `/api/payments/mypayments` | user+ | Own payments |
|
||||
| GET | `/api/payments/:id` | user+ | Payment by ID |
|
||||
@@ -654,6 +679,8 @@ Returns: `{ redirectUrl }` or `{ priceUpdated: true, newTotal, message }` if ear
|
||||
|
||||
Dynamic placeholders: `{{name}}`, `{{event.title}}`, `{{event.start}}`, `{{event.link}}`, `{{balance}}`, `{{promo.title}}`, `{{promo.link}}`
|
||||
|
||||
**Message kind (POPIA).** Every bulk send — these broadcasts, WhatsApp broadcasts, and `POST /api/events/:id/email-attendees` / `whatsapp-attendees` (and their `/schedule`) — takes `kind`: `"update"` or `"news"`. Broadcasts default to `news`, attendee messages to `update`; `promoEventId` (the next-event promo automation) forces `news`, `template: "tickets"` forces `update`. `news` goes only to accounts opted in on that channel (`User.newsEmail` / `newsWhatsApp`) and skips addresses in `MessagingOptOut`; it gets an unsubscribe link (email: plus `List-Unsubscribe` / `List-Unsubscribe-Post` headers; WhatsApp: "Reply STOP"). `update` emails get a footer saying why they were sent. Responses (and dry runs) include `kind` and `skipped`. See [Privacy](#privacy-popia).
|
||||
|
||||
---
|
||||
|
||||
## WhatsApp Broadcasts
|
||||
@@ -769,7 +796,7 @@ Manages the WAWP WhatsApp API instance. Credentials are stored in `AppSetting` (
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials |
|
||||
| GET | `/api/whatsapp/config` | admin | Get current WAWP credentials, and `webhookUrl` (with its secret) to paste into WAWP |
|
||||
| POST | `/api/whatsapp/config` | admin | Save WAWP credentials to DB |
|
||||
| GET | `/api/whatsapp/status` | admin | WAWP session status |
|
||||
| GET | `/api/whatsapp/qr` | admin | QR code for WhatsApp device linking |
|
||||
@@ -788,7 +815,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/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 +838,7 @@ For what each individual setting key defaults to when it's never been saved, see
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"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;
|
||||
@@ -75,6 +75,12 @@ model User {
|
||||
notificationPreference NotificationPreference @default(email)
|
||||
twoFactorEnabled Boolean @default(false)
|
||||
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?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -121,6 +127,9 @@ model User {
|
||||
trustedDevices TrustedDevice[]
|
||||
certificates Certificate[]
|
||||
certificatesIssued Certificate[] @relation("CertificateIssuedBy")
|
||||
communicationConsents CommunicationConsent[]
|
||||
dataRequests DataRequest[]
|
||||
dataRequestsHandled DataRequest[] @relation("DataRequestHandledBy")
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -571,6 +580,9 @@ enum AdminAuditAction {
|
||||
certificate_issued
|
||||
certificate_revoked
|
||||
job_run_manual
|
||||
data_request_completed
|
||||
data_request_rejected
|
||||
user_anonymised
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -1567,3 +1579,75 @@ model Certificate {
|
||||
@@index([ticketId])
|
||||
@@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])
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ function buildEventContext(event) {
|
||||
}
|
||||
|
||||
const { replacePlaceholders } = require('../utils/placeholders');
|
||||
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
|
||||
|
||||
function parseFreeformEmails(lines) {
|
||||
// Supports formats:
|
||||
@@ -103,16 +104,16 @@ const previewBroadcast = async (req, res) => {
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
|
||||
|
||||
// Optionally resolve event info for link/title placeholder
|
||||
let event = null;
|
||||
if (eventId && typeof eventId === 'string') {
|
||||
event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
}
|
||||
const eventCtx = buildEventContext(event);
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -147,9 +148,12 @@ const sendBroadcast = async (req, res) => {
|
||||
if (!email) continue;
|
||||
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
// Ad-hoc broadcasts are News & announcements unless the sender marks them as an event
|
||||
// update; news skips people who haven't opted in or unsubscribed.
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients' });
|
||||
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from news.' : 'No valid recipients' });
|
||||
}
|
||||
|
||||
// Resolve event
|
||||
@@ -177,9 +181,10 @@ const sendBroadcast = async (req, res) => {
|
||||
eventLinkHtml: eventCtx.eventLinkHtml,
|
||||
} : ctxBase;
|
||||
const finalSubject = await replacePlaceholders(subject, ctxBase);
|
||||
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
|
||||
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: eventCtx.eventTitle });
|
||||
const finalHtml = html ? (await replacePlaceholders(html, ctxForHtml)) + footer.html : undefined;
|
||||
const finalText = (!html ? (await replacePlaceholders(text || '', ctxBase)) + footer.text : undefined);
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
@@ -189,7 +194,7 @@ const sendBroadcast = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
return res.json({ kind, matched: recipients.length, skipped, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -200,13 +205,13 @@ const sendBroadcast = async (req, res) => {
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {};
|
||||
const { scheduledAt, subject, html, text, userIds, emails, eventId, kind } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
|
||||
|
||||
const payload = { subject, html, text, userIds, emails, eventId };
|
||||
const payload = { subject, html, text, userIds, emails, eventId, kind };
|
||||
|
||||
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
|
||||
|
||||
|
||||
@@ -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) {
|
||||
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');
|
||||
@@ -1730,10 +1738,11 @@ const emailEventAttendees = async (req, res) => {
|
||||
|
||||
// Always perform placeholder replacement on whatever we have
|
||||
const finalSubject = await replacePlaceholders(subj || '', ctxBase);
|
||||
const finalHtml = h ? await replacePlaceholders(h, ctxHtml) : undefined;
|
||||
const finalText = (!h ? await replacePlaceholders(t || '', ctxBase) : undefined);
|
||||
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: event.title });
|
||||
const finalHtml = h ? (await replacePlaceholders(h, ctxHtml)) + footer.html : undefined;
|
||||
const finalText = (!h ? (await replacePlaceholders(t || '', ctxBase)) + footer.text : undefined);
|
||||
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
|
||||
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
@@ -1824,10 +1833,15 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
uniqMap.set(phone, { phone, name: r.user?.name || '', userId: r.user?.id, registrationId: r.id });
|
||||
}
|
||||
}
|
||||
const recipients = Array.from(uniqMap.values());
|
||||
// POPIA: see emailEventAttendees — news only to people who opted in to WhatsApp news.
|
||||
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
|
||||
const promoEventId = req.body?.promoEventId;
|
||||
const kind = template === 'tickets' ? 'update' : (promoEventId ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
|
||||
const allowed = await filterRecipients(Array.from(uniqMap.values()), { channel: 'whatsapp', kind });
|
||||
const recipients = allowed.recipients;
|
||||
|
||||
if (dryRun) {
|
||||
return res.json({ eventId, matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ eventId, kind, matched: recipients.length, skipped: allowed.skipped, recipients: recipients.slice(0, 20) });
|
||||
}
|
||||
|
||||
const { sendText } = require('../utils/whatsapp');
|
||||
@@ -1927,7 +1941,7 @@ const whatsappEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
const finalMessage = await replacePlaceholders(msg || '', ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
|
||||
}));
|
||||
results.forEach((r, i) => {
|
||||
if (r.status === 'rejected') {
|
||||
@@ -1955,7 +1969,7 @@ function describeAttendeeFilter(filter) {
|
||||
const scheduleEmailEventAttendees = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const { scheduledAt, subject, html, text, filter, template } = req.body || {};
|
||||
const { scheduledAt, subject, html, text, filter, template, kind } = req.body || {};
|
||||
|
||||
// Validate event
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
@@ -1973,7 +1987,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
}
|
||||
|
||||
// Build payload matching emailEventAttendees body
|
||||
const payload = { subject, html, text, filter: filter || {}, template };
|
||||
const payload = { subject, html, text, filter: filter || {}, template, kind };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
@@ -1996,7 +2010,7 @@ const scheduleEmailEventAttendees = async (req, res) => {
|
||||
const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
try {
|
||||
const eventId = req.params.id;
|
||||
const { scheduledAt, message, filter, template } = req.body || {};
|
||||
const { scheduledAt, message, filter, template, kind } = req.body || {};
|
||||
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId } });
|
||||
if (!event) { res.status(404); throw new Error('Event not found'); }
|
||||
@@ -2010,7 +2024,7 @@ const scheduleWhatsappEventAttendees = async (req, res) => {
|
||||
throw new Error('message is required unless template is "tickets"');
|
||||
}
|
||||
|
||||
const payload = { message, filter: filter || {}, template };
|
||||
const payload = { message, filter: filter || {}, template, kind };
|
||||
|
||||
const { addJob } = require('../utils/scheduledEmails');
|
||||
const created = addJob({
|
||||
|
||||
@@ -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 payments = require('../payments');
|
||||
const { checkoutPaid, checkCheckouts, openCheckouts } = require('../payments/status');
|
||||
const { safeErrorMessage } = require('../utils/errorUtils');
|
||||
const { invalidate: invalidateSettingsCache, warmCache } = require('../utils/settingsCache');
|
||||
const { logAdminAction } = require('../utils/adminAudit');
|
||||
@@ -62,15 +63,7 @@ const updatePaymentProviderSettings = async (req, res) => {
|
||||
async function loadCheckout(id) {
|
||||
const checkout = await prisma.providerCheckout.findUnique({ where: { id: String(id || '') } });
|
||||
if (!checkout) return null;
|
||||
const refs = [checkout.id, checkout.externalId].filter(Boolean);
|
||||
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 };
|
||||
return { checkout, paid: await checkoutPaid(checkout) };
|
||||
}
|
||||
|
||||
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,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,
|
||||
};
|
||||
@@ -139,7 +139,7 @@ async function sendWelcomeEmail(user) {
|
||||
// Fields the login flow needs once the password (and any 2FA code) has checked out.
|
||||
const LOGIN_USER_SELECT = {
|
||||
id: true, name: true, email: true, role: true, tokenVersion: true, isActive: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true,
|
||||
phoneNumber: true, notificationPreference: true, twoFactorEnabled: true, twoFactorMethod: true, newsAskedAt: true,
|
||||
};
|
||||
|
||||
// Errors from utils/twoFactor carry their own HTTP status; everything else follows
|
||||
@@ -161,6 +161,8 @@ function completeLogin(user, req, res, extra = {}) {
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
token: generateToken(user.id, user.role, user.tokenVersion),
|
||||
// POPIA: ask about News & announcements once (see NewsPrompt on the frontend).
|
||||
newsPromptPending: !user.newsAskedAt,
|
||||
...extra
|
||||
});
|
||||
}
|
||||
@@ -170,7 +172,7 @@ function completeLogin(user, req, res, extra = {}) {
|
||||
// @access Public
|
||||
const registerUser = async (req, res) => {
|
||||
try {
|
||||
const { name, email, password, phoneNumber, notificationPreference } = req.body;
|
||||
const { name, email, password, phoneNumber, notificationPreference, newsOptIn } = req.body;
|
||||
|
||||
// Normalize phone using SA-aware normalisation
|
||||
const { normalizeZAPhone, isValidZAPhone } = require('../utils/whatsapp');
|
||||
@@ -215,6 +217,15 @@ const registerUser = async (req, res) => {
|
||||
});
|
||||
|
||||
if (user) {
|
||||
// POPIA: the sign-up form's (unticked) "news and announcements" box. Recorded either
|
||||
// way, so they aren't asked again at their first login.
|
||||
await require('../utils/privacy').setNewsPreferences(
|
||||
user.id,
|
||||
{ email: newsOptIn === true, whatsapp: newsOptIn === true },
|
||||
{ source: 'signup', ip: getClientIp(req), answered: true, logAll: true },
|
||||
).catch(e => console.warn('[signup] news preference not saved:', e?.message || e));
|
||||
|
||||
|
||||
// Send welcome email in the background — don't block the response
|
||||
sendWelcomeEmail(user).catch(() => {});
|
||||
|
||||
@@ -515,12 +526,15 @@ const getUserProfile = async (req, res) => {
|
||||
twoFactorMethod: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
isActive: true
|
||||
isActive: true,
|
||||
newsAskedAt: true
|
||||
}
|
||||
});
|
||||
|
||||
if (user) {
|
||||
res.json({ ...user, twoFactorRequired: requiresTwoFactor(user) });
|
||||
const { newsAskedAt, ...rest } = user;
|
||||
// newsPromptPending: they haven't answered the news opt-in question yet (asked once).
|
||||
res.json({ ...rest, twoFactorRequired: requiresTwoFactor(user), newsPromptPending: !newsAskedAt && !req.impersonation });
|
||||
} else {
|
||||
res.status(404);
|
||||
throw new Error('User not found');
|
||||
@@ -874,21 +888,22 @@ const anonymizeUser = async (req, res) => {
|
||||
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
|
||||
if (!user) { res.status(404); throw new Error('User not found'); }
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: req.params.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${req.params.id}@deleted.local`,
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
twoFactorEnabled: false,
|
||||
twoFactorMethod: null,
|
||||
tokenVersion: { increment: 1 },
|
||||
updatedAt: new Date()
|
||||
}
|
||||
const { deletionBlockers, anonymiseUser } = require('../utils/privacy');
|
||||
const blockers = await deletionBlockers(user.id);
|
||||
if (blockers.length) {
|
||||
res.status(409);
|
||||
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
|
||||
}
|
||||
const counts = await anonymiseUser(user.id);
|
||||
// An open deletion request from this person is now done.
|
||||
await prisma.dataRequest.updateMany({
|
||||
where: { userId: user.id, type: 'deletion', status: 'open' },
|
||||
data: { status: 'completed', closedAt: new Date(), handledById: req.user.id },
|
||||
});
|
||||
require('../utils/adminAudit').logAdminAction({
|
||||
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: user.id,
|
||||
metadata: counts, ip: getClientIp(req),
|
||||
});
|
||||
await clearTrustedDevices(req.params.id);
|
||||
await require('../utils/consent').clearConsentDataForUser(req.params.id);
|
||||
|
||||
res.json({ message: 'User data deleted' });
|
||||
} catch (error) {
|
||||
@@ -1134,34 +1149,11 @@ const closeAccount = async (req, res) => {
|
||||
const realName = user.name;
|
||||
|
||||
if (deleteData) {
|
||||
// Anonymise: wipe all personal data while keeping the row intact for referential integrity
|
||||
await prisma.user.update({
|
||||
where: { id: req.user.id },
|
||||
data: {
|
||||
name: 'Deleted User',
|
||||
email: `deleted-${uuidv4()}@deleted.invalid`,
|
||||
password: '',
|
||||
phoneNumber: null,
|
||||
isActive: false,
|
||||
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.' });
|
||||
// POPIA: deleting personal data is a request an admin approves (see privacyController);
|
||||
// the account is closed straight away.
|
||||
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type: 'deletion', status: 'open' } });
|
||||
if (!open) await require('./privacyController').requestAccountDeletion(user);
|
||||
return res.json({ message: 'Your account is closed. Your personal information will be deleted once your request has been processed — we will email you.' });
|
||||
} else {
|
||||
// Soft-deactivate only
|
||||
await prisma.user.update({
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const prisma = require('../config/db');
|
||||
const { normaliseKind, filterRecipients, whatsAppNewsFooter } = require('../utils/privacy');
|
||||
|
||||
function fmtDate(d) {
|
||||
try { return new Date(d).toLocaleString(); } catch { return String(d); }
|
||||
@@ -82,9 +83,10 @@ const previewWhatsAppBroadcast = async (req, res) => {
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
||||
|
||||
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20) });
|
||||
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20) });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -123,9 +125,12 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
if (!phone) continue;
|
||||
if (!map.has(phone)) map.set(phone, { phone, name: r.name || '' });
|
||||
}
|
||||
const recipients = Array.from(map.values());
|
||||
// News unless marked as an event update; news skips people who haven't opted in to
|
||||
// WhatsApp news, replied STOP or unsubscribed.
|
||||
const kind = normaliseKind(req.body?.kind) || 'news';
|
||||
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'whatsapp', kind });
|
||||
if (recipients.length === 0) {
|
||||
return res.status(400).json({ message: 'No valid recipients with phone numbers' });
|
||||
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from WhatsApp news.' : 'No valid recipients with phone numbers' });
|
||||
}
|
||||
|
||||
let event = null;
|
||||
@@ -139,7 +144,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
const results = await Promise.allSettled(recipients.map(async rcpt => {
|
||||
const ctx = { name: rcpt.name || '', eventTitle, eventStart, eventLink };
|
||||
const finalMessage = replacePlaceholders(message, ctx);
|
||||
await sendText(rcpt.phone, finalMessage);
|
||||
await sendText(rcpt.phone, kind === 'news' ? finalMessage + whatsAppNewsFooter(rcpt) : finalMessage);
|
||||
}));
|
||||
|
||||
const sent = results.filter(r => r.status === 'fulfilled').length;
|
||||
@@ -149,7 +154,7 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
return res.json({ matched: recipients.length, sent });
|
||||
return res.json({ kind, matched: recipients.length, skipped, sent });
|
||||
} catch (error) {
|
||||
return res.status(400).json({ message: error.message });
|
||||
}
|
||||
@@ -160,13 +165,13 @@ const sendWhatsAppBroadcast = async (req, res) => {
|
||||
// @access Private/Supervisor or Admin
|
||||
const scheduleWhatsAppBroadcast = async (req, res) => {
|
||||
try {
|
||||
const { scheduledAt, message, userIds, phones, eventId } = req.body || {};
|
||||
const { scheduledAt, message, userIds, phones, eventId, kind } = req.body || {};
|
||||
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
|
||||
const when = new Date(scheduledAt);
|
||||
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
|
||||
if (!message) return res.status(400).json({ message: 'message is required' });
|
||||
|
||||
const payload = { message, userIds, phones, eventId };
|
||||
const payload = { message, userIds, phones, eventId, kind };
|
||||
|
||||
const recipientSummary = await describeBroadcastRecipients({ userIds, phones });
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ const getConfigHandler = async (req, res) => {
|
||||
hasToken: !!token,
|
||||
hasInstance: !!instanceId,
|
||||
configured: !!(token && instanceId),
|
||||
webhookUrl: await require('../utils/whatsapp').webhookUrl(),
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(500).json({ message: e?.message || 'Failed to load config' });
|
||||
@@ -162,6 +163,7 @@ const handleWebhook = async (req, res) => {
|
||||
|
||||
try {
|
||||
const { event, session } = req.body || {};
|
||||
if (event === 'message') return await handleIncomingMessage(req);
|
||||
if (event !== 'session.status') return;
|
||||
|
||||
const status = session?.status;
|
||||
@@ -210,6 +212,37 @@ 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;
|
||||
}
|
||||
|
||||
const payload = req.body?.payload || {};
|
||||
if (payload.fromMe || !isStopKeyword(payload.body)) return;
|
||||
const phone = phoneFromChatId(payload.from);
|
||||
if (!phone) {
|
||||
// Groups, channels, and senders WhatsApp only identifies by a "LID" (no phone number).
|
||||
console.warn('[whatsapp webhook] STOP from a sender without a phone number:', payload.from);
|
||||
return;
|
||||
}
|
||||
const result = await whatsAppStop(phone);
|
||||
if (!result) return;
|
||||
await sendText(phone, "You won't get news and announcements from us on WhatsApp any more. You'll still get messages about events you've registered for.").catch(() => {});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getConfigHandler,
|
||||
saveConfigHandler,
|
||||
|
||||
+33
-1
@@ -148,6 +148,7 @@ const adminRoutes = require('./routes/adminRoutes');
|
||||
const backupRoutes = require('./routes/backupRoutes');
|
||||
const yocoRoutes = require('./routes/yocoRoutes');
|
||||
const payfastRoutes = require('./routes/payfastRoutes');
|
||||
const privacyRoutes = require('./routes/privacyRoutes');
|
||||
|
||||
// Mount webhook routes BEFORE JSON body parser to avoid double-reading the stream
|
||||
app.use('/api/webhooks', webhookRoutes);
|
||||
@@ -173,6 +174,7 @@ app.use('/api/scheduled-emails', scheduledEmailRoutes);
|
||||
app.use('/api/sections', sectionRoutes);
|
||||
app.use('/api/banner', bannerRoutes);
|
||||
app.use('/api/whatsapp', whatsappRoutes);
|
||||
app.use('/api/privacy', privacyRoutes);
|
||||
app.use('/api/settings', settingsRoutes);
|
||||
app.use('/api/setup', setupRoutes);
|
||||
app.use('/api/stats', statsRoutes);
|
||||
@@ -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',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[{ status:200, desc:'Success', body:{ message:'Sessions revoked for user.' }}]},
|
||||
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user — sets name to "Deleted User", email to deleted-{id}@deleted.local, clears phone number, deactivates account, revokes all sessions. Irreversible.',
|
||||
{ method:'POST', path:'/api/users/:id/anonymize', auth:'admin', desc:'Erase personal data for a user (POPIA) — the account, the people they registered, form answers, consent details, sponsor names, login history and payer details in provider payloads; payments, registrations and tickets stay as "Deleted User". Refused (409) while they have a registration for an upcoming event. Completes an open deletion request. Irreversible; audited as user_anonymised.',
|
||||
pathParams:{ ':id':'User UUID' },
|
||||
responses:[
|
||||
{ status:200, desc:'Anonymised', body:{ message:'User data deleted' }},
|
||||
@@ -805,6 +807,8 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:{ providers:[{ id:'yoco', label:'Yoco' }], minimumAmount:15 }}]},
|
||||
{ method:'GET', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'The payer\'s page for a checkout: amount, description, providers to choose from, or the PayFast form to post' },
|
||||
{ method:'POST', path:'/api/payments/pay/:id', auth:'— (checkout id)', desc:'Start the checkout with the provider the payer picked: { provider } → { redirectUrl } or { form }' },
|
||||
{ method:'GET', path:'/api/payments/checkouts/open', auth:'supervisor+', desc:'Online checkouts from the last 14 days with no payment yet, and whether their provider can be asked (canCheck)' },
|
||||
{ method:'POST', path:'/api/payments/checkouts/check', auth:'supervisor+', desc:'Ask providers whether checkouts were paid ({ ids? }, default all open) and record what they received. PayFast: transaction history; Yoco: cant_check' },
|
||||
{ method:'GET', path:'/api/payments/providers/settings', auth:'admin', desc:'Every provider with enabled, configured and capabilities ({ refund, getTransaction, listTransactions }), in display order' },
|
||||
{ method:'PUT', path:'/api/payments/providers/settings', auth:'admin', desc:'Turn providers on/off and set their order (the list order). Audit logged.',
|
||||
request:{ body:{ providers:[{ id:'yoco', enabled:true }] }}},
|
||||
@@ -882,6 +886,33 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:200, desc:'Success', body:[{ eventId:'ev-uuid-...', title:'Camp 2025', totalTickets:50, scanned:38, remaining:12 }]}]},
|
||||
]},
|
||||
|
||||
{ title: 'Privacy (POPIA)', base: '/api/privacy', endpoints: [
|
||||
{ method:'GET', path:'/api/privacy/me', auth:'user', desc:'My news opt-in, its history, and my privacy requests',
|
||||
responses:[{ status:200, desc:'Success', body:{ news:{ email:true, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z', canWhatsApp:false, notificationPreference:'email', history:[{ channel:'email', granted:true, source:'signup', createdAt:'2026-10-23T08:00:00.000Z' }], requests:[] }}]},
|
||||
{ method:'PUT', path:'/api/privacy/me/news', auth:'user', desc:'Change the News & announcements opt-in per channel (source "prompt" for the one-time question after login)',
|
||||
request:{ body:{ email:false, whatsapp:false } },
|
||||
responses:[{ status:200, desc:'Saved', body:{ news:{ email:false, whatsapp:false }, newsAskedAt:'2026-10-23T08:00:00.000Z' }}]},
|
||||
{ method:'GET', path:'/api/privacy/me/export', auth:'user', desc:'Download my data — JSON, or ?format=pdf for a readable PDF. Blocked while impersonating',
|
||||
responses:[{ status:200, desc:'File download' }]},
|
||||
{ method:'POST', path:'/api/privacy/me/requests', auth:'user', desc:'Correction request ({ type:"correction", details }) or delete my account ({ type:"deletion", password }) — deletion closes the account at once',
|
||||
request:{ body:{ type:'correction', details:'My son\'s date of birth on the Camp registration is wrong — it should be 3 May 2012.' } },
|
||||
responses:[{ status:201, desc:'Created', body:{ request:{ id:'req-uuid-...', type:'correction', status:'open', dueAt:'2026-11-22T08:00:00.000Z' }, message:'Your request was sent.' }}, { status:409, desc:'Already open', body:{ message:'You already have an open correction request.' }}]},
|
||||
{ method:'GET', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'What an unsubscribe link (signed token) is for',
|
||||
responses:[{ status:200, desc:'Success', body:{ kind:'account', email:'jo***@example.com', hasPhone:true, news:{ email:true, whatsapp:true } }}]},
|
||||
{ method:'POST', path:'/api/privacy/unsubscribe/:token', auth:'public', desc:'Unsubscribe from news ({ channels?: ["email","whatsapp"] }, default both)',
|
||||
responses:[{ status:200, desc:'Done', body:{ message:'You have been unsubscribed.' }}]},
|
||||
{ method:'POST', path:'/api/privacy/unsubscribe/:token/one-click', auth:'public', desc:'RFC 8058 one-click unsubscribe from the List-Unsubscribe email header (email news off)',
|
||||
responses:[{ status:200, desc:'Done' }]},
|
||||
{ method:'GET', path:'/api/privacy/requests', auth:'admin', desc:'Data requests inbox (?status=open|closed|all), with due dates and, for deletions, the registrations still blocking it',
|
||||
responses:[{ status:200, desc:'Success', body:{ requests:[{ id:'req-uuid-...', type:'deletion', status:'open', name:'Jane Doe', email:'jane@example.com', dueAt:'2026-11-22T08:00:00.000Z', overdue:false, blockers:[] }], openCount:1 }}]},
|
||||
{ method:'POST', path:'/api/privacy/requests/:id/complete', auth:'admin', desc:'Complete a request (emailed to the person). Deletion anonymises the account; 409 while it has a registration for an upcoming event',
|
||||
request:{ body:{ response:'Corrected the date of birth on the Camp 2026 registration.' } },
|
||||
responses:[{ status:200, desc:'Completed', body:{ request:{ status:'completed' }, anonymised:null }}]},
|
||||
{ method:'POST', path:'/api/privacy/requests/:id/reject', auth:'admin', desc:'Reject a request, or mark it withdrawn (cancelled:true); a deletion request reopens the account',
|
||||
request:{ body:{ response:'We could not find a registration with that name.', cancelled:false } },
|
||||
responses:[{ status:200, desc:'Closed', body:{ request:{ status:'rejected' } }}]},
|
||||
]},
|
||||
|
||||
{ title: 'Broadcasts (Email)', base: '/api/broadcasts', endpoints: [
|
||||
{ method:'POST', path:'/api/broadcasts/preview', auth:'supervisor+', desc:'Dry-run a broadcast — returns resolved recipient list without sending',
|
||||
request:{ body:{ userIds:['user-uuid-1','user-uuid-2'], emails:'extra@example.com\nJohn <john@example.com>', eventId:'ev-uuid-...' }},
|
||||
@@ -1087,6 +1118,7 @@ app.get('/docs', async (req, res) => {
|
||||
{ title: 'PayFast', base: '/api/payfast', endpoints: [
|
||||
{ method:'GET', path:'/api/payfast/config', auth:'admin', desc:'PayFast settings (passphrase masked), mode and the ITN notify URL (with a warning when PayFast can\'t reach it).' },
|
||||
{ method:'PUT', path:'/api/payfast/config', auth:'admin', desc:'Save merchantId, merchantKey, passphrase (encrypted) and/or mode (sandbox|live). Audit logged.' },
|
||||
{ method:'POST', path:'/api/payfast/test', auth:'admin', desc:'Check the saved merchant ID and passphrase with PayFast (signed API ping).' },
|
||||
]},
|
||||
{ title: 'Yoco', base: '/api/yoco', endpoints: [
|
||||
{ method:'GET', path:'/api/yoco/config', auth:'admin', desc:'Yoco settings with secrets masked (prefix + last 3 characters), where each comes from (db, env or none), test/live mode and the default webhook URL.',
|
||||
|
||||
@@ -29,6 +29,10 @@ const JOBS = [
|
||||
name: 'waitlist', label: 'Waitlist: expire offers, offer freed places', cron: '*/10 * * * *', env: 'WAITLIST_JOB_ENABLED',
|
||||
run: () => require('../utils/waitlist').runWaitlistJob(),
|
||||
},
|
||||
{
|
||||
name: 'payment-status', label: 'Payments: check providers for notifications that never arrived', cron: '*/15 * * * *', env: 'PAYMENT_STATUS_JOB_ENABLED',
|
||||
run: () => require('../payments/status').runPaymentStatusJob(),
|
||||
},
|
||||
{
|
||||
name: 'feedback', label: 'Post-event feedback forms and reminders', cron: '*/15 * * * *', env: 'FEEDBACK_JOB_ENABLED',
|
||||
run: () => require('../utils/feedback').runFeedbackJob(),
|
||||
@@ -48,6 +52,10 @@ const JOBS = [
|
||||
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: '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() }),
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
* 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.
|
||||
*
|
||||
* Refunds and transaction lookups aren't done through PayFast's API here: refunds are made in
|
||||
* the PayFast dashboard and recorded with the manual refund flow.
|
||||
* Status checks: when an ITN never arrives, findPayments looks the checkout up in PayFast's
|
||||
* transaction history (its API, signed differently — see apiSignature). Refunds aren't done
|
||||
* through the API: they're made in the PayFast dashboard and recorded with the manual flow.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
@@ -21,7 +22,8 @@ const dns = require('dns').promises;
|
||||
const axios = require('axios');
|
||||
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.
|
||||
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 = {
|
||||
id: 'payfast', label: 'PayFast', capabilities,
|
||||
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody,
|
||||
isConfigured, createCheckout, verifyWebhook, parseWebhook, readWebhookBody, findPayments, testConnection,
|
||||
// for tests
|
||||
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS,
|
||||
pfEncode, paramString, signature, cleanText, parsePairs, VALID_HOSTS, apiSignature, parseCsv,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -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
|
||||
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); }
|
||||
const settled = await settlePayment(provider, event, body, checkout);
|
||||
if (settled.needsReview) {
|
||||
return res.status(200).json({ success: true, message: 'Webhook acknowledged; amount differs from the checkout, so it needs review', data: { needsReview: true } });
|
||||
}
|
||||
|
||||
const processedData = await recordSucceededPayment(provider.id, event, body);
|
||||
const processedData = settled.processedData;
|
||||
return res.status(200).json({
|
||||
success: true,
|
||||
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. */
|
||||
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 };
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { getConfig, updateConfig } = require('../controllers/payfastController');
|
||||
const { getConfig, updateConfig, testConnection } = require('../controllers/payfastController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
router.get('/config', protect, admin, getConfig);
|
||||
router.put('/config', protect, admin, updateConfig);
|
||||
router.post('/test', protect, admin, testConnection);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -15,7 +15,7 @@ const {
|
||||
getPaymentStats,
|
||||
sendReceipt,
|
||||
} = 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');
|
||||
|
||||
// Protected routes
|
||||
@@ -23,6 +23,8 @@ router.post('/', protect, supervisor, createPayment);
|
||||
router.get('/providers', listPaymentProviders);
|
||||
router.get('/pay/:id', getPayPage);
|
||||
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.put('/providers/settings', protect, admin, updatePaymentProviderSettings);
|
||||
router.post('/checkout', protect, createCheckout);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
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 { 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);
|
||||
|
||||
module.exports = router;
|
||||
@@ -54,7 +54,7 @@ function isSmtpConfigured() {
|
||||
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
|
||||
if (!to || String(to).endsWith('@deleted.invalid')) {
|
||||
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 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) {
|
||||
try {
|
||||
@@ -420,7 +420,24 @@ function buildAccountClosedEmail({ name, dataDeleted }) {
|
||||
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, '&').replace(/</g, '<').replace(/>/g, '>').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 = {
|
||||
buildPrivacyEmail,
|
||||
sendMail,
|
||||
isSmtpConfigured,
|
||||
getOrg,
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
/**
|
||||
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 ? ` · <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) } });
|
||||
|
||||
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] = 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 } }),
|
||||
]);
|
||||
|
||||
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 })),
|
||||
},
|
||||
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,
|
||||
};
|
||||
@@ -297,7 +297,36 @@ async function sendPdf(toPhone, localPdfPath, filename, caption) {
|
||||
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 = {
|
||||
getWebhookSecret,
|
||||
webhookUrl,
|
||||
phoneFromChatId,
|
||||
getConfig,
|
||||
setConfig,
|
||||
normalizeZAPhone,
|
||||
|
||||
@@ -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×tamp=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' })]);
|
||||
});
|
||||
});
|
||||
@@ -2,12 +2,12 @@ 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(), create: jest.fn(), update: jest.fn() },
|
||||
providerCheckout: { create: jest.fn(), update: jest.fn(), delete: jest.fn(), findUnique: jest.fn(), findFirst: 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(), findMany: jest.fn() },
|
||||
ticket: { findMany: 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/ticketUtils', () => ({ generateTicketsForRegistration: jest.fn().mockResolvedValue([]) }));
|
||||
jest.mock('../src/utils/notifications', () => ({ sendPaymentEmails: jest.fn() }));
|
||||
|
||||
@@ -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 <2026>');
|
||||
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);
|
||||
});
|
||||
});
|
||||
+51
-12
@@ -1,6 +1,6 @@
|
||||
# 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.38.0**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -8,27 +8,27 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 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.33.0 | Multi-day events phase A — sessions, late joining + late pricing, per-session check-in, attendance register, session reminders, certificates (4 templates, verify page) |
|
||||
| 1.34.0 | Background jobs on pg-boss (admin Background jobs page), Playwright e2e tests, Gitea CI workflow, `scripts/deploy.sh` |
|
||||
| 1.35.0 | Multi-day events phase B — "pick sessions" (per-session tickets and prices, session capacity) |
|
||||
| 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.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) |
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.38.0 SnapScan → 1.39.0 Zapper** (one release each), each as a provider in
|
||||
`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
|
||||
1. **1.39.0 — POPIA toolkit part 2** (spec §3 in the roadmap): Information Officer on the
|
||||
privacy notice/contact page, privacy notice editor with versions and re-acceptance, consent
|
||||
log page (the data is already in `CommunicationConsent`), retention policies + nightly job,
|
||||
operator register, access log (exports, attendee lists, medical list, impersonation),
|
||||
breach register, POPIA readiness page.
|
||||
2. **1.40.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
|
||||
## Working on this repo
|
||||
@@ -38,6 +38,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`
|
||||
into `main` → `git tag vX.Y.Z` → `git push origin main <branch> vX.Y.Z`. One minor
|
||||
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.
|
||||
- **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
|
||||
@@ -49,6 +50,11 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
need a migration.
|
||||
- **Server Node version:** the deployment notes install Node 20, so dependencies must
|
||||
support Node 20 (that's why pg-boss is pinned to 10.x).
|
||||
- **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
|
||||
saved credential); pushing from your own terminal works.
|
||||
|
||||
@@ -100,6 +106,10 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
- `backend/src/payments/webhook.js` — shared webhook processing (Payment with `provider`,
|
||||
donation split, registration status, tickets, emails, `ProviderTransaction`).
|
||||
- `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
|
||||
`PROVIDERS` in `index.js`, mount `handleProviderWebhook('<id>')` at `/api/webhooks/<id>`
|
||||
(`routes/webhookRoutes.js`), and give it a settings card in
|
||||
@@ -107,8 +117,37 @@ 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;
|
||||
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`.
|
||||
|
||||
## 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
|
||||
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.
|
||||
|
||||
+43
-36
@@ -17,8 +17,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
|
||||
(pick sessions, per-session capacity), with 1.35.1 adding the per-session waitlist, the session
|
||||
picker at the door and session choice on "All sessions" tickets; 1.36.0 payment provider interface
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page.
|
||||
**Next: 1.38.0 SnapScan, then Zapper.**
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests).
|
||||
**Next: 1.39.0 POPIA toolkit part 2 (admin tools).**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -32,7 +35,7 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
## Contents
|
||||
|
||||
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)
|
||||
4. [Kids check-in with security labels](#4-kids-check-in-with-security-labels)
|
||||
5. [Volunteer / serving rosters](#5-volunteer--serving-rosters)
|
||||
@@ -267,11 +270,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
|
||||
**Zapper**, and let the admin **enable one or more** in Site settings. At
|
||||
checkout the user picks from the enabled ones.
|
||||
Add **PayFast** next to Yoco, and let the admin **enable one or both** in Site
|
||||
settings. At checkout the user picks from the enabled ones. (Shipped in
|
||||
1.36.0–1.37.2. SnapScan and Zapper were planned too, but dropped on 2026-10-23:
|
||||
two providers are enough.)
|
||||
|
||||
**Who:** Admins configure providers. Users choose at payment. Supervisors
|
||||
reconcile.
|
||||
@@ -280,10 +284,10 @@ reconcile.
|
||||
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
**Fees are not handled automatically.** Payments are recorded at the full
|
||||
@@ -300,10 +304,7 @@ total. Fees are dealt with by people during **cashup**, as today. There is no
|
||||
`getTransaction`, `refund` (if the provider supports it via API),
|
||||
`listTransactions` (for reconciliation).
|
||||
- Yoco is moved behind the same interface first (no behaviour change), then
|
||||
PayFast, SnapScan and Zapper are added one release each.
|
||||
- SnapScan and Zapper are QR/app-based: on desktop show the QR, on mobile
|
||||
deep-link into the app; the page polls/waits for the webhook and moves on
|
||||
automatically when payment lands.
|
||||
PayFast is added.
|
||||
- `Payment.method` gets the provider name; `YocoTransaction` is generalised to
|
||||
`ProviderTransaction` (`provider` column) so the existing reconciliation page
|
||||
works for all providers.
|
||||
@@ -606,8 +607,10 @@ whether they're healthy, basic usage stats, and their **licence** status.
|
||||
repos (`docs/hub-protocol.md`). The Hub accepts the current and previous
|
||||
protocol version, so churches on older versions keep reporting while they
|
||||
wait to update.
|
||||
- Runs on its own server/domain (e.g. `hub.<your-domain>`). If the Hub is
|
||||
- Runs on its own server at **`events-hub.crosscode.co.za`**. If the Hub is
|
||||
down, **no church install is affected**.
|
||||
- **Licence keys are generated by the Hub:** Joshua clicks "Generate" on a
|
||||
customer and the Hub creates the key (not typed by hand).
|
||||
- Hope Family Church's own site is the first registered install.
|
||||
|
||||
### Registration (setup → Hub)
|
||||
@@ -634,7 +637,10 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
- **Basic stats (aggregate counts only):** users, active/upcoming events,
|
||||
registrations and tickets (30 days / all time), check-ins, WhatsApp/email
|
||||
sent, enabled payment providers, campuses on/off.
|
||||
- **Optional (off by default, customer chooses):** payment totals.
|
||||
- **Income in ranges, never exact amounts:** total payments received (30
|
||||
days / all time) reported as a band — R0–R1 000, R1 001–R5 000,
|
||||
R5 001–R20 000, R20 001–R50 000, R50 001–R100 000, over R100 000. The
|
||||
band is worked out on the install; the exact figure never leaves it.
|
||||
- **Install → Hub only.** The install pushes; the Hub never needs to reach
|
||||
into the install, so it works behind firewalls and the Hub has no access
|
||||
to church data.
|
||||
@@ -672,8 +678,8 @@ 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
|
||||
events people already paid for, and get its own data out (POPIA access
|
||||
rights). Suspension blocks *new* activity only.
|
||||
- Optional plan limits later (e.g. number of campuses, WhatsApp) carried in
|
||||
the token.
|
||||
- **One plan for now.** Plan limits (e.g. number of campuses, WhatsApp) can
|
||||
be carried in the token later.
|
||||
|
||||
### Things to be aware of
|
||||
|
||||
@@ -726,14 +732,14 @@ 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 |
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.39.0 | Payment provider interface (Yoco moved behind it) → PayFast → SnapScan → Zapper, one release each | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.40.0 – 1.41.0 | **POPIA toolkit** (user privacy centre + unsubscribe first, then admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.42.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.43.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.44.0 – 1.45.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.46.0 – 1.48.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.49.0 – 1.51.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.52.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ – 1.39.0 | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: admin tools, retention jobs, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.40.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.41.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.42.0 – 1.43.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.44.0 – 1.46.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.47.0 – 1.49.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.50.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
@@ -756,21 +762,22 @@ feature.
|
||||
| — | Pro-rata rounding | Nearest R10 |
|
||||
| — | Multi-campus | In scope (§12), inside one install |
|
||||
| — | Central management | **Hope Events Hub** in its **own git repo** (`joshua/hope-events-hub`): installs register on setup, send heartbeats (health, version, aggregate stats), licences switched on/off centrally |
|
||||
| 7 | Payment provider order | Yoco · PayFast · SnapScan · Zapper. Joshua can supply documentation links |
|
||||
| 7 | Payment provider order | Yoco · PayFast. Joshua can supply documentation links |
|
||||
| — | SnapScan, Zapper | Dropped (2026-10-23): Yoco and PayFast are enough. Later versions moved up by two |
|
||||
| — | Provider fees | Not deducted or estimated automatically; handled by people during cashup. No "pass fee to payer" |
|
||||
| — | Minimum online payment | R15 for all providers |
|
||||
| — | Scope | All features in this document approved; POPIA toolkit and kids check-in are highlights |
|
||||
| — | Early-bird ribbon | Added before everything else; shipped as 1.32.0, all later versions moved up by one |
|
||||
| — | Hub domain | `events-hub.crosscode.co.za` (2026-10-23) |
|
||||
| — | Licence keys | Generated by the Hub when Joshua clicks "Generate" on a customer (2026-10-23) |
|
||||
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
|
||||
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
|
||||
| — | Licence plans | One plan for now (2026-10-23) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
|
||||
| — | POPIA: 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
|
||||
|
||||
Hub (needed before phase 6, not before 1.33.0):
|
||||
|
||||
1. Hub domain and server, and where licence keys come from (created by hand
|
||||
in the Hub per customer?).
|
||||
2. Grace period length (proposed 14 days) and suspended = read-only (tickets
|
||||
still scan, data export still works) — agreed?
|
||||
3. Should payment totals be in the heartbeat at all (opt-in per customer), or
|
||||
counts only?
|
||||
4. Licence plans/tiers (e.g. limits on campuses or WhatsApp), or one plan for
|
||||
now?
|
||||
Nothing for now.
|
||||
|
||||
@@ -7,6 +7,8 @@ export type Seed = {
|
||||
users: Record<Role, { id: string; name: string; email: string; token: string }>;
|
||||
events: { free: string; paid: string; course: string; dropIn: string; conference: string };
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
unsubscribeToken: string;
|
||||
newsPromptUser: { id: string; token: string };
|
||||
};
|
||||
|
||||
export const API = "http://localhost:5100";
|
||||
|
||||
@@ -43,6 +43,15 @@ test.describe("PayFast", () => {
|
||||
await page.waitForURL(/sandbox\.payfast\.co\.za/, { timeout: 30_000 });
|
||||
await expect(page.locator("body")).not.toContainText(/signature/i);
|
||||
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 {
|
||||
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: "" });
|
||||
|
||||
+30
-1
@@ -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 };
|
||||
|
||||
@@ -34,4 +34,33 @@ test.describe("payment providers", () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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$/);
|
||||
});
|
||||
});
|
||||
@@ -48,8 +48,10 @@ async function seed(url) {
|
||||
});
|
||||
|
||||
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({
|
||||
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 = {
|
||||
admin: await mk('admin', 'Ada Admin', 'admin@e2e.test', '0820000001'),
|
||||
@@ -155,6 +157,13 @@ 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) }])),
|
||||
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 },
|
||||
// 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) };
|
||||
})(),
|
||||
// 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.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 = {
|
||||
apps: [
|
||||
{
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -40,6 +40,9 @@ const ACTIONS = [
|
||||
"yoco_config_updated",
|
||||
"yoco_webhook_registered",
|
||||
"yoco_webhook_deleted",
|
||||
"data_request_completed",
|
||||
"data_request_rejected",
|
||||
"user_anonymised",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -72,6 +75,9 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
yoco_config_updated: "Yoco settings changed",
|
||||
yoco_webhook_registered: "Yoco webhook registered",
|
||||
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",
|
||||
};
|
||||
|
||||
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 "Deleted User".</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'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>
|
||||
);
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import { useStableState } from "@/hooks/useStableState";
|
||||
import { useVisiblePolling } from "@/hooks/useVisiblePolling";
|
||||
import {
|
||||
Calendar, Banknote, Gift, Users, Ticket, QrCode, ClipboardList,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck,
|
||||
UserPlus, FileText, MessageCircle, BarChart2, Mail, Wallet, DoorOpen, History, TicketPercent, ShieldCheck, ListChecks, WifiOff, BedDouble, Filter, ClipboardCheck, Inbox,
|
||||
} from "lucide-react";
|
||||
import { StatCard, StatCardRow } from "@/components/shared/StatCard";
|
||||
import { QuickActionTile, QuickActionGrid } from "@/components/shared/QuickActionTile";
|
||||
@@ -42,6 +42,7 @@ const QUICK_ACTIONS = [
|
||||
{ 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/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 },
|
||||
] as const;
|
||||
|
||||
type OverviewMetric = { thisMonth: number; lastMonth: number; pctChange: number | null };
|
||||
|
||||
@@ -746,6 +746,8 @@ interface ConfigResponse {
|
||||
hasToken: boolean;
|
||||
hasInstance: boolean;
|
||||
configured: boolean;
|
||||
// Where WAWP should send events; carries the secret incoming messages are checked against.
|
||||
webhookUrl?: string;
|
||||
}
|
||||
|
||||
interface StatusResponse {
|
||||
@@ -1182,6 +1184,22 @@ function WhatsAppTab({ active }: { active: boolean }) {
|
||||
</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">
|
||||
<h3 className="text-base font-semibold">Session Controls</h3>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
|
||||
import { Navbar } from "@/components/layout/Navbar";
|
||||
import { Footer } from "@/components/layout/Footer";
|
||||
import { Sidebar, MobileSidebar } from "@/components/layout/Sidebar";
|
||||
import { NewsPrompt } from "@/components/shared/NewsPrompt";
|
||||
|
||||
// The sidebar shows only on these exact routes — each role's dashboard root,
|
||||
// plus Profile & Security and Site Settings (matching the redesign
|
||||
@@ -17,6 +18,7 @@ const SIDEBAR_ROUTES = [
|
||||
"/dashboard/staff",
|
||||
"/dashboard/user",
|
||||
"/dashboard/user/profile",
|
||||
"/dashboard/user/privacy",
|
||||
"/dashboard/admin/settings",
|
||||
];
|
||||
|
||||
@@ -85,6 +87,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
<Navbar />
|
||||
<NewsPrompt />
|
||||
<MobileSidebar />
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<Sidebar />
|
||||
@@ -100,6 +103,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Navbar />
|
||||
<NewsPrompt />
|
||||
<main className="flex-1 p-6 bg-gray-50">{children}</main>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { AllocationGroupFilter } from "@/components/shared/AllocationGroupFilter
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
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 TemplateKey = 'custom'|'payment_reminder'|'event_reminder'|'tickets';
|
||||
@@ -243,6 +244,7 @@ function EmailAttendeesPageInner() {
|
||||
}, [preselectEventId]);
|
||||
|
||||
const [templateKey, setTemplateKey] = useState<TemplateKey>('custom');
|
||||
const [messageKind, setMessageKind] = useState<MessageKind>('update');
|
||||
const [showInfo, setShowInfo] = useState(false);
|
||||
|
||||
// Broadcasts state
|
||||
@@ -256,7 +258,9 @@ function EmailAttendeesPageInner() {
|
||||
const [broadcastPreviewCount, setBroadcastPreviewCount] = useState<number|null>(null);
|
||||
const [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{email:string;name?:string}[]|null>(null);
|
||||
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
|
||||
const [broadcastKind, setBroadcastKind] = useState<MessageKind>('news');
|
||||
const resetBroadcastForm = () => {
|
||||
setBroadcastKind('news');
|
||||
setSelectedUserIds([]);
|
||||
setBroadcastEventId("");
|
||||
setBroadcastSubject("");
|
||||
@@ -423,11 +427,12 @@ function EmailAttendeesPageInner() {
|
||||
if (!subject.trim()) { setError("Subject 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)",
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
dryRun: true,
|
||||
template: templateKey,
|
||||
kind: messageKind,
|
||||
};
|
||||
if (templateKey !== 'tickets') {
|
||||
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 });
|
||||
setPreviewCount(res?.matched ?? 0);
|
||||
setPreviewSample(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 recipients');
|
||||
}
|
||||
@@ -443,6 +448,7 @@ function EmailAttendeesPageInner() {
|
||||
|
||||
const resetAttendeesForm = () => {
|
||||
setTemplateKey('custom');
|
||||
setMessageKind('update');
|
||||
setSubject(''); setBody('');
|
||||
setSubjectDirty(false); setBodyDirty(false);
|
||||
setStatus('any');
|
||||
@@ -463,10 +469,11 @@ function EmailAttendeesPageInner() {
|
||||
if (!body.trim()) { setError("Message is required"); return; }
|
||||
}
|
||||
setSending(true);
|
||||
const payload: { subject: string; filter: MessageFilter; template: TemplateKey } & EmailContent = {
|
||||
const payload: { subject: string; filter: MessageFilter; template: TemplateKey; kind: MessageKind } & EmailContent = {
|
||||
subject,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
kind: messageKind,
|
||||
};
|
||||
if (templateKey !== 'tickets') {
|
||||
if (body.trim().startsWith('<')) payload.html = body; else payload.text = body.replace(/\n/g, '\n');
|
||||
@@ -571,6 +578,10 @@ function EmailAttendeesPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templateKey !== 'tickets' && (
|
||||
<MessageKindPicker name="attendeesKind" value={messageKind} onChange={setMessageKind} />
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<div className="fixed inset-0 z-20">
|
||||
<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; }
|
||||
}
|
||||
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,
|
||||
filter: { status: status !== 'any' ? status : undefined, attendeeIds: selectedAttendeeIds && selectedAttendeeIds.length ? selectedAttendeeIds : undefined, allocationGroupId: groupId || undefined },
|
||||
template: templateKey,
|
||||
kind: messageKind,
|
||||
scheduledAt: whenIso,
|
||||
};
|
||||
if (templateKey !== 'tickets') {
|
||||
@@ -739,6 +751,8 @@ function EmailAttendeesPageInner() {
|
||||
<PrefWarning attendees={users} selectedIds={selectedUserIds} channel="email" />
|
||||
)}
|
||||
|
||||
<MessageKindPicker name="broadcastKind" value={broadcastKind} onChange={setBroadcastKind} />
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<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 (!broadcastSubject.trim()) { setError('Subject 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 });
|
||||
setBroadcastPreviewCount(res?.matched ?? 0);
|
||||
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 recipients');
|
||||
}
|
||||
@@ -798,10 +812,10 @@ Jane Doe <jane@example.com>
|
||||
if (!token) { setError('Not authenticated'); return; }
|
||||
if (!broadcastSubject.trim()) { setError('Subject 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;
|
||||
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
|
||||
resetBroadcastForm();
|
||||
} catch (e) {
|
||||
@@ -816,7 +830,7 @@ Jane Doe <jane@example.com>
|
||||
if (!broadcastSubject.trim()) { setError('Subject is required'); return; }
|
||||
if (!broadcastBody.trim()) { setError('Message is required'); return; }
|
||||
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;
|
||||
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.');
|
||||
|
||||
@@ -11,6 +11,8 @@ import { errorMessage } from "@/lib/utils";
|
||||
import { registrationOptionUnitPrice } from "@/lib/pricing";
|
||||
import type { Event, Payment, Registration, User } from "@/types";
|
||||
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
|
||||
// "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() {
|
||||
const { user, loading, token } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const router = useRouter();
|
||||
const search = useSearchParams();
|
||||
|
||||
@@ -541,16 +544,23 @@ function PaymentsContent() {
|
||||
<ScanLine className="w-4 h-4" />
|
||||
Reconcile
|
||||
</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`}>
|
||||
<input type="radio" name="mode" value="link" className="hidden" checked={mode==='link'} onChange={() => setMode('link')} />
|
||||
<Link2 className="w-4 h-4" />
|
||||
Payment Link
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{mode === 'reconcile' && (
|
||||
<WaitingForPayment token={token} onRecorded={() => { loadUnreconciled(); loadPayments(); loadRegistrations(); }} />
|
||||
)}
|
||||
|
||||
{/* Online payment reconciliation panel */}
|
||||
{mode === 'reconcile' && (
|
||||
<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="text-lg font-semibold mb-3">Create payment link</div>
|
||||
<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 type { Event, MessageSendResult, Registration, User } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { MessageKindPicker, matchedText, type MessageKind } from "@/components/shared/MessageKindPicker";
|
||||
|
||||
// Attendee with preference info
|
||||
type Attendee = { id: string; name: string; phone: string; pref: string };
|
||||
@@ -404,6 +405,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const [eventId, setEventId] = useState<string>(preselectEventId);
|
||||
useEffect(() => { if (preselectEventId) setEventId(preselectEventId); }, [preselectEventId]);
|
||||
const [templateKey, setTemplateKey] = useState<TemplateKey>("custom");
|
||||
const [messageKind, setMessageKind] = useState<MessageKind>("update");
|
||||
const [message, setMessage] = useState("");
|
||||
// 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.
|
||||
@@ -456,9 +458,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
}, [eventId, token]);
|
||||
|
||||
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 },
|
||||
template: templateKey,
|
||||
kind: messageKind,
|
||||
dryRun: dryRun || undefined,
|
||||
};
|
||||
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) });
|
||||
setPreviewCount(res?.matched ?? 0);
|
||||
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"); }
|
||||
};
|
||||
|
||||
const resetAttendeesForm = () => {
|
||||
setTemplateKey("custom"); setMessage(""); messageDirtyRef.current = false;
|
||||
setTemplateKey("custom"); setMessageKind("update"); setMessage(""); messageDirtyRef.current = false;
|
||||
setStatus("any"); setScheduledAtLocal(""); setPreviewCount(null); setPreviewSample(null);
|
||||
setSelectedAttendeeIds(attendees.map((a) => a.id));
|
||||
};
|
||||
@@ -505,10 +508,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
if (templateKey !== "tickets" && !message.trim()) { setError("Message is required"); return; }
|
||||
if (!scheduledAtLocal) { setError("Scheduled time is required"); return; }
|
||||
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,
|
||||
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 });
|
||||
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 [broadcastPreviewSample, setBroadcastPreviewSample] = useState<{ phone: string; name?: string }[] | null>(null);
|
||||
const [broadcastScheduledAtLocal, setBroadcastScheduledAtLocal] = useState<string>("");
|
||||
const [broadcastKind, setBroadcastKind] = useState<MessageKind>("news");
|
||||
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
@@ -546,7 +550,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
const resetBroadcastForm = () => {
|
||||
setSelectedUserIds([]); setBroadcastEventId(""); setBroadcastMessage("");
|
||||
setBroadcastPhones(""); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
|
||||
setBroadcastScheduledAtLocal("");
|
||||
setBroadcastScheduledAtLocal(""); setBroadcastKind("news");
|
||||
};
|
||||
|
||||
// ── Scheduled tab ──────────────────────────────────────────────────────────
|
||||
@@ -683,6 +687,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{templateKey !== "tickets" && (
|
||||
<MessageKindPicker name="waAttendeesKind" value={messageKind} onChange={setMessageKind} />
|
||||
)}
|
||||
|
||||
{showInfo && (
|
||||
<div className="fixed inset-0 z-20">
|
||||
<div className="absolute inset-0 bg-black/30" onClick={() => setShowInfo(false)} />
|
||||
@@ -817,6 +825,8 @@ function WhatsAppAttendeesPageInner() {
|
||||
<PrefWarning attendees={users} selectedIds={selectedUserIds} channel="whatsapp" />
|
||||
)}
|
||||
|
||||
<MessageKindPicker name="waBroadcastKind" value={broadcastKind} onChange={setBroadcastKind} />
|
||||
|
||||
<div>
|
||||
<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}
|
||||
@@ -843,10 +853,10 @@ function WhatsAppAttendeesPageInner() {
|
||||
try {
|
||||
setError(null); setInfo(null); setBroadcastPreviewCount(null); setBroadcastPreviewSample(null);
|
||||
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);
|
||||
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"); }
|
||||
}}>Preview recipients</button>
|
||||
|
||||
@@ -855,8 +865,8 @@ function WhatsAppAttendeesPageInner() {
|
||||
setError(null); setInfo(null);
|
||||
if (!token) { setError("Not authenticated"); 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 } });
|
||||
setInfo(`Sent ${res?.sent ?? 0} out of ${res?.matched ?? 0} recipient(s).`);
|
||||
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).${res?.skipped ? ` ${res.skipped} skipped — not opted in to news or unsubscribed.` : ""}`);
|
||||
resetBroadcastForm();
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to send"); }
|
||||
}}>Send now</button>
|
||||
@@ -867,7 +877,7 @@ function WhatsAppAttendeesPageInner() {
|
||||
if (!token) { setError("Not authenticated"); return; }
|
||||
if (!broadcastMessage.trim()) { setError("Message is required"); return; }
|
||||
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.");
|
||||
resetBroadcastForm();
|
||||
} catch (e) { setError(errorMessage(e) || "Failed to schedule broadcast"); }
|
||||
|
||||
@@ -7,9 +7,11 @@ import { HandHeart } from "lucide-react";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import type { Event } from "@/types";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
|
||||
export default function DonatePage() {
|
||||
const { token } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const [events, setEvents] = useState<Event[]>([]);
|
||||
const [eventId, setEventId] = useState<string>("");
|
||||
const [amount, setAmount] = useState<string>("");
|
||||
@@ -72,6 +74,11 @@ export default function DonatePage() {
|
||||
</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'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">
|
||||
{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>}
|
||||
@@ -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"
|
||||
>{loading?"Starting checkout...":"Donate"}</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch, createFullPaymentCheckout } from "@/lib/api";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { formatDate } from "@/lib/date";
|
||||
import { printTicketCards } from "@/lib/ticketPrint";
|
||||
@@ -81,6 +82,7 @@ function effectiveEventOptionUnitPrice(eo: EventOption, variantId: string | null
|
||||
|
||||
export default function UserDashboardPage() {
|
||||
const { token, user } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const [registrations, setRegistrations] = useState<Registration[]>([]);
|
||||
const [tickets, setTickets] = useState<TicketRecord[]>([]);
|
||||
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"
|
||||
onClick={() => router.push("/dashboard/user/payments")}
|
||||
>Payment history</button>
|
||||
<button
|
||||
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"
|
||||
onClick={() => router.push("/dashboard/user/donate")}
|
||||
>Make donation</button>
|
||||
{online.available && (
|
||||
<button
|
||||
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"
|
||||
onClick={() => router.push("/dashboard/user/donate")}
|
||||
>Make donation</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{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"
|
||||
onClick={() => setChannelPicker({ kind: 'invoice', id: activeReg.id })}
|
||||
>Invoice</button>
|
||||
{canModifyActive && activeBill && activeBill.outstanding > 0 && (
|
||||
{canModifyActive && activeBill && online.canPay(activeBill.outstanding) && (
|
||||
<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"
|
||||
onClick={() => payNow(activeReg.id)}
|
||||
@@ -1207,6 +1211,10 @@ export default function UserDashboardPage() {
|
||||
}}>Print all tickets</button>
|
||||
</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 */}
|
||||
{canModifyActive && activeReg.status !== 'cancelled' && (activeBill?.totalPaid ?? 0) === 0 && (
|
||||
<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'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 & 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 & Security</Link>.
|
||||
For anything you can't change there — such as details on a past registration, an attendee's
|
||||
date of birth or a form answer — tell us what is wrong and we'll correct it.
|
||||
</p>
|
||||
{openCorrection ? (
|
||||
<p className="text-sm text-gray-600">You have a correction request open — we'll email you when it'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'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'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 { useAuth } from "@/hooks/useAuth";
|
||||
import { useRouter } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { isValidZAPhone } from "@/lib/phone";
|
||||
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">
|
||||
<SectionHeader icon={Trash2} title="Close account" tone="red" />
|
||||
<p className="text-sm text-gray-500 mb-4">
|
||||
Closing your account will deactivate it immediately. You can also request that your personal
|
||||
information (name, email, phone number) be permanently deleted. Tickets and payment records
|
||||
will remain for accounting purposes but will show as "Deleted User".
|
||||
Closing your account will deactivate it immediately. You can also ask for your personal
|
||||
information to be deleted — we do that once the request is processed (see{" "}
|
||||
<Link href="/dashboard/user/privacy" className="text-brand-600 hover:underline">Privacy</Link>). Tickets and
|
||||
payment records remain for accounting purposes but show as "Deleted User".
|
||||
</p>
|
||||
|
||||
{closeStep === "idle" && (
|
||||
@@ -562,8 +564,9 @@ export default function UserProfilePage() {
|
||||
onChange={e => setDeleteData(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-gray-700">
|
||||
<span className="font-medium">Also delete my personal data</span> — your name, email address,
|
||||
and phone number will be permanently removed and cannot be recovered.
|
||||
<span className="font-medium">Also delete my personal data</span> — your name, contact details,
|
||||
the people you registered and your form answers are permanently removed once your request
|
||||
is processed (within 30 days).
|
||||
</span>
|
||||
</label>
|
||||
|
||||
@@ -595,7 +598,7 @@ export default function UserProfilePage() {
|
||||
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"
|
||||
>
|
||||
{closing ? "Processing…" : deleteData ? "Delete my data & close account" : "Close my account"}
|
||||
{closing ? "Processing…" : deleteData ? "Close account & request deletion" : "Close my account"}
|
||||
</button>
|
||||
</div>
|
||||
{closeMsg && <Alert msg={closeMsg} />}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { CouponCodeField, AppliedCoupon } from "@/components/shared/CouponCodeField";
|
||||
import { BundleBuilder, BundleOffers, BundleSavings, useBundleQuote } from "@/components/shared/BundleDeals";
|
||||
import type { EventSession, LateJoinInfo, PublicBundle } from "@/types";
|
||||
@@ -375,6 +376,7 @@ function DonationPicker({
|
||||
|
||||
export default function RegisterForm({ event: baseEvent }: { event: RegisterEvent }) {
|
||||
const { token, user } = useAuth();
|
||||
const online = useOnlinePayments();
|
||||
const eventId = baseEvent.id;
|
||||
|
||||
// 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 [customDonation, setCustomDonation] = useState("");
|
||||
const presets = useMemo(() => donationPresets(total), [total]);
|
||||
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut;
|
||||
const showDonation = isLoggedIn && payable > 0 && !eventSoldOut && online.available;
|
||||
const donation = !showDonation ? 0
|
||||
: donationChoice.kind === "preset" ? (presets[donationChoice.index] ?? 0)
|
||||
: donationChoice.kind === "custom" ? Math.max(0, Math.round((parseFloat(customDonation) || 0) * 100) / 100)
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Footer } from "@/components/layout/Footer";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { CheckCircle2 } from "lucide-react";
|
||||
import { useOnlinePayments } from "@/lib/onlinePayments";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
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 = () => {
|
||||
router.push("/dashboard/user");
|
||||
};
|
||||
@@ -129,7 +135,7 @@ function RegistrationSuccessContent() {
|
||||
<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>
|
||||
) : (totalDue > 0 || (!reg && fallbackTotalDue > 0)) ? (
|
||||
) : payOnline ? (
|
||||
<>
|
||||
{donation > 0 && (
|
||||
<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>
|
||||
</>
|
||||
) : owed && online.reason(payAmount) ? (
|
||||
<p className="text-gray-700 mb-6">{online.reason(payAmount)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-3 mt-4">
|
||||
{(totalDue > 0 || (!reg && fallbackTotalDue > 0)) && (
|
||||
{payOnline && (
|
||||
<button
|
||||
onClick={goPay}
|
||||
disabled={!registrationId || payLoading}
|
||||
@@ -151,7 +159,7 @@ function RegistrationSuccessContent() {
|
||||
<button
|
||||
onClick={goDashboard}
|
||||
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>
|
||||
{error && <p className="text-sm text-red-600 mt-3">{error}</p>}
|
||||
{!registrationId && (
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function robots(): MetadataRoute.Robots {
|
||||
rules: {
|
||||
userAgent: "*",
|
||||
allow: "/",
|
||||
disallow: ["/dashboard", "/self-service", "/set-banner", "/lockdown-rules"],
|
||||
disallow: ["/dashboard", "/self-service", "/set-banner", "/lockdown-rules", "/unsubscribe"],
|
||||
},
|
||||
sitemap: `${base}/sitemap.xml`,
|
||||
};
|
||||
|
||||
@@ -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're unsubscribed from news and announcements{info.kind === "address" ? ` for ${info.address}` : ""}.
|
||||
You'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 () => {
|
||||
if (!token || !window.confirm("Remove the passphrase? Only do this if it's also removed on your PayFast dashboard.")) return;
|
||||
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>.
|
||||
{cfg.notifyUrlProblem && <p className="text-amber-700 mt-1">{cfg.notifyUrlProblem}</p>}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@ export function RegisterForm() {
|
||||
const [phoneNumber, setPhoneNumber] = useState("");
|
||||
const [notificationPreference, setNotificationPreference] = useState<"email" | "whatsapp" | "both">("email");
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -53,6 +55,7 @@ export function RegisterForm() {
|
||||
password,
|
||||
phoneNumber: phoneNumber || undefined,
|
||||
notificationPreference: hasValidPhone ? notificationPreference : "email",
|
||||
newsOptIn,
|
||||
});
|
||||
window.location.href = redirectTo;
|
||||
} catch (err) {
|
||||
@@ -150,6 +153,19 @@ export function RegisterForm() {
|
||||
</label>
|
||||
</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">
|
||||
<a
|
||||
href={loginHref}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { HeartHandshake, X } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
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 };
|
||||
|
||||
@@ -30,6 +31,7 @@ export function SponsorSpot({ eventId, eventTitle }: { eventId: string; eventTit
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [returned, setReturned] = useState<string | null>(null);
|
||||
const online = useOnlinePayments();
|
||||
|
||||
useEffect(() => {
|
||||
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 */ }
|
||||
}, [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 loggedIn = !!(token && user);
|
||||
const total = mode === "spots" && price ? spots * price : Number(amount) || 0;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import React, { useState } from "react";
|
||||
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 { RoleBadge, type Role } from "@/components/shared/RoleBadge";
|
||||
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[] }[] = [
|
||||
{ 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/privacy", label: "Privacy", icon: Lock, roles: ["user", "staff", "supervisor", "admin"] },
|
||||
{ href: "/dashboard/staff", label: "Staff", icon: LayoutDashboard, roles: ["staff"] },
|
||||
{ href: "/dashboard/supervisor", label: "Supervisor", icon: LayoutDashboard, roles: ["supervisor"] },
|
||||
{ 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't been confirmed. If the provider's notification didn'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'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'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,92 @@
|
||||
import React from "react";
|
||||
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle } 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 & 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 & 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/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 "also delete my data" 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'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't take a person'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'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 "Deleted User" for the books and reports. It can't be undone and is logged in the audit log.
|
||||
</GuideItem>
|
||||
<GuideItem icon={MessageCircle} title="Reject or cancel" tone="amber">
|
||||
Give a reason; it's emailed to them. Tick "They withdrew it" if they changed their mind. Rejecting or cancelling a deletion opens their account again.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -26,6 +26,7 @@ import { supervisorBundlesHelpContent } from "./supervisor-bundles";
|
||||
import { supervisorConsentHelpContent } from "./supervisor-consent";
|
||||
import { supervisorWaitlistHelpContent } from "./supervisor-waitlist";
|
||||
import { supervisorAttendanceHelpContent } from "./supervisor-attendance";
|
||||
import { userPrivacyHelpContent, adminDataRequestsHelpContent } from "./privacy";
|
||||
import { supervisorOfflineScansHelpContent } from "./supervisor-offline-scans";
|
||||
import { supervisorAllocationsHelpContent, staffMyGroupsHelpContent } from "./supervisor-allocations";
|
||||
import { supervisorEmailAttendeesHelpContent } from "./supervisor-email-attendees";
|
||||
@@ -179,6 +180,16 @@ const REGISTRY: HelpRegistryEntry[] = [
|
||||
matches: pathname => pathname === "/dashboard/supervisor/attendance",
|
||||
content: supervisorAttendanceHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "user-privacy",
|
||||
matches: pathname => pathname === "/dashboard/user/privacy",
|
||||
content: userPrivacyHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "admin-data-requests",
|
||||
matches: pathname => pathname === "/dashboard/admin/data-requests",
|
||||
content: adminDataRequestsHelpContent,
|
||||
},
|
||||
{
|
||||
slug: "supervisor-consent",
|
||||
matches: pathname => pathname === "/dashboard/supervisor/consent",
|
||||
|
||||
@@ -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.
|
||||
</GuideItem>
|
||||
<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 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't deducted or added to the payer's total — deal with them at cashup.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 type { HelpContent } from "./types";
|
||||
|
||||
@@ -22,6 +22,9 @@ export const supervisorEmailAttendeesHelpContent: HelpContent = {
|
||||
<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.
|
||||
</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 & 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>
|
||||
),
|
||||
@@ -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's dates.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Mail} title="Broadcasts" tone="violet">
|
||||
Send to any selected users or pasted external addresses, independent of a specific event's attendee list.
|
||||
Send to any selected users or pasted external addresses, independent of a specific event's attendee list. Broadcasts are News & announcements unless you choose Event update.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Clock} title="Scheduled" tone="gray">
|
||||
Review, edit, or cancel any email that's scheduled but not yet sent.
|
||||
|
||||
@@ -25,7 +25,7 @@ export const supervisorPaymentsHelpContent: HelpContent = {
|
||||
Assign an unassigned donation to a registration with an outstanding balance. Donations can be partially allocated, leaving the remainder available for another registration. Money from "Sponsor a spot" is marked as a sponsorship. Tick "Only the sponsorship pool" to assign just those, and you'll see the sponsor's message. The Donations breakdown report shows each event's pool: received, assigned and remaining.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ScanLine} title="Reconcile" tone="green">
|
||||
Match unreconciled online payments (from Yoco, PayFast…) to a registration, log them as a donation, or ignore them. A payment of a different amount from its checkout lands here with a note — check it in the provider's dashboard first.
|
||||
Match unreconciled online payments (from Yoco, PayFast…) to a registration, log them as a donation, or ignore them. A payment of a different amount from its checkout lands here with a note — check it in the provider's dashboard first. "Waiting for payment" lists online checkouts with no payment yet: if someone paid but it hasn't shown up, press Check status (PayFast) — it's also checked automatically every 15 minutes. Yoco can't be checked automatically: find the payment in the Yoco portal.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Link2} title="Payment Link" tone="violet">
|
||||
Generate an online payment link for a user — copy it or send by email/WhatsApp. It uses the default payment provider (Site settings → Payments). Online payments must be at least R15. Generating a new link invalidates the previous one.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { MessageCircle, Users, Zap, Clock } from "lucide-react";
|
||||
import { MessageCircle, Users, Zap, Clock, ShieldCheck } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -19,6 +19,9 @@ export const supervisorWhatsappAttendeesHelpContent: HelpContent = {
|
||||
<GuideItem icon={MessageCircle} title="Who's eligible" tone="brand">
|
||||
Only recipients with a phone number can be messaged; the list is pre-filtered and colour-coded by notification preference (WhatsApp / Both), though you can still select a mismatch with a warning.
|
||||
</GuideItem>
|
||||
<GuideItem icon={ShieldCheck} title="Message type (POPIA)" tone="violet">
|
||||
<strong>Event updates</strong> go to everyone selected. <strong>News & announcements</strong> only go to people who opted in to WhatsApp news, and end with "Reply STOP" and an unsubscribe link. Broadcasts are news unless you choose Event update.
|
||||
</GuideItem>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -11,7 +11,7 @@ export type AuthContextValue = {
|
||||
login: (email: string, password: string) => Promise<AuthUser | TwoFactorChallenge>;
|
||||
verifyTwoFactor: (challengeId: string, code: string, rememberDevice: boolean) => Promise<AuthUser>;
|
||||
resendTwoFactor: (challengeId: string) => Promise<TwoFactorChallenge>;
|
||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => Promise<void>;
|
||||
register: (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both"; newsOptIn?: boolean }) => Promise<void>;
|
||||
logout: () => void;
|
||||
updateToken: (token: string) => void;
|
||||
};
|
||||
@@ -116,7 +116,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both" }) => {
|
||||
const register = useCallback(async (data: { name: string; email: string; password: string; phoneNumber?: string; notificationPreference?: "email" | "whatsapp" | "both"; newsOptIn?: boolean }) => {
|
||||
const res = await apiFetch<AuthResponse>("/api/users", {
|
||||
method: "POST",
|
||||
body: data,
|
||||
|
||||
@@ -9,6 +9,8 @@ export type AuthUser = {
|
||||
twoFactorMethod?: TwoFactorMethod | null;
|
||||
// True for roles that must use 2FA (admin, supervisor) — it can't be turned off.
|
||||
twoFactorRequired?: boolean;
|
||||
// POPIA: hasn't answered the "news and announcements" opt-in question yet (asked once).
|
||||
newsPromptPending?: boolean;
|
||||
};
|
||||
|
||||
export type AuthResponse = AuthUser & {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ProvidersResponse = { providers: { id: string; label: string }[]; minimumAmount: number };
|
||||
|
||||
export type OnlinePayments = {
|
||||
/** The answer has arrived (until then, nothing offers online payment). */
|
||||
ready: boolean;
|
||||
/** At least one payment provider is switched on and set up. */
|
||||
available: boolean;
|
||||
/** Online payments must be at least this much (R15). */
|
||||
minimum: number;
|
||||
/** Whether `amount` can be paid online now. */
|
||||
canPay: (amount: number) => boolean;
|
||||
/** Why `amount` can't be paid online, for showing instead of the button; null when it can. */
|
||||
reason: (amount: number) => string | null;
|
||||
};
|
||||
|
||||
// One request per page load, shared by every component that asks.
|
||||
let pending: Promise<ProvidersResponse | null> | null = null;
|
||||
function load() {
|
||||
if (!pending) pending = apiFetch<ProvidersResponse>("/api/payments/providers").catch(() => null);
|
||||
return pending;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether people can pay online: only when a payment provider is enabled (Site settings →
|
||||
* Payments) and the amount is at least the online minimum. "Pay online" buttons use this to
|
||||
* stay hidden otherwise.
|
||||
*/
|
||||
export function useOnlinePayments(): OnlinePayments {
|
||||
const [state, setState] = useState<{ ready: boolean; available: boolean; minimum: number }>({ ready: false, available: false, minimum: 15 });
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
load().then(res => {
|
||||
if (!cancelled) setState({ ready: true, available: !!res && res.providers.length > 0, minimum: res?.minimumAmount ?? 15 });
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const canPay = (amount: number) => state.ready && state.available && amount >= state.minimum;
|
||||
const reason = (amount: number) => {
|
||||
if (!state.ready || canPay(amount)) return null;
|
||||
if (!state.available) return "Online payment isn't available at the moment. You can pay at the event, or contact the organiser.";
|
||||
return `Online payments must be at least R${state.minimum}. You can pay this at the event.`;
|
||||
};
|
||||
return { ...state, canPay, reason };
|
||||
}
|
||||
@@ -618,6 +618,8 @@ export type DashboardStats = {
|
||||
// What the email/WhatsApp send, preview and schedule endpoints return.
|
||||
export type MessageSendResult<Recipient = { phone: string; name?: string }> = {
|
||||
matched?: number;
|
||||
// News & announcements: selected people left out (not opted in, or unsubscribed).
|
||||
skipped?: number;
|
||||
queued?: number;
|
||||
sent?: number;
|
||||
recipients?: Recipient[];
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.37.0",
|
||||
"version": "1.38.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
+63
-13
@@ -1,31 +1,81 @@
|
||||
#!/usr/bin/env bash
|
||||
# Updates this install to a tagged release: back up the database, check out the tag,
|
||||
# install, migrate, build, and reload PM2. If anything fails before the reload, the
|
||||
# previous version keeps running and the checkout goes back to it.
|
||||
# Updates this install: back up the database, check out the release, install, migrate,
|
||||
# build, and reload PM2. If anything fails before the reload, the previous version keeps
|
||||
# running and the checkout goes back to it.
|
||||
#
|
||||
# Usage (on the server, from the repo folder): bash scripts/deploy.sh v1.34.0
|
||||
# CI runs this over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
|
||||
set -euo pipefail
|
||||
# Usage (on the server, from the repo folder):
|
||||
# bash scripts/deploy.sh # the latest main
|
||||
# bash scripts/deploy.sh v1.37.3 # a tagged release
|
||||
# CI runs it with the tag over SSH when a v* tag is pushed (see .gitea/workflows/ci.yml).
|
||||
#
|
||||
# PM2 uses this install's own ecosystem.config.js (app names, ports). It isn't in git, so
|
||||
# deploys never change it; the first deploy creates it from ecosystem.config.example.js.
|
||||
set -Eeuo pipefail
|
||||
|
||||
TAG="${1:?Usage: scripts/deploy.sh <tag>}"
|
||||
cd "$(dirname "$0")/.."
|
||||
# The checkout below replaces this file, and bash reads a script as it runs — so run from a
|
||||
# copy that can't change underneath us.
|
||||
if [ -z "${HOPE_DEPLOY_COPY:-}" ]; then
|
||||
COPY="$(mktemp)"
|
||||
cp "$0" "$COPY"
|
||||
HOPE_DEPLOY_COPY=1 HOPE_DEPLOY_DIR="$(cd "$(dirname "$0")/.." && pwd)" exec bash "$COPY" "$@"
|
||||
fi
|
||||
|
||||
cd "$HOPE_DEPLOY_DIR"
|
||||
TARGET="${1:-main}"
|
||||
PREVIOUS="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
||||
|
||||
echo "==> Deploying $TAG (currently $PREVIOUS)"
|
||||
# Keep this install's PM2 config safe across the checkout (older releases tracked it in git).
|
||||
PM2_CONFIG="ecosystem.config.js"
|
||||
PM2_KEEP="$(mktemp)"
|
||||
HAVE_PM2_CONFIG=0
|
||||
if [ -f "$PM2_CONFIG" ]; then cp "$PM2_CONFIG" "$PM2_KEEP"; HAVE_PM2_CONFIG=1; fi
|
||||
trap 'rm -f "$0" "$PM2_KEEP"' EXIT
|
||||
# Out of the way before a checkout: back to the committed version if git tracks it in this
|
||||
# release, otherwise removed (a release that still tracks it would refuse to overwrite it).
|
||||
clear_pm2_config() {
|
||||
git checkout -q -- "$PM2_CONFIG" 2>/dev/null || rm -f "$PM2_CONFIG"
|
||||
}
|
||||
restore_pm2_config() {
|
||||
if [ "$HAVE_PM2_CONFIG" = 1 ]; then cp "$PM2_KEEP" "$PM2_CONFIG"; fi
|
||||
}
|
||||
|
||||
# Check out a tag, or the latest main (detached, so local main is never in the way).
|
||||
checkout() {
|
||||
clear_pm2_config
|
||||
if [ "$1" = "main" ]; then
|
||||
git fetch --quiet origin main
|
||||
git checkout -q --detach origin/main
|
||||
else
|
||||
git fetch --tags --quiet origin
|
||||
git checkout -q "$1"
|
||||
fi
|
||||
restore_pm2_config
|
||||
}
|
||||
|
||||
echo "==> Deploying $TARGET (currently $PREVIOUS)"
|
||||
|
||||
echo "==> Backing up the database"
|
||||
(cd backend && node -e "require('dotenv').config(); require('./src/utils/backupUtils').runBackup().then(r => console.log(' ', r.filename)).catch(e => { console.error(' Backup failed:', e.message); process.exit(1); })")
|
||||
|
||||
rollback() {
|
||||
# set -E passes this trap into ( … ) subshells too; only the script itself rolls back.
|
||||
if [ "$BASH_SUBSHELL" -gt 0 ]; then return; fi
|
||||
echo "!!> Deploy failed — going back to $PREVIOUS (the running version was not touched)"
|
||||
clear_pm2_config
|
||||
git checkout -q "$PREVIOUS"
|
||||
restore_pm2_config
|
||||
(cd backend && npm ci --silent) || true
|
||||
(cd frontend && npm ci --silent && npm run build --silent) || true
|
||||
}
|
||||
trap rollback ERR
|
||||
|
||||
git fetch --tags --quiet origin
|
||||
git checkout -q "$TAG"
|
||||
checkout "$TARGET"
|
||||
DEPLOYED="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
|
||||
|
||||
if [ ! -f "$PM2_CONFIG" ]; then
|
||||
cp ecosystem.config.example.js "$PM2_CONFIG"
|
||||
echo " Created $PM2_CONFIG from the example — check its app names and port."
|
||||
fi
|
||||
|
||||
echo "==> Backend: install and migrate"
|
||||
(cd backend && npm ci && npm run prisma:deploy)
|
||||
@@ -35,6 +85,6 @@ echo "==> Frontend: install and build"
|
||||
|
||||
trap - ERR
|
||||
echo "==> Reloading PM2"
|
||||
pm2 reload ecosystem.config.js --update-env
|
||||
pm2 reload "$PM2_CONFIG" --update-env
|
||||
|
||||
echo "==> Deployed $TAG"
|
||||
echo "==> Deployed $TARGET ($DEPLOYED)"
|
||||
|
||||
Reference in New Issue
Block a user