Files
breakpilot-core/pitch-deck/lib/masking.ts
T
Sharang Parnerkar 5946aa47d5
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
fix(pitch-deck): GDPR compliance — automated cleanup, full Art. 13 notice
- 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>
2026-05-01 15:11:51 +02:00

98 lines
3.1 KiB
TypeScript

import pool from '@/lib/db'
const MASKING_DAYS = parseInt(process.env.DATA_MASKING_DAYS || '30')
const NEVER_ACTIVATED_DAYS = parseInt(process.env.NEVER_ACTIVATED_DAYS || '90')
export interface CleanupStats {
investors_masked: number
sessions_deleted: number
audit_ips_anonymized: number
audit_details_redacted: number
magic_links_deleted: number
}
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 last_login_at IS NOT NULL
AND last_login_at < $1
AND data_masked_at IS NULL
RETURNING id`,
[activeCutoff],
)
// 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`,
[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
}