feat: Erinnerungen, Wartungslauf und Playwright-Tests der Anmeldung
Etappe 8, erster Teil. Erinnerungsmails sind bewusst eine Zugabe: Ist kein SMTP konfiguriert, wird nichts gesendet und nichts geworfen. Die Fristen stehen ohnehin im Dashboard und im abonnierten Kalender - eine ausgefallene Mail darf nicht dazu fuehren, dass der Wartungslauf abbricht und danach auch das Aufraeumen liegen bleibt. In der Mail steht, DASS etwas faellig ist und wo man nachsieht - nicht, worum es geht. Kein Name der betreuten Person, keine Inhalte. E-Mail ist unterwegs unverschluesselt und landet auf fremden Servern. Je Frist geht hoechstens eine Mail pro Vorwarnstufe raus. Taeglich waere nach einer Woche Rauschen, das man wegklickt - und genau dann verpasst man die eine, auf die es ankam. Der Wartungslauf faengt jeden Schritt einzeln ab: Papierkorb leeren, Anmeldeversuche und altes Audit-Log aufraeumen laufen auch dann, wenn der SMTP-Server nicht antwortet. Angestossen wird er ueber /api/wartung mit einem HMAC aus AUTH_SECRET - ohne gueltigen Schluessel antwortet die Route mit 404 statt 401. Ein echter Fund aus den Tests: Auth.js reicht den Fehlercode aus authorize() nicht an die Server Action zurueck. Bei gesperrtem Zugang sah man deshalb nur die allgemeine Meldung "stimmen nicht" und haette ratlos weiterprobiert, obwohl die Sperre bereits lief. Die Sperre wird jetzt zusaetzlich in der Server Action geprueft und benannt; die Pruefung in authorize() bleibt als eigentliche Sperre bestehen. Sechs Playwright-Tests der Anmeldung, darunter zwei, die Sicherheitszusagen nachweisen: dass zwei Fehlversuche mit unterschiedlicher Ursache dieselbe Meldung liefern (sonst liesse sich ablesen, welche Adressen existieren), und dass die Anmeldeseite keine einzige Anfrage an einen fremden Host stellt. Etappe: 8 Phase: Haertung
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
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'
|
||||
|
||||
/**
|
||||
* 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)
|
||||
*
|
||||
* 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
|
||||
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<Wartungsergebnis> {
|
||||
const ergebnis: Wartungsergebnis = {
|
||||
erinnerungen: 0,
|
||||
papierkorbGeleert: 0,
|
||||
loginversucheEntfernt: 0,
|
||||
auditEintraegeEntfernt: 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)}`)
|
||||
}
|
||||
|
||||
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<number> {
|
||||
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<string[]> {
|
||||
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<number> {
|
||||
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<string, { deleteMany: (a: unknown) => Promise<{ count: number }> }>)[
|
||||
tabelle
|
||||
]
|
||||
if (!modell) continue
|
||||
|
||||
const { count } = await modell.deleteMany({
|
||||
where: { geloeschtAm: { lt: grenze } },
|
||||
})
|
||||
entfernt += count
|
||||
}
|
||||
|
||||
return entfernt
|
||||
}
|
||||
Reference in New Issue
Block a user