diff --git a/backend/prisma/migrations/20260513_mail_blocked_stats_aggregation/migration.sql b/backend/prisma/migrations/20260513_mail_blocked_stats_aggregation/migration.sql new file mode 100644 index 0000000..d5477db --- /dev/null +++ b/backend/prisma/migrations/20260513_mail_blocked_stats_aggregation/migration.sql @@ -0,0 +1,66 @@ +-- Migration: mail_blocked_stats_aggregation +-- Permanente Aggregat-Tabelle für blockierte Mails (counts/dates only, KEINE Inhalte). +-- Löst das DSGVO-Spannungsfeld: mail_blocked wird nach 24h gelöscht (Datenminimierung +-- Art. 5 Abs. 1 lit. e), während Stats-Charts weiterhin historische Daten zeigen können. +-- +-- Stats enthalten ausschließlich: userId, date (UTC), connectionId, provider-host, count. +-- Kein Subject, kein Sender, kein Mail-Inhalt — Aggregat ist unbedenklich i.S. Art. 9. +-- +-- Lösch-Verhalten: ON DELETE CASCADE wenn MailConnection gelöscht wird. +-- Begründung: User-initiierter Disconnect = Art. 17-Löschanfrage; Stats sind an +-- diese Connection geknüpft; Cascade hält DB konsistent ohne nullable foreign-key. +-- Hans-Müller-Note: Aggregat-Stats sind kein "Personenbezug zur Mail" mehr, aber +-- der Bezug zur Connection-ID bleibt — Cascade ist DSGVO-konservativere Wahl. +-- +-- Backfill: Einmalig aus bestehenden mail_blocked-Rows der letzten 30 Tage. +-- provider-Spalte speichert imap_host als rohen String; resolveProviderMeta() +-- wird zur Read-Zeit im Stats-Endpoint aufgerufen (TypeScript, nicht SQL). +-- Für den Backfill wird imap_host doppelt als providerLabel eingetragen — +-- der Live-Scan-Code schreibt ab jetzt den korrekten resolved Label. +-- Ein separater Script (backend/scripts/backfill-stats-provider.ts) kann nachgezogen +-- werden wenn die Labels im Chart sauber sein sollen; für MVP nicht kritisch. +-- +-- Deploy: automatisch via GitHub Actions (pnpm prisma migrate deploy) + +CREATE TABLE "rebreak"."mail_blocked_stats" ( + "id" TEXT NOT NULL, + "user_id" UUID NOT NULL, + "date" DATE NOT NULL, + "mail_connection_id" UUID NOT NULL, + "provider" TEXT NOT NULL, + "provider_label" TEXT NOT NULL, + "count" INTEGER NOT NULL DEFAULT 0, + + CONSTRAINT "mail_blocked_stats_pkey" PRIMARY KEY ("id"), + CONSTRAINT "mail_blocked_stats_unique_day" + UNIQUE ("user_id", "date", "mail_connection_id"), + CONSTRAINT "mail_blocked_stats_connection_fkey" + FOREIGN KEY ("mail_connection_id") + REFERENCES "rebreak"."mail_connections" ("id") + ON DELETE CASCADE ON UPDATE CASCADE +); + +CREATE INDEX "mail_blocked_stats_user_date_idx" + ON "rebreak"."mail_blocked_stats" ("user_id", "date"); + +CREATE INDEX "mail_blocked_stats_user_connection_idx" + ON "rebreak"."mail_blocked_stats" ("user_id", "mail_connection_id"); + +-- Backfill: bestehende mail_blocked-Rows der letzten 30 Tage aggregieren. +-- imap_host wird als provider UND provider_label gespeichert (rohes Datum). +-- Live-Scan schreibt ab jetzt resolved labels via resolveProviderMeta(). +INSERT INTO "rebreak"."mail_blocked_stats" + ("id", "user_id", "date", "mail_connection_id", "provider", "provider_label", "count") +SELECT + gen_random_uuid()::text, + mb."user_id", + DATE(mb."created_at" AT TIME ZONE 'UTC') AS "date", + mb."connection_id", + mc."imap_host", + mc."imap_host", + COUNT(*)::integer +FROM "rebreak"."mail_blocked" mb +JOIN "rebreak"."mail_connections" mc ON mc."id" = mb."connection_id" +WHERE mb."created_at" > NOW() - INTERVAL '30 days' +GROUP BY mb."user_id", DATE(mb."created_at" AT TIME ZONE 'UTC'), mb."connection_id", mc."imap_host" +ON CONFLICT ("user_id", "date", "mail_connection_id") DO NOTHING; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index a4113d5..f9306e9 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -534,7 +534,8 @@ model MailConnection { consentVersion String? @map("consent_version") consentIpAddress String? @map("consent_ip_address") - blockedMails MailBlocked[] + blockedMails MailBlocked[] + blockedStats MailBlockedStat[] @@unique([userId, email]) @@map("mail_connections") @@ -646,6 +647,33 @@ model MailBlocked { @@schema("rebreak") } +/// Permanente Aggregat-Statistiken blockierter Mails pro Tag + Connection. +/// Befüllt live beim Scan (vor dem 24h-Cleanup von mail_blocked). +/// Enthält KEINE Mail-Inhalte — nur counts/dates (Datenminimierung Art. 5 DSGVO). +/// Bei Connection-Disconnect werden Stats mitgelöscht (Cascade) — Konsistenz +/// mit Art. 17 DSGVO: User-initiierter Disconnect = Recht auf Löschung aller +/// zu dieser Connection gehörenden Daten. +model MailBlockedStat { + id String @id @default(cuid()) + userId String @map("user_id") @db.Uuid + /// UTC-Datum (time=00:00:00) — ein Eintrag pro User+Tag+Connection + date DateTime @db.Date @map("date") + mailConnectionId String @map("mail_connection_id") @db.Uuid + /// IMAP-Host-Slug, z.B. "imap.gmail.com" (raw, für resolveProviderMeta zur Read-Zeit) + provider String @map("provider") + /// Human-readable Label, z.B. "Gmail" (wird bei Scan-Zeit aus resolveProviderMeta befüllt) + providerLabel String @map("provider_label") + count Int @default(0) + + connection MailConnection @relation(fields: [mailConnectionId], references: [id], onDelete: Cascade) + + @@unique([userId, date, mailConnectionId], map: "mail_blocked_stats_unique_day") + @@index([userId, date]) + @@index([userId, mailConnectionId]) + @@map("mail_blocked_stats") + @@schema("rebreak") +} + model ImapProxyAccount { id String @id @default(uuid()) @db.Uuid userId String @map("user_id") @db.Uuid diff --git a/backend/server/api/mail/scan-internal.post.ts b/backend/server/api/mail/scan-internal.post.ts index 4bcd619..4dba53f 100644 --- a/backend/server/api/mail/scan-internal.post.ts +++ b/backend/server/api/mail/scan-internal.post.ts @@ -4,11 +4,13 @@ import { deleteOldMailBlocked, getAlreadyBlockedUidSet, insertMailBlocked, + upsertMailBlockedStat, updateMailConnectionScanStats, } from "../../db/mail"; import { getBlocklistedDomainsSet } from "../../db/domains"; import { getProfile } from "../../db/profile"; import { getPlanLimits } from "../../utils/plan-features"; +import { resolveProviderMeta } from "../../utils/imap-providers"; // Single-Source-of-Truth (Mo's Finding #4) // @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[] import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs"; @@ -183,6 +185,18 @@ export default defineEventHandler(async (event) => { } await insertMailBlocked(toInsert); + + // Aggregat-Stats aktualisieren (vor 24h-Cleanup resistent) + if (toInsert.length > 0) { + const providerMeta = resolveProviderMeta(connection.imapHost); + await upsertMailBlockedStat({ + userId, + mailConnectionId: connection.id, + provider: providerMeta.provider, + providerLabel: providerMeta.providerLabel, + count: toInsert.length, + }); + } } finally { lock.release(); } diff --git a/backend/server/api/mail/scan.post.ts b/backend/server/api/mail/scan.post.ts index 6c45e4d..51db7d4 100644 --- a/backend/server/api/mail/scan.post.ts +++ b/backend/server/api/mail/scan.post.ts @@ -4,11 +4,13 @@ import { deleteOldMailBlocked, getAlreadyBlockedUidSet, insertMailBlocked, + upsertMailBlockedStat, updateMailConnectionScanStats, } from "../../db/mail"; import { getBlocklistedDomainsSet } from "../../db/domains"; import { getProfile } from "../../db/profile"; import { getPlanLimits } from "../../utils/plan-features"; +import { resolveProviderMeta } from "../../utils/imap-providers"; // Single-Source-of-Truth (Mo's Finding #4) // @ts-expect-error — .mjs ohne types, GAMBLING_KEYWORDS ist string[] import { GAMBLING_KEYWORDS } from "../../utils/gambling-keywords.mjs"; @@ -173,6 +175,18 @@ export default defineEventHandler(async (event) => { } await insertMailBlocked(toInsert); + + // Aggregat-Stats aktualisieren (vor 24h-Cleanup resistent) + if (toInsert.length > 0) { + const providerMeta = resolveProviderMeta(connection.imapHost); + await upsertMailBlockedStat({ + userId: user.id, + mailConnectionId: connection.id, + provider: providerMeta.provider, + providerLabel: providerMeta.providerLabel, + count: toInsert.length, + }); + } } finally { lock.release(); } diff --git a/backend/server/db/mail.ts b/backend/server/db/mail.ts index 7591639..e023f7a 100644 --- a/backend/server/db/mail.ts +++ b/backend/server/db/mail.ts @@ -230,6 +230,48 @@ export async function deleteOldMailBlocked(userId: string) { }); } +/** + * UPSERT einen Aggregat-Zähler in mail_blocked_stats. + * Wird direkt nach insertMailBlocked pro Connection aufgerufen. + * date ist UTC-Mitternacht des aktuellen Tages. + * Bei Conflict (selber User+Tag+Connection): count += 1. + */ +export async function upsertMailBlockedStat(entry: { + userId: string; + mailConnectionId: string; + provider: string; + providerLabel: string; + count: number; +}) { + const db = usePrisma(); + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + return db.mailBlockedStat.upsert({ + where: { + userId_date_mailConnectionId: { + userId: entry.userId, + date: today, + mailConnectionId: entry.mailConnectionId, + }, + }, + create: { + userId: entry.userId, + date: today, + mailConnectionId: entry.mailConnectionId, + provider: entry.provider, + providerLabel: entry.providerLabel, + count: entry.count, + }, + update: { + // Neuester Label gewinnt (falls User IMAP-Host gewechselt hat) + provider: entry.provider, + providerLabel: entry.providerLabel, + count: { increment: entry.count }, + }, + }); +} + export async function getMailBlockedPaginated( userId: string, page: number, @@ -281,6 +323,7 @@ export async function updateMailConnectionTitle( /** * Geblockte Mails pro Tag (UTC) für die letzten N Tage — für Bar-Chart. + * Liest aus mail_blocked_stats (permanent, kein 24h-Cleanup). * Fehlende Tage werden mit count=0 aufgefüllt. */ export async function getBlockedMailsByDay( @@ -289,15 +332,16 @@ export async function getBlockedMailsByDay( ): Promise<{ date: string; count: number }[]> { const db = usePrisma(); const since = new Date(Date.now() - days * 86_400_000); + since.setUTCHours(0, 0, 0, 0); - // Prisma hat kein groupBy auf DATE-Funktionen → raw query + // Aggregiere SUM(count) pro Tag aus der permanenten Stats-Tabelle const rows = await db.$queryRaw<{ date: string; count: bigint }[]>` - SELECT TO_CHAR(DATE("created_at"), 'YYYY-MM-DD') AS date, COUNT(*) AS count - FROM "rebreak"."mail_blocked" + SELECT TO_CHAR("date", 'YYYY-MM-DD') AS date, SUM("count")::bigint AS count + FROM "rebreak"."mail_blocked_stats" WHERE "user_id" = ${userId}::uuid - AND "created_at" >= ${since} - GROUP BY DATE("created_at") - ORDER BY DATE("created_at") ASC + AND "date" >= ${since}::date + GROUP BY "date" + ORDER BY "date" ASC `; const map: Record = {}; @@ -315,20 +359,24 @@ export async function getBlockedMailsByDay( /** * Anzahl blockierter Mails pro MailConnection — für Half-Donut-Chart. - * Connections ohne blocked emails werden NICHT included. + * Liest aus mail_blocked_stats (permanent). + * Connections ohne blocked emails (stats=0) werden NICHT included. + * Gibt imapHost zurück — resolveProviderMeta() wird im Endpoint aufgerufen. */ export async function getBlockedMailsByConnection(userId: string) { const db = usePrisma(); - const rows = await db.mailBlocked.groupBy({ - by: ["connectionId"], + + // SUM(count) pro Connection aus Stats-Tabelle + const rows = await db.mailBlockedStat.groupBy({ + by: ["mailConnectionId"], where: { userId }, - _count: { id: true }, - orderBy: { _count: { id: "desc" } }, + _sum: { count: true }, + orderBy: { _sum: { count: "desc" } }, }); if (rows.length === 0) return []; - const connectionIds = rows.map((r) => r.connectionId); + const connectionIds = rows.map((r) => r.mailConnectionId); const connections = await db.mailConnection.findMany({ where: { id: { in: connectionIds } }, select: { id: true, email: true, title: true, providerName: true, imapHost: true }, @@ -337,14 +385,14 @@ export async function getBlockedMailsByConnection(userId: string) { const connMap = new Map(connections.map((c) => [c.id, c])); return rows.map((r) => { - const conn = connMap.get(r.connectionId); + const conn = connMap.get(r.mailConnectionId); return { - connectionId: r.connectionId, + connectionId: r.mailConnectionId, title: conn?.title ?? null, email: conn?.email ?? "", providerName: conn?.providerName ?? null, imapHost: conn?.imapHost ?? "", - count: r._count.id, + count: r._sum.count ?? 0, }; }); }