import 'server-only' import { db, papierkorbGrenze } from '@/lib/db' import { env } from '@/lib/env' import { fristErinnerung, mailSenden } from '@/lib/mail' import { alteVersucheEntfernen } from '@/lib/auth/ratelimit' import { abgelaufeneAnfragenEntfernen } from '@/lib/auth/passkey' /** * Taeglicher Wartungslauf. * * Wird ueber die Route /api/wartung angestossen (durch cron, systemd-timer * oder den Docker-Sicherungsdienst). Vier Aufgaben: * * 1. Fristerinnerungen verschicken, wenn eine Vorwarnstufe erreicht ist * 2. Papierkorb leeren (30 Tage) * 3. Alte Anmeldeversuche entfernen (30 Tage) * 4. Altes Audit-Log entfernen (10 Jahre, ueber die DB-Funktion) * 5. Liegengebliebene Passkey-Challenges entfernen (5 Minuten) * * Jeder Schritt faengt seine Fehler selbst ab. Faellt die Mail aus, soll * trotzdem aufgeraeumt werden - sonst laeuft die Datenbank voll, weil ein * SMTP-Server nicht antwortet. */ export type Wartungsergebnis = { erinnerungen: number papierkorbGeleert: number loginversucheEntfernt: number auditEintraegeEntfernt: number passkeyAnfragenEntfernt: number fehler: string[] } /** Tabellen mit Papierkorb - Reihenfolge egal, Fremdschluessel raeumen mit. */ const PAPIERKORB_TABELLEN = [ 'frist', 'vorgang', 'genehmigung', 'dokument', 'buchung', 'konto', 'vermoegensposten', 'immobilie', 'vertrag', 'kontakt', 'kontaktprotokoll', 'gesundheitsereignis', 'sozialhilfeAntrag', 'nachforderung', 'bescheid', 'leistung', 'bericht', 'betreute', ] as const export async function wartungslauf(): Promise { const ergebnis: Wartungsergebnis = { erinnerungen: 0, papierkorbGeleert: 0, loginversucheEntfernt: 0, auditEintraegeEntfernt: 0, passkeyAnfragenEntfernt: 0, fehler: [], } // --- 1. Fristerinnerungen --------------------------------------------- try { ergebnis.erinnerungen = await erinnerungenVerschicken() } catch (fehler) { ergebnis.fehler.push(`Erinnerungen: ${beschreiben(fehler)}`) } // --- 2. Papierkorb leeren ---------------------------------------------- try { ergebnis.papierkorbGeleert = await papierkorbLeeren() } catch (fehler) { ergebnis.fehler.push(`Papierkorb: ${beschreiben(fehler)}`) } // --- 3. Anmeldeversuche ------------------------------------------------- try { ergebnis.loginversucheEntfernt = await alteVersucheEntfernen() } catch (fehler) { ergebnis.fehler.push(`Anmeldeversuche: ${beschreiben(fehler)}`) } // --- 4. Audit-Log -------------------------------------------------------- try { // Die Tabelle ist gegen DELETE gesperrt; nur diese Funktion darf // aufraeumen. Zehn Jahre entsprechen der Aufbewahrung der // Rechnungslegungsunterlagen. const zeilen = await db.$queryRaw< { audit_log_aufraeumen: bigint }[] >`SELECT audit_log_aufraeumen('10 years'::interval)` ergebnis.auditEintraegeEntfernt = Number(zeilen[0]?.audit_log_aufraeumen ?? 0) } catch (fehler) { ergebnis.fehler.push(`Audit-Log: ${beschreiben(fehler)}`) } // --- 5. Passkey-Challenges ---------------------------------------------- try { // Normalerweise raeumt jede Anmeldung selbst auf. Bleibt der letzte // Versuch des Tages liegen, faengt es dieser Lauf ab. ergebnis.passkeyAnfragenEntfernt = await abgelaufeneAnfragenEntfernen() } catch (fehler) { ergebnis.fehler.push(`Passkey-Anfragen: ${beschreiben(fehler)}`) } return ergebnis } function beschreiben(fehler: unknown): string { return fehler instanceof Error ? fehler.message : String(fehler) } /** * Verschickt je Frist hoechstens eine Mail pro Vorwarnstufe. * * letzteErinnerungStufe merkt sich die zuletzt gemeldete Stufe. Erst wenn * eine engere Stufe erreicht ist, geht wieder eine Mail raus - sonst kaeme * sie taeglich, und man wuerde sie nach einer Woche ignorieren. Genau das * soll nicht passieren. */ async function erinnerungenVerschicken(): Promise { if (!env.smtpKonfiguriert) return 0 const heute = new Date() heute.setHours(0, 0, 0, 0) const fristen = await db.frist.findMany({ where: { status: 'OFFEN', geloeschtAm: null }, select: { id: true, titel: true, datum: true, rechtsgrundlage: true, ausschlussfrist: true, vorwarnungTage: true, letzteErinnerungStufe: true, betreuteId: true, }, }) let verschickt = 0 for (const frist of fristen) { const tageBis = Math.round((frist.datum.getTime() - heute.getTime()) / 86_400_000) // Erreichte Stufen: alle Vorwarnungen, die schon greifen. Ueberfaellige // Fristen bekommen die Stufe 0. const erreicht = frist.vorwarnungTage.filter((tage) => tageBis <= tage) const stufe = tageBis < 0 ? 0 : erreicht.length > 0 ? Math.min(...erreicht) : null if (stufe === null) continue // Schon fuer diese oder eine engere Stufe gemeldet? if (frist.letzteErinnerungStufe !== null && frist.letzteErinnerungStufe <= stufe) continue const empfaenger = await empfaengerFuer(frist.betreuteId) if (empfaenger.length === 0) continue const vorlage = fristErinnerung({ titel: frist.titel, datum: frist.datum, tageBis, ausschlussfrist: frist.ausschlussfrist, rechtsgrundlage: frist.rechtsgrundlage, }) let mindestensEine = false for (const an of empfaenger) { const { gesendet } = await mailSenden({ ...vorlage, an }) if (gesendet) mindestensEine = true } if (mindestensEine) { await db.frist.update({ where: { id: frist.id }, data: { letzteErinnerungStufe: stufe }, }) verschickt += 1 } } return verschickt } /** Nur Betreuer bekommen Erinnerungen, keine Mitleser. */ async function empfaengerFuer(betreuteId: string): Promise { const zugaenge = await db.betreuungsZugang.findMany({ where: { betreuteId, rolle: 'BETREUER', user: { aktiv: true, geloeschtAm: null } }, select: { user: { select: { email: true } } }, }) return zugaenge.map((z) => z.user.email) } /** * Entfernt endgueltig, was laenger als 30 Tage im Papierkorb liegt. * * Das Audit-Log bleibt davon unberuehrt - dort steht weiterhin, dass und * wann etwas geloescht wurde. Genau darum geht es bei einem Nachweis. */ async function papierkorbLeeren(): Promise { const grenze = papierkorbGrenze() let entfernt = 0 for (const tabelle of PAPIERKORB_TABELLEN) { // Prisma hat keinen dynamischen Zugriff mit Typsicherheit - deshalb // hier ein bewusster, eng begrenzter Umweg ueber den Modellnamen. const modell = (db as unknown as Record Promise<{ count: number }> }>)[ tabelle ] if (!modell) continue const { count } = await modell.deleteMany({ where: { geloeschtAm: { lt: grenze } }, }) entfernt += count } return entfernt }