Files
hope-events/backend/src/controllers/privacyAdminController.js
T
joshuaandClaude Opus 5.5 5dea712b72 Privacy notice: built-in text up to date, review flow, acceptance (1.48.1)
The built-in notice now covers volunteer rosters, event series, season
passes, webhooks, how people agree to it, and the provider-notices
storage item. Sites with their own published version see what changed
in the editor (sections marked, use new text per section or all, add
missing sections, "Done - hide this"). Account activation asks for
agreement to the terms and notice; anyone who never accepted is asked
once after login. The cookie button shows above any cookie section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-28 13:15:57 +02:00

467 lines
20 KiB
JavaScript

const prisma = require('../config/db');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { ACCESS_ACTIONS } = require('../utils/accessLog');
const notice = require('../utils/privacyNotice');
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
const PAGE_SIZE = 50;
const page = req => Math.max(parseInt(req.query.page, 10) || 1, 1);
// ─── Privacy notice ───────────────────────────────────────────────────────────
// @desc The privacy notice as published (current, or ?version=N), placeholders filled in
// @route GET /api/privacy/notice
// @access Public
const getNotice = async (req, res) => {
try {
const version = req.query.version != null ? parseInt(req.query.version, 10) : null;
if (req.query.version != null && !(version >= 0)) { res.status(400); throw new Error('Invalid version'); }
const result = await notice.publicNotice(version);
if (!result) { res.status(404); throw new Error('That version of the privacy notice does not exist.'); }
res.json(result);
} catch (error) {
fail(res, error);
}
};
// @desc Accept the current privacy notice
// @route POST /api/privacy/me/notice/accept
// @access Private
const acceptMyNotice = async (req, res) => {
try {
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
const version = await notice.acceptNotice(req.user.id, { source: 'prompt', ip: getClientIp(req) });
res.json({ version });
} catch (error) {
fail(res, error);
}
};
async function versionList() {
const [versions, counts] = await Promise.all([
prisma.privacyNotice.findMany({
orderBy: { version: 'desc' },
select: { version: true, publishedAt: true, changeSummary: true, requiresAcceptance: true, publishedBy: { select: { name: true } } },
}),
prisma.noticeAcceptance.groupBy({ by: ['version'], _count: { _all: true } }),
]);
const byVersion = new Map(counts.map(c => [c.version, c._count._all]));
return versions.map(v => ({ ...v, publishedBy: v.publishedBy?.name || null, acceptances: byVersion.get(v.version) || 0 }));
}
// @desc The editor: the draft (or current text), how it reads, and the published versions
// @route GET /api/privacy/notice/draft
// @access Private/Admin
const getNoticeDraft = async (req, res) => {
try {
const draft = await notice.getDraft();
const ctx = await notice.noticeContext();
res.json({
...draft,
rendered: notice.renderSections(draft.sections, ctx),
placeholders: notice.PLACEHOLDERS,
versions: await versionList(),
builtIn: notice.defaultSections(),
// Changes to the built-in text since this site last reviewed them (only once it publishes its own).
builtInUpdates: await notice.builtInUpdates(),
});
} catch (error) {
fail(res, error);
}
};
// @desc The built-in text changes have been gone through (the editor stops listing them)
// @route POST /api/privacy/notice/builtin-reviewed
// @access Private/Admin
const markBuiltInReviewed = async (req, res) => {
try {
await notice.markBuiltInReviewed();
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'settings_updated', targetType: 'privacy_notice',
metadata: { builtInReviewed: notice.BUILT_IN_REVISION }, ip: getClientIp(req),
});
res.json({ builtInUpdates: [] });
} catch (error) {
fail(res, error);
}
};
// @desc Save the draft (body.sections)
// @route PUT /api/privacy/notice/draft
// @access Private/Admin
const saveNoticeDraft = async (req, res) => {
try {
const sections = await notice.saveDraft(req.body?.sections);
res.json({ sections, rendered: notice.renderSections(sections, await notice.noticeContext()), isDraft: true, savedAt: new Date() });
} catch (error) {
fail(res, error);
}
};
// @desc Throw the draft away (back to the current published text)
// @route DELETE /api/privacy/notice/draft
// @access Private/Admin
const discardNoticeDraft = async (req, res) => {
try {
await notice.discardDraft();
res.json({ message: 'Draft discarded.' });
} catch (error) {
fail(res, error);
}
};
// @desc Publish body.sections as the next version (body.changeSummary, body.requiresAcceptance)
// @route POST /api/privacy/notice/publish
// @access Private/Admin
const publishNotice = async (req, res) => {
try {
const { sections, changeSummary, requiresAcceptance } = req.body || {};
if (requiresAcceptance && !String(changeSummary || '').trim()) {
res.status(400);
throw new Error('Say what changed — people see it when they are asked to accept the new version.');
}
const published = await notice.publish({ sections, changeSummary, requiresAcceptance, publishedById: req.user.id });
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'privacy_notice_published', targetType: 'privacy_notice', targetId: published.id,
metadata: { version: published.version, requiresAcceptance: published.requiresAcceptance, changeSummary: published.changeSummary }, ip: getClientIp(req),
});
res.status(201).json({ version: published.version, publishedAt: published.publishedAt, requiresAcceptance: published.requiresAcceptance });
} catch (error) {
fail(res, error);
}
};
// ─── Operators ────────────────────────────────────────────────────────────────
const OPERATOR_FIELDS = ['name', 'purpose', 'dataShared', 'location', 'website'];
function operatorData(body, { partial = false } = {}) {
const data = {};
for (const f of OPERATOR_FIELDS) {
if (body?.[f] === undefined) continue;
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'name' ? 200 : 1000);
data[f] = v || (f === 'location' || f === 'website' ? null : '');
}
if (typeof body?.active === 'boolean') data.active = body.active;
if (Number.isInteger(body?.order)) data.order = body.order;
if ((!partial || 'name' in data) && !data.name) throw new Error('Give the operator a name.');
if ((!partial || 'purpose' in data) && !data.purpose) throw new Error('Say what the operator does for you.');
if (data.website && !/^https?:\/\//i.test(data.website)) data.website = `https://${data.website}`;
return data;
}
// @desc The operator register
// @route GET /api/privacy/operators
// @access Private/Admin
const listOperators = async (req, res) => {
try {
res.json(await prisma.operator.findMany({ orderBy: [{ order: 'asc' }, { name: 'asc' }] }));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/privacy/operators
const createOperator = async (req, res) => {
try {
const data = operatorData(req.body);
if (data.order === undefined) data.order = (await prisma.operator.count()) + 1;
const op = await prisma.operator.create({ data });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_created', targetType: 'operator', targetId: op.id, metadata: { name: op.name }, ip: getClientIp(req) });
res.status(201).json(op);
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/privacy/operators/:id
const updateOperator = async (req, res) => {
try {
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Operator not found'); }
const op = await prisma.operator.update({ where: { id: existing.id }, data: operatorData(req.body, { partial: true }) });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_updated', targetType: 'operator', targetId: op.id, metadata: { name: op.name, active: op.active }, ip: getClientIp(req) });
res.json(op);
} catch (error) {
fail(res, error);
}
};
// @route DELETE /api/privacy/operators/:id
const deleteOperator = async (req, res) => {
try {
const existing = await prisma.operator.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Operator not found'); }
await prisma.operator.delete({ where: { id: existing.id } });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'operator_deleted', targetType: 'operator', targetId: existing.id, metadata: { name: existing.name }, ip: getClientIp(req) });
res.json({ message: 'Operator removed.' });
} catch (error) {
fail(res, error);
}
};
// ─── Consent log ──────────────────────────────────────────────────────────────
// Filter on the person's name or email.
function personWhere(q) {
return q ? { OR: [{ name: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' } }] } : undefined;
}
// @desc Proof of consent: ?type=news (news opt-in changes), notice (privacy notice
// acceptances) or parental (parental consent answers); ?q= name/email; ?page=
// @route GET /api/privacy/consent-log
// @access Private/Admin
const consentLog = async (req, res) => {
try {
const type = String(req.query.type || 'news');
const q = String(req.query.q || '').trim();
const skip = (page(req) - 1) * PAGE_SIZE;
const user = { select: { name: true, email: true } };
if (type === 'news') {
const where = q ? { OR: [{ user: personWhere(q) }, { address: { contains: q, mode: 'insensitive' } }] } : {};
const [rows, total] = await Promise.all([
prisma.communicationConsent.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
prisma.communicationConsent.count({ where }),
]);
return res.json({
total, pageSize: PAGE_SIZE,
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || r.address, channel: r.channel, granted: r.granted, source: r.source, ip: r.ip })),
});
}
if (type === 'notice') {
const where = q ? { user: personWhere(q) } : {};
const [rows, total] = await Promise.all([
prisma.noticeAcceptance.findMany({ where, orderBy: { createdAt: 'desc' }, skip, take: PAGE_SIZE, include: { user } }),
prisma.noticeAcceptance.count({ where }),
]);
return res.json({
total, pageSize: PAGE_SIZE,
rows: rows.map(r => ({ id: r.id, at: r.createdAt, name: r.user?.name || null, email: r.user?.email || null, version: r.version, source: r.source, ip: r.ip })),
});
}
if (type === 'parental') {
const where = {
status: { in: ['given', 'declined'] },
...(q ? { OR: [{ attendee: { name: { contains: q, mode: 'insensitive' } } }, { parentName: { contains: q, mode: 'insensitive' } }, { parentEmail: { contains: q, mode: 'insensitive' } }, { signedName: { contains: q, mode: 'insensitive' } }] } : {}),
};
const [rows, total] = await Promise.all([
prisma.consentRequest.findMany({
where, orderBy: { respondedAt: 'desc' }, skip, take: PAGE_SIZE,
select: { id: true, status: true, signedName: true, parentName: true, textVersion: true, photoConsent: true, respondedAt: true, ip: true, medicalPurgedAt: true, attendee: { select: { name: true } }, event: { select: { title: true } } },
}),
prisma.consentRequest.count({ where }),
]);
return res.json({
total, pageSize: PAGE_SIZE,
rows: rows.map(r => ({
id: r.id, at: r.respondedAt, child: r.attendee?.name, event: r.event?.title, status: r.status, signedName: r.signedName,
parentName: r.parentName, textVersion: r.textVersion, photoConsent: r.photoConsent, ip: r.ip, medicalDeleted: !!r.medicalPurgedAt,
})),
});
}
res.status(400);
throw new Error('type must be news, notice or parental');
} catch (error) {
fail(res, error);
}
};
// ─── Access log ───────────────────────────────────────────────────────────────
// @desc Who exported or viewed personal information in bulk (?action=, ?actorId=, ?from=, ?to=, ?page=)
// @route GET /api/privacy/access-log
// @access Private/Admin
const accessLog = async (req, res) => {
try {
const where = {};
if (req.query.action) where.action = String(req.query.action);
if (req.query.sensitive === 'true') where.sensitive = true;
if (req.query.actorId) where.actorId = String(req.query.actorId);
if (req.query.from || req.query.to) {
where.createdAt = {};
if (req.query.from) where.createdAt.gte = new Date(String(req.query.from));
if (req.query.to) where.createdAt.lte = new Date(String(req.query.to));
}
const [rows, total, actors] = await Promise.all([
prisma.dataAccessLog.findMany({ where, orderBy: { createdAt: 'desc' }, skip: (page(req) - 1) * PAGE_SIZE, take: PAGE_SIZE, include: { actor: { select: { name: true } } } }),
prisma.dataAccessLog.count({ where }),
prisma.dataAccessLog.findMany({ where: { actorId: { not: null } }, distinct: ['actorId'], select: { actorId: true, actor: { select: { name: true } } } }),
]);
res.json({
total, pageSize: PAGE_SIZE,
actions: ACCESS_ACTIONS,
actors: actors.map(a => ({ id: a.actorId, name: a.actor?.name || 'Unknown' })),
rows: rows.map(r => ({ id: r.id, at: r.createdAt, actor: r.actor?.name || 'Deleted user', actorRole: r.actorRole, action: r.action, what: r.what, records: r.records, eventId: r.eventId, ip: r.ip, sensitive: r.sensitive })),
});
} catch (error) {
fail(res, error);
}
};
// ─── Readiness ────────────────────────────────────────────────────────────────
// @desc POPIA readiness checklist with a score
// @route GET /api/privacy/readiness
// @access Private/Admin
const getReadiness = async (req, res) => {
try {
res.json(await require('../utils/readiness').readiness());
} catch (error) {
fail(res, error);
}
};
// ─── Retention ────────────────────────────────────────────────────────────────
async function retentionState() {
const retention = require('../utils/retention');
const { getSetting } = require('../utils/settingsCache');
const current = await retention.getPolicies();
const [preview, lastRun, medicalDays, backupCount] = await Promise.all([
retention.preview(current.policies),
retention.lastRun(),
getSetting('consent_medical_retention_days', ''),
getSetting('backup_retain_count', ''),
]);
return {
...current,
definitions: Object.fromEntries(Object.entries(retention.POLICIES).map(([k, d]) => [k, { label: d.label, description: d.description, minMonths: d.minMonths, defaults: d.defaults }])),
preview,
lastRun,
related: { medicalDays: parseInt(medicalDays, 10) || 30, backupCount: parseInt(backupCount, 10) || 14, analyticsDays: 90 },
};
}
// @desc Retention policies, what they'd affect now, and the last run
// @route GET /api/privacy/retention
// @access Private/Admin
const getRetention = async (req, res) => {
try {
res.json(await retentionState());
} catch (error) {
fail(res, error);
}
};
// @desc Save retention policies (body.policies: { key: { enabled, months } })
// @route PUT /api/privacy/retention
// @access Private/Admin
const saveRetention = async (req, res) => {
try {
const saved = await require('../utils/retention').savePolicies(req.body?.policies, { reviewedBy: req.user.name });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'retention_policies_updated', targetType: 'retention', metadata: saved.policies, ip: getClientIp(req) });
res.json(await retentionState());
} catch (error) {
fail(res, error);
}
};
// @desc Run the retention job now
// @route POST /api/privacy/retention/run
// @access Private/Admin
const runRetentionNow = async (req, res) => {
try {
const result = await require('../utils/retention').runRetention(new Date());
res.json({ result, state: await retentionState() });
} catch (error) {
fail(res, error);
}
};
// ─── Breach register ──────────────────────────────────────────────────────────
const BREACH_TEXT = ['title', 'description', 'dataAffected', 'whoAffected', 'consequences', 'actionsTaken', 'adviceToPeople', 'causedBy'];
const BREACH_DATES = ['discoveredAt', 'occurredAt', 'regulatorNotifiedAt', 'peopleNotifiedAt'];
function breachData(body, { partial = false } = {}) {
const data = {};
for (const f of BREACH_TEXT) {
if (body?.[f] === undefined) continue;
const v = body[f] == null ? '' : String(body[f]).trim().slice(0, f === 'title' ? 200 : 10000);
data[f] = v || (['title', 'description', 'dataAffected'].includes(f) ? '' : null);
}
for (const f of BREACH_DATES) {
if (body?.[f] === undefined) continue;
if (body[f] === null || body[f] === '') { data[f] = null; continue; }
const d = new Date(body[f]);
if (Number.isNaN(d.getTime())) throw new Error(`${f} is not a valid date.`);
data[f] = d;
}
if (body?.peopleAffected !== undefined) {
const n = body.peopleAffected === null || body.peopleAffected === '' ? null : parseInt(body.peopleAffected, 10);
if (n !== null && (!Number.isInteger(n) || n < 0)) throw new Error('People affected must be a whole number.');
data.peopleAffected = n;
}
if (body?.status !== undefined) {
if (!['open', 'contained', 'closed'].includes(body.status)) throw new Error('status must be open, contained or closed');
data.status = body.status;
}
for (const f of ['title', 'description', 'dataAffected']) {
if ((!partial || f in data) && !data[f]) throw new Error({ title: 'Give it a short title.', description: 'Describe what happened.', dataAffected: 'Say what information was affected.' }[f]);
}
if (!partial && !data.discoveredAt) throw new Error('When was it discovered?');
if (partial && 'discoveredAt' in data && !data.discoveredAt) throw new Error('When was it discovered?');
return data;
}
// @route GET /api/privacy/breaches
const listBreaches = async (req, res) => {
try {
res.json(await prisma.securityBreach.findMany({ orderBy: { discoveredAt: 'desc' }, include: { createdBy: { select: { name: true } } } }));
} catch (error) {
fail(res, error);
}
};
// @route POST /api/privacy/breaches
const createBreach = async (req, res) => {
try {
const breach = await prisma.securityBreach.create({ data: { ...breachData(req.body), createdById: req.user.id } });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_recorded', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title }, ip: getClientIp(req) });
res.status(201).json(breach);
} catch (error) {
fail(res, error);
}
};
// @route PUT /api/privacy/breaches/:id
const updateBreach = async (req, res) => {
try {
const existing = await prisma.securityBreach.findUnique({ where: { id: req.params.id } });
if (!existing) { res.status(404); throw new Error('Not found'); }
const data = breachData(req.body, { partial: true });
const breach = await prisma.securityBreach.update({ where: { id: existing.id }, data });
logAdminAction({ actorId: req.user.id, actorRole: req.user.role, action: 'breach_updated', targetType: 'security_breach', targetId: breach.id, metadata: { title: breach.title, changed: Object.keys(data), status: breach.status }, ip: getClientIp(req) });
res.json(breach);
} catch (error) {
fail(res, error);
}
};
module.exports = {
markBuiltInReviewed,
getReadiness,
getRetention,
saveRetention,
runRetentionNow,
listBreaches,
createBreach,
updateBreach,
getNotice,
acceptMyNotice,
getNoticeDraft,
saveNoticeDraft,
discardNoticeDraft,
publishNotice,
listOperators,
createOperator,
updateOperator,
deleteOperator,
consentLog,
accessLog,
};