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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
2026-10-23 18:13:47 +02:00

359 lines
17 KiB
JavaScript

const prisma = require('../config/db');
const { comparePassword } = require('../config/auth');
const { safeErrorMessage } = require('../utils/errorUtils');
const { getClientIp } = require('../utils/requestUtils');
const { logAdminAction } = require('../utils/adminAudit');
const { isValidZAPhone } = require('../utils/whatsapp');
const privacy = require('../utils/privacy');
const fail = (res, error) => res.status(res.statusCode === 200 ? 400 : res.statusCode).json({ message: safeErrorMessage(error) });
const REQUEST_LABEL = { access: 'access to your information', correction: 'correction of your information', deletion: 'deletion of your account and personal information' };
function frontendUrl() {
return String(process.env.FRONTEND_URL || process.env.APP_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
}
function sendPrivacyEmail(to, subject, content) {
const { sendMail, buildPrivacyEmail } = require('../utils/email');
if (!to) return;
sendMail({ to, subject, ...buildPrivacyEmail(content) }).catch(() => {});
}
/** Tell the Information Officer (when set) and the admins that a request came in. */
async function notifyAdminsOfRequest(request) {
const to = await privacy.privacyContacts();
const url = `${frontendUrl()}/dashboard/admin/data-requests`;
for (const email of to) {
sendPrivacyEmail(email, `New privacy request: ${request.type}`, {
name: '',
heading: 'New privacy request',
paragraphs: [
`${request.name} (${request.email}) asked for ${REQUEST_LABEL[request.type]}.`,
...(request.details ? [`Their message: ${request.details}`] : []),
`POPIA asks for an answer within 30 days — by ${request.dueAt.toLocaleDateString('en-ZA')}. Open the Data requests page to handle it: ${url}`,
],
});
}
}
/**
* "Delete my account" (password already checked by the caller): lock the account straight
* away and file a deletion request. The personal information is removed when an admin
* approves — registrations for upcoming events have to be sorted out first.
*/
async function requestAccountDeletion(user, { details = null } = {}) {
const request = await privacy.createDataRequest({ user, type: 'deletion', details: details ? String(details).trim() : null });
await prisma.user.update({ where: { id: user.id }, data: { isActive: false, tokenVersion: { increment: 1 }, newsEmail: false, newsWhatsApp: false } });
await require('../utils/twoFactor').clearTrustedDevices(user.id);
const blockers = await privacy.deletionBlockers(user.id);
sendPrivacyEmail(user.email, 'We received your request to delete your account', {
name: user.name,
heading: 'Deletion request received',
paragraphs: [
'We received your request to delete your account and personal information. Your account has been closed, so you can no longer log in.',
blockers.length
? `You still have a registration for ${blockers.map(b => b.eventTitle).join(', ')}. Your information is deleted after that, or sooner if the registration is cancelled. Contact us if you'd like to cancel it.`
: 'We will remove your personal information within 30 days and let you know when it is done.',
'Records we must keep by law, such as payment amounts and dates, are kept without your name on them.',
'If you did not ask for this, contact us immediately.',
],
});
notifyAdminsOfRequest(request).catch(() => {});
return request;
}
// ─── The person themselves ────────────────────────────────────────────────────
// @desc My privacy settings: news opt-in, consent history, my requests
// @route GET /api/privacy/me
// @access Private
const getMyPrivacy = async (req, res) => {
try {
const user = await prisma.user.findUnique({
where: { id: req.user.id },
select: { phoneNumber: true, notificationPreference: true, newsEmail: true, newsWhatsApp: true, newsAskedAt: true },
});
if (!user) { res.status(404); throw new Error('User not found'); }
const [history, requests] = await Promise.all([
privacy.consentHistory(req.user.id),
prisma.dataRequest.findMany({
where: { userId: req.user.id, selfService: false },
orderBy: { createdAt: 'desc' },
select: { id: true, type: true, status: true, details: true, response: true, createdAt: true, closedAt: true },
}),
]);
res.json({
news: { email: user.newsEmail, whatsapp: user.newsWhatsApp },
newsAskedAt: user.newsAskedAt,
canWhatsApp: isValidZAPhone(user.phoneNumber),
notificationPreference: user.notificationPreference,
history,
requests,
});
} catch (error) {
fail(res, error);
}
};
// @desc Change my News & announcements opt-in
// @route PUT /api/privacy/me/news
// @access Private
const updateMyNews = async (req, res) => {
try {
const { email, whatsapp, source } = req.body || {};
// 'prompt' = the one-time question after login; anything else is the privacy centre.
const fromPrompt = source === 'prompt';
const updated = await privacy.setNewsPreferences(
req.user.id,
{ email: typeof email === 'boolean' ? email : undefined, whatsapp: typeof whatsapp === 'boolean' ? whatsapp : undefined },
{ source: fromPrompt ? 'prompt' : 'privacy_centre', ip: getClientIp(req), answered: true, logAll: fromPrompt },
);
if (!updated) { res.status(404); throw new Error('User not found'); }
res.json({ news: { email: updated.newsEmail, whatsapp: updated.newsWhatsApp }, newsAskedAt: updated.newsAskedAt });
} catch (error) {
fail(res, error);
}
};
// @desc Download my data (JSON, or a readable PDF with ?format=pdf)
// @route GET /api/privacy/me/export
// @access Private
const exportMyData = async (req, res) => {
try {
// An admin viewing as this person must not walk off with their whole record.
if (req.impersonation) { res.status(403); throw new Error('Not available while viewing as another user.'); }
const data = await privacy.exportUserData(req.user.id);
if (!data) { res.status(404); throw new Error('User not found'); }
// Kept as a closed access request: the record that the person got their data.
await privacy.createDataRequest({ user: { id: req.user.id, name: data.account.name, email: data.account.email }, type: 'access', selfService: true });
const stamp = new Date().toISOString().slice(0, 10);
if (req.query.format === 'pdf') {
const pdf = await privacy.dataExportPdf(data);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.pdf"`);
return res.send(pdf);
}
res.setHeader('Content-Type', 'application/json');
res.setHeader('Content-Disposition', `attachment; filename="my-data-${stamp}.json"`);
res.send(JSON.stringify(data, null, 2));
} catch (error) {
fail(res, error);
}
};
// @desc Make a privacy request: correction (details required) or deletion (password required)
// @route POST /api/privacy/me/requests
// @access Private
const createMyRequest = async (req, res) => {
try {
const { type, details, password } = req.body || {};
if (!['correction', 'deletion'].includes(type)) { res.status(400); throw new Error('type must be correction or deletion'); }
const user = await prisma.user.findUnique({ where: { id: req.user.id } });
if (!user) { res.status(404); throw new Error('User not found'); }
const open = await prisma.dataRequest.findFirst({ where: { userId: user.id, type, status: 'open' } });
if (open) { res.status(409); throw new Error(`You already have an open ${type} request.`); }
if (type === 'correction') {
if (!details || !String(details).trim()) { res.status(400); throw new Error('Tell us what needs correcting.'); }
const request = await privacy.createDataRequest({ user, type, details: String(details).trim() });
notifyAdminsOfRequest(request).catch(() => {});
return res.status(201).json({ request, message: 'Your request was sent. We will get back to you by email.' });
}
if (!(await comparePassword(password || '', user.password))) { res.status(400); throw new Error('Incorrect password'); }
const request = await requestAccountDeletion(user, { details });
res.status(201).json({ request, message: 'Your account is closed and your deletion request was sent.' });
} catch (error) {
fail(res, error);
}
};
// ─── Unsubscribe links (no login) ─────────────────────────────────────────────
// @desc What an unsubscribe link refers to
// @route GET /api/privacy/unsubscribe/:token
// @access Public (signed token)
const getUnsubscribe = async (req, res) => {
try {
const info = await privacy.describeUnsubscribe(req.params.token);
if (!info) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
res.json(info);
} catch (error) {
fail(res, error);
}
};
// @desc Unsubscribe from news (body.channels: ['email','whatsapp'], default both)
// @route POST /api/privacy/unsubscribe/:token
// @access Public (signed token)
const postUnsubscribe = async (req, res) => {
try {
const channels = Array.isArray(req.body?.channels) ? req.body.channels : undefined;
const result = await privacy.unsubscribe(req.params.token, { channels, ip: getClientIp(req) });
if (!result) { res.status(404); throw new Error('This unsubscribe link is not valid.'); }
res.json({ message: 'You have been unsubscribed.' });
} catch (error) {
fail(res, error);
}
};
// @desc One-click unsubscribe (RFC 8058) from the List-Unsubscribe header — email only
// @route POST /api/privacy/unsubscribe/:token/one-click
// @access Public (signed token)
const oneClickUnsubscribe = async (req, res) => {
try {
await privacy.unsubscribe(req.params.token, { channels: ['email'], ip: getClientIp(req) });
} catch { /* mail apps ignore the body; never fail loudly */ }
res.status(200).send('Unsubscribed');
};
// @desc A person opening the List-Unsubscribe link in a browser: send them to the page
// @route GET /api/privacy/unsubscribe/:token/one-click
// @access Public
const oneClickRedirect = (req, res) => {
res.redirect(302, `${frontendUrl()}/unsubscribe?t=${encodeURIComponent(req.params.token)}`);
};
// ─── Admin: data requests inbox ───────────────────────────────────────────────
// @desc Data requests (?status=open|closed|all, default open)
// @route GET /api/privacy/requests
// @access Private/Admin
const listRequests = async (req, res) => {
try {
const status = String(req.query.status || 'open');
const where = { selfService: false };
if (status === 'open') where.status = 'open';
else if (status === 'closed') where.status = { not: 'open' };
const rows = await prisma.dataRequest.findMany({
where,
orderBy: status === 'open' ? { dueAt: 'asc' } : { createdAt: 'desc' },
take: 200,
include: { handledBy: { select: { name: true } }, user: { select: { isActive: true, phoneNumber: true } } },
});
const requests = await Promise.all(rows.map(async r => ({
id: r.id, type: r.type, status: r.status, name: r.name, email: r.email, details: r.details, response: r.response,
dueAt: r.dueAt, closedAt: r.closedAt, createdAt: r.createdAt, userId: r.userId,
handledBy: r.handledBy?.name || null,
overdue: r.status === 'open' && r.dueAt < new Date(),
blockers: r.type === 'deletion' && r.status === 'open' && r.userId ? await privacy.deletionBlockers(r.userId) : [],
})));
const openCount = status === 'open' ? requests.length : await prisma.dataRequest.count({ where: { selfService: false, status: 'open' } });
res.json({ requests, openCount });
} catch (error) {
fail(res, error);
}
};
async function loadOpenRequest(req, res) {
const request = await prisma.dataRequest.findUnique({ where: { id: req.params.id } });
if (!request) { res.status(404); throw new Error('Request not found'); }
if (request.status !== 'open') { res.status(409); throw new Error('This request is already closed.'); }
return request;
}
// @desc Complete a request. Deletion: anonymises the account (refused while it has a
// registration for an upcoming event). body.response is emailed to the person.
// @route POST /api/privacy/requests/:id/complete
// @access Private/Admin
const completeRequest = async (req, res) => {
try {
const request = await loadOpenRequest(req, res);
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : null;
if (request.type === 'correction' && !response) { res.status(400); throw new Error('Say what was corrected — it is emailed to the person.'); }
let counts = null;
if (request.type === 'deletion' && request.userId) {
const blockers = await privacy.deletionBlockers(request.userId);
if (blockers.length) {
res.status(409);
throw new Error(`Cancel (and refund where needed) these registrations first: ${blockers.map(b => b.eventTitle).join(', ')}.`);
}
counts = await privacy.anonymiseUser(request.userId);
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'user_anonymised', targetType: 'user', targetId: request.userId,
metadata: { dataRequestId: request.id, ...counts }, ip: getClientIp(req),
});
}
const closed = await prisma.dataRequest.update({
where: { id: request.id },
data: { status: 'completed', response, closedAt: new Date(), handledById: req.user.id },
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_completed', targetType: 'data_request', targetId: request.id,
metadata: { type: request.type, name: request.name }, ip: getClientIp(req),
});
sendPrivacyEmail(request.email, request.type === 'deletion' ? 'Your personal information has been deleted' : 'Your privacy request has been completed', {
name: request.name,
heading: request.type === 'deletion' ? 'Your information has been deleted' : 'Request completed',
paragraphs: [
request.type === 'deletion'
? 'Your account and personal information have been deleted. Records we must keep by law, such as payment amounts and dates, are kept without your name on them. Backups that still contain your information are deleted as they age out.'
: `We have completed your request for ${REQUEST_LABEL[request.type]}.`,
...(response ? [response] : []),
],
});
res.json({ request: closed, anonymised: counts });
} catch (error) {
fail(res, error);
}
};
// @desc Reject or cancel a request (body.response says why; emailed). Rejecting a
// deletion request opens the account again (e.g. the person changed their mind).
// @route POST /api/privacy/requests/:id/reject
// @access Private/Admin
const rejectRequest = async (req, res) => {
try {
const request = await loadOpenRequest(req, res);
const response = req.body?.response ? String(req.body.response).trim().slice(0, 5000) : '';
if (!response) { res.status(400); throw new Error('Give a reason — it is emailed to the person.'); }
const cancelled = req.body?.cancelled === true;
if (request.type === 'deletion' && request.userId) {
await prisma.user.update({ where: { id: request.userId }, data: { isActive: true } });
}
const closed = await prisma.dataRequest.update({
where: { id: request.id },
data: { status: cancelled ? 'cancelled' : 'rejected', response, closedAt: new Date(), handledById: req.user.id },
});
logAdminAction({
actorId: req.user.id, actorRole: req.user.role, action: 'data_request_rejected', targetType: 'data_request', targetId: request.id,
metadata: { type: request.type, name: request.name, cancelled, reactivated: request.type === 'deletion' }, ip: getClientIp(req),
});
sendPrivacyEmail(request.email, cancelled ? 'Your privacy request was cancelled' : 'About your privacy request', {
name: request.name,
heading: cancelled ? 'Request cancelled' : 'About your request',
paragraphs: [
`Your request for ${REQUEST_LABEL[request.type]} was ${cancelled ? 'cancelled' : 'not carried out'}.`,
response,
...(request.type === 'deletion' ? ['Your account is open again — you can log in as before.'] : []),
...(cancelled ? [] : ['If you disagree, you may complain to the Information Regulator (inforegulator.org.za).']),
],
});
res.json({ request: closed });
} catch (error) {
fail(res, error);
}
};
module.exports = {
getMyPrivacy,
updateMyNews,
exportMyData,
createMyRequest,
getUnsubscribe,
postUnsubscribe,
oneClickUnsubscribe,
oneClickRedirect,
listRequests,
completeRequest,
rejectRequest,
notifyAdminsOfRequest,
requestAccountDeletion,
};