/** * Mail-Retention-Cron — Phase 1 Data-Foundation. * * Zweck: DSGVO-konforme Datensparsamkeit (Art. 5 Abs. 1e — Speicherbegrenzung) * + Disk-Budget-Kontrolle (Staging + Prod). * * Drei Retention-Jobs: * * 1. Subject-Nullification (täglich): * Raw-Subject auf null setzen wenn älter als 30 Tage. Das sanitisierte * Subject (features.subjectSanitized) bleibt erhalten. * * 2. Sample-Purge (monatlich): * Samples älter als 12 Monate hart löschen (für LLM-Training irrelevant). * User-Lösch-Recht (Art. 17) → deleteUserMailClassificationSamples(). * * 3. Row-Cap (täglich): * Hält die Tabelle auf MAX_SAMPLE_ROWS. Löscht älteste Rows wenn Cap * überschritten. Verhindert unbegrenztes Disk-Wachstum (Staging + Prod). * Cap = 100k Rows ≈ ~500MB bei durchschnittlicher Row-Größe. * * Läuft nur in Production (import.meta.dev guard). * Alle Jobs laufen beim Server-Start einmalig + dann periodisch. */ import { consola } from "consola"; import { usePrisma } from "../utils/prisma"; // Subject-Nullification: täglich const SUBJECT_NULLIFY_INTERVAL_MS = 24 * 60 * 60 * 1000; const SUBJECT_NULLIFY_AGE_DAYS = 30; // Sample-Purge: alle 30 Tage (monatlich) const SAMPLE_PURGE_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; const SAMPLE_PURGE_AGE_DAYS = 365; // Row-Cap: täglich — hält Tabelle unter diesem Limit const ROW_CAP_INTERVAL_MS = 24 * 60 * 60 * 1000; const MAX_SAMPLE_ROWS = 100_000; export default defineNitroPlugin((nitro) => { if (import.meta.dev) return; consola.info("[mail-retention-cron] Starting — subject-nullify daily, sample-purge monthly, row-cap daily"); void runSubjectNullification().catch(() => {}); void runSamplePurge().catch(() => {}); void runRowCap().catch(() => {}); const nullifyInterval = setInterval(() => { void runSubjectNullification().catch(() => {}); }, SUBJECT_NULLIFY_INTERVAL_MS); const purgeInterval = setInterval(() => { void runSamplePurge().catch(() => {}); }, SAMPLE_PURGE_INTERVAL_MS); const capInterval = setInterval(() => { void runRowCap().catch(() => {}); }, ROW_CAP_INTERVAL_MS); nitro.hooks.hook("close", () => { clearInterval(nullifyInterval); clearInterval(purgeInterval); clearInterval(capInterval); }); }); /** * Setzt raw-Subject-Feld auf null für Samples älter als 30 Tage. * features.subjectSanitized bleibt erhalten (wurde bei Insert geschrieben). * * Verwendet $executeRaw weil Prisma updateMany kein * WHERE subject IS NOT NULL direkt unterstützt (kein affected-rows-count). */ async function runSubjectNullification(): Promise { const db = usePrisma(); const cutoff = new Date(Date.now() - SUBJECT_NULLIFY_AGE_DAYS * 86_400_000); const result = await db.$executeRaw` UPDATE "rebreak"."mail_classification_samples" SET "subject" = NULL WHERE "subject" IS NOT NULL AND "created_at" < ${cutoff}::timestamptz `; if (result > 0) { consola.info(`[mail-retention-cron] subject-nullify: ${result} samples anonymized`); } } /** * Löscht MailClassificationSamples älter als 12 Monate. */ async function runSamplePurge(): Promise { const db = usePrisma(); const cutoff = new Date(Date.now() - SAMPLE_PURGE_AGE_DAYS * 86_400_000); const result = await db.mailClassificationSample.deleteMany({ where: { createdAt: { lt: cutoff } }, }); if (result.count > 0) { consola.info(`[mail-retention-cron] sample-purge: ${result.count} old samples deleted`); } } /** * Row-Cap: hält mail_classification_samples unter MAX_SAMPLE_ROWS. * * Löscht älteste Rows wenn der Cap überschritten wird. Nutzt einen * CTE-basierten Delete der nur einen Index-Scan braucht (created_at INDEX). * Kein Full-Table-Scan — auch auf großen Tabellen effizient. */ async function runRowCap(): Promise { const db = usePrisma(); const count = await db.mailClassificationSample.count(); if (count <= MAX_SAMPLE_ROWS) return; const toDelete = count - MAX_SAMPLE_ROWS; // Löscht die `toDelete` ältesten Rows via Subquery auf created_at-Index. const result = await db.$executeRaw` DELETE FROM "rebreak"."mail_classification_samples" WHERE "id" IN ( SELECT "id" FROM "rebreak"."mail_classification_samples" ORDER BY "created_at" ASC LIMIT ${toDelete} ) `; consola.info(`[mail-retention-cron] row-cap: ${result} samples pruned (cap=${MAX_SAMPLE_ROWS}, was=${count})`); }