- Retention policies (Admin -> Privacy -> Retention) applied by a nightly
`retention` job: inactive accounts anonymised after 5 years with a
30-day warning (on by default; never staff roles or people with an
upcoming registration), form answers, login history, payer details in
payment notifications, audit/access logs
- User.lastLoginAt (set on login, backfilled) and inactivityWarnedAt
- Breach register (SecurityBreach) with drafts for the people affected
and the Information Regulator
- Readiness checklist with a score
- Children's/medical flag in the access log; {retention} placeholder in
the privacy notice
- Unit tests (retention.test.js), e2e (privacy-retention.spec.ts); docs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012mt6TCv85T9VKRq3cV7rXq
60 lines
2.4 KiB
JavaScript
60 lines
2.4 KiB
JavaScript
const prisma = require('../config/db');
|
|
const { getClientIp } = require('./requestUtils');
|
|
|
|
// What each DataAccessLog action means, for the admin page.
|
|
const ACCESS_ACTIONS = {
|
|
report_pdf: 'Report downloaded (PDF)',
|
|
report_excel: 'Report downloaded (Excel)',
|
|
report_email: 'Report emailed',
|
|
report_whatsapp: 'Report sent by WhatsApp',
|
|
attendance_export: 'Attendance register exported',
|
|
certificates_download: 'Certificates downloaded',
|
|
group_lists: 'Room/group lists opened',
|
|
form_responses: 'Attendee form answers viewed',
|
|
consent_list: 'Parental consent list viewed',
|
|
medical_list: 'Medical list viewed',
|
|
backup_download: 'Database backup downloaded',
|
|
impersonation: 'Viewed the site as another user',
|
|
};
|
|
|
|
/**
|
|
* 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, 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,
|
|
actorRole: req.user?.role || 'unknown',
|
|
action,
|
|
what: String(what || ACCESS_ACTIONS[action] || action).slice(0, 500),
|
|
eventId: eventId || null,
|
|
records: Number.isFinite(records) ? records : null,
|
|
sensitive: isSensitive,
|
|
ip: getClientIp(req),
|
|
},
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to log data access:', e?.message);
|
|
}
|
|
}
|
|
|
|
/** Rows in a report payload (main table or layered sections, plus extra tables). */
|
|
function reportRowCount(payload) {
|
|
const p = payload || {};
|
|
let n = Array.isArray(p.table?.rows) ? p.table.rows.length : 0;
|
|
if (Array.isArray(p.layered?.sections)) n += p.layered.sections.reduce((s, sec) => s + (Array.isArray(sec.items) ? sec.items.length : 0), 0);
|
|
if (Array.isArray(p.extraTables)) n += p.extraTables.reduce((s, t) => s + (Array.isArray(t.rows) ? t.rows.length : 0), 0);
|
|
return n;
|
|
}
|
|
|
|
module.exports = { ACCESS_ACTIONS, logDataAccess, reportRowCount };
|