Files
hope-events/backend/src/utils/accessLog.js
T
joshuaandClaude Opus 5.5 a1ecbb9617 POPIA toolkit part 3: retention, breach register, readiness
- 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
2026-10-23 20:25:58 +02:00

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 };