POPIA toolkit part 1: privacy page, unsubscribe, WhatsApp STOP, data requests

- Privacy page: news opt-in per channel with history, download my data
  (JSON + PDF), correction and deletion requests
- Bulk messages are an Event update or News & announcements; news only
  reaches people who opted in, with unsubscribe links, one-click
  List-Unsubscribe headers and "Reply STOP" on WhatsApp
- WhatsApp STOP via WAWP's message webhook, checked against a secret in
  the webhook URL
- Sign-up opt-in box; existing accounts kept opted in and asked once
- Admin -> Data requests with 30-day due dates and a daily reminder job
- Deleting an account is a request; anonymising now covers attendees,
  form answers, consent details, payer details in provider payloads, etc.
- Unit tests (privacy.test.js) and e2e (privacy.spec.ts); docs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
This commit is contained in:
2026-10-23 18:13:47 +02:00
co-authored by Claude Opus 5.5
parent be5ad620da
commit 594cb35a85
47 changed files with 2871 additions and 143 deletions
+2
View File
@@ -71,6 +71,8 @@ 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
+28 -2
View File
@@ -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 |
@@ -656,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
@@ -771,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 |
@@ -790,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` |
---
@@ -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;
+84
View File
@@ -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])
}
+16 -11
View File
@@ -30,6 +30,7 @@ function buildEventContext(event) {
}
const { replacePlaceholders } = require('../utils/placeholders');
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
function parseFreeformEmails(lines) {
// Supports formats:
@@ -103,16 +104,16 @@ const previewBroadcast = async (req, res) => {
if (!email) continue;
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
}
const recipients = Array.from(map.values());
const kind = normaliseKind(req.body?.kind) || 'news';
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
// Optionally resolve event info for link/title placeholder
let event = null;
if (eventId && typeof eventId === 'string') {
event = await prisma.event.findUnique({ where: { id: eventId } });
}
const eventCtx = buildEventContext(event);
return res.json({ matched: recipients.length, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
return res.json({ kind, matched: recipients.length, skipped, recipients: recipients.slice(0, 20), event: event ? { id: event.id, title: event.title } : null, placeholders: ['{{name}}','{{event.title}}','{{event.start}}','{{event.link}}'] });
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -147,9 +148,12 @@ const sendBroadcast = async (req, res) => {
if (!email) continue;
if (!map.has(email)) map.set(email, { email, name: r.name || '' });
}
const recipients = Array.from(map.values());
// Ad-hoc broadcasts are News & announcements unless the sender marks them as an event
// update; news skips people who haven't opted in or unsubscribed.
const kind = normaliseKind(req.body?.kind) || 'news';
const { recipients, skipped } = await filterRecipients(Array.from(map.values()), { channel: 'email', kind });
if (recipients.length === 0) {
return res.status(400).json({ message: 'No valid recipients' });
return res.status(400).json({ message: skipped ? 'Everyone selected has unsubscribed from news.' : 'No valid recipients' });
}
// Resolve event
@@ -177,9 +181,10 @@ const sendBroadcast = async (req, res) => {
eventLinkHtml: eventCtx.eventLinkHtml,
} : ctxBase;
const finalSubject = await replacePlaceholders(subject, ctxBase);
const finalHtml = html ? await replacePlaceholders(html, ctxForHtml) : undefined;
const finalText = (!html ? await replacePlaceholders(text || '', ctxBase) : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText });
const footer = bulkEmailFooter({ kind, recipient: rcpt, eventTitle: eventCtx.eventTitle });
const finalHtml = html ? (await replacePlaceholders(html, ctxForHtml)) + footer.html : undefined;
const finalText = (!html ? (await replacePlaceholders(text || '', ctxBase)) + footer.text : undefined);
await sendMail({ to: rcpt.email, subject: finalSubject, html: finalHtml, text: finalText, headers: footer.headers });
}));
const sent = results.filter(r => r.status === 'fulfilled').length;
@@ -189,7 +194,7 @@ const sendBroadcast = async (req, res) => {
}
});
return res.json({ matched: recipients.length, sent });
return res.json({ kind, matched: recipients.length, skipped, sent });
} catch (error) {
return res.status(400).json({ message: error.message });
}
@@ -200,13 +205,13 @@ const sendBroadcast = async (req, res) => {
// @access Private/Supervisor or Admin
const scheduleBroadcast = async (req, res) => {
try {
const { scheduledAt, subject, html, text, userIds, emails, eventId } = req.body || {};
const { scheduledAt, subject, html, text, userIds, emails, eventId, kind } = req.body || {};
if (!scheduledAt) return res.status(400).json({ message: 'scheduledAt is required' });
const when = new Date(scheduledAt);
if (isNaN(when.getTime())) return res.status(400).json({ message: 'scheduledAt must be a valid ISO date-time' });
if (!subject || !(html || text)) return res.status(400).json({ message: 'Subject and message (html or text) are required' });
const payload = { subject, html, text, userIds, emails, eventId };
const payload = { subject, html, text, userIds, emails, eventId, kind };
const recipientSummary = await describeBroadcastRecipients({ userIds, emails: parseFreeformEmails(emails) });
+25 -11
View File
@@ -1586,8 +1586,16 @@ const emailEventAttendees = async (req, res) => {
}
}
// POPIA: an "update" (about this event) goes to everyone selected; "news" (the next-event
// promo, or when the sender says so) only to people who opted in. Tickets are always sent.
const { normaliseKind, filterRecipients, bulkEmailFooter } = require('../utils/privacy');
const kind = template === 'tickets' ? 'update' : (promoEvent ? 'news' : (normaliseKind(req.body?.kind) || 'update'));
const allowed = await filterRecipients(recipients, { channel: 'email', kind });
recipients.length = 0;
recipients.push(...allowed.recipients);
if (dryRun) {
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({
@@ -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,
};
+38 -46
View File
@@ -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,
+30 -1
View File
@@ -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' }},
@@ -884,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-...' }},
+4
View File
@@ -52,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() }),
+39
View File
@@ -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;
+19 -2
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br/>');
const body = `
<p style="font-size:22px;font-weight:800;color:#0f172a;margin:0 0 24px 0;letter-spacing:-0.3px">${esc(heading)}</p>
<p style="margin:0 0 16px 0;color:#374151">Hi <strong>${esc(name || 'there')}</strong>,</p>
${paragraphs.map(p => `<p style="margin:0 0 16px 0;color:#374151">${esc(p)}</p>`).join('')}
<p style="margin:0;color:#374151">Questions? Contact us at <a href="mailto:${org.email}" style="color:#2563eb">${org.email}</a>.</p>`;
const text = `Hi ${name || 'there'},\n\n${paragraphs.join('\n\n')}\n\nQuestions? Contact us at ${org.email}.\n\n${org.name}`;
return { text, html: emailWrapper(body, { preheader: heading }) };
}
module.exports = {
buildPrivacyEmail,
sendMail,
isSmtpConfigured,
getOrg,
+635
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
/**
* The footer and headers a bulk email gets. News: unsubscribe link + List-Unsubscribe
* (one click in Gmail/Apple Mail). Updates: why they got it, and where to manage
* preferences. Returns { html, text, headers }.
*/
function bulkEmailFooter({ kind, recipient, eventTitle = '' }) {
const prefsUrl = `${frontendUrl()}/dashboard/user/privacy`;
const style = 'color:#94a3b8;font-size:12px;line-height:1.6;margin:24px 0 0 0;border-top:1px solid #e2e8f0;padding-top:12px';
if (kind === 'news') {
const target = recipient.userId ? { userId: recipient.userId } : { address: normaliseEmail(recipient.email), channel: 'email' };
const url = unsubscribePageUrl(target);
return {
html: `<p style="${style}">You're receiving this because you chose to get news and announcements from us. <a href="${url}" style="color:#64748b">Unsubscribe</a>${recipient.userId ? ` &middot; <a href="${prefsUrl}" style="color:#64748b">Manage preferences</a>` : ''}</p>`,
text: `\n\n—\nYou're receiving this because you chose to get news and announcements from us.\nUnsubscribe: ${url}`,
headers: {
'List-Unsubscribe': `<${oneClickUrl(target)}>`,
'List-Unsubscribe-Post': 'List-Unsubscribe=One-Click',
},
};
}
const why = eventTitle ? `you registered for ${eventTitle}` : 'you have a registration or account with us';
return {
html: `<p style="${style}">You're receiving this because ${escapeHtml(why)}. <a href="${prefsUrl}" style="color:#64748b">Manage your communication preferences</a></p>`,
text: `\n\n—\nYou're receiving this because ${why}.\nManage your communication preferences: ${prefsUrl}`,
headers: {},
};
}
/** The line a bulk news WhatsApp ends with. */
function whatsAppNewsFooter(recipient) {
const target = recipient.userId ? { userId: recipient.userId } : { address: addressFor('whatsapp', recipient.phone), channel: 'whatsapp' };
return `\n\n_Reply STOP to stop news messages, or unsubscribe: ${unsubscribePageUrl(target)}_`;
}
/** Recognises an opt-out reply ("STOP", "stop.", "Unsubscribe"). */
function isStopKeyword(text) {
return /^\s*(stop|unsubscribe|stop all|opt out|optout)\s*[.!]?\s*$/i.test(String(text || ''));
}
/**
* Someone replied STOP on WhatsApp: no more news on WhatsApp for that number — the
* account's opt-in if the number belongs to one, otherwise the number itself.
* Returns 'account' | 'address' | null (number not usable).
*/
async function whatsAppStop(rawPhone) {
const phone = addressFor('whatsapp', rawPhone);
if (!phone) return null;
const user = await prisma.user.findFirst({ where: { phoneNumber: phone }, select: { id: true } });
if (user) {
await setNewsPreferences(user.id, { whatsapp: false }, { source: 'whatsapp_stop' });
return 'account';
}
await optOutAddress('whatsapp', phone, { source: 'whatsapp_stop' });
return 'address';
}
// ─── Data requests ────────────────────────────────────────────────────────────
function dueDate(from = new Date()) {
return new Date(from.getTime() + REQUEST_DUE_DAYS * 24 * 60 * 60 * 1000);
}
async function createDataRequest({ user, type, details = null, selfService = false }, db = prisma) {
const now = new Date();
return db.dataRequest.create({
data: {
type,
userId: user.id,
name: user.name,
email: user.email,
details: details ? String(details).slice(0, 5000) : null,
selfService,
status: selfService ? 'completed' : 'open',
closedAt: selfService ? now : null,
dueAt: dueDate(now),
},
});
}
/** Who hears about privacy requests: the Information Officer (when set) and every admin. */
async function privacyContacts(db = prisma) {
const { getSettingSync } = require('./settingsCache');
const admins = await db.user.findMany({ where: { role: 'admin', isActive: true }, select: { email: true } });
const io = getSettingSync('legal_io_email', '');
return [...new Set([...(io && !io.endsWith('@example.com') ? [io] : []), ...admins.map(a => a.email)])];
}
/**
* Daily: email the Information Officer and admins a list of open requests due within 7 days
* or overdue. Nothing is sent when there are none.
*/
async function remindDueRequests(now = new Date(), db = prisma) {
const soon = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000);
const due = await db.dataRequest.findMany({
where: { status: 'open', selfService: false, dueAt: { lte: soon } },
orderBy: { dueAt: 'asc' },
select: { type: true, name: true, dueAt: true },
});
if (!due.length) return { due: 0, notified: 0 };
const { sendMail, buildPrivacyEmail } = require('./email');
const to = await privacyContacts(db);
const fmt = d => d.toLocaleDateString('en-ZA', { timeZone: 'Africa/Johannesburg' });
const content = buildPrivacyEmail({
name: '',
heading: 'Privacy requests need an answer',
paragraphs: [
`${due.length} privacy request${due.length === 1 ? ' is' : 's are'} due within a week or overdue. POPIA asks for an answer within 30 days.`,
due.map(r => `${r.dueAt < now ? 'OVERDUE' : `Due ${fmt(r.dueAt)}`} — ${r.type} request from ${r.name}`).join('\n'),
`Handle them on the Data requests page: ${frontendUrl()}/dashboard/admin/data-requests`,
],
});
await Promise.allSettled(to.map(email => sendMail({ to: email, subject: 'Privacy requests need an answer', ...content })));
return { due: due.length, notified: to.length };
}
/**
* Why an account can't be anonymised yet: registrations (not cancelled) for events that
* haven't ended. The person may still need the ticket, or be owed or owe money.
*/
async function deletionBlockers(userId, db = prisma) {
const regs = await db.registration.findMany({
where: { userId, status: { not: 'cancelled' }, event: { endDate: { gte: new Date() } } },
select: { id: true, status: true, event: { select: { id: true, title: true, endDate: true } } },
orderBy: { createdAt: 'asc' },
});
return regs.map(r => ({ registrationId: r.id, status: r.status, eventId: r.event.id, eventTitle: r.event.title, endDate: r.event.endDate }));
}
// ─── Anonymising ──────────────────────────────────────────────────────────────
// Keys in a provider's raw webhook payload that can hold the payer's details (PayFast:
// name_first, name_last, email_address, cell_number; Yoco: customer metadata).
const PERSONAL_KEY = /(^|_)(name|first|last|email|phone|cell|mobile|address|customer|surname)/i;
function scrubPersonal(value) {
if (Array.isArray(value)) return value.map(scrubPersonal);
if (value && typeof value === 'object') {
const out = {};
for (const [k, v] of Object.entries(value)) {
out[k] = PERSONAL_KEY.test(k) && (typeof v === 'string' || typeof v === 'number') ? '[deleted]' : scrubPersonal(v);
}
return out;
}
return value;
}
/**
* Remove a person's personal information while keeping the records the church must keep
* (payments, registrations, tickets, check-ins — amounts and dates) for its accounts and
* historic reports, where they show as "Deleted User". Also covers the attendees on their
* registrations (people they booked for) and their place on other people's bookings.
* Irreversible. Returns counts for the audit log.
*/
async function anonymiseUser(userId, db = prisma) {
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true } });
if (!user) return null;
const { clearTrustedDevices } = require('./twoFactor');
const counts = await db.$transaction(async tx => {
await tx.user.update({
where: { id: userId },
data: {
name: DELETED_NAME,
email: `deleted-${crypto.randomUUID()}@deleted.invalid`,
password: '',
phoneNumber: null,
isActive: false,
twoFactorEnabled: false,
twoFactorMethod: null,
tokenVersion: { increment: 1 },
newsEmail: false,
newsWhatsApp: false,
},
});
const attendees = await tx.attendee.findMany({ where: { OR: [{ userId }, { registration: { userId } }] }, select: { id: true } });
const attendeeIds = attendees.map(a => a.id);
if (attendeeIds.length) {
await tx.attendee.updateMany({
where: { id: { in: attendeeIds } },
data: { name: DELETED_ATTENDEE_NAME, email: null, phone: null, dateOfBirth: null, gender: null, parentName: null, parentEmail: null, parentPhone: null },
});
await tx.consentRequest.updateMany({
where: { attendeeId: { in: attendeeIds } },
data: { medical: null, emergency: null, parentName: null, parentEmail: null, parentPhone: null, signedName: null, ip: null, device: null, medicalPurgedAt: new Date() },
});
}
// Form answers on their registrations (and for their attendees elsewhere) hold
// whatever the form asked — delete the answers, keep that a form was filled in.
const answers = await tx.formAnswer.deleteMany({
where: { response: { OR: [{ registration: { userId } }, ...(attendeeIds.length ? [{ attendeeId: { in: attendeeIds } }] : [])] } },
});
await tx.formDraft.deleteMany({ where: { userId } });
// Feedback stays (it's about the event) but no longer says who gave it.
await tx.feedbackResponse.updateMany({ where: { userId }, data: { userId: null } });
await tx.feedbackInvite.deleteMany({ where: { userId } });
await tx.waitlistEntry.updateMany({ where: { userId, status: { in: ['waiting', 'offered'] } }, data: { status: 'left', offerTokenHash: null } });
await tx.ticketTransferRequest.deleteMany({ where: { requestedById: userId } });
await tx.ticketTransfer.updateMany({ where: { fromUserId: userId }, data: { fromHolderName: DELETED_NAME } });
await tx.ticketTransfer.updateMany({ where: { toUserId: userId }, data: { toName: DELETED_NAME } });
await tx.certificate.updateMany({ where: { userId }, data: { name: DELETED_NAME } });
await tx.payment.updateMany({ where: { userId }, data: { sponsorName: null, sponsorMessage: null } });
const payments = await tx.payment.findMany({ where: { userId }, select: { id: true } });
const txns = payments.length
? await tx.providerTransaction.findMany({ where: { paymentId: { in: payments.map(p => p.id) }, raw: { not: null } }, select: { id: true, raw: true } })
: [];
for (const t of txns) await tx.providerTransaction.update({ where: { id: t.id }, data: { raw: scrubPersonal(t.raw) } });
const checkouts = await tx.providerCheckout.findMany({ where: { metadata: { path: ['userId'], equals: userId } }, select: { id: true, request: true } });
for (const c of checkouts) await tx.providerCheckout.update({ where: { id: c.id }, data: { request: scrubPersonal(c.request) } });
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,
};
+29
View File
@@ -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,
+161
View File
@@ -0,0 +1,161 @@
jest.mock('../src/config/db', () => ({
user: { findUnique: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), update: jest.fn() },
communicationConsent: { createMany: jest.fn(), create: jest.fn() },
messagingOptOut: { findMany: jest.fn(), findUnique: jest.fn(), create: jest.fn() },
registration: { findMany: jest.fn() },
}));
process.env.JWT_SECRET = 'test-secret';
process.env.FRONTEND_URL = 'https://events.example.org';
process.env.BACKEND_URL = 'https://api.example.org';
const prisma = require('../src/config/db');
const privacy = require('../src/utils/privacy');
const { phoneFromChatId } = require('../src/utils/whatsapp');
beforeEach(() => {
jest.clearAllMocks();
prisma.messagingOptOut.findMany.mockResolvedValue([]);
});
describe('unsubscribe tokens', () => {
test('round-trip an account and an address', () => {
expect(privacy.readUnsubscribeToken(privacy.unsubscribeToken({ userId: 'u1' }))).toEqual({ userId: 'u1' });
const t = privacy.unsubscribeToken({ address: 'a@b.co', channel: 'email' });
expect(privacy.readUnsubscribeToken(t)).toEqual({ address: 'a@b.co', channel: 'email' });
});
test('a tampered token is refused', () => {
const [, sig] = privacy.unsubscribeToken({ userId: 'u1' }).split('.');
const forged = `${Buffer.from(JSON.stringify({ u: 'someone-else' })).toString('base64url')}.${sig}`;
expect(privacy.readUnsubscribeToken(forged)).toBeNull();
expect(privacy.readUnsubscribeToken('garbage')).toBeNull();
expect(privacy.readUnsubscribeToken('')).toBeNull();
});
test('an address token needs a known channel', () => {
const payload = Buffer.from(JSON.stringify({ a: 'x@y.z', c: 'sms' })).toString('base64url');
const real = privacy.unsubscribeToken({ address: 'x@y.z', channel: 'email' });
expect(privacy.readUnsubscribeToken(`${payload}.${real.split('.')[1]}`)).toBeNull();
});
});
describe('filterRecipients', () => {
const users = [
{ id: 'in', email: 'in@x.co', phoneNumber: '27820000001', newsEmail: true, newsWhatsApp: true },
{ id: 'out', email: 'Out@x.co', phoneNumber: '27820000002', newsEmail: false, newsWhatsApp: false },
];
test('updates go to everyone, with the account filled in', async () => {
prisma.user.findMany.mockResolvedValue(users);
const res = await privacy.filterRecipients([{ email: 'in@x.co' }, { email: 'out@x.co' }, { email: 'guest@x.co' }], { channel: 'email', kind: 'update' });
expect(res.skipped).toBe(0);
expect(res.recipients.map(r => r.userId)).toEqual(['in', 'out', null]);
expect(prisma.messagingOptOut.findMany).not.toHaveBeenCalled();
});
test('news skips accounts without the opt-in (any letter case) and unsubscribed addresses', async () => {
prisma.user.findMany.mockResolvedValue(users);
prisma.messagingOptOut.findMany.mockResolvedValue([{ address: 'gone@x.co' }]);
const res = await privacy.filterRecipients(
[{ email: 'in@x.co' }, { email: 'OUT@x.co' }, { email: 'gone@x.co' }, { email: 'new@x.co' }],
{ channel: 'email', kind: 'news' },
);
expect(res.recipients.map(r => r.email)).toEqual(['in@x.co', 'new@x.co']);
expect(res.skipped).toBe(2);
});
test('WhatsApp news uses the WhatsApp opt-in and normalised numbers', async () => {
prisma.user.findMany.mockResolvedValue(users);
const res = await privacy.filterRecipients([{ phone: '082 000 0001' }, { phone: '0820000002' }], { channel: 'whatsapp', kind: 'news' });
expect(res.recipients).toHaveLength(1);
expect(res.recipients[0].userId).toBe('in');
});
});
describe('setNewsPreferences', () => {
test('logs only the channels that changed, and WhatsApp needs a phone', async () => {
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: null, newsEmail: true, newsWhatsApp: false });
prisma.user.update.mockResolvedValue({ newsEmail: false, newsWhatsApp: false, newsAskedAt: null });
await privacy.setNewsPreferences('u1', { email: false, whatsapp: true }, { source: 'privacy_centre' });
expect(prisma.user.update).toHaveBeenCalledWith(expect.objectContaining({ data: { newsEmail: false } }));
expect(prisma.communicationConsent.createMany).toHaveBeenCalledWith({
data: [{ userId: 'u1', channel: 'email', granted: false, source: 'privacy_centre', ip: null }],
});
});
test('the first answer is logged even when nothing changes', async () => {
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: '27820000001', newsEmail: true, newsWhatsApp: true });
prisma.user.update.mockResolvedValue({ newsEmail: true, newsWhatsApp: true, newsAskedAt: new Date() });
await privacy.setNewsPreferences('u1', { email: true, whatsapp: true }, { source: 'prompt', answered: true, logAll: true });
expect(prisma.user.update.mock.calls[0][0].data.newsAskedAt).toBeInstanceOf(Date);
expect(prisma.communicationConsent.createMany.mock.calls[0][0].data).toHaveLength(2);
});
});
describe('footers', () => {
test('news emails carry an unsubscribe link and one-click headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'news', recipient: { email: 'a@b.co', userId: 'u1' } });
expect(f.html).toContain('https://events.example.org/unsubscribe?t=');
expect(f.headers['List-Unsubscribe']).toMatch(/^<https:\/\/api\.example\.org\/api\/privacy\/unsubscribe\/.+\/one-click>$/);
expect(f.headers['List-Unsubscribe-Post']).toBe('List-Unsubscribe=One-Click');
});
test('update emails explain why and link to the preferences, without unsubscribe headers', () => {
const f = privacy.bulkEmailFooter({ kind: 'update', recipient: { email: 'a@b.co' }, eventTitle: 'Camp <2026>' });
expect(f.html).toContain('you registered for Camp &lt;2026&gt;');
expect(f.html).toContain('/dashboard/user/privacy');
expect(f.headers).toEqual({});
});
test('news WhatsApps mention STOP', () => {
expect(privacy.whatsAppNewsFooter({ phone: '0820000001' })).toMatch(/Reply STOP/);
});
});
describe('WhatsApp STOP', () => {
test.each(['STOP', 'stop', ' Stop. ', 'unsubscribe', 'Opt out'])('%p is an opt-out', text => {
expect(privacy.isStopKeyword(text)).toBe(true);
});
test.each(['please stop by the office', 'STOPPED', '', null])('%p is not', text => {
expect(privacy.isStopKeyword(text)).toBe(false);
});
test('phone numbers only from personal chats', () => {
expect(phoneFromChatId('27821234567@c.us')).toBe('27821234567');
expect(phoneFromChatId('1203630@g.us')).toBeNull();
expect(phoneFromChatId('99887766@lid')).toBeNull();
});
test('an account number turns off WhatsApp news; an unknown number is opted out', async () => {
prisma.user.findFirst.mockResolvedValueOnce({ id: 'u1' });
prisma.user.findUnique.mockResolvedValueOnce({ id: 'u1', phoneNumber: '27821234567', newsEmail: true, newsWhatsApp: true });
prisma.user.update.mockResolvedValue({});
expect(await privacy.whatsAppStop('27821234567')).toBe('account');
expect(prisma.user.update.mock.calls[0][0].data).toEqual({ newsWhatsApp: false });
prisma.user.findFirst.mockResolvedValueOnce(null);
prisma.messagingOptOut.findUnique.mockResolvedValueOnce(null);
expect(await privacy.whatsAppStop('0829999999')).toBe('address');
expect(prisma.messagingOptOut.create).toHaveBeenCalledWith({ data: { channel: 'whatsapp', address: '27829999999', source: 'whatsapp_stop' } });
});
});
describe('anonymising helpers', () => {
test('scrubPersonal removes payer details from provider payloads, keeps amounts and ids', () => {
const raw = { m_payment_id: 'chk_1', pf_payment_id: '123', amount_gross: '80.00', name_first: 'Ann', name_last: 'Lee', email_address: 'ann@x.co', nested: { customer: 'Ann Lee', id: 'x' } };
expect(privacy.scrubPersonal(raw)).toEqual({
m_payment_id: 'chk_1', pf_payment_id: '123', amount_gross: '80.00',
name_first: '[deleted]', name_last: '[deleted]', email_address: '[deleted]', nested: { customer: '[deleted]', id: 'x' },
});
});
test('deletion is blocked by registrations for events that have not ended', async () => {
prisma.registration.findMany.mockResolvedValue([{ id: 'r1', status: 'paid', event: { id: 'e1', title: 'Camp', endDate: new Date('2099-01-01') } }]);
const blockers = await privacy.deletionBlockers('u1');
expect(blockers).toEqual([{ registrationId: 'r1', status: 'paid', eventId: 'e1', eventTitle: 'Camp', endDate: new Date('2099-01-01') }]);
const where = prisma.registration.findMany.mock.calls[0][0].where;
expect(where.status).toEqual({ not: 'cancelled' });
expect(where.event.endDate.gte).toBeInstanceOf(Date);
});
});