- 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
96 lines
6.7 KiB
JavaScript
96 lines
6.7 KiB
JavaScript
/**
|
|
* 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 };
|