diff --git a/.gitea/workflows/pitch-cleanup.yml b/.gitea/workflows/pitch-cleanup.yml new file mode 100644 index 0000000..6458f29 --- /dev/null +++ b/.gitea/workflows/pitch-cleanup.yml @@ -0,0 +1,36 @@ +# Daily GDPR data cleanup for the pitch deck. +# Calls /api/admin/cleanup which runs runDataCleanup(): +# - anonymizes investors inactive 30+ days +# - anonymizes never-activated invites after 90 days +# - deletes sessions + magic links older than 30 days +# - anonymizes IPs in audit logs older than 30 days +# +# Requires Gitea Actions secret: PITCH_ADMIN_SECRET + +name: Pitch deck — GDPR cleanup + +on: + schedule: + - cron: '0 2 * * *' + +jobs: + cleanup: + runs-on: docker + container: + image: alpine:3.19 + steps: + - name: Run data cleanup + env: + PITCH_ADMIN_SECRET: ${{ secrets.PITCH_ADMIN_SECRET }} + run: | + apk add --no-cache curl + RESPONSE=$(curl -sSf -w "\n%{http_code}" -X POST \ + -H "Authorization: Bearer $PITCH_ADMIN_SECRET" \ + -H "Content-Type: application/json" \ + https://pitch.breakpilot.com/api/admin/cleanup) \ + || { echo "Cleanup request failed"; exit 1; } + HTTP_CODE=$(echo "$RESPONSE" | tail -n1) + BODY=$(echo "$RESPONSE" | head -n-1) + echo "Response: $BODY" + [ "$HTTP_CODE" = "200" ] || { echo "Unexpected status $HTTP_CODE"; exit 1; } + echo "GDPR cleanup completed successfully" diff --git a/pitch-deck/app/api/admin/cleanup/route.ts b/pitch-deck/app/api/admin/cleanup/route.ts new file mode 100644 index 0000000..a765875 --- /dev/null +++ b/pitch-deck/app/api/admin/cleanup/route.ts @@ -0,0 +1,11 @@ +import { NextRequest, NextResponse } from 'next/server' +import { requireAdmin } from '@/lib/admin-auth' +import { runDataCleanup } from '@/lib/masking' + +export async function POST(request: NextRequest) { + const guard = await requireAdmin(request) + if (guard.kind === 'response') return guard.response + + const stats = await runDataCleanup() + return NextResponse.json({ success: true, stats }) +} diff --git a/pitch-deck/app/api/admin/investors/[id]/route.ts b/pitch-deck/app/api/admin/investors/[id]/route.ts index 3998a25..15ef3cf 100644 --- a/pitch-deck/app/api/admin/investors/[id]/route.ts +++ b/pitch-deck/app/api/admin/investors/[id]/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { requireAdmin, logAdminAudit } from '@/lib/admin-auth' -import { maskOverdueInvestors } from '@/lib/masking' +import { runDataCleanup } from '@/lib/masking' interface RouteContext { params: Promise<{ id: string }> @@ -13,7 +13,7 @@ export async function GET(request: NextRequest, ctx: RouteContext) { const { id } = await ctx.params - await maskOverdueInvestors() + await runDataCleanup() const [investor, sessions, snapshots, audit] = await Promise.all([ pool.query( diff --git a/pitch-deck/app/api/admin/investors/route.ts b/pitch-deck/app/api/admin/investors/route.ts index 85d8715..d9e645f 100644 --- a/pitch-deck/app/api/admin/investors/route.ts +++ b/pitch-deck/app/api/admin/investors/route.ts @@ -1,13 +1,13 @@ import { NextRequest, NextResponse } from 'next/server' import pool from '@/lib/db' import { requireAdmin } from '@/lib/admin-auth' -import { maskOverdueInvestors } from '@/lib/masking' +import { runDataCleanup } from '@/lib/masking' export async function GET(request: NextRequest) { const guard = await requireAdmin(request) if (guard.kind === 'response') return guard.response - await maskOverdueInvestors() + await runDataCleanup() const { rows } = await pool.query( `SELECT i.id, i.email, i.name, i.company, i.status, i.last_login_at, i.login_count, i.created_at, diff --git a/pitch-deck/app/auth/page.tsx b/pitch-deck/app/auth/page.tsx index cd39c96..55b13c4 100644 --- a/pitch-deck/app/auth/page.tsx +++ b/pitch-deck/app/auth/page.tsx @@ -127,8 +127,7 @@ export default function AuthPage() {

- Datenschutzhinweis: Beim Zugriff auf diese Seite werden technische Zugriffsdaten (insbesondere IP-Adresse und Zeitpunkt) verarbeitet, um die sichere Nutzung des Zugangs zu gewährleisten und Missbrauch zu verhindern. Die Speicherung erfolgt für maximal 72 Stunden. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). -

+ Datenschutzhinweis (Art. 13 DSGVO): Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie – soweit eingeladen – personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 15–21 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).

Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com

diff --git a/pitch-deck/app/auth/verify/page.tsx b/pitch-deck/app/auth/verify/page.tsx index f3a4d4e..d363ffb 100644 --- a/pitch-deck/app/auth/verify/page.tsx +++ b/pitch-deck/app/auth/verify/page.tsx @@ -119,8 +119,7 @@ export default function VerifyPage() {

- Datenschutzhinweis: Beim Zugriff auf diese Seite werden technische Zugriffsdaten (insbesondere IP-Adresse und Zeitpunkt) verarbeitet, um die sichere Nutzung des Zugangs zu gewährleisten und Missbrauch zu verhindern. Die Speicherung erfolgt für maximal 72 Stunden. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Weitere Informationen zum Datenschutz erhalten Sie auf Anfrage. -

+ Datenschutzhinweis (Art. 13 DSGVO): Beim Zugriff werden technische Zugriffsdaten (IP-Adresse, Zeitpunkt, Browser) sowie – soweit eingeladen – personenbezogene Kontaktdaten (E-Mail, Name, Unternehmen) verarbeitet. Zweck: Zugangsverwaltung und Missbrauchsprävention. Rechtsgrundlage: Art. 6 Abs. 1 lit. f DSGVO (berechtigtes Interesse). Speicherdauer: max. 30 Tage nach letztem Zugriff; nicht aktivierte Zugänge nach 90 Tagen. Danach automatische Anonymisierung. Ihre Rechte gem. Art. 15–21 DSGVO (Auskunft, Berichtigung, Löschung, Einschränkung, Datenübertragbarkeit, Widerspruch): Anfragen an pitch@breakpilot.ai. Beschwerderecht bei der Aufsichtsbehörde: LfDI Baden-Württemberg (www.baden-wuerttemberg.datenschutz.de).

Verantwortlich: Benjamin Bönisch & Sharang Parnerkar · Kontakt: info@breakpilot.com

diff --git a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx index d642dce..385c33f 100644 --- a/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx +++ b/pitch-deck/app/pitch-admin/(authed)/investors/[id]/page.tsx @@ -198,15 +198,15 @@ export default function InvestorDetailPage() {
{inv.data_masked_at ? (
- Data anonymized on {new Date(inv.data_masked_at).toLocaleString()} · 72h window elapsed after first activity + Data anonymized on {new Date(inv.data_masked_at).toLocaleString()} · 30-day inactivity window elapsed
) : ( <>
{inv.company || '—'}
{inv.email}
- {inv.first_activity_at && ( + {inv.last_login_at && (
- ⏱ Data window: 72h from first login · expires {new Date(new Date(inv.first_activity_at).getTime() + 72 * 60 * 60 * 1000).toLocaleString()} + ⏱ Data window: 30 days after last login · auto-anonymizes {new Date(new Date(inv.last_login_at).getTime() + 30 * 24 * 60 * 60 * 1000).toLocaleString()}
)} diff --git a/pitch-deck/lib/masking.ts b/pitch-deck/lib/masking.ts index 38830d6..9d3d0a5 100644 --- a/pitch-deck/lib/masking.ts +++ b/pitch-deck/lib/masking.ts @@ -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 { - 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 { + 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 }