fix(pitch-deck): GDPR compliance — automated cleanup, full Art. 13 notice
Build pitch-deck / build-push-deploy (push) Successful in 1m37s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 38s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 30s
Build pitch-deck / build-push-deploy (push) Successful in 1m37s
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-consent (push) Successful in 38s
CI / test-python-voice (push) Successful in 32s
CI / test-bqas (push) Successful in 30s
- runDataCleanup() replaces maskOverdueInvestors(): now also anonymizes never-activated invites after 90 days, deletes sessions + magic links older than 30 days, NULLs IPs in audit logs older than 30 days, and redacts email from audit log details JSONB for masked investors - New /api/admin/cleanup POST endpoint for scheduled invocation - New .gitea/workflows/pitch-cleanup.yml: daily cron at 02:00 UTC calls the cleanup endpoint so anonymization is genuinely automatic, not lazy - Switch masking window from first_activity_at to last_login_at (30 days of inactivity; resets on each login) - Both auth pages: DSGVO footer now covers all Art. 13 requirements — data categories, retention cutoffs, Art. 15–21 rights, contact address, LfDI Baden-Württemberg as supervisory authority Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+81
-14
@@ -1,30 +1,97 @@
|
||||
import pool from '@/lib/db'
|
||||
|
||||
const MASKING_HOURS = parseInt(process.env.DATA_MASKING_HOURS || '72')
|
||||
const MASKING_DAYS = parseInt(process.env.DATA_MASKING_DAYS || '30')
|
||||
const NEVER_ACTIVATED_DAYS = parseInt(process.env.NEVER_ACTIVATED_DAYS || '90')
|
||||
|
||||
export async function maskOverdueInvestors(): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - MASKING_HOURS * 60 * 60 * 1000)
|
||||
export interface CleanupStats {
|
||||
investors_masked: number
|
||||
sessions_deleted: number
|
||||
audit_ips_anonymized: number
|
||||
audit_details_redacted: number
|
||||
magic_links_deleted: number
|
||||
}
|
||||
|
||||
const { rows } = await pool.query<{ id: string }>(
|
||||
export async function runDataCleanup(): Promise<CleanupStats> {
|
||||
const stats: CleanupStats = {
|
||||
investors_masked: 0,
|
||||
sessions_deleted: 0,
|
||||
audit_ips_anonymized: 0,
|
||||
audit_details_redacted: 0,
|
||||
magic_links_deleted: 0,
|
||||
}
|
||||
|
||||
const activeCutoff = new Date(Date.now() - MASKING_DAYS * 24 * 60 * 60 * 1000)
|
||||
const neverActivatedCutoff = new Date(Date.now() - NEVER_ACTIVATED_DAYS * 24 * 60 * 60 * 1000)
|
||||
|
||||
// 1. Mask investors inactive for MASKING_DAYS
|
||||
const { rows: maskedActive } = await pool.query<{ id: string }>(
|
||||
`UPDATE pitch_investors
|
||||
SET email = 'anon.' || id::text,
|
||||
name = NULL,
|
||||
company = NULL,
|
||||
status = 'revoked',
|
||||
data_masked_at = NOW(),
|
||||
updated_at = NOW()
|
||||
WHERE first_activity_at IS NOT NULL
|
||||
AND first_activity_at < $1
|
||||
name = NULL, company = NULL,
|
||||
status = 'revoked', data_masked_at = NOW(), updated_at = NOW()
|
||||
WHERE last_login_at IS NOT NULL
|
||||
AND last_login_at < $1
|
||||
AND data_masked_at IS NULL
|
||||
RETURNING id`,
|
||||
[cutoff],
|
||||
[activeCutoff],
|
||||
)
|
||||
|
||||
if (rows.length > 0) {
|
||||
// 2. Mask investors invited but never activated after NEVER_ACTIVATED_DAYS
|
||||
const { rows: maskedNever } = await pool.query<{ id: string }>(
|
||||
`UPDATE pitch_investors
|
||||
SET email = 'anon.' || id::text,
|
||||
name = NULL, company = NULL,
|
||||
status = 'revoked', data_masked_at = NOW(), updated_at = NOW()
|
||||
WHERE last_login_at IS NULL
|
||||
AND created_at < $1
|
||||
AND data_masked_at IS NULL
|
||||
RETURNING id`,
|
||||
[neverActivatedCutoff],
|
||||
)
|
||||
|
||||
const allMaskedIds = [...maskedActive, ...maskedNever].map((r) => r.id)
|
||||
stats.investors_masked = allMaskedIds.length
|
||||
|
||||
if (allMaskedIds.length > 0) {
|
||||
// 3. Revoke sessions for newly masked investors
|
||||
await pool.query(
|
||||
`UPDATE pitch_sessions SET revoked = true
|
||||
WHERE investor_id = ANY($1::uuid[]) AND NOT revoked`,
|
||||
[rows.map((r) => r.id)],
|
||||
[allMaskedIds],
|
||||
)
|
||||
|
||||
// 4. Redact email field from audit log details for masked investors
|
||||
const { rowCount } = await pool.query(
|
||||
`UPDATE pitch_audit_logs
|
||||
SET details = details - 'email'
|
||||
WHERE investor_id = ANY($1::uuid[]) AND details ? 'email'`,
|
||||
[allMaskedIds],
|
||||
)
|
||||
stats.audit_details_redacted = rowCount ?? 0
|
||||
}
|
||||
|
||||
// 5. Delete sessions older than MASKING_DAYS (expired, contain IP/UA)
|
||||
const { rowCount: sessionsDeleted } = await pool.query(
|
||||
`DELETE FROM pitch_sessions WHERE created_at < $1`,
|
||||
[activeCutoff],
|
||||
)
|
||||
stats.sessions_deleted = sessionsDeleted ?? 0
|
||||
|
||||
// 6. Anonymize IP addresses in audit logs older than MASKING_DAYS
|
||||
const { rowCount: logsAnonymized } = await pool.query(
|
||||
`UPDATE pitch_audit_logs
|
||||
SET ip_address = NULL
|
||||
WHERE created_at < $1 AND ip_address IS NOT NULL`,
|
||||
[activeCutoff],
|
||||
)
|
||||
stats.audit_ips_anonymized = logsAnonymized ?? 0
|
||||
|
||||
// 7. Delete magic links older than MASKING_DAYS
|
||||
const { rowCount: linksDeleted } = await pool.query(
|
||||
`DELETE FROM pitch_magic_links WHERE created_at < $1`,
|
||||
[activeCutoff],
|
||||
)
|
||||
stats.magic_links_deleted = linksDeleted ?? 0
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user