Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fd2bb07047 | ||
|
|
2d4bc5c38d | ||
|
|
f7a19f254a | ||
|
|
8c5e6bd23c | ||
|
|
a1ecbb9617 | ||
|
|
6eee10b610 |
@@ -7,6 +7,23 @@ and this project follows [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [1.40.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
- **POPIA toolkit, part 3.** Three new tabs on Admin → Privacy (POPIA):
|
||||
- **Readiness** (now the first tab): a checklist with a score — Information Officer and legal details set, privacy notice published, operators described (no generic names, a location for each), retention reviewed and the job running, no overdue requests, breaches reported to the Regulator, unsubscribe links working (`BACKEND_URL` on https), email set up, WhatsApp STOP replies arriving, two-factor login required, backups running. Each open item links to where you fix it.
|
||||
- **Retention**: how long personal information is kept, with what each setting would affect right now. A nightly job (`retention`, 03:30; `RETENTION_JOBS_ENABLED=false` to switch off) applies it:
|
||||
- **Inactive accounts** — no login, registration, payment or being named as an attendee for **5 years**: a warning by email (or WhatsApp) 30 days before, then the account is anonymised. Logging in keeps it. Never staff, supervisors or admins, and never while they have a registration for an upcoming event. **On by default.**
|
||||
- **Attendee form answers** — deleted 12 months after the event.
|
||||
- **Login history** — kept 24 months.
|
||||
- **Payer names and emails in Yoco/PayFast notifications** — removed after 24 months (payments themselves are kept).
|
||||
- **Audit log, access log and "view as" sessions** — kept 5 years.
|
||||
- Medical details, visit statistics and backups keep their existing settings, shown alongside. "Run now" applies them straight away.
|
||||
- **Breaches**: a register of security compromises (what happened, what information, who, consequences, what was done, advice to the people affected), with status and the dates the Information Regulator and the people affected were told. Each entry has a draft message for the people affected and a summary for the Regulator's form, ready to copy.
|
||||
- **Children's and medical information** is flagged in the access log (medical lists, and anything about an event that asks for parental consent), with a filter.
|
||||
- The privacy notice gets a `{retention}` placeholder — one line per retention period, following the settings. The built-in notice uses it; if you already published your own version, add a line with `{retention}` to your retention section.
|
||||
|
||||
## [1.39.0] - 2026-10-23
|
||||
|
||||
### Added
|
||||
|
||||
+12
-1
@@ -590,7 +590,17 @@ Information Officer.
|
||||
answers, searchable.
|
||||
- **Access log** — every bulk export or view of personal information (reports, attendance
|
||||
registers, certificates, group lists, form answers, consent and medical lists, backups)
|
||||
and every "view as" session, with the number of records.
|
||||
and every "view as" session, with the number of records. Children's and medical
|
||||
information is flagged (medical lists, and anything about a youth event).
|
||||
- **Retention** — how long each kind of personal information is kept, applied by a nightly
|
||||
job: inactive accounts anonymised after 5 years (warning 30 days before; never staff
|
||||
roles or people with an upcoming registration), form answers deleted 12 months after
|
||||
the event, login history kept 24 months, payer details in payment notifications removed
|
||||
after 24 months, audit/access logs kept 5 years. The notice's `{retention}` placeholder
|
||||
lists them.
|
||||
- **Breaches** — the register of security compromises, with when the Information Regulator
|
||||
and the people affected were told, and drafts of both notifications.
|
||||
- **Readiness** — a scored checklist of what's set up, each item linking to its fix.
|
||||
|
||||
**For admins** — **Data requests** (`/dashboard/admin/data-requests`): correction and
|
||||
deletion requests with a 30-day due date, emailed to the Information Officer and
|
||||
@@ -631,6 +641,7 @@ can't start, the same jobs run on in-process timers.
|
||||
| `sessions` — session reminders, and certificates for events that ended in the last 14 days | Every 5 minutes (`SESSION_JOBS_ENABLED=false`) |
|
||||
| `consent` — parental consent reminders, and deleting medical details after events | 09:00 daily (`CONSENT_JOBS_ENABLED=false`) |
|
||||
| `privacy` — remind admins of privacy requests due within a week or overdue | 08:00 daily (`PRIVACY_JOBS_ENABLED=false`) |
|
||||
| `retention` — delete/anonymise personal information past its retention period (Admin → Privacy → Retention) | 03:30 daily (`RETENTION_JOBS_ENABLED=false`) |
|
||||
| `analytics-rollup` — roll raw visit rows older than 90 days into daily totals | 04:15 daily (`ANALYTICS_ROLLUP_ENABLED=false`) |
|
||||
| `backup` — database backup (`pg_dump`, gzipped, 14-day retention) | 02:00 daily (`BACKUP_ENABLED=false`) |
|
||||
| `temp-cleanup` — temp files older than 7 days in `/temp` | 03:00 daily |
|
||||
|
||||
@@ -97,6 +97,10 @@ Managed in the Payments tab (through `/api/payments/providers/settings` and `/ap
|
||||
|---|---|---|
|
||||
| `legal_io_email` (Information Officer, Site Settings → Legal) | — | Also where new privacy requests and the daily "requests due" reminder are emailed, together with all admins. The `io@example.com` placeholder is skipped. |
|
||||
| `PRIVACY_JOBS_ENABLED` (env) | `true` | The daily 08:00 `privacy` job that reminds admins of requests due within a week or overdue. |
|
||||
| `retention_policies` | Inactive accounts 60 months (on), form answers 12, login history 24, payment details 24, audit/access logs 60 — all on | Admin → Privacy → Retention (JSON `{ policies: { key: { enabled, months } }, reviewedAt, reviewedBy }`). Minimums: inactive accounts and logs 12 months, login history and payment details 3, form answers 1; maximum 240. Saving marks them reviewed (readiness check). |
|
||||
| `retention_last_run` | — | When the `retention` job last ran and what it did (readiness check: within 2 days). |
|
||||
| `RETENTION_JOBS_ENABLED` (env) | `true` | The nightly 03:30 `retention` job. Off means nothing expires automatically. |
|
||||
| `wawp_webhook_last_seen` | — | Set when WAWP's webhook arrives with the right secret; the readiness page uses it to check WhatsApp STOP is set up. |
|
||||
| `privacy_notice_draft` | — | The unpublished draft from Admin → Privacy → Privacy notice (JSON). Published versions are in the `PrivacyNotice` table. With no published version the built-in notice (`backend/src/utils/privacyNotice.js`) is shown. The notice's org name, Information Officer, website, emails and medical retention days come from the settings above when it's shown. |
|
||||
|
||||
People's News & announcements opt-in is per account (`User.newsEmail`, `User.newsWhatsApp`), not a site setting. Unsubscribe links are signed with `JWT_SECRET` and don't expire — changing `JWT_SECRET` breaks links in emails already sent.
|
||||
|
||||
@@ -73,6 +73,9 @@ WAITLIST_JOB_ENABLED=true
|
||||
PAYMENT_STATUS_JOB_ENABLED=true
|
||||
# Privacy (POPIA): daily 08:00 reminder to admins of data requests due within a week
|
||||
PRIVACY_JOBS_ENABLED=true
|
||||
# Privacy (POPIA): nightly 03:30 retention job (Admin → Privacy → Retention). Note: inactive
|
||||
# accounts are anonymised after 5 years by default (with a warning 30 days before).
|
||||
RETENTION_JOBS_ENABLED=true
|
||||
# Nightly database backup at 02:00 (requires the `pg_dump` binary on this host).
|
||||
# Retention count is set via Admin → Site Settings → Backups (default 14).
|
||||
BACKUP_ENABLED=true
|
||||
|
||||
@@ -582,8 +582,16 @@ Privacy notice, operators and logs (`src/utils/privacyNotice.js`, `src/utils/acc
|
||||
| PUT / DELETE | `/api/privacy/operators/:id` | admin | Change / remove an operator (audited as `operator_*`) |
|
||||
| GET | `/api/privacy/consent-log?type=news\|notice\|parental&q=&page=` | admin | News opt-in changes, notice acceptances, or parental consent answers (50 per page) |
|
||||
| GET | `/api/privacy/access-log?action=&actorId=&from=&to=&page=` | admin | Bulk exports/views of personal information, with `actions` (labels) and `actors` for the filters |
|
||||
| GET | `/api/privacy/readiness` | admin | `{ score, passed, total, checks: [{ key, label, ok, detail, href, applies }] }` |
|
||||
| GET | `/api/privacy/retention` | admin | `{ policies, definitions, reviewedAt, reviewedBy, preview, lastRun, related }` — `preview` is what the next run would affect |
|
||||
| PUT | `/api/privacy/retention` | admin | `{ policies: { inactive_accounts\|form_answers\|login_history\|payment_details\|audit_logs: { enabled, months } } }` — all five required. Audited as `retention_policies_updated` |
|
||||
| POST | `/api/privacy/retention/run` | admin | Run the retention job now: `{ result, state }` |
|
||||
| GET / POST | `/api/privacy/breaches` | admin | The breach register / record one `{ title, discoveredAt, description, dataAffected, occurredAt?, peopleAffected?, whoAffected?, consequences?, actionsTaken?, adviceToPeople?, causedBy? }` |
|
||||
| PUT | `/api/privacy/breaches/:id` | admin | Change any of those, `status` (open\|contained\|closed), `regulatorNotifiedAt`, `peopleNotifiedAt`. Audited as `breach_updated` |
|
||||
|
||||
Placeholders in notice sections: `{org}`, `{orgEmail}`, `{website}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}`, and `{operators}` on a line of its own (one bullet per active operator). Login and `GET /api/users/profile` return `noticePromptPending` — true when a version with `requiresAcceptance` is newer than the one the person accepted. Sign-up records an acceptance of the current version (source `signup`).
|
||||
The `retention` job (03:30, `RETENTION_JOBS_ENABLED=false` to disable) works through each switched-on policy. Inactive accounts: role `user`, not anonymised, whose latest of created / last login (`User.lastLoginAt`) / registration or its event's end / named as attendee / payment is older than the period. Those within 30 days of it are warned (email, else WhatsApp; `User.inactivityWarnedAt`), and those warned at least 30 days ago — after their last activity — are anonymised unless they have a registration for an upcoming event. Logging in clears the warning. At most 300 warnings and 200 anonymisations per run. Payment details: `ProviderTransaction.raw` and `ProviderCheckout.request` older than the period are scrubbed and marked `_scrubbed`. Access log rows carry `sensitive` (medical lists, and anything for an event with `requiresParentalConsent`); `?sensitive=true` filters them.
|
||||
|
||||
Placeholders in notice sections: `{org}`, `{orgEmail}`, `{website}`, `{ioName}`, `{ioEmail}`, `{medicalRetentionDays}`, and — each on a line of its own — `{operators}` (one bullet per active operator) and `{retention}` (one bullet per retention period). Login and `GET /api/users/profile` return `noticePromptPending` — true when a version with `requiresAcceptance` is newer than the one the person accepted. Sign-up records an acceptance of the current version (source `signup`).
|
||||
|
||||
Access log actions (`DataAccessLog.action`): `report_pdf`, `report_excel`, `report_email`, `report_whatsapp` (`/api/reports/*`, with the row count), `attendance_export`, `certificates_download`, `group_lists` (or `medical_list` with `?medical=true`), `form_responses` (first page of a list), `consent_list`, `medical_list`, `backup_download`, `impersonation`.
|
||||
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "event-management-backend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@prisma/adapter-pg": "^7.10.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "event-management-backend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"description": "Event Management System Backend",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "BreachStatus" AS ENUM ('open', 'contained', 'closed');
|
||||
|
||||
-- AlterEnum
|
||||
-- This migration adds more than one value to an enum.
|
||||
-- With PostgreSQL versions 11 and earlier, this is not possible
|
||||
-- in a single migration. This can be worked around by creating
|
||||
-- multiple migrations, each migration adding only one value to
|
||||
-- the enum.
|
||||
|
||||
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'retention_policies_updated';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'retention_run';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'breach_recorded';
|
||||
ALTER TYPE "AdminAuditAction" ADD VALUE 'breach_updated';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "DataAccessLog" ADD COLUMN "sensitive" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "inactivityWarnedAt" TIMESTAMP(3),
|
||||
ADD COLUMN "lastLoginAt" TIMESTAMP(3);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SecurityBreach" (
|
||||
"id" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"status" "BreachStatus" NOT NULL DEFAULT 'open',
|
||||
"discoveredAt" TIMESTAMP(3) NOT NULL,
|
||||
"occurredAt" TIMESTAMP(3),
|
||||
"description" TEXT NOT NULL,
|
||||
"dataAffected" TEXT NOT NULL,
|
||||
"peopleAffected" INTEGER,
|
||||
"whoAffected" TEXT,
|
||||
"consequences" TEXT,
|
||||
"actionsTaken" TEXT,
|
||||
"adviceToPeople" TEXT,
|
||||
"causedBy" TEXT,
|
||||
"regulatorNotifiedAt" TIMESTAMP(3),
|
||||
"peopleNotifiedAt" TIMESTAMP(3),
|
||||
"createdById" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SecurityBreach_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SecurityBreach_status_discoveredAt_idx" ON "SecurityBreach"("status", "discoveredAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SecurityBreach" ADD CONSTRAINT "SecurityBreach_createdById_fkey" FOREIGN KEY ("createdById") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- Last login, from the login history (which the retention job will start expiring).
|
||||
UPDATE "User" u SET "lastLoginAt" = l.at
|
||||
FROM (SELECT "userId", MAX("createdAt") AS at FROM "SecurityEvent" WHERE "type" = 'login' AND "userId" IS NOT NULL GROUP BY "userId") l
|
||||
WHERE u."id" = l."userId";
|
||||
@@ -84,6 +84,10 @@ model User {
|
||||
// The privacy notice version they last accepted (0 = the built-in notice). Null = never
|
||||
// recorded. Asked again when a version that needs re-acceptance is newer.
|
||||
noticeVersionAccepted Int?
|
||||
// Retention: when they last logged in (kept here because login history itself expires),
|
||||
// and when they were warned that their inactive account is about to be deleted.
|
||||
lastLoginAt DateTime?
|
||||
inactivityWarnedAt DateTime?
|
||||
registrations Registration[]
|
||||
payments Payment[]
|
||||
paymentsRecorded Payment[] @relation("PaymentRecordedBy")
|
||||
@@ -136,6 +140,7 @@ model User {
|
||||
noticesPublished PrivacyNotice[]
|
||||
noticeAcceptances NoticeAcceptance[]
|
||||
dataAccessLogs DataAccessLog[]
|
||||
breachesRecorded SecurityBreach[]
|
||||
}
|
||||
|
||||
model Event {
|
||||
@@ -593,6 +598,10 @@ enum AdminAuditAction {
|
||||
operator_created
|
||||
operator_updated
|
||||
operator_deleted
|
||||
retention_policies_updated
|
||||
retention_run
|
||||
breach_recorded
|
||||
breach_updated
|
||||
}
|
||||
|
||||
// Append-only audit trail for admin/supervisor-initiated actions with money or
|
||||
@@ -1724,6 +1733,8 @@ model DataAccessLog {
|
||||
what String
|
||||
eventId String?
|
||||
records Int?
|
||||
// Children's or special personal information (youth events, medical details).
|
||||
sensitive Boolean @default(false)
|
||||
ip String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@ -1731,3 +1742,37 @@ model DataAccessLog {
|
||||
@@index([actorId, createdAt])
|
||||
@@index([action, createdAt])
|
||||
}
|
||||
|
||||
enum BreachStatus {
|
||||
open
|
||||
contained
|
||||
closed
|
||||
}
|
||||
|
||||
// POPIA s22: a security compromise — what happened, whose information, what was done, and
|
||||
// when the Information Regulator and the people affected were told.
|
||||
model SecurityBreach {
|
||||
id String @id @default(uuid())
|
||||
title String
|
||||
status BreachStatus @default(open)
|
||||
discoveredAt DateTime
|
||||
occurredAt DateTime?
|
||||
description String
|
||||
dataAffected String
|
||||
peopleAffected Int?
|
||||
// Free text: who (groups of people, events…), since a breach can involve people without accounts.
|
||||
whoAffected String?
|
||||
consequences String?
|
||||
actionsTaken String?
|
||||
adviceToPeople String?
|
||||
// Who caused it, if known (POPIA asks for this when notifying).
|
||||
causedBy String?
|
||||
regulatorNotifiedAt DateTime?
|
||||
peopleNotifiedAt DateTime?
|
||||
createdById String?
|
||||
createdBy User? @relation(fields: [createdById], references: [id], onDelete: SetNull)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, discoveredAt])
|
||||
}
|
||||
|
||||
@@ -261,6 +261,7 @@ const accessLog = async (req, res) => {
|
||||
try {
|
||||
const where = {};
|
||||
if (req.query.action) where.action = String(req.query.action);
|
||||
if (req.query.sensitive === 'true') where.sensitive = true;
|
||||
if (req.query.actorId) where.actorId = String(req.query.actorId);
|
||||
if (req.query.from || req.query.to) {
|
||||
where.createdAt = {};
|
||||
@@ -276,14 +277,161 @@ const accessLog = async (req, res) => {
|
||||
total, pageSize: PAGE_SIZE,
|
||||
actions: ACCESS_ACTIONS,
|
||||
actors: actors.map(a => ({ id: a.actorId, name: a.actor?.name || 'Unknown' })),
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip })),
|
||||
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip, sensitive: r.sensitive })),
|
||||
});
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Readiness ────────────────────────────────────────────────────────────────
|
||||
|
||||
// @desc POPIA readiness checklist with a score
|
||||
// @route GET /api/privacy/readiness
|
||||
// @access Private/Admin
|
||||
const getReadiness = async (req, res) => {
|
||||
try {
|
||||
res.json(await require('../utils/readiness').readiness());
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Retention ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function retentionState() {
|
||||
const retention = require('../utils/retention');
|
||||
const { getSetting } = require('../utils/settingsCache');
|
||||
const current = await retention.getPolicies();
|
||||
const [preview, lastRun, medicalDays, backupCount] = await Promise.all([
|
||||
retention.preview(current.policies),
|
||||
retention.lastRun(),
|
||||
getSetting('consent_medical_retention_days', ''),
|
||||
getSetting('backup_retain_count', ''),
|
||||
]);
|
||||
return {
|
||||
...current,
|
||||
definitions: Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { label: d.label, description: d.description, minMonths: d.minMonths, defaults: d.defaults }])),
|
||||
preview,
|
||||
lastRun,
|
||||
related: { medicalDays: parseInt(medicalDays, 10) || 30, backupCount: parseInt(backupCount, 10) || 14, analyticsDays: 90 },
|
||||
};
|
||||
}
|
||||
|
||||
// @desc Retention policies, what they'd affect now, and the last run
|
||||
// @route GET /api/privacy/retention
|
||||
// @access Private/Admin
|
||||
const getRetention = async (req, res) => {
|
||||
try {
|
||||
res.json(await retentionState());
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Save retention policies (body.policies: { key: { enabled, months } })
|
||||
// @route PUT /api/privacy/retention
|
||||
// @access Private/Admin
|
||||
const saveRetention = async (req, res) => {
|
||||
try {
|
||||
const saved = await require('../utils/retention').savePolicies(req.body?.policies, { reviewedBy: req.user.name });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'retention_policies_updated', targetType: 'retention', metadata: saved.policies, ip: getClientIp(req) });
|
||||
res.json(await retentionState());
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @desc Run the retention job now
|
||||
// @route POST /api/privacy/retention/run
|
||||
// @access Private/Admin
|
||||
const runRetentionNow = async (req, res) => {
|
||||
try {
|
||||
const result = await require('../utils/retention').runRetention(new Date());
|
||||
res.json({ result, state: await retentionState() });
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Breach register ──────────────────────────────────────────────────────────
|
||||
|
||||
const BREACH_TEXT = ['title', 'description', 'dataAffected', 'whoAffected', 'consequences', 'actionsTaken', 'adviceToPeople', 'causedBy'];
|
||||
const BREACH_DATES = ['discoveredAt', 'occurredAt', 'regulatorNotifiedAt', 'peopleNotifiedAt'];
|
||||
|
||||
function breachData(body, { partial = false } = {}) {
|
||||
const data = {};
|
||||
for (const f of BREACH_TEXT) {
|
||||
if (body?.[f] === undefined) continue;
|
||||
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'title' ? 200 : 10000);
|
||||
data[f] = v || (['title', 'description', 'dataAffected'].includes(f) ? '' : null);
|
||||
}
|
||||
for (const f of BREACH_DATES) {
|
||||
if (body?.[f] === undefined) continue;
|
||||
if (body[f] === null || body[f] === '') { data[f] = null; continue; }
|
||||
const d = new Date(body[f]);
|
||||
if (Number.isNaN(d.getTime())) throw new Error(`${f} is not a valid date.`);
|
||||
data[f] = d;
|
||||
}
|
||||
if (body?.peopleAffected !== undefined) {
|
||||
const n = body.peopleAffected === null || body.peopleAffected === '' ? null : parseInt(body.peopleAffected, 10);
|
||||
if (n !== null && (!Number.isInteger(n) || n < 0)) throw new Error('People affected must be a whole number.');
|
||||
data.peopleAffected = n;
|
||||
}
|
||||
if (body?.status !== undefined) {
|
||||
if (!['open', 'contained', 'closed'].includes(body.status)) throw new Error('status must be open, contained or closed');
|
||||
data.status = body.status;
|
||||
}
|
||||
for (const f of ['title', 'description', 'dataAffected']) {
|
||||
if ((!partial || f in data) && !data[f]) throw new Error({ title: 'Give it a short title.', description: 'Describe what happened.', dataAffected: 'Say what information was affected.' }[f]);
|
||||
}
|
||||
if (!partial && !data.discoveredAt) throw new Error('When was it discovered?');
|
||||
if (partial && 'discoveredAt' in data && !data.discoveredAt) throw new Error('When was it discovered?');
|
||||
return data;
|
||||
}
|
||||
|
||||
// @route GET /api/privacy/breaches
|
||||
const listBreaches = async (req, res) => {
|
||||
try {
|
||||
res.json(await prisma.securityBreach.findMany({ orderBy: { discoveredAt: 'desc' }, include: { createdBy: { select: { name: true } } } }));
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route POST /api/privacy/breaches
|
||||
const createBreach = async (req, res) => {
|
||||
try {
|
||||
const breach = await prisma.securityBreach.create({ data: { ...breachData(req.body), createdById: req.user.id } });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_recorded', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title }, ip: getClientIp(req) });
|
||||
res.status(201).json(breach);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
// @route PUT /api/privacy/breaches/:id
|
||||
const updateBreach = async (req, res) => {
|
||||
try {
|
||||
const existing = await prisma.securityBreach.findUnique({ where: { id: req.params.id } });
|
||||
if (!existing) { res.status(404); throw new Error('Not found'); }
|
||||
const data = breachData(req.body, { partial: true });
|
||||
const breach = await prisma.securityBreach.update({ where: { id: existing.id }, data });
|
||||
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_updated', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title, changed: Object.keys(data), status: breach.status }, ip: getClientIp(req) });
|
||||
res.json(breach);
|
||||
} catch (error) {
|
||||
fail(res, error);
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
runRetentionNow,
|
||||
listBreaches,
|
||||
createBreach,
|
||||
updateBreach,
|
||||
getNotice,
|
||||
acceptMyNotice,
|
||||
getNoticeDraft,
|
||||
|
||||
@@ -153,6 +153,8 @@ function errorStatus(error, res) {
|
||||
// required): notify, record the activity, and issue the session token.
|
||||
async function completeLogin(user, req, res, extra = {}) {
|
||||
const noticePromptPending = await require('../utils/privacyNotice').noticePromptPending(user).catch(() => false);
|
||||
// Retention: logging in keeps the account (and cancels an inactivity warning).
|
||||
prisma.user.update({ where: { id: user.id }, data: { lastLoginAt: new Date(), inactivityWarnedAt: null } }).catch(() => {});
|
||||
|
||||
// Send login notification in the background
|
||||
sendLoginNotification(user, req).catch(() => {});
|
||||
|
||||
@@ -230,6 +230,11 @@ async function handleIncomingMessage(req) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Proof for the POPIA readiness page that WAWP's webhook (and so STOP) is set up.
|
||||
const prisma = require('../config/db');
|
||||
const now = new Date().toISOString();
|
||||
prisma.appSetting.upsert({ where: { key: 'wawp_webhook_last_seen' }, update: { value: now }, create: { key: 'wawp_webhook_last_seen', value: now } }).catch(() => {});
|
||||
|
||||
const payload = req.body?.payload || {};
|
||||
if (payload.fromMe || !isStopKeyword(payload.body)) return;
|
||||
const phone = phoneFromChatId(payload.from);
|
||||
|
||||
+12
-1
@@ -932,7 +932,18 @@ app.get('/docs', async (req, res) => {
|
||||
responses:[{ status:201, desc:'Created' }]},
|
||||
{ method:'GET', path:'/api/privacy/consent-log', auth:'admin', desc:'Consent records: ?type=news|notice|parental, ?q= name/email, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', name:'Jane Doe', email:'jane@example.com', channel:'email', granted:false, source:'unsubscribe_link' }] }}]},
|
||||
{ method:'GET', path:'/api/privacy/access-log', auth:'admin', desc:'Bulk exports/views of personal information: ?action=, ?actorId=, ?from=, ?to=, ?page=',
|
||||
{ method:'GET', path:'/api/privacy/readiness', auth:'admin', desc:'POPIA readiness checklist with a score; each open item has a link to fix it',
|
||||
responses:[{ status:200, desc:'Success', body:{ score:77, passed:10, total:13, checks:[{ key:'notice', label:'Privacy notice published', ok:true, detail:'2 versions published', href:'/dashboard/admin/privacy?tab=notice', applies:true }] }}]},
|
||||
{ method:'GET', path:'/api/privacy/retention', auth:'admin', desc:'Retention policies, what the next run would affect, and the last run',
|
||||
responses:[{ status:200, desc:'Success', body:{ policies:{ inactive_accounts:{ enabled:true, months:60 } }, preview:{ inactive_accounts:{ toWarn:3, toAnonymise:0 } }, reviewedAt:null }}]},
|
||||
{ method:'PUT', path:'/api/privacy/retention', auth:'admin', desc:'Save the retention policies (all five)',
|
||||
request:{ body:{ policies:{ inactive_accounts:{ enabled:true, months:60 }, form_answers:{ enabled:true, months:12 }, login_history:{ enabled:true, months:24 }, payment_details:{ enabled:true, months:24 }, audit_logs:{ enabled:true, months:60 } } } },
|
||||
responses:[{ status:200, desc:'Saved' }]},
|
||||
{ method:'POST', path:'/api/privacy/retention/run', auth:'admin', desc:'Run the retention job now (warn/anonymise inactive accounts, delete expired data)',
|
||||
responses:[{ status:200, desc:'Done', body:{ result:{ inactive_accounts:{ warned:3, anonymised:0, waiting:0 }, login_history:{ deleted:120 } } }}]},
|
||||
{ method:'GET', path:'/api/privacy/breaches', auth:'admin', desc:'The breach register (security compromises); POST to record one, PUT /:id to update or mark the Regulator/people told',
|
||||
responses:[{ status:200, desc:'Success', body:[{ id:'br-uuid-...', title:'Phone with the attendee list lost', status:'open', discoveredAt:'2026-10-23T08:00:00.000Z', regulatorNotifiedAt:null }] }]},
|
||||
{ method:'GET', path:'/api/privacy/access-log', auth:'admin', desc:'Bulk exports/views of personal information: ?action=, ?actorId=, ?sensitive=true, ?from=, ?to=, ?page=',
|
||||
responses:[{ status:200, desc:'Success', body:{ total:1, pageSize:50, rows:[{ at:'2026-10-23T08:00:00.000Z', actor:'Sam Supervisor', action:'report_pdf', what:'Camp 2026 attendees', records:48 }] }}]},
|
||||
]},
|
||||
|
||||
|
||||
@@ -56,6 +56,10 @@ const JOBS = [
|
||||
name: 'privacy', label: 'Privacy requests: remind admins of requests due soon', cron: '0 8 * * *', env: 'PRIVACY_JOBS_ENABLED',
|
||||
run: () => require('../utils/privacy').remindDueRequests(new Date()),
|
||||
},
|
||||
{
|
||||
name: 'retention', label: 'Retention: delete or anonymise personal information past its retention period', cron: '30 3 * * *', env: 'RETENTION_JOBS_ENABLED',
|
||||
run: () => require('../utils/retention').runRetention(new Date()),
|
||||
},
|
||||
{
|
||||
name: 'analytics-rollup', label: 'Funnel analytics: roll up visits older than 90 days', cron: '15 4 * * *', env: 'ANALYTICS_ROLLUP_ENABLED',
|
||||
run: async () => ({ rolledUp: await require('../utils/analytics').rollupOldEvents() }),
|
||||
|
||||
@@ -27,6 +27,13 @@ const {
|
||||
deleteOperator,
|
||||
consentLog,
|
||||
accessLog,
|
||||
getReadiness,
|
||||
getRetention,
|
||||
saveRetention,
|
||||
runRetentionNow,
|
||||
listBreaches,
|
||||
createBreach,
|
||||
updateBreach,
|
||||
} = require('../controllers/privacyAdminController');
|
||||
const { protect, admin } = require('../middleware/authMiddleware');
|
||||
|
||||
@@ -66,4 +73,13 @@ router.delete('/operators/:id', protect, admin, deleteOperator);
|
||||
router.get('/consent-log', protect, admin, consentLog);
|
||||
router.get('/access-log', protect, admin, accessLog);
|
||||
|
||||
// Admin: readiness, retention, breach register
|
||||
router.get('/readiness', protect, admin, getReadiness);
|
||||
router.get('/retention', protect, admin, getRetention);
|
||||
router.put('/retention', protect, admin, saveRetention);
|
||||
router.post('/retention/run', protect, admin, runRetentionNow);
|
||||
router.get('/breaches', protect, admin, listBreaches);
|
||||
router.post('/breaches', protect, admin, createBreach);
|
||||
router.put('/breaches/:id', protect, admin, updateBreach);
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -21,8 +21,15 @@ const ACCESS_ACTIONS = {
|
||||
* POPIA access log: someone exported or viewed personal information in bulk. Fire-and-
|
||||
* forget like logAdminAction — a logging failure never blocks the export itself.
|
||||
*/
|
||||
async function logDataAccess(req, { action, what, eventId = null, records = null }) {
|
||||
async function logDataAccess(req, { action, what, eventId = null, records = null, sensitive }) {
|
||||
try {
|
||||
// Children's or special information: medical lists always; otherwise anything about a
|
||||
// youth event (one that asks for parental consent).
|
||||
let isSensitive = sensitive ?? (action === 'medical_list');
|
||||
if (!isSensitive && eventId) {
|
||||
const event = await prisma.event.findUnique({ where: { id: eventId }, select: { requiresParentalConsent: true } });
|
||||
isSensitive = !!event?.requiresParentalConsent;
|
||||
}
|
||||
await prisma.dataAccessLog.create({
|
||||
data: {
|
||||
actorId: req.user?.id || null,
|
||||
@@ -31,6 +38,7 @@ async function logDataAccess(req, { action, what, eventId = null, records = null
|
||||
what: String(what || ACCESS_ACTIONS[action] || action).slice(0, 500),
|
||||
eventId: eventId || null,
|
||||
records: Number.isFinite(records) ? records : null,
|
||||
sensitive: isSensitive,
|
||||
ip: getClientIp(req),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -7,11 +7,12 @@
|
||||
* notice is shown, so it follows Site settings and the operator register:
|
||||
* {org} {orgEmail} {website} {ioName} {ioEmail} {medicalRetentionDays}
|
||||
* {operators} — on a line of its own: one bullet per active operator.
|
||||
* {retention} — on a line of its own: one bullet per retention period (utils/retention.js).
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const PLACEHOLDERS = ['org', 'orgEmail', 'website', 'ioName', 'ioEmail', 'medicalRetentionDays', 'operators'];
|
||||
const PLACEHOLDERS = ['org', 'orgEmail', 'website', 'ioName', 'ioEmail', 'medicalRetentionDays', 'operators', 'retention'];
|
||||
const MAX_SECTIONS = 40;
|
||||
const MAX_TITLE = 200;
|
||||
const MAX_BODY = 20000;
|
||||
@@ -120,13 +121,10 @@ const DEFAULT_SECTIONS = [
|
||||
id: 'retention',
|
||||
title: '7. Data Retention',
|
||||
body: [
|
||||
'We retain your personal data for up to 5 years after your last event registration, or until you ask us to delete your account — whichever comes first.',
|
||||
'Medical and emergency-contact details from parental consent forms are deleted {medicalRetentionDays} days after the event ends. The record of who gave consent, when, and to which wording is kept.',
|
||||
'Detailed visit statistics are combined into anonymous daily totals after 90 days.',
|
||||
"Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'Records of support access to accounts (see section 9), of who exported personal information, and other audit records are kept for accountability.',
|
||||
'You can ask for your account to be deleted from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
'Backups of our database still contain deleted information until they are replaced, which happens automatically within a few weeks.',
|
||||
'We keep personal information only as long as we need it:',
|
||||
'{retention}',
|
||||
"• Ticket lists downloaded to a staff member's scanner for offline check-in are deleted from that device when offline mode is ended.",
|
||||
'You can ask for your account to be deleted at any time from the Privacy page in your account. Your account is closed straight away, and your personal information — your name and contact details, the people you registered, your form answers — is deleted within 30 days (after any upcoming event you registered for). Payment and registration records are kept without your name, because the law requires us to keep financial records.',
|
||||
].join('\n'),
|
||||
},
|
||||
{
|
||||
@@ -263,7 +261,7 @@ function cleanSections(input) {
|
||||
/** Settings and operators the placeholders are filled from. */
|
||||
async function noticeContext(db = prisma) {
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const [org, orgEmail, website, ioName, ioEmail, medicalDays, operators] = await Promise.all([
|
||||
const [org, orgEmail, website, ioName, ioEmail, medicalDays, operators, retention] = await Promise.all([
|
||||
getSetting('org_name', process.env.ORG_NAME || 'Cross Code'),
|
||||
getSetting('org_email', ''),
|
||||
getSetting('legal_website_url', ''),
|
||||
@@ -271,6 +269,7 @@ async function noticeContext(db = prisma) {
|
||||
getSetting('legal_io_email', ''),
|
||||
getSetting('consent_medical_retention_days', ''),
|
||||
db.operator.findMany({ where: { active: true }, orderBy: [{ order: 'asc' }, { name: 'asc' }] }),
|
||||
require('./retention').noticeLines(db),
|
||||
]);
|
||||
// Unset legal fields show an obviously-unfinished placeholder, never someone's real details.
|
||||
return {
|
||||
@@ -281,9 +280,16 @@ async function noticeContext(db = prisma) {
|
||||
ioEmail: ioEmail || 'io@example.com',
|
||||
medicalRetentionDays: String(parseInt(medicalDays, 10) || 30),
|
||||
operators,
|
||||
retention,
|
||||
};
|
||||
}
|
||||
|
||||
// Placeholders that stand on a line of their own and become several bullet lines.
|
||||
const LIST_PLACEHOLDERS = {
|
||||
operators: ctx => (ctx.operators.length ? ctx.operators.map(operatorLine) : ['• (No service providers listed.)']),
|
||||
retention: ctx => ctx.retention || [],
|
||||
};
|
||||
|
||||
function operatorLine(o) {
|
||||
return `• ${o.name}${o.location ? ` (${o.location})` : ''} — ${o.purpose}. ${o.dataShared ? `Receives: ${o.dataShared}.` : ''}`.trim();
|
||||
}
|
||||
@@ -293,8 +299,11 @@ function renderSections(sections, ctx) {
|
||||
return sections.map(s => {
|
||||
const body = String(s.body || '')
|
||||
.split('\n')
|
||||
.flatMap(line => (line.trim() === '{operators}' ? (ctx.operators.length ? ctx.operators.map(operatorLine) : ['• (No service providers listed.)']) : [line]))
|
||||
.map(line => line.replace(/\{(\w+)\}/g, (m, key) => (key !== 'operators' && PLACEHOLDERS.includes(key) ? ctx[key] : m)))
|
||||
.flatMap(line => {
|
||||
const list = line.trim().match(/^\{(\w+)\}$/)?.[1];
|
||||
return list && LIST_PLACEHOLDERS[list] ? LIST_PLACEHOLDERS[list](ctx) : [line];
|
||||
})
|
||||
.map(line => line.replace(/\{(\w+)\}/g, (m, key) => (!LIST_PLACEHOLDERS[key] && PLACEHOLDERS.includes(key) ? ctx[key] : m)))
|
||||
.join('\n');
|
||||
return { id: s.id, title: s.title, body };
|
||||
});
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* The POPIA readiness checklist for Admin → Privacy: what's set up, what isn't, and where to
|
||||
* fix it. Checks that don't apply to this install (e.g. WhatsApp STOP without WhatsApp) are
|
||||
* left out of the score.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
// Generic names the operator register starts with, which should be replaced with the real provider.
|
||||
const GENERIC_OPERATORS = ['Email delivery provider', 'Hosting provider'];
|
||||
|
||||
async function readiness(now = new Date(), db = prisma) {
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const setting = key => getSetting(key, '');
|
||||
const [ioName, ioEmail, operatorName, website, smtpHost] = await Promise.all([
|
||||
setting('legal_io_name'), setting('legal_io_email'), setting('legal_operator_name'), setting('legal_website_url'), setting('smtp_host'),
|
||||
]);
|
||||
|
||||
const retention = require('./retention');
|
||||
const { isEnabled, JOBS } = require('../jobs');
|
||||
const [notices, operators, policies, lastRun, overdue, openBreaches, webhookSeen, whatsappConfigured, backups] = await Promise.all([
|
||||
db.privacyNotice.count(),
|
||||
db.operator.findMany({ where: { active: true }, select: { name: true, location: true } }),
|
||||
retention.getPolicies(db),
|
||||
retention.lastRun(db),
|
||||
db.dataRequest.count({ where: { status: 'open', selfService: false, dueAt: { lt: now } } }),
|
||||
db.securityBreach.findMany({ where: { status: { not: 'closed' } }, select: { title: true, discoveredAt: true, regulatorNotifiedAt: true } }),
|
||||
db.appSetting.findUnique({ where: { key: 'wawp_webhook_last_seen' } }),
|
||||
require('./whatsapp').isConfigured().catch(() => false),
|
||||
require('./backupUtils').listBackups().catch(() => []),
|
||||
]);
|
||||
|
||||
const checks = [];
|
||||
const add = (key, label, ok, detail, href, applies = true) => checks.push({ key, label, ok: !!ok, detail, href, applies });
|
||||
|
||||
add('io', 'Information Officer named', ioName && ioEmail,
|
||||
ioName && ioEmail ? `${ioName} (${ioEmail})` : 'Set the Information Officer\'s name and email address — they appear on the privacy notice and contact page.',
|
||||
'/dashboard/admin/settings?tab=legal');
|
||||
add('legal', 'Responsible party and website filled in', operatorName && website,
|
||||
operatorName && website ? `${operatorName} · ${website}` : 'Fill in the operator/responsible party and website for the legal pages.',
|
||||
'/dashboard/admin/settings?tab=legal');
|
||||
add('notice', 'Privacy notice published', notices > 0,
|
||||
notices > 0 ? `${notices} version${notices === 1 ? '' : 's'} published` : 'Review the privacy notice and publish your own version (the built-in text is a starting point).',
|
||||
'/dashboard/admin/privacy?tab=notice');
|
||||
|
||||
const vague = operators.filter(o => !o.location || GENERIC_OPERATORS.includes(o.name)).map(o => o.name);
|
||||
add('operators', 'Operators listed with their details', operators.length && !vague.length,
|
||||
!operators.length ? 'List the services that process personal information for you.' : vague.length ? `Check: ${vague.join(', ')} (name the real provider and where it processes data).` : `${operators.length} operator${operators.length === 1 ? '' : 's'} listed`,
|
||||
'/dashboard/admin/privacy?tab=operators');
|
||||
|
||||
add('retention_reviewed', 'Retention periods reviewed', policies.reviewedAt,
|
||||
policies.reviewedAt ? `Saved ${new Date(policies.reviewedAt).toLocaleDateString('en-ZA')}` : 'Check the retention periods suit your organisation, and save them.',
|
||||
'/dashboard/admin/privacy?tab=retention');
|
||||
const retentionJob = JOBS.find(j => j.name === 'retention');
|
||||
const ranRecently = lastRun?.at && now - new Date(lastRun.at) < 2 * DAY;
|
||||
add('retention_running', 'Retention job running', isEnabled(retentionJob) && ranRecently,
|
||||
!isEnabled(retentionJob) ? 'The retention job is switched off (RETENTION_JOBS_ENABLED or JOBS_ENABLED).' : ranRecently ? `Last ran ${new Date(lastRun.at).toLocaleString('en-ZA')}` : 'It hasn\'t run in the last two days — check Background jobs.',
|
||||
'/dashboard/admin/settings?tab=jobs');
|
||||
|
||||
add('requests', 'Privacy requests answered on time', overdue === 0,
|
||||
overdue ? `${overdue} request${overdue === 1 ? ' is' : 's are'} overdue.` : 'None overdue',
|
||||
'/dashboard/admin/data-requests');
|
||||
const unreported = openBreaches.filter(b => !b.regulatorNotifiedAt && now - new Date(b.discoveredAt) > 3 * DAY);
|
||||
add('breaches', 'Security compromises reported', unreported.length === 0,
|
||||
unreported.length ? `Not yet reported to the Information Regulator: ${unreported.map(b => b.title).join(', ')}.` : openBreaches.length ? `${openBreaches.length} open, all reported` : 'Nothing open',
|
||||
'/dashboard/admin/privacy?tab=breaches');
|
||||
|
||||
const backendUrl = process.env.BACKEND_URL || '';
|
||||
// The .env.example value ("https://your-backend-domain.com") doesn't count.
|
||||
const realHttps = /^https:\/\//i.test(backendUrl) && !/your-|example\./i.test(backendUrl);
|
||||
add('https', 'Unsubscribe links work in mail apps', realHttps,
|
||||
realHttps ? backendUrl : 'Set BACKEND_URL on the server to the site\'s public https:// address (used by one-click unsubscribe and the WhatsApp webhook).',
|
||||
null);
|
||||
add('smtp', 'Email sending set up', smtpHost || process.env.SMTP_HOST || process.env.EMAIL_HOST,
|
||||
smtpHost || process.env.SMTP_HOST || process.env.EMAIL_HOST ? 'SMTP configured' : 'Without email, people don\'t get unsubscribe links, request confirmations or inactivity warnings.',
|
||||
'/dashboard/admin/settings?tab=email');
|
||||
const seen = webhookSeen?.value ? new Date(webhookSeen.value) : null;
|
||||
add('whatsapp_stop', 'WhatsApp STOP replies reach the site', seen && now - seen < 30 * DAY,
|
||||
seen ? `Last message from WAWP ${seen.toLocaleString('en-ZA')}${now - seen >= 30 * DAY ? ' — over a month ago, check the webhook' : ''}` : 'Paste the webhook URL from Site settings → WhatsApp into WAWP (events: message, session.status).',
|
||||
'/dashboard/admin/settings?tab=whatsapp', whatsappConfigured);
|
||||
const twoFactorOn = String(process.env.TWO_FACTOR_REQUIRED_ROLES || 'true').toLowerCase() !== 'false';
|
||||
add('two_factor', 'Two-factor login required for admins and supervisors', twoFactorOn,
|
||||
twoFactorOn ? 'Required' : 'TWO_FACTOR_REQUIRED_ROLES=false on the server — only for emergencies.', null);
|
||||
const lastBackup = backups[0]?.createdAt;
|
||||
add('backups', 'Backups running', lastBackup && now - new Date(lastBackup) < 2 * DAY,
|
||||
lastBackup ? `Last backup ${new Date(lastBackup).toLocaleString('en-ZA')}` : 'No backups found on this server.',
|
||||
'/dashboard/admin/settings?tab=backups');
|
||||
|
||||
const applicable = checks.filter(c => c.applies);
|
||||
const passed = applicable.filter(c => c.ok).length;
|
||||
return { score: applicable.length ? Math.round((passed / applicable.length) * 100) : 0, passed, total: applicable.length, checks };
|
||||
}
|
||||
|
||||
module.exports = { readiness, GENERIC_OPERATORS };
|
||||
@@ -0,0 +1,319 @@
|
||||
/**
|
||||
* POPIA retention: how long each kind of personal information is kept, and the nightly
|
||||
* `retention` job that deletes or anonymises what's past its time.
|
||||
*
|
||||
* Policies live in AppSetting `retention_policies` (JSON) on top of the defaults below.
|
||||
* Medical details (consent_medical_retention_days), visit statistics (90 days, fixed) and
|
||||
* backups (backup_retain_count) have their own settings and jobs; they're shown alongside.
|
||||
*/
|
||||
|
||||
const prisma = require('../config/db');
|
||||
|
||||
const SETTING_KEY = 'retention_policies';
|
||||
const LAST_RUN_KEY = 'retention_last_run';
|
||||
const WARNING_DAYS = 30;
|
||||
const DAY = 24 * 60 * 60 * 1000;
|
||||
// Spread a first big run over several nights instead of emailing everyone at once.
|
||||
const MAX_WARNINGS_PER_RUN = 300;
|
||||
const MAX_ANONYMISED_PER_RUN = 200;
|
||||
const BATCH = 500;
|
||||
|
||||
const POLICIES = {
|
||||
inactive_accounts: {
|
||||
label: 'Inactive accounts',
|
||||
description: 'Accounts with no login, registration or payment in this time are anonymised, after a warning 30 days before. Staff, supervisor and admin accounts are never touched, nor anyone with a registration for an upcoming event.',
|
||||
defaults: { enabled: true, months: 60 },
|
||||
minMonths: 12,
|
||||
},
|
||||
form_answers: {
|
||||
label: 'Attendee form answers',
|
||||
description: 'Answers to event forms are deleted this long after the event ends.',
|
||||
defaults: { enabled: true, months: 12 },
|
||||
minMonths: 1,
|
||||
},
|
||||
login_history: {
|
||||
label: 'Login history',
|
||||
description: 'Logins, password changes and similar account events (with IP address and device).',
|
||||
defaults: { enabled: true, months: 24 },
|
||||
minMonths: 3,
|
||||
},
|
||||
payment_details: {
|
||||
label: 'Payer details in payment notifications',
|
||||
description: 'Names and email addresses that Yoco and PayFast send with a payment are removed. The payment itself (amount, date, method) is always kept for the accounts.',
|
||||
defaults: { enabled: true, months: 24 },
|
||||
minMonths: 3,
|
||||
},
|
||||
audit_logs: {
|
||||
label: 'Audit and access logs',
|
||||
description: 'The audit log, the access log and "view as" sessions.',
|
||||
defaults: { enabled: true, months: 60 },
|
||||
minMonths: 12,
|
||||
},
|
||||
};
|
||||
|
||||
const MAX_MONTHS = 240;
|
||||
|
||||
function monthsAgo(months, now = new Date()) {
|
||||
const d = new Date(now);
|
||||
d.setMonth(d.getMonth() - months);
|
||||
return d;
|
||||
}
|
||||
|
||||
function describeMonths(months) {
|
||||
return months % 12 === 0 ? `${months / 12} year${months === 12 ? '' : 's'}` : `${months} month${months === 1 ? '' : 's'}`;
|
||||
}
|
||||
|
||||
/** The policies in force (defaults overlaid with what the admin saved), and when they were last reviewed. */
|
||||
async function getPolicies(db = prisma) {
|
||||
const row = await db.appSetting.findUnique({ where: { key: SETTING_KEY } });
|
||||
let saved = {};
|
||||
try { saved = row?.value ? JSON.parse(row.value) : {}; } catch { saved = {}; }
|
||||
const policies = {};
|
||||
for (const [key, def] of Object.entries(POLICIES)) {
|
||||
const s = saved.policies?.[key] || {};
|
||||
policies[key] = {
|
||||
enabled: typeof s.enabled === 'boolean' ? s.enabled : def.defaults.enabled,
|
||||
months: Number.isInteger(s.months) ? s.months : def.defaults.months,
|
||||
};
|
||||
}
|
||||
return { policies, reviewedAt: saved.reviewedAt || null, reviewedBy: saved.reviewedBy || null };
|
||||
}
|
||||
|
||||
/** Save the admin's policies (validated). Throws a message fit for the admin. */
|
||||
async function savePolicies(input, { reviewedBy } = {}, db = prisma) {
|
||||
const policies = {};
|
||||
for (const [key, def] of Object.entries(POLICIES)) {
|
||||
const p = input?.[key] || {};
|
||||
const months = parseInt(p.months, 10);
|
||||
if (!Number.isInteger(months) || months < def.minMonths || months > MAX_MONTHS) {
|
||||
throw new Error(`${def.label}: choose between ${def.minMonths} and ${MAX_MONTHS} months.`);
|
||||
}
|
||||
policies[key] = { enabled: p.enabled !== false, months };
|
||||
}
|
||||
const value = JSON.stringify({ policies, reviewedAt: new Date().toISOString(), reviewedBy: reviewedBy || null });
|
||||
await db.appSetting.upsert({ where: { key: SETTING_KEY }, update: { value }, create: { key: SETTING_KEY, value } });
|
||||
return getPolicies(db);
|
||||
}
|
||||
|
||||
// ─── Inactive accounts ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Ordinary accounts (role user, not already anonymised) whose last sign of life — account
|
||||
* created, last login, a registration (or the end of the event it was for), being named as
|
||||
* an attendee, a payment — is before `before`.
|
||||
* warned: false — not warned since that last activity (a warning before it no longer counts);
|
||||
* otherwise — warned since their last activity, on or before `warnedBefore`.
|
||||
*/
|
||||
async function inactiveAccounts(before, { warned, warnedBefore, limit }, db = prisma) {
|
||||
const rows = await db.$queryRawUnsafe(`
|
||||
SELECT "id" FROM (
|
||||
SELECT u."id", u."createdAt", u."inactivityWarnedAt" AS warned_at, GREATEST(
|
||||
u."createdAt",
|
||||
COALESCE(u."lastLoginAt", 'epoch'),
|
||||
COALESCE((SELECT MAX(GREATEST(r."createdAt", e."endDate")) FROM "Registration" r JOIN "Event" e ON e."id" = r."eventId" WHERE r."userId" = u."id"), 'epoch'),
|
||||
COALESCE((SELECT MAX(a."createdAt") FROM "Attendee" a WHERE a."userId" = u."id"), 'epoch'),
|
||||
COALESCE((SELECT MAX(p."createdAt") FROM "Payment" p WHERE p."userId" = u."id"), 'epoch')
|
||||
) AS last_active
|
||||
FROM "User" u
|
||||
WHERE u."role" = 'user'
|
||||
AND u."email" NOT LIKE '%@deleted.invalid' AND u."email" NOT LIKE '%@deleted.local'
|
||||
) x
|
||||
WHERE last_active < $1
|
||||
AND ${warned === false ? '(warned_at IS NULL OR warned_at < last_active)' : 'warned_at >= last_active AND warned_at <= $2'}
|
||||
ORDER BY "createdAt"
|
||||
LIMIT ${Math.max(1, Math.min(limit || BATCH, 5000))}`,
|
||||
before, ...(warned === false ? [] : [warnedBefore]));
|
||||
return rows.map(r => r.id);
|
||||
}
|
||||
|
||||
async function warnInactive(userId, deleteOn, months, db = prisma) {
|
||||
const user = await db.user.findUnique({ where: { id: userId }, select: { id: true, name: true, email: true, phoneNumber: true } });
|
||||
if (!user) return false;
|
||||
const { getSettingSync } = require('./settingsCache');
|
||||
const org = getSettingSync('org_name', process.env.ORG_NAME || 'Cross Code');
|
||||
const url = String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
|
||||
const when = deleteOn.toLocaleDateString('en-ZA', { day: 'numeric', month: 'long', year: 'numeric', timeZone: 'Africa/Johannesburg' });
|
||||
const text = `Your ${org} account hasn't been used for ${describeMonths(months)}. To protect your personal information, we'll delete it on ${when}. If you'd like to keep your account, just log in before then: ${url}/login`;
|
||||
|
||||
const realEmail = user.email && !/@(guest\.local|deleted\.invalid|deleted\.local)$/.test(user.email);
|
||||
if (realEmail) {
|
||||
const { sendMail, buildPrivacyEmail } = require('./email');
|
||||
await sendMail({
|
||||
to: user.email,
|
||||
subject: `Your ${org} account will be deleted on ${when}`,
|
||||
...buildPrivacyEmail({ name: user.name, heading: 'Your account will be deleted', paragraphs: [text, 'If you do nothing, your account is closed and your personal information deleted. Payment records are kept without your name, as the law requires.'] }),
|
||||
}).catch(() => {});
|
||||
} else {
|
||||
const { waTextAny } = require('./notify');
|
||||
await waTextAny(user, text);
|
||||
}
|
||||
await db.user.update({ where: { id: userId }, data: { inactivityWarnedAt: new Date() } });
|
||||
return true;
|
||||
}
|
||||
|
||||
async function runInactiveAccounts(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
const deleteOn = new Date(now.getTime() + WARNING_DAYS * DAY);
|
||||
const { anonymiseUser, deletionBlockers } = require('./privacy');
|
||||
|
||||
// Warn those who'll pass the limit within the warning period.
|
||||
const toWarn = await inactiveAccounts(new Date(cutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: MAX_WARNINGS_PER_RUN }, db);
|
||||
let warned = 0;
|
||||
for (const id of toWarn) if (await warnInactive(id, deleteOn, policy.months, db)) warned++;
|
||||
|
||||
// Anonymise those past the limit who were warned at least 30 days ago and still haven't come back.
|
||||
const due = await inactiveAccounts(cutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: MAX_ANONYMISED_PER_RUN }, db);
|
||||
let anonymised = 0;
|
||||
let waiting = 0;
|
||||
for (const id of due) {
|
||||
if ((await deletionBlockers(id, db)).length) { waiting++; continue; }
|
||||
await anonymiseUser(id, db);
|
||||
await db.dataRequest.updateMany({ where: { userId: id, type: 'deletion', status: 'open' }, data: { status: 'completed', closedAt: now, response: 'Deleted after a long period without use.' } });
|
||||
anonymised++;
|
||||
}
|
||||
return { warned, anonymised, waiting };
|
||||
}
|
||||
|
||||
// ─── The other policies ───────────────────────────────────────────────────────
|
||||
|
||||
async function runFormAnswers(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
const where = { response: { registration: { event: { endDate: { lt: cutoff } } } } };
|
||||
const answers = await db.formAnswer.deleteMany({ where });
|
||||
const drafts = await db.formDraft.deleteMany({ where: { registration: { event: { endDate: { lt: cutoff } } } } });
|
||||
return { answers: answers.count, drafts: drafts.count };
|
||||
}
|
||||
|
||||
async function runLoginHistory(policy, now, db = prisma) {
|
||||
const res = await db.securityEvent.deleteMany({ where: { createdAt: { lt: monthsAgo(policy.months, now) } } });
|
||||
return { deleted: res.count };
|
||||
}
|
||||
|
||||
// Rows not scrubbed yet carry no "_scrubbed" key; scrubbed ones get it so they're skipped next time.
|
||||
async function scrubJsonColumn(table, column, cutoff, db) {
|
||||
const { scrubPersonal } = require('./privacy');
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const rows = await db.$queryRawUnsafe(
|
||||
`SELECT "id", "${column}" AS data FROM "${table}" WHERE "createdAt" < $1 AND "${column}" IS NOT NULL AND jsonb_typeof("${column}") = 'object' AND NOT ("${column}" ? '_scrubbed') LIMIT ${BATCH}`,
|
||||
cutoff,
|
||||
);
|
||||
if (!rows.length) break;
|
||||
for (const r of rows) {
|
||||
const model = table === 'ProviderTransaction' ? db.providerTransaction : db.providerCheckout;
|
||||
await model.update({ where: { id: r.id }, data: { [column]: { ...scrubPersonal(r.data), _scrubbed: true } } });
|
||||
}
|
||||
total += rows.length;
|
||||
if (rows.length < BATCH) break;
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function runPaymentDetails(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
return {
|
||||
notifications: await scrubJsonColumn('ProviderTransaction', 'raw', cutoff, db),
|
||||
checkouts: await scrubJsonColumn('ProviderCheckout', 'request', cutoff, db),
|
||||
};
|
||||
}
|
||||
|
||||
async function runAuditLogs(policy, now, db = prisma) {
|
||||
const cutoff = monthsAgo(policy.months, now);
|
||||
const [audit, access, sessions] = await Promise.all([
|
||||
db.adminAuditLog.deleteMany({ where: { createdAt: { lt: cutoff } } }),
|
||||
db.dataAccessLog.deleteMany({ where: { createdAt: { lt: cutoff } } }),
|
||||
db.impersonationSession.deleteMany({ where: { startedAt: { lt: cutoff } } }),
|
||||
]);
|
||||
return { audit: audit.count, access: access.count, viewAsSessions: sessions.count };
|
||||
}
|
||||
|
||||
const RUNNERS = {
|
||||
inactive_accounts: runInactiveAccounts,
|
||||
form_answers: runFormAnswers,
|
||||
login_history: runLoginHistory,
|
||||
payment_details: runPaymentDetails,
|
||||
audit_logs: runAuditLogs,
|
||||
};
|
||||
|
||||
/** The nightly job: apply every switched-on policy. */
|
||||
async function runRetention(now = new Date(), db = prisma) {
|
||||
const { policies } = await getPolicies(db);
|
||||
const result = {};
|
||||
for (const [key, policy] of Object.entries(policies)) {
|
||||
if (!policy.enabled) continue;
|
||||
try {
|
||||
result[key] = await RUNNERS[key](policy, now, db);
|
||||
} catch (e) {
|
||||
result[key] = { error: e?.message || String(e) };
|
||||
console.warn(`[retention] ${key} failed:`, e?.message || e);
|
||||
}
|
||||
}
|
||||
const value = JSON.stringify({ at: now.toISOString(), result });
|
||||
await db.appSetting.upsert({ where: { key: LAST_RUN_KEY }, update: { value }, create: { key: LAST_RUN_KEY, value } });
|
||||
const { isNoop } = require('../jobs');
|
||||
if (!isNoop(result)) {
|
||||
require('./adminAudit').logAdminAction({ actorId: null, actorRole: 'system', action: 'retention_run', targetType: 'retention', metadata: result });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function lastRun(db = prisma) {
|
||||
const row = await db.appSetting.findUnique({ where: { key: LAST_RUN_KEY } });
|
||||
try { return row?.value ? JSON.parse(row.value) : null; } catch { return null; }
|
||||
}
|
||||
|
||||
/** How much each policy would affect if the job ran now — for the admin page. */
|
||||
async function preview(policies, now = new Date(), db = prisma) {
|
||||
const out = {};
|
||||
const p = policies;
|
||||
const inactiveCutoff = monthsAgo(p.inactive_accounts.months, now);
|
||||
const [toWarn, warnedDue, formAnswers, logins, audit] = await Promise.all([
|
||||
inactiveAccounts(new Date(inactiveCutoff.getTime() + WARNING_DAYS * DAY), { warned: false, limit: 5000 }, db),
|
||||
inactiveAccounts(inactiveCutoff, { warnedBefore: new Date(now.getTime() - WARNING_DAYS * DAY), limit: 5000 }, db),
|
||||
db.formAnswer.count({ where: { response: { registration: { event: { endDate: { lt: monthsAgo(p.form_answers.months, now) } } } } } }),
|
||||
db.securityEvent.count({ where: { createdAt: { lt: monthsAgo(p.login_history.months, now) } } }),
|
||||
db.adminAuditLog.count({ where: { createdAt: { lt: monthsAgo(p.audit_logs.months, now) } } }),
|
||||
]);
|
||||
const payments = await db.$queryRawUnsafe(
|
||||
`SELECT COUNT(*)::int AS n FROM "ProviderTransaction" WHERE "createdAt" < $1 AND "raw" IS NOT NULL AND jsonb_typeof("raw") = 'object' AND NOT ("raw" ? '_scrubbed')`,
|
||||
monthsAgo(p.payment_details.months, now),
|
||||
);
|
||||
out.inactive_accounts = { toWarn: toWarn.length, toAnonymise: warnedDue.length };
|
||||
out.form_answers = { count: formAnswers };
|
||||
out.login_history = { count: logins };
|
||||
out.payment_details = { count: payments[0]?.n || 0 };
|
||||
out.audit_logs = { count: audit };
|
||||
return out;
|
||||
}
|
||||
|
||||
/** One line per policy for the privacy notice's {retention} placeholder. */
|
||||
async function noticeLines(db = prisma) {
|
||||
const { policies } = await getPolicies(db);
|
||||
const { getSetting } = require('./settingsCache');
|
||||
const [medical, backups] = await Promise.all([getSetting('consent_medical_retention_days', ''), getSetting('backup_retain_count', '')]);
|
||||
const line = (key, on, off) => (policies[key].enabled ? on(describeMonths(policies[key].months)) : off);
|
||||
return [
|
||||
line('inactive_accounts', t => `• Accounts with no login, registration or payment for ${t} are deleted, after a warning 30 days before.`, '• Accounts are kept until you ask us to delete them.'),
|
||||
line('form_answers', t => `• Answers to event forms are deleted ${t} after the event.`, '• Answers to event forms are kept until you ask us to delete them.'),
|
||||
`• Medical and emergency-contact details from parental consent forms are deleted ${parseInt(medical, 10) || 30} days after the event. The record of who gave consent, when and to which wording is kept.`,
|
||||
line('login_history', t => `• Login history (time, IP address, device) is kept for ${t}.`, '• Login history is kept until you ask us to delete it.'),
|
||||
line('payment_details', t => `• Payer names and email addresses received with payments are removed after ${t}. Payment records themselves (amount, date, method) are kept as tax law requires.`, '• Payment records are kept as tax law requires.'),
|
||||
line('audit_logs', t => `• Audit and access records (who did what in the system) are kept for ${t}.`, '• Audit and access records are kept for accountability.'),
|
||||
'• Detailed visit statistics become anonymous daily totals after 90 days.',
|
||||
`• Database backups are kept for about ${parseInt(backups, 10) || 14} days, so deleted information stays in them until they are replaced.`,
|
||||
];
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
POLICIES,
|
||||
WARNING_DAYS,
|
||||
getPolicies,
|
||||
savePolicies,
|
||||
inactiveAccounts,
|
||||
runRetention,
|
||||
lastRun,
|
||||
preview,
|
||||
noticeLines,
|
||||
describeMonths,
|
||||
monthsAgo,
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
jest.mock('../src/config/db', () => ({
|
||||
appSetting: { findUnique: jest.fn(), upsert: jest.fn(), findMany: jest.fn().mockResolvedValue([]) },
|
||||
$queryRawUnsafe: jest.fn(),
|
||||
}));
|
||||
|
||||
const prisma = require('../src/config/db');
|
||||
const retention = require('../src/utils/retention');
|
||||
|
||||
function saved(value) {
|
||||
prisma.appSetting.findUnique.mockResolvedValue(value === undefined ? null : { value: JSON.stringify(value) });
|
||||
}
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
describe('policies', () => {
|
||||
test('defaults: inactive accounts after 5 years, on', async () => {
|
||||
saved(undefined);
|
||||
const { policies, reviewedAt } = await retention.getPolicies();
|
||||
expect(policies.inactive_accounts).toEqual({ enabled: true, months: 60 });
|
||||
expect(policies.form_answers).toEqual({ enabled: true, months: 12 });
|
||||
expect(reviewedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('saved values override the defaults, one policy at a time', async () => {
|
||||
saved({ policies: { form_answers: { enabled: false, months: 6 } }, reviewedAt: '2026-10-23T00:00:00.000Z' });
|
||||
const { policies, reviewedAt } = await retention.getPolicies();
|
||||
expect(policies.form_answers).toEqual({ enabled: false, months: 6 });
|
||||
expect(policies.login_history).toEqual({ enabled: true, months: 24 });
|
||||
expect(reviewedAt).toBe('2026-10-23T00:00:00.000Z');
|
||||
});
|
||||
|
||||
test('saving refuses periods below the minimum or silly ones', async () => {
|
||||
const ok = Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { ...d.defaults }]));
|
||||
await expect(retention.savePolicies({ ...ok, inactive_accounts: { enabled: true, months: 6 } })).rejects.toThrow(/Inactive accounts: choose between 12 and 240/);
|
||||
await expect(retention.savePolicies({ ...ok, form_answers: { enabled: true, months: 'x' } })).rejects.toThrow(/Attendee form answers/);
|
||||
saved(undefined);
|
||||
await retention.savePolicies(ok, { reviewedBy: 'Ada' });
|
||||
const value = JSON.parse(prisma.appSetting.upsert.mock.calls[0][0].create.value);
|
||||
expect(value.reviewedBy).toBe('Ada');
|
||||
expect(value.policies.audit_logs).toEqual({ enabled: true, months: 60 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('describing periods', () => {
|
||||
test('whole years read as years', () => {
|
||||
expect(retention.describeMonths(60)).toBe('5 years');
|
||||
expect(retention.describeMonths(12)).toBe('1 year');
|
||||
expect(retention.describeMonths(18)).toBe('18 months');
|
||||
expect(retention.describeMonths(1)).toBe('1 month');
|
||||
});
|
||||
|
||||
test('monthsAgo goes back calendar months', () => {
|
||||
expect(retention.monthsAgo(24, new Date('2026-10-23T12:00:00Z')).toISOString()).toBe('2024-10-23T12:00:00.000Z');
|
||||
});
|
||||
|
||||
test('the notice lines follow the policies, and say so when one is off', async () => {
|
||||
saved({ policies: { inactive_accounts: { enabled: false, months: 60 }, form_answers: { enabled: true, months: 24 } } });
|
||||
const lines = await retention.noticeLines();
|
||||
expect(lines[0]).toBe('• Accounts are kept until you ask us to delete them.');
|
||||
expect(lines[1]).toBe('• Answers to event forms are deleted 2 years after the event.');
|
||||
expect(lines.every(l => l.startsWith('• '))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('inactive accounts query', () => {
|
||||
test('not-yet-warned: a warning older than the last activity no longer counts', async () => {
|
||||
prisma.$queryRawUnsafe.mockResolvedValue([{ id: 'u1' }]);
|
||||
const before = new Date('2021-10-23');
|
||||
expect(await retention.inactiveAccounts(before, { warned: false, limit: 10 })).toEqual(['u1']);
|
||||
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
|
||||
expect(sql).toMatch(/warned_at IS NULL OR warned_at < last_active/);
|
||||
expect(sql).toMatch(/u\."role" = 'user'/);
|
||||
expect(params).toEqual([before]);
|
||||
});
|
||||
|
||||
test('due for deletion: warned since the last activity, long enough ago', async () => {
|
||||
prisma.$queryRawUnsafe.mockResolvedValue([]);
|
||||
const before = new Date('2021-10-23');
|
||||
const warnedBefore = new Date('2026-09-23');
|
||||
await retention.inactiveAccounts(before, { warnedBefore });
|
||||
const [sql, ...params] = prisma.$queryRawUnsafe.mock.calls[0];
|
||||
expect(sql).toMatch(/warned_at >= last_active AND warned_at <= \$2/);
|
||||
expect(params).toEqual([before, warnedBefore]);
|
||||
});
|
||||
});
|
||||
+11
-8
@@ -1,6 +1,6 @@
|
||||
# Handover — where things stand and how to carry on
|
||||
|
||||
Last updated: 2026-10-23, after **1.39.0**. The plan itself is in
|
||||
Last updated: 2026-10-23, after **1.40.0**. The plan itself is in
|
||||
[ROADMAP-v2.md](./ROADMAP-v2.md) (decisions table at the end); the previous round of
|
||||
features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
@@ -8,6 +8,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
| Version | What |
|
||||
|---------|------|
|
||||
| 1.40.0 | POPIA toolkit part 3 — readiness checklist with score, retention policies + nightly `retention` job (inactive accounts anonymised after 5 years with a 30-day warning, on by default), breach register with notification drafts, children's/medical flag in the access log, `{retention}` in the notice |
|
||||
| 1.39.0 | POPIA toolkit part 2 — Admin → Privacy: privacy notice editor (built-in text as the start, placeholders, drafts, numbered versions, re-acceptance after login), operator register, consent log, access log; Information Officer on the contact page |
|
||||
| 1.38.0 | POPIA toolkit part 1 — Privacy page (news opt-in + history, download my data, correction and deletion requests), Event update vs News & announcements on every bulk send, unsubscribe links + one-click headers, WhatsApp STOP, one-time news question, Admin → Data requests, thorough anonymising |
|
||||
| 1.32.0 | Early-bird ribbon on event cards |
|
||||
@@ -24,12 +25,7 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
|
||||
## Next up
|
||||
|
||||
1. **1.40.0 — POPIA toolkit part 3** (spec §3 in the roadmap, decisions table): retention
|
||||
policies per data category + nightly job (inactive accounts anonymised after 5 years, **on by
|
||||
default**, warning email 30 days before, never staff/supervisor/admin), breach register with
|
||||
notification templates, children's-data flags in reports, POPIA readiness page with a score.
|
||||
The notice's retention section should then fill in from the policies (a new placeholder).
|
||||
2. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
1. **1.41.0 + Hub v1** — install/update tooling and the Hope Events Hub (separate repo
|
||||
`joshua/hope-events-hub`).
|
||||
|
||||
## Working on this repo
|
||||
@@ -144,7 +140,14 @@ features is in [FEATURES-ROUND-1.md](./FEATURES-ROUND-1.md).
|
||||
- Access log: `utils/accessLog.js` (`logDataAccess(req, { action, what, eventId, records })`).
|
||||
**Any new bulk export or list of personal data should call it** and get a label in
|
||||
`ACCESS_ACTIONS`. Operators: `Operator` table, seeded in the 1.39.0 migration.
|
||||
- e2e: `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
|
||||
- Retention: `utils/retention.js` (policies in AppSetting `retention_policies`, `runRetention`,
|
||||
`preview`, `noticeLines` for `{retention}`), job `retention` (03:30). **Anything new that
|
||||
stores personal data should get a retention rule** (a new entry in `POLICIES` + runner).
|
||||
Inactive = latest of `User.createdAt`, `lastLoginAt` (set on login), registrations/event end,
|
||||
attendee rows, payments. Readiness: `utils/readiness.js`. Breaches: `SecurityBreach`.
|
||||
Admin panels: `components/admin/privacy/{Readiness,Retention,Breaches}Panel.tsx`.
|
||||
- e2e: `privacy-retention.spec.ts` runs the retention job on seeded six-year-old accounts
|
||||
(`prepare-db.js` → `retention`); `privacy-notice.spec.ts` publishes a version that needs accepting, then accepts it for all
|
||||
seeded users in `afterAll` so later spec files don't meet the prompt.
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
+146
-8
@@ -8,7 +8,8 @@ previous round (1.12.0 – 1.23.0). Where to pick up: [HANDOVER.md](./HANDOVER.m
|
||||
|
||||
**Theme for 2.0:** turn a feature-rich church app into a product that can be
|
||||
sold — trustworthy (POPIA), flexible (multi-day events, several payment
|
||||
providers), and maintainable (tests, CI, typed backend, versioned API).
|
||||
providers), maintainable (tests, CI, typed backend, versioned API), and with
|
||||
**AI helpers** as the headline feature (§15).
|
||||
|
||||
**Status (2026-10-23):** all features in this document are approved. Shipped
|
||||
so far: 1.32.0 early-bird ribbon on event cards; 1.33.0 multi-day events
|
||||
@@ -20,8 +21,8 @@ picker at the door and session choice on "All sessions" tickets; 1.36.0 payment
|
||||
(Yoco moved behind it, Site settings → Payments); 1.37.0 PayFast and the "Pay online" page;
|
||||
1.37.1 "Pay online" hidden when it can't be used; 1.37.2 payment status checks for missing
|
||||
notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (2026-10-23);
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs).
|
||||
**Next: 1.40.0 POPIA toolkit part 3 (retention, breach register, readiness).** Hub moved to 1.41.0.
|
||||
1.38.0 POPIA toolkit part 1 (privacy page, unsubscribe, WhatsApp STOP, data requests); 1.39.0 part 2 (privacy notice editor and versions, operator register, consent and access logs); 1.40.0 part 3 (retention policies and job, breach register, children’s-data flags, readiness page).
|
||||
**Next: 1.41.0 install/update tooling + the Hope Events Hub (§13).**
|
||||
|
||||
**Repositories:**
|
||||
|
||||
@@ -48,8 +49,10 @@ notifications. SnapScan and Zapper are dropped — Yoco and PayFast are enough (
|
||||
12. [Multi-campus (one install per church)](#12-multi-campus-one-install-per-church)
|
||||
13. [Central management platform (Hope Events Hub)](#13-central-management-platform-hope-events-hub)
|
||||
14. [Foundations (platform work)](#14-foundations-platform-work)
|
||||
15. [Suggested build order](#suggested-build-order)
|
||||
16. [Decisions](#decisions-2026-10-22)
|
||||
15. [AI helpers (2.0 headline)](#15-ai-helpers-20-headline)
|
||||
16. [AI: later stages](#16-ai-later-stages)
|
||||
17. [Suggested build order](#suggested-build-order)
|
||||
18. [Decisions](#decisions-2026-10-22)
|
||||
|
||||
---
|
||||
|
||||
@@ -679,7 +682,8 @@ All later calls are signed with the install secret (HMAC) and go over HTTPS.
|
||||
events people already paid for, and get its own data out (POPIA access
|
||||
rights). Suspension blocks *new* activity only.
|
||||
- **One plan for now.** Plan limits (e.g. number of campuses, WhatsApp) can
|
||||
be carried in the token later.
|
||||
be carried in the token later. The first per-licence limit comes with 2.0: the
|
||||
**monthly AI cap in rand** (§15), kept and enforced on the Hub, since the Hub meters AI.
|
||||
|
||||
### Things to be aware of
|
||||
|
||||
@@ -703,6 +707,8 @@ model Install { id, customerId, licenceId, name, url, secretHash, version,
|
||||
model Heartbeat { id, installId, receivedAt, version, health Json, stats Json }
|
||||
// rolled up to daily after 30 days
|
||||
model InstallEvent { id, installId, type, message, createdAt } // alerts, version changes
|
||||
// 2.0, AI (§15): Licence gets aiMonthlyCapCents; usage per call, no prompt/response text
|
||||
model AiUsage { id, installId, helper, model, inputTokens, outputTokens, costCents, createdAt }
|
||||
```
|
||||
|
||||
---
|
||||
@@ -724,6 +730,134 @@ keep adding to.
|
||||
|
||||
---
|
||||
|
||||
## 15. AI helpers (2.0 headline)
|
||||
|
||||
Small, one-button AI helpers in the places people already work. **No chat** in 2.0:
|
||||
each helper is one request with a predictable cost, and a person always reviews
|
||||
the result before anything is saved or sent.
|
||||
|
||||
**Who:** Supervisors and admins (the people who create events and read reports).
|
||||
|
||||
### The helpers
|
||||
|
||||
| Helper | Where | What it does |
|
||||
|--------|-------|--------------|
|
||||
| **Draft an event** | Event wizard, first step: "Describe your event" | *"Youth camp 14–16 March at Camp Wartburg, R450, 60 places, kids need consent"* → fills in title, description, dates/sessions, location, ticket options and prices, capacity, consent settings. The supervisor checks every field and saves as usual |
|
||||
| **Summarise this report** | Every report (Reports page) | A few plain-language insights under the report: trends, outliers, comparisons with the previous period |
|
||||
| **Draft a message** | Email / WhatsApp attendees, broadcasts | Turns a short note (*"remind them to bring a sleeping bag, bus leaves 7:30"*) into a friendly message in the right tone and length (shorter for WhatsApp), with placeholders like `{{name}}` |
|
||||
| **Summarise feedback** | Post-event feedback results | Themes, what people liked, what to improve, notable comments |
|
||||
| **Improve this text** | Event description, banner, message boxes | Rewrite for clarity, fix spelling, shorten |
|
||||
|
||||
Every helper shows **"AI suggestion — check before saving"**, can be undone, and
|
||||
never saves, sends or publishes by itself.
|
||||
|
||||
### Provider and model
|
||||
|
||||
- **One provider: Claude (Anthropic), model Claude Haiku 5.5** — the cheapest
|
||||
current model ($0.10 per million input tokens, $0.50 per million output, for
|
||||
prompts under 100K tokens; prices as of 2026-10). Good enough for drafting and
|
||||
summarising; a helper can move to a bigger model later if quality needs it.
|
||||
- **Rough cost:** a helper call is ~2,000–10,000 tokens in and ~500 out, so well under
|
||||
US$0.002 per click — about R1.50–R3 per thousand clicks. Fixed instructions are
|
||||
prompt-cached, which makes repeat calls cheaper still.
|
||||
- Structured output (a JSON schema) for "Draft an event", so the reply maps straight
|
||||
onto the event form and is validated before it's shown.
|
||||
|
||||
### Key, caps and billing — through the Hub
|
||||
|
||||
Joshua provides the API key; churches don't need their own. The key must never sit on
|
||||
an install (one leaked install would leak it for everyone), so:
|
||||
|
||||
- **Installs call AI through the Hub:** `POST /api/ai/complete` on the Hub, signed with
|
||||
the install secret like the heartbeat. The Hub adds the key, calls Claude, and returns
|
||||
the result. Still **install → Hub only** — the Hub never reaches into an install.
|
||||
- **The Hub meters every call** in rand: tokens used × the model's price × an exchange rate
|
||||
Joshua sets in the Hub (with an optional margin). Usage is kept per install per month.
|
||||
- **Monthly rand cap per licence**, set by Joshua in the Hub to match what the church pays
|
||||
(e.g. R0 = AI off, R50, R200). The licence's cap is the first per-plan limit. At 80% the
|
||||
install's admins get a warning email; at 100% the helpers are switched off until the next
|
||||
month (or until Joshua raises the cap). Optional per-user daily limit against runaway use.
|
||||
- **Admins see their usage** in Site settings → AI: used this month (R and number of uses),
|
||||
the cap, a daily chart, use per helper and per person, and an on/off switch per helper.
|
||||
- If the Hub or Claude is unavailable, the buttons say so; nothing else in the app is affected.
|
||||
|
||||
### POPIA
|
||||
|
||||
- Anthropic is added to the **operator register** (and Joshua's Hub, which relays the
|
||||
requests) and to the privacy notice: processing outside South Africa (POPIA s72),
|
||||
for drafting and summarising only.
|
||||
- **Send as little personal information as possible:** reports and feedback are sent as
|
||||
counts, totals and anonymised comments — no names, contact details, medical or
|
||||
children's information. Drafting an event or a message needs none.
|
||||
- The Hub stores **no prompt or response text** — only the install, helper, tokens, cost
|
||||
and time. Check Anthropic's commercial terms on retention and training before launch.
|
||||
- Uses that include personal information (e.g. feedback comments) go in the access log.
|
||||
|
||||
### Plumbing
|
||||
|
||||
- Install: `backend/src/ai/` — one client for the Hub's AI endpoint, one prompt + schema
|
||||
per helper, usage shown from the Hub. Feature flag per helper, all off when the licence
|
||||
has no AI or the Hub isn't connected.
|
||||
- Hub: the AI proxy, metering, caps, warnings, an exchange-rate setting, and a per-install
|
||||
usage view for Joshua (alongside the existing install detail page).
|
||||
- A small **eval set** (example event descriptions, reports, notes, with what a good answer
|
||||
looks like) to check quality when changing the prompt or model.
|
||||
- Help content, audit log entries (AI settings changed), CHANGELOG, FEATURES.md, SETTINGS.md.
|
||||
|
||||
---
|
||||
|
||||
## 16. AI: later stages
|
||||
|
||||
Ideas for after 2.0, in rough order. **Not tied to versions** — each is decided when it's
|
||||
picked up, and all of them reuse the Hub proxy, metering and caps from §15.
|
||||
|
||||
### Ask AI (read-only assistant)
|
||||
|
||||
A chat panel for supervisors and admins: *"How many paid for Camp compared with last
|
||||
year?"*, *"Which events this month are under half full?"*, *"Who hasn't paid yet for
|
||||
Men's Breakfast?"*.
|
||||
|
||||
- The AI can only call **tools the app defines** — the existing stats and report queries,
|
||||
"find an event", "registrations by week" — never its own SQL. It runs with the
|
||||
logged-in person's permissions, so it sees nothing they couldn't.
|
||||
- Answers show the numbers they're based on and link to the matching report.
|
||||
- Costs more per question than a button (several tool calls), so it counts against the same
|
||||
cap; may need a bigger model for harder questions.
|
||||
|
||||
### Help and navigation assistant
|
||||
|
||||
*"How do I add a coupon?"*, *"Where do I see who checked in?"* → a short answer from the
|
||||
in-app help content (`frontend/src/content/help/`) with a link straight to the right page.
|
||||
No personal information involved, so it's cheap and simple — could also be offered to
|
||||
ordinary users.
|
||||
|
||||
### Actions with confirmation
|
||||
|
||||
The Ask assistant can also *do* things — create an event draft, schedule a reminder, offer
|
||||
waitlist places, record a cash payment — always as **"here's what I'll do → Confirm"**, with
|
||||
each confirmed action in the audit log.
|
||||
|
||||
### More helpers
|
||||
|
||||
Smaller one-button additions as they come up: suggest an event's price tiers from past
|
||||
events, write a post-event thank-you, draft certificate wording, translate a message into
|
||||
Afrikaans (ties in with §10), flag unusual payments for the cashup.
|
||||
|
||||
### Self-hosted AI (long term)
|
||||
|
||||
Run an open-weight model on Joshua's own GPU server instead of a paid API, once usage and
|
||||
income justify the hardware:
|
||||
|
||||
- Because every install already talks to the **Hub's** AI endpoint, moving to self-hosting
|
||||
is a change on the Hub only — the Hub gets a provider adapter (Claude now, self-hosted
|
||||
later, or both: simple helpers self-hosted, harder questions on Claude).
|
||||
- **POPIA bonus:** the data can stay in South Africa — no cross-border transfer.
|
||||
- **When it makes sense:** compare the monthly API bill with the cost of a GPU server
|
||||
(hardware or cloud rental, power, upkeep), and check the self-hosted model against the
|
||||
eval set from §15 before switching any helper over.
|
||||
|
||||
---
|
||||
|
||||
## Suggested build order
|
||||
|
||||
| Phase | Version | Features | Why |
|
||||
@@ -733,14 +867,14 @@ keep adding to.
|
||||
| 2 | 1.34.0 ✅ | CI + Playwright E2E + job queue | Safety net before the riskiest work (payments). Session reminders move to the queue |
|
||||
| 3 | 1.35.0 ✅ | Multi-day events — phase B (pick sessions, per-session capacity) | Builds on phase A, now with E2E coverage |
|
||||
| 4 | 1.36.0 ✅ – 1.37.2 | Payment provider interface (Yoco moved behind it) → PayFast, then hiding "Pay online" when it can't be used and payment status checks (SnapScan and Zapper dropped) | Payment code gets split out of the big controller as part of this |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ – 1.40.0 | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 5 | 1.38.0 ✅ 1.39.0 ✅ 1.40.0 ✅ | **POPIA toolkit** (1.38.0: user privacy centre, unsubscribe, WhatsApp STOP, data requests inbox; 1.39.0: privacy notice editor + versions, operator register, consent log, access log; 1.40.0: retention jobs, breach register, children’s-data flags, readiness page) | Main selling point; retention jobs use the job queue |
|
||||
| 6 | 1.41.0 + Hub v1 | Install/update scripts, `/health`, licence step in `/setup`, "Connect to Hub", heartbeat, licence states — plus the **Hub** app (separate repo) | Must exist before the first church is sold; Hope Family Church registers as install #1 |
|
||||
| 7 | 1.42.0 | **Multi-campus** | Kids rooms and volunteer roles are per campus, so campuses come first |
|
||||
| 8 | 1.43.0 – 1.44.0 | Kids check-in, Volunteer rosters | Depend on sessions and campuses |
|
||||
| 9 | 1.45.0 – 1.47.0 | Event series & cloning, Wallet passes, Outbound webhooks | Smaller, independent wins |
|
||||
| 10 | 1.48.0 – 1.50.0 | Admin dashboard/Ctrl+K/bulk actions, Afrikaans, Accessibility pass | Polish before the big release |
|
||||
| 11 | 1.51.0 | TypeScript backend + OpenAPI + `/api/v2` alongside v1 | The breaking-change groundwork |
|
||||
| 12 | **2.0.0** | v1 API removed, migration guide, marketing site/docs for selling | The release |
|
||||
| 12 | **2.0.0** | **AI helpers** (§15 — headline feature: draft an event, summarise reports and feedback, draft messages, improve text; through the Hub with a rand cap per licence), v1 API removed, migration guide, marketing site/docs for selling | The release. AI needs the Hub (phase 6) for the key, metering and caps |
|
||||
|
||||
Each phase should add matching help content (`frontend/src/content/help/`),
|
||||
audit log entries, Jest tests, a CHANGELOG entry, and updates to FEATURES.md
|
||||
@@ -773,6 +907,10 @@ feature.
|
||||
| — | Grace period / suspension | 14-day grace; suspended = read-only (tickets still scan, data export still works) (2026-10-23) |
|
||||
| — | Income in the heartbeat | Included, as **ranges** only (R0–R1 000, R1 001–R5 000, …), never exact amounts (2026-10-23) |
|
||||
| — | Licence plans | One plan for now (2026-10-23) |
|
||||
| — | AI in 2.0 | The **headline feature of 2.0**: one-button helpers only (draft an event, summarise reports and feedback, draft messages, improve text) — no chat yet (2026-10-24) |
|
||||
| — | AI provider and key | One provider, **Claude** (Haiku 5.5 to start, for cost). Joshua provides the key; installs call AI **through the Hub**, which holds the key and meters usage (2026-10-24) |
|
||||
| — | AI cost limits | A **monthly rand cap per install**, set in the Hub per licence to match what the church pays; admins see their usage in Site settings → AI (2026-10-24) |
|
||||
| — | AI later | Ask AI (read-only, tools only), help/navigation assistant, actions with confirmation, more helpers, and long-term **self-hosted** AI behind the same Hub endpoint — not tied to versions (2026-10-24) |
|
||||
| — | POPIA: message kinds | Bulk messages are either **Event updates** (to registrants — still sent after unsubscribing) or **News & announcements** (marketing — opt-in, one-click unsubscribe). Every bulk email links to the preferences (2026-10-23) |
|
||||
| — | POPIA: existing users | Kept opted in to news (logged as "existing account"), asked once at their next login to confirm or opt out. New sign-ups choose with an unticked box (2026-10-23) |
|
||||
| — | POPIA: deleting an account | A request: the account is deactivated at once, personal info is anonymised when an admin approves. Waits while the person has an open registration for an upcoming event (2026-10-23) |
|
||||
|
||||
@@ -9,6 +9,7 @@ export type Seed = {
|
||||
course: { sessions: { id: string; label: string }[]; ticketId: string; qrCode: string };
|
||||
unsubscribeToken: string;
|
||||
newsPromptUser: { id: string; token: string };
|
||||
retention: { toWarn: string; toAnonymise: string };
|
||||
};
|
||||
|
||||
export const API = "http://localhost:5100";
|
||||
|
||||
@@ -32,7 +32,7 @@ test.describe("privacy notice, operators and access log (POPIA)", () => {
|
||||
const op = await api<Operator>(page, "admin", "POST", "/api/privacy/operators", { name: "E2E Mailer", purpose: "Sending emails", dataShared: "Email address", location: "Ireland" });
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/privacy");
|
||||
await page.goto("/dashboard/admin/privacy?tab=notice");
|
||||
const intro = page.getByLabel("1. Introduction text");
|
||||
await expect(intro).toBeVisible();
|
||||
await intro.fill("{org} looks after your information carefully. (E2E edit)");
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test, expect, seed, loginAs, api } from "./fixtures";
|
||||
|
||||
type RunResult = { result: Record<string, Record<string, number>> };
|
||||
type UserRow = { name: string; email: string; isActive: boolean };
|
||||
type Readiness = { score: number; checks: { key: string; ok: boolean; applies: boolean }[] };
|
||||
type Breach = { id: string; status: string; regulatorNotifiedAt: string | null };
|
||||
|
||||
test.describe("retention, breaches and readiness (POPIA)", () => {
|
||||
test("the retention job warns inactive accounts first, and anonymises them 30 days after the warning", async ({ page }) => {
|
||||
const { result } = await api<RunResult>(page, "admin", "POST", "/api/privacy/retention/run");
|
||||
expect(result.inactive_accounts.warned).toBeGreaterThanOrEqual(1);
|
||||
expect(result.inactive_accounts.anonymised).toBeGreaterThanOrEqual(1);
|
||||
expect(result.login_history.deleted).toBeGreaterThanOrEqual(1);
|
||||
|
||||
const warned = await api<UserRow>(page, "admin", "GET", `/api/users/${seed().retention.toWarn}`);
|
||||
expect(warned.email).toBe("dormant@e2e.test");
|
||||
const gone = await api<UserRow>(page, "admin", "GET", `/api/users/${seed().retention.toAnonymise}`);
|
||||
expect(gone.name).toBe("Deleted User");
|
||||
expect(gone.email).toMatch(/@deleted\.invalid$/);
|
||||
|
||||
// Seeded accounts (staff roles, and the recently active user) are untouched.
|
||||
expect((await api<UserRow>(page, "admin", "GET", `/api/users/${seed().users.user.id}`)).email).toBe("user@e2e.test");
|
||||
});
|
||||
|
||||
test("retention periods are validated and saved; the notice follows them", async ({ page }) => {
|
||||
const state = await api<{ policies: Record<string, { enabled: boolean; months: number }> }>(page, "admin", "GET", "/api/privacy/retention");
|
||||
await expect(api(page, "admin", "PUT", "/api/privacy/retention", { policies: { ...state.policies, inactive_accounts: { enabled: true, months: 3 } } })).rejects.toThrow(/400/);
|
||||
await api(page, "admin", "PUT", "/api/privacy/retention", { policies: { ...state.policies, form_answers: { enabled: true, months: 18 } } });
|
||||
|
||||
await page.goto("/legal/privacy");
|
||||
await expect(page.getByText("Answers to event forms are deleted 18 months after the event.", { exact: false })).toBeVisible();
|
||||
});
|
||||
|
||||
test("a breach is recorded, reported, and drafts are ready to copy", async ({ page }) => {
|
||||
const breach = await api<Breach>(page, "admin", "POST", "/api/privacy/breaches", {
|
||||
title: "Phone with the attendee list lost", discoveredAt: new Date().toISOString(),
|
||||
description: "A leader's phone was stolen at the camp site.", dataAffected: "Names and phone numbers of 40 campers",
|
||||
});
|
||||
expect(breach.status).toBe("open");
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/privacy?tab=breaches");
|
||||
await page.getByRole("button", { name: /Phone with the attendee list lost/ }).click();
|
||||
await expect(page.getByText("Draft message to the people affected")).toBeVisible();
|
||||
await expect(page.getByText("Personal information affected: Names and phone numbers of 40 campers")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Regulator told today" }).click();
|
||||
await expect(page.getByRole("button", { name: "Regulator told today" })).toHaveCount(0);
|
||||
|
||||
const list = await api<Breach[]>(page, "admin", "GET", "/api/privacy/breaches");
|
||||
expect(list.find(b => b.id === breach.id)?.regulatorNotifiedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
test("the readiness page scores the install", async ({ page }) => {
|
||||
const r = await api<Readiness>(page, "admin", "GET", "/api/privacy/readiness");
|
||||
expect(r.score).toBeGreaterThan(0);
|
||||
expect(r.checks.find(c => c.key === "retention_reviewed")?.ok).toBe(true);
|
||||
expect(r.checks.find(c => c.key === "retention_running")?.ok).toBe(false); // jobs are off in e2e
|
||||
|
||||
await loginAs(page, "admin");
|
||||
await page.goto("/dashboard/admin/privacy");
|
||||
await expect(page.getByText("POPIA readiness")).toBeVisible();
|
||||
await expect(page.getByText(`${r.score}%`)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -162,6 +162,16 @@ async function seed(url) {
|
||||
const u = await prisma.user.create({ data: { name: 'Nomsa New', email: 'prompt@e2e.test', role: 'user', password, isActive: true, updatedAt: new Date(), newsEmail: true } });
|
||||
return { id: u.id, token: generateToken(u.id, u.role, 0) };
|
||||
})(),
|
||||
// Retention: two accounts untouched for six years — one not yet warned, one warned 40 days
|
||||
// ago (so the next run anonymises it) — and three-year-old login history.
|
||||
retention: await (async () => {
|
||||
const longAgo = new Date(Date.now() - 6 * 365 * 24 * 60 * 60 * 1000);
|
||||
const oldUser = (email, warnedAt) => prisma.user.create({ data: { name: `Old ${email}`, email, role: 'user', password, isActive: true, createdAt: longAgo, updatedAt: longAgo, inactivityWarnedAt: warnedAt } });
|
||||
const toWarn = await oldUser('dormant@e2e.test', null);
|
||||
const toAnonymise = await oldUser('gone-quiet@e2e.test', new Date(Date.now() - 40 * 24 * 60 * 60 * 1000));
|
||||
await prisma.securityEvent.create({ data: { userId: toWarn.id, type: 'login', createdAt: new Date(Date.now() - 3 * 365 * 24 * 60 * 60 * 1000) } });
|
||||
return { toWarn: toWarn.id, toAnonymise: toAnonymise.id };
|
||||
})(),
|
||||
// The link a news email to the seeded user would carry (signed with JWT_SECRET).
|
||||
unsubscribeToken: require(path.join(backend, 'src/utils/privacy')).unsubscribeToken({ userId: users.user.id }),
|
||||
};
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"dependencies": {
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@hookform/resolvers": "^5.2.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events-frontend",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
||||
@@ -47,6 +47,10 @@ const ACTIONS = [
|
||||
"operator_created",
|
||||
"operator_updated",
|
||||
"operator_deleted",
|
||||
"retention_policies_updated",
|
||||
"retention_run",
|
||||
"breach_recorded",
|
||||
"breach_updated",
|
||||
] as const;
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
@@ -86,6 +90,10 @@ const ACTION_LABELS: Record<string, string> = {
|
||||
operator_created: "Operator added",
|
||||
operator_updated: "Operator changed",
|
||||
operator_deleted: "Operator removed",
|
||||
retention_policies_updated: "Retention periods changed",
|
||||
retention_run: "Retention job deleted/anonymised data",
|
||||
breach_recorded: "Security compromise recorded",
|
||||
breach_updated: "Security compromise updated",
|
||||
};
|
||||
|
||||
interface AuditLogEntry {
|
||||
|
||||
@@ -3,18 +3,24 @@
|
||||
import React, { Suspense } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { ShieldCheck, ScrollText, Building2, ListChecks, Eye, Inbox } from "lucide-react";
|
||||
import { ShieldCheck, ScrollText, Building2, ListChecks, Eye, Inbox, Gauge, Timer, AlertTriangle } from "lucide-react";
|
||||
import { useAuth } from "@/hooks/useAuth";
|
||||
import { BackLink } from "@/components/shared/BackLink";
|
||||
import { NoticeEditor } from "@/components/admin/privacy/NoticeEditor";
|
||||
import { OperatorsPanel } from "@/components/admin/privacy/OperatorsPanel";
|
||||
import { ConsentLogPanel, AccessLogPanel } from "@/components/admin/privacy/LogPanels";
|
||||
import { ReadinessPanel } from "@/components/admin/privacy/ReadinessPanel";
|
||||
import { RetentionPanel } from "@/components/admin/privacy/RetentionPanel";
|
||||
import { BreachesPanel } from "@/components/admin/privacy/BreachesPanel";
|
||||
|
||||
const TABS = [
|
||||
{ key: "readiness", label: "Readiness", icon: Gauge },
|
||||
{ key: "notice", label: "Privacy notice", icon: ScrollText },
|
||||
{ key: "operators", label: "Operators", icon: Building2 },
|
||||
{ key: "retention", label: "Retention", icon: Timer },
|
||||
{ key: "consent", label: "Consent log", icon: ListChecks },
|
||||
{ key: "access", label: "Access log", icon: Eye },
|
||||
{ key: "breaches", label: "Breaches", icon: AlertTriangle },
|
||||
] as const;
|
||||
type TabKey = (typeof TABS)[number]["key"];
|
||||
|
||||
@@ -22,7 +28,7 @@ function PrivacyAdmin() {
|
||||
const { token } = useAuth();
|
||||
const router = useRouter();
|
||||
const params = useSearchParams();
|
||||
const tab = (TABS.find(t => t.key === params.get("tab"))?.key || "notice") as TabKey;
|
||||
const tab = (TABS.find(t => t.key === params.get("tab"))?.key || "readiness") as TabKey;
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto w-full">
|
||||
@@ -53,10 +59,13 @@ function PrivacyAdmin() {
|
||||
|
||||
{token && (
|
||||
<div className="border rounded-xl p-5 bg-white shadow-sm">
|
||||
{tab === "readiness" && <ReadinessPanel token={token} />}
|
||||
{tab === "notice" && <NoticeEditor token={token} />}
|
||||
{tab === "operators" && <OperatorsPanel token={token} />}
|
||||
{tab === "retention" && <RetentionPanel token={token} />}
|
||||
{tab === "consent" && <ConsentLogPanel token={token} />}
|
||||
{tab === "access" && <AccessLogPanel token={token} />}
|
||||
{tab === "breaches" && <BreachesPanel token={token} />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Plus, Copy, Pencil } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
import { useSiteSettings } from "@/contexts/SiteSettingsContext";
|
||||
|
||||
type Status = "open" | "contained" | "closed";
|
||||
type Breach = {
|
||||
id: string; title: string; status: Status; discoveredAt: string; occurredAt: string | null; description: string; dataAffected: string;
|
||||
peopleAffected: number | null; whoAffected: string | null; consequences: string | null; actionsTaken: string | null; adviceToPeople: string | null;
|
||||
causedBy: string | null; regulatorNotifiedAt: string | null; peopleNotifiedAt: string | null; createdBy: { name: string } | null; createdAt: string;
|
||||
};
|
||||
type Form = Record<"title" | "description" | "dataAffected" | "whoAffected" | "consequences" | "actionsTaken" | "adviceToPeople" | "causedBy" | "discoveredAt" | "occurredAt" | "peopleAffected", string>;
|
||||
|
||||
const EMPTY: Form = { title: "", description: "", dataAffected: "", whoAffected: "", consequences: "", actionsTaken: "", adviceToPeople: "", causedBy: "", discoveredAt: "", occurredAt: "", peopleAffected: "" };
|
||||
const STATUS: Record<Status, { label: string; tone: string }> = {
|
||||
open: { label: "Open", tone: "bg-red-50 text-red-700" },
|
||||
contained: { label: "Contained", tone: "bg-amber-50 text-amber-800" },
|
||||
closed: { label: "Closed", tone: "bg-gray-100 text-gray-600" },
|
||||
};
|
||||
const inputCls = "w-full border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-brand-500";
|
||||
const fmt = (d: string | null) => (d ? new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" }) : "—");
|
||||
// <input type="datetime-local"> wants local "YYYY-MM-DDTHH:mm".
|
||||
const toLocal = (d: string | null) => {
|
||||
if (!d) return "";
|
||||
const x = new Date(d);
|
||||
return new Date(x.getTime() - x.getTimezoneOffset() * 60000).toISOString().slice(0, 16);
|
||||
};
|
||||
|
||||
function BreachForm({ initial, onSave, onCancel, busy }: { initial: Form; onSave: (f: Form) => void; onCancel: () => void; busy: boolean }) {
|
||||
const [f, setF] = useState(initial);
|
||||
const field = (k: keyof Form, label: string, opts: { rows?: number; required?: boolean; type?: string; placeholder?: string } = {}) => (
|
||||
<label className="block text-sm text-gray-700">
|
||||
{label}{opts.required && " *"}
|
||||
{opts.rows ? (
|
||||
<textarea className={inputCls} rows={opts.rows} value={f[k]} required={opts.required} placeholder={opts.placeholder} onChange={e => setF({ ...f, [k]: e.target.value })} />
|
||||
) : (
|
||||
<input className={inputCls} type={opts.type || "text"} value={f[k]} required={opts.required} placeholder={opts.placeholder} onChange={e => setF({ ...f, [k]: e.target.value })} />
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
return (
|
||||
<form onSubmit={e => { e.preventDefault(); onSave(f); }} className="border rounded-xl p-4 bg-gray-50 space-y-3">
|
||||
{field("title", "Short title", { required: true, placeholder: "e.g. Attendee list emailed to the wrong group" })}
|
||||
<div className="grid sm:grid-cols-3 gap-3">
|
||||
{field("discoveredAt", "Discovered", { type: "datetime-local", required: true })}
|
||||
{field("occurredAt", "Happened (if known)", { type: "datetime-local" })}
|
||||
{field("peopleAffected", "People affected (about)", { type: "number" })}
|
||||
</div>
|
||||
{field("description", "What happened", { rows: 3, required: true })}
|
||||
{field("dataAffected", "What information was affected", { rows: 2, required: true, placeholder: "e.g. names, phone numbers and medical details of 40 camp attendees" })}
|
||||
{field("whoAffected", "Who was affected", { rows: 2, placeholder: "e.g. everyone registered for Youth Camp 2026" })}
|
||||
{field("consequences", "Possible consequences for them", { rows: 2 })}
|
||||
{field("actionsTaken", "What you've done or will do about it", { rows: 2 })}
|
||||
{field("adviceToPeople", "What affected people should do", { rows: 2, placeholder: "e.g. be careful of messages asking for payment" })}
|
||||
{field("causedBy", "Who caused it (if known)", {})}
|
||||
<div className="flex gap-2">
|
||||
<button type="button" onClick={onCancel} className="px-3 py-1.5 rounded-lg bg-white border text-sm hover:bg-gray-100">Cancel</button>
|
||||
<button type="submit" disabled={busy} className="px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">{busy ? "Saving…" : "Save"}</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
function CopyBlock({ title, text }: { title: string; text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return (
|
||||
<div className="border rounded-lg bg-white">
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-sm font-medium text-gray-800">{title}</span>
|
||||
<button onClick={() => { navigator.clipboard?.writeText(text); setCopied(true); setTimeout(() => setCopied(false), 1500); }} className="inline-flex items-center gap-1 text-xs text-brand-600 hover:underline">
|
||||
<Copy className="w-3.5 h-3.5" /> {copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre className="p-3 text-xs text-gray-700 whitespace-pre-wrap font-sans">{text}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Admin → Privacy → Breaches: the register of security compromises (POPIA s22). */
|
||||
export function BreachesPanel({ token }: { token: string }) {
|
||||
const { settings } = useSiteSettings();
|
||||
const [list, setList] = useState<Breach[] | null>(null);
|
||||
const [editing, setEditing] = useState<string | "new" | null>(null);
|
||||
const [open, setOpen] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setList(await apiFetch<Breach[]>("/api/privacy/breaches", { authToken: token }));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load the register.");
|
||||
}
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const send = async (id: string | "new", body: Record<string, unknown>) => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (id === "new") await apiFetch("/api/privacy/breaches", { method: "POST", authToken: token, body });
|
||||
else await apiFetch(`/api/privacy/breaches/${id}`, { method: "PUT", authToken: token, body });
|
||||
setEditing(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not save.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const save = (f: Form) => send(editing!, {
|
||||
...f,
|
||||
discoveredAt: f.discoveredAt ? new Date(f.discoveredAt).toISOString() : "",
|
||||
occurredAt: f.occurredAt ? new Date(f.occurredAt).toISOString() : null,
|
||||
peopleAffected: f.peopleAffected === "" ? null : f.peopleAffected,
|
||||
});
|
||||
|
||||
const org = settings.org_name || "We";
|
||||
const io = [settings.legal_io_name, settings.legal_io_email].filter(Boolean).join(", ") || "our Information Officer";
|
||||
|
||||
const peopleText = (b: Breach) => [
|
||||
`Dear member,`,
|
||||
``,
|
||||
`We are writing to tell you about a security incident that affected your personal information held by ${org}.`,
|
||||
``,
|
||||
`What happened: ${b.description}`,
|
||||
`Information involved: ${b.dataAffected}`,
|
||||
b.consequences ? `What this could mean for you: ${b.consequences}` : "",
|
||||
b.actionsTaken ? `What we have done: ${b.actionsTaken}` : "",
|
||||
b.adviceToPeople ? `What you can do: ${b.adviceToPeople}` : "",
|
||||
b.causedBy ? `Who was responsible: ${b.causedBy}` : "",
|
||||
``,
|
||||
`We are sorry this happened. If you have questions, contact ${io}. You may also complain to the Information Regulator (inforegulator.org.za).`,
|
||||
].filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n");
|
||||
|
||||
const regulatorText = (b: Breach) => [
|
||||
`Responsible party: ${settings.legal_operator_name || org}`,
|
||||
`Information Officer: ${io}`,
|
||||
`Discovered: ${fmt(b.discoveredAt)}${b.occurredAt ? ` · Happened: ${fmt(b.occurredAt)}` : ""}`,
|
||||
`Description: ${b.description}`,
|
||||
`Personal information affected: ${b.dataAffected}`,
|
||||
`People affected: ${b.peopleAffected ?? "unknown"}${b.whoAffected ? ` — ${b.whoAffected}` : ""}`,
|
||||
b.consequences ? `Possible consequences: ${b.consequences}` : "",
|
||||
b.actionsTaken ? `Measures taken: ${b.actionsTaken}` : "",
|
||||
b.adviceToPeople ? `Advice given to data subjects: ${b.adviceToPeople}` : "",
|
||||
b.causedBy ? `Identity of the unauthorised person (if known): ${b.causedBy}` : "",
|
||||
`Data subjects notified: ${b.peopleNotifiedAt ? fmt(b.peopleNotifiedAt) : "not yet"}`,
|
||||
].filter(Boolean).join("\n");
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-gray-600">
|
||||
Record any security compromise — a lost phone with the attendee list, an email to the wrong people, a hacked account. POPIA (section 22)
|
||||
requires you to tell the Information Regulator and the people affected as soon as reasonably possible. Each entry gives you a draft
|
||||
for both; report to the Regulator on its website (inforegulator.org.za) and record the date here.
|
||||
</p>
|
||||
{error && <p className="text-sm text-red-700 bg-red-50 border border-red-200 rounded-lg p-3">{error}</p>}
|
||||
|
||||
{editing === "new" ? (
|
||||
<BreachForm busy={busy} initial={{ ...EMPTY, discoveredAt: toLocal(new Date().toISOString()) }} onCancel={() => setEditing(null)} onSave={save} />
|
||||
) : (
|
||||
<button onClick={() => setEditing("new")} className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700">
|
||||
<Plus className="w-4 h-4" /> Record a security compromise
|
||||
</button>
|
||||
)}
|
||||
|
||||
{list === null ? <p className="text-sm text-gray-400">Loading…</p> : list.length === 0 ? <p className="text-sm text-gray-500">Nothing recorded.</p> : (
|
||||
<ul className="space-y-3">
|
||||
{list.map(b => editing === b.id ? (
|
||||
<li key={b.id}>
|
||||
<BreachForm busy={busy} onCancel={() => setEditing(null)} onSave={save} initial={{
|
||||
title: b.title, description: b.description, dataAffected: b.dataAffected, whoAffected: b.whoAffected || "", consequences: b.consequences || "",
|
||||
actionsTaken: b.actionsTaken || "", adviceToPeople: b.adviceToPeople || "", causedBy: b.causedBy || "",
|
||||
discoveredAt: toLocal(b.discoveredAt), occurredAt: toLocal(b.occurredAt), peopleAffected: b.peopleAffected == null ? "" : String(b.peopleAffected),
|
||||
}} />
|
||||
</li>
|
||||
) : (
|
||||
<li key={b.id} className="border rounded-xl bg-white">
|
||||
<button onClick={() => setOpen(open === b.id ? null : b.id)} className="w-full flex flex-wrap items-center gap-2 p-3 text-left">
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${STATUS[b.status].tone}`}>{STATUS[b.status].label}</span>
|
||||
<span className="font-medium text-gray-900">{b.title}</span>
|
||||
<span className="ml-auto text-xs text-gray-500">
|
||||
Discovered {fmt(b.discoveredAt)} · Regulator {b.regulatorNotifiedAt ? "told" : <strong className="text-red-700">not told</strong>} · People {b.peopleNotifiedAt ? "told" : "not told"}
|
||||
</span>
|
||||
</button>
|
||||
{open === b.id && (
|
||||
<div className="border-t p-3 space-y-3 text-sm">
|
||||
<dl className="grid sm:grid-cols-[10rem_1fr] gap-x-3 gap-y-1 text-gray-700">
|
||||
<dt className="text-gray-500">What happened</dt><dd className="whitespace-pre-line">{b.description}</dd>
|
||||
<dt className="text-gray-500">Information</dt><dd className="whitespace-pre-line">{b.dataAffected}</dd>
|
||||
<dt className="text-gray-500">Who</dt><dd>{b.peopleAffected ?? "?"} people{b.whoAffected ? ` — ${b.whoAffected}` : ""}</dd>
|
||||
{b.actionsTaken && <><dt className="text-gray-500">Done</dt><dd className="whitespace-pre-line">{b.actionsTaken}</dd></>}
|
||||
<dt className="text-gray-500">Recorded</dt><dd>{fmt(b.createdAt)}{b.createdBy ? ` by ${b.createdBy.name}` : ""}</dd>
|
||||
</dl>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={() => setEditing(b.id)} className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50"><Pencil className="w-3.5 h-3.5" /> Edit</button>
|
||||
{!b.regulatorNotifiedAt && <button disabled={busy} onClick={() => send(b.id, { regulatorNotifiedAt: new Date().toISOString() })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Regulator told today</button>}
|
||||
{!b.peopleNotifiedAt && <button disabled={busy} onClick={() => send(b.id, { peopleNotifiedAt: new Date().toISOString() })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">People told today</button>}
|
||||
{b.status !== "contained" && b.status !== "closed" && <button disabled={busy} onClick={() => send(b.id, { status: "contained" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Mark contained</button>}
|
||||
{b.status !== "closed" ? (
|
||||
<button disabled={busy} onClick={() => send(b.id, { status: "closed" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Close</button>
|
||||
) : (
|
||||
<button disabled={busy} onClick={() => send(b.id, { status: "open" })} className="px-3 py-1.5 rounded-lg border text-sm hover:bg-gray-50">Reopen</button>
|
||||
)}
|
||||
</div>
|
||||
<CopyBlock title="Draft message to the people affected" text={peopleText(b)} />
|
||||
<CopyBlock title="Summary for the Information Regulator's form" text={regulatorText(b)} />
|
||||
<p className="text-xs text-gray-500">These are drafts — check them with your Information Officer before sending.</p>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -119,13 +119,14 @@ export function ConsentLogPanel({ token }: { token: string }) {
|
||||
|
||||
// ─── Access log ───────────────────────────────────────────────────────────────
|
||||
|
||||
type AccessRow = { id: string; at: string; actor: string; actorRole: string; action: string; what: string; records: number | null; ip: string | null };
|
||||
type AccessRow = { id: string; at: string; actor: string; actorRole: string; action: string; what: string; records: number | null; ip: string | null; sensitive: boolean };
|
||||
type AccessResponse = LogResponse<AccessRow> & { actions: Record<string, string>; actors: { id: string; name: string }[] };
|
||||
|
||||
/** Admin → Privacy → Access log: who took personal information out of the system in bulk. */
|
||||
export function AccessLogPanel({ token }: { token: string }) {
|
||||
const [action, setAction] = useState("");
|
||||
const [actorId, setActorId] = useState("");
|
||||
const [sensitiveOnly, setSensitiveOnly] = useState(false);
|
||||
const [page, setPage] = useState(1);
|
||||
const [data, setData] = useState<AccessResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -135,12 +136,13 @@ export function AccessLogPanel({ token }: { token: string }) {
|
||||
const params = new URLSearchParams({ page: String(page) });
|
||||
if (action) params.set("action", action);
|
||||
if (actorId) params.set("actorId", actorId);
|
||||
if (sensitiveOnly) params.set("sensitive", "true");
|
||||
setData(await apiFetch<AccessResponse>(`/api/privacy/access-log?${params}`, { authToken: token }));
|
||||
setError(null);
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load the access log.");
|
||||
}
|
||||
}, [token, action, actorId, page]);
|
||||
}, [token, action, actorId, sensitiveOnly, page]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
return (
|
||||
@@ -158,6 +160,10 @@ export function AccessLogPanel({ token }: { token: string }) {
|
||||
<option value="">Everyone</option>
|
||||
{(data?.actors || []).map(a => <option key={a.id} value={a.id}>{a.name}</option>)}
|
||||
</select>
|
||||
<label className="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={sensitiveOnly} onChange={e => { setSensitiveOnly(e.target.checked); setPage(1); }} />
|
||||
Children's or medical information only
|
||||
</label>
|
||||
</div>
|
||||
{error && <p className="text-sm text-red-700">{error}</p>}
|
||||
{!data ? <p className="text-sm text-gray-400">Loading…</p> : data.rows.length === 0 ? <p className="text-sm text-gray-500">Nothing recorded yet.</p> : (
|
||||
@@ -168,7 +174,11 @@ export function AccessLogPanel({ token }: { token: string }) {
|
||||
<tr key={r.id} className="border-t align-top">
|
||||
<td className="p-2 whitespace-nowrap">{fmt(r.at)}</td>
|
||||
<td className="p-2">{r.actor}<div className="text-xs text-gray-500">{r.actorRole}{r.ip ? ` · ${r.ip}` : ""}</div></td>
|
||||
<td className="p-2">{data.actions[r.action] || r.action}<div className="text-xs text-gray-500">{r.what}</div></td>
|
||||
<td className="p-2">
|
||||
{data.actions[r.action] || r.action}
|
||||
{r.sensitive && <span className="ml-2 text-[11px] px-1.5 py-0.5 rounded bg-rose-50 text-rose-700" title="Children's or special personal information">children/medical</span>}
|
||||
<div className="text-xs text-gray-500">{r.what}</div>
|
||||
</td>
|
||||
<td className="p-2 text-right">{r.records ?? "—"}</td>
|
||||
</tr>
|
||||
))}</tbody>
|
||||
|
||||
@@ -19,6 +19,7 @@ const PLACEHOLDER_HELP: [string, string][] = [
|
||||
["{ioEmail}", "Information Officer's email"],
|
||||
["{medicalRetentionDays}", "days before medical details are deleted"],
|
||||
["{operators}", "on its own line: one bullet per active operator"],
|
||||
["{retention}", "on its own line: one bullet per retention period"],
|
||||
];
|
||||
|
||||
const fmt = (d: string) => new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { CheckCircle2, Circle, ArrowRight } from "lucide-react";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Check = { key: string; label: string; ok: boolean; detail: string; href: string | null; applies: boolean };
|
||||
type Readiness = { score: number; passed: number; total: number; checks: Check[] };
|
||||
|
||||
/** Admin → Privacy → Readiness: a checklist of what's set up for POPIA, with a score. */
|
||||
export function ReadinessPanel({ token }: { token: string }) {
|
||||
const [data, setData] = useState<Readiness | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
setData(await apiFetch<Readiness>("/api/privacy/readiness", { authToken: token }));
|
||||
} catch (e) {
|
||||
setError(errorMessage(e) || "Could not load the checklist.");
|
||||
}
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
if (error) return <p className="text-sm text-red-700">{error}</p>;
|
||||
if (!data) return <p className="text-sm text-gray-400">Loading…</p>;
|
||||
|
||||
const tone = data.score >= 90 ? "text-green-700 bg-green-50 border-green-200" : data.score >= 60 ? "text-amber-800 bg-amber-50 border-amber-200" : "text-red-700 bg-red-50 border-red-200";
|
||||
const todo = data.checks.filter(c => c.applies && !c.ok);
|
||||
const done = data.checks.filter(c => c.applies && c.ok);
|
||||
const na = data.checks.filter(c => !c.applies);
|
||||
|
||||
const Row = ({ c }: { c: Check }) => (
|
||||
<li className="flex items-start gap-3 border rounded-xl p-3 bg-white">
|
||||
{c.ok ? <CheckCircle2 className="w-5 h-5 text-green-600 shrink-0 mt-0.5" /> : <Circle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />}
|
||||
<div className="min-w-0 flex-1 text-sm">
|
||||
<div className="font-medium text-gray-900">{c.label}</div>
|
||||
<div className="text-gray-600 break-words">{c.detail}</div>
|
||||
</div>
|
||||
{!c.ok && c.href && (
|
||||
<Link href={c.href} className="shrink-0 inline-flex items-center gap-1 text-sm text-brand-600 hover:underline">
|
||||
Fix <ArrowRight className="w-3.5 h-3.5" />
|
||||
</Link>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className={`flex items-center gap-4 border rounded-xl p-4 ${tone}`}>
|
||||
<div className="text-4xl font-bold tabular-nums">{data.score}%</div>
|
||||
<div className="text-sm">
|
||||
<div className="font-semibold">POPIA readiness</div>
|
||||
<div>{data.passed} of {data.total} checks done. This helps you comply; it isn't legal advice — your Information Officer stays responsible.</div>
|
||||
</div>
|
||||
</div>
|
||||
{todo.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">To do</h3>
|
||||
<ul className="space-y-2">{todo.map(c => <Row key={c.key} c={c} />)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{done.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-gray-900 mb-2">Done</h3>
|
||||
<ul className="space-y-2">{done.map(c => <Row key={c.key} c={c} />)}</ul>
|
||||
</div>
|
||||
)}
|
||||
{na.length > 0 && <p className="text-xs text-gray-500">Not counted (doesn't apply here): {na.map(c => c.label).join(", ")}.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"use client";
|
||||
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { apiFetch } from "@/lib/api";
|
||||
import { errorMessage } from "@/lib/utils";
|
||||
|
||||
type Policy = { enabled: boolean; months: number };
|
||||
type Definition = { label: string; description: string; minMonths: number; defaults: Policy };
|
||||
type State = {
|
||||
policies: Record<string, Policy>;
|
||||
definitions: Record<string, Definition>;
|
||||
reviewedAt: string | null;
|
||||
reviewedBy: string | null;
|
||||
preview: Record<string, Record<string, number>>;
|
||||
lastRun: { at: string; result: Record<string, Record<string, number | string>> } | null;
|
||||
related: { medicalDays: number; backupCount: number; analyticsDays: number };
|
||||
};
|
||||
type Msg = { type: "ok" | "err"; text: string } | null;
|
||||
|
||||
const fmt = (d: string) => new Date(d).toLocaleString(undefined, { day: "numeric", month: "short", year: "numeric", hour: "2-digit", minute: "2-digit" });
|
||||
|
||||
function previewText(key: string, p: Record<string, number> | undefined) {
|
||||
if (!p) return "";
|
||||
if (key === "inactive_accounts") {
|
||||
if (!p.toWarn && !p.toAnonymise) return "Nobody is affected right now.";
|
||||
return `Next run: ${p.toWarn} to warn, ${p.toAnonymise} to anonymise (warned 30+ days ago).`;
|
||||
}
|
||||
return p.count ? `Next run affects ${p.count} record${p.count === 1 ? "" : "s"}.` : "Nothing is due right now.";
|
||||
}
|
||||
|
||||
/** Admin → Privacy → Retention: how long personal information is kept. */
|
||||
export function RetentionPanel({ token }: { token: string }) {
|
||||
const [state, setState] = useState<State | null>(null);
|
||||
const [policies, setPolicies] = useState<Record<string, Policy>>({});
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [msg, setMsg] = useState<Msg>(null);
|
||||
|
||||
const apply = (s: State) => { setState(s); setPolicies(s.policies); };
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
apply(await apiFetch<State>("/api/privacy/retention", { authToken: token }));
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not load retention settings." });
|
||||
}
|
||||
}, [token]);
|
||||
useEffect(() => { load(); }, [load]);
|
||||
|
||||
const save = async () => {
|
||||
const inactive = policies.inactive_accounts;
|
||||
if (state && inactive?.enabled && !state.policies.inactive_accounts.enabled && !confirm("Switch on deleting inactive accounts? People are warned 30 days before; after that it can't be undone.")) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
apply(await apiFetch<State>("/api/privacy/retention", { method: "PUT", authToken: token, body: { policies } }));
|
||||
setMsg({ type: "ok", text: "Saved. The privacy notice's retention section is updated to match." });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "Could not save." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runNow = async () => {
|
||||
if (!confirm("Run the retention job now? It deletes and anonymises everything past its retention period, just like the nightly run.")) return;
|
||||
setBusy(true);
|
||||
setMsg(null);
|
||||
try {
|
||||
const res = await apiFetch<{ state: State }>("/api/privacy/retention/run", { method: "POST", authToken: token });
|
||||
apply(res.state);
|
||||
setMsg({ type: "ok", text: "Done — see \"Last run\" below." });
|
||||
} catch (e) {
|
||||
setMsg({ type: "err", text: errorMessage(e) || "The run failed." });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!state) return msg ? <p className="text-sm text-red-700">{msg.text}</p> : <p className="text-sm text-gray-400">Loading…</p>;
|
||||
const dirty = JSON.stringify(policies) !== JSON.stringify(state.policies);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<p className="text-sm text-gray-600">
|
||||
POPIA says personal information mustn't be kept longer than needed. Every night at 03:30 the retention job deletes or anonymises
|
||||
what's past these periods. The defaults are a sensible start — check them with your Information Officer and save, even if you change nothing.
|
||||
{state.reviewedAt ? ` Last saved ${fmt(state.reviewedAt)}${state.reviewedBy ? ` by ${state.reviewedBy}` : ""}.` : " Not reviewed yet."}
|
||||
</p>
|
||||
|
||||
<ul className="space-y-3">
|
||||
{Object.entries(state.definitions).map(([key, def]) => {
|
||||
const p = policies[key];
|
||||
return (
|
||||
<li key={key} className="border rounded-xl p-4 bg-white">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="flex items-center gap-2 font-medium text-gray-900">
|
||||
<input type="checkbox" className="h-4 w-4 accent-brand-600" checked={p.enabled} onChange={e => setPolicies({ ...policies, [key]: { ...p, enabled: e.target.checked } })} />
|
||||
{def.label}
|
||||
</label>
|
||||
<label className="ml-auto flex items-center gap-2 text-sm text-gray-700">
|
||||
after
|
||||
<input type="number" min={def.minMonths} max={240} className="w-20 border rounded-lg px-2 py-1 text-sm" value={p.months} disabled={!p.enabled}
|
||||
onChange={e => setPolicies({ ...policies, [key]: { ...p, months: parseInt(e.target.value, 10) || def.minMonths } })} aria-label={`${def.label} months`} />
|
||||
months
|
||||
</label>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600">{def.description}</p>
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{p.enabled ? previewText(key, state.preview[key]) : "Off — kept until someone asks for it to be deleted."}
|
||||
{(p.months !== def.defaults.months || p.enabled !== def.defaults.enabled) && ` (Default: ${def.defaults.enabled ? `${def.defaults.months} months` : "off"}.)`}
|
||||
</p>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
<div className="border rounded-xl p-4 bg-gray-50 text-sm text-gray-600 space-y-1">
|
||||
<div className="font-medium text-gray-800">Set elsewhere</div>
|
||||
<div>Medical details from parental consent: deleted {state.related.medicalDays} days after the event — <Link href="/dashboard/admin/settings?tab=legal" className="text-brand-600 hover:underline">Site settings → Legal</Link>.</div>
|
||||
<div>Database backups: the last {state.related.backupCount} are kept — <Link href="/dashboard/admin/settings?tab=backups" className="text-brand-600 hover:underline">Site settings → Backups</Link>.</div>
|
||||
<div>Visit statistics: rolled up into anonymous daily totals after {state.related.analyticsDays} days.</div>
|
||||
<div>Consent records and privacy notice acceptances are kept as proof (without the IP address once an account is deleted).</div>
|
||||
</div>
|
||||
|
||||
{msg && <div className={`p-3 rounded-lg text-sm ${msg.type === "ok" ? "bg-green-50 text-green-800 border border-green-200" : "bg-red-50 text-red-800 border border-red-200"}`}>{msg.text}</div>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button onClick={save} disabled={busy} className="px-4 py-2 rounded-lg bg-brand-600 text-white text-sm font-medium hover:bg-brand-700 disabled:opacity-50">
|
||||
{busy ? "Saving…" : dirty ? "Save changes" : "Save (confirm reviewed)"}
|
||||
</button>
|
||||
<button onClick={runNow} disabled={busy || dirty} className="px-4 py-2 rounded-lg border bg-white text-sm hover:bg-gray-50 disabled:opacity-50" title={dirty ? "Save first" : undefined}>
|
||||
Run now
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{state.lastRun && (
|
||||
<div className="text-xs text-gray-500">
|
||||
<span className="font-medium text-gray-700">Last run</span> {fmt(state.lastRun.at)}:{" "}
|
||||
{Object.entries(state.lastRun.result).length === 0 ? "nothing switched on." : Object.entries(state.lastRun.result).map(([k, r]) => `${state.definitions[k]?.label || k} — ${Object.entries(r).map(([a, b]) => `${a}: ${b}`).join(", ")}`).join("; ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from "react";
|
||||
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye } from "lucide-react";
|
||||
import { Megaphone, Download, PencilLine, Trash2, MailX, Inbox, Clock, ShieldCheck, MessageCircle, ScrollText, Send, ListChecks, Building2, Eye, Gauge, Timer, AlertTriangle } from "lucide-react";
|
||||
import { GuideItem } from "@/components/shared/GuideItem";
|
||||
import type { HelpContent } from "./types";
|
||||
|
||||
@@ -50,8 +50,26 @@ export const userPrivacyHelpContent: HelpContent = {
|
||||
/** Help content for /dashboard/admin/privacy. */
|
||||
export const adminPrivacyHelpContent: HelpContent = {
|
||||
title: "Privacy (POPIA)",
|
||||
subtitle: "Your privacy notice, operators, and the consent and access records.",
|
||||
subtitle: "Your privacy notice, operators, retention, breaches and the consent and access records.",
|
||||
tabs: [
|
||||
{
|
||||
key: "readiness",
|
||||
label: "Readiness & retention",
|
||||
icon: Gauge,
|
||||
content: (
|
||||
<div className="space-y-4">
|
||||
<GuideItem icon={Gauge} title="Readiness" tone="brand">
|
||||
A checklist with a score: Information Officer set, notice published, operators described, retention reviewed and running, requests answered on time, breaches reported, unsubscribe and WhatsApp STOP working, backups, two-factor login. Each open item links to where you fix it.
|
||||
</GuideItem>
|
||||
<GuideItem icon={Timer} title="Retention" tone="amber">
|
||||
How long each kind of personal information is kept. Every night at 03:30 the retention job deletes or anonymises what's older: inactive accounts (after a warning 30 days before — logging in keeps the account), event form answers, login history, payer details from payment notifications, and old audit/access records. The privacy notice's retention section follows these settings ({"{retention}"}). Review and save them, even if you keep the defaults.
|
||||
</GuideItem>
|
||||
<GuideItem icon={AlertTriangle} title="Breaches" tone="rose">
|
||||
Record any security compromise. POPIA requires telling the Information Regulator and the people affected as soon as reasonably possible; each entry has a draft for both, and you record when you told them. Readiness flags any not reported to the Regulator within 3 days.
|
||||
</GuideItem>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "notice",
|
||||
label: "Privacy notice",
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "hope-events",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.64.0",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "hope-events",
|
||||
"version": "1.39.0",
|
||||
"version": "1.40.0",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev:backend": "cd backend && npm run dev",
|
||||
|
||||
Reference in New Issue
Block a user