Files
betreuungs-cockpit/src/lib/wartung.ts
T
Florian Wilde f8d7752c27 Eigene Vorlagen fuer Anschreiben
Wer zum dritten Mal denselben Brief tippt, tippt ihn beim vierten Mal
falsch. Neu unter Einstellungen -> Schreibvorlagen: eigene Texte
hinterlegen, beim freien Schreiben auswaehlen, Platzhalter fuellen sich
aus den Daten, die ohnehin im System stehen.

Aufbau:
- Die Ersetzung als reine Funktionen in schreiben/platzhalter.ts. Drei
  Faelle, keiner davon still: Wert einsetzen, sichtbare Luecke
  [Aktenzeichen des Gerichts], unbekannter Platzhalter markiert. Ein
  Tippfehler in der Vorlage darf das Schreiben nicht verhindern - aber
  er muss ins Auge springen.
- Die Bestellungsabsatz-Warnung ist zweistufig: Der erste Speicherversuch
  ohne ihn kommt mit einer Rueckfrage zurueck, erst die Bestaetigung
  speichert. Ohne den Nachweis der Vertretungsmacht kommt keine Auskunft,
  und das faellt erst nach vier Wochen auf. Ein ausformulierter Absatz
  zaehlt so viel wie der Platzhalter, ein halber Nachweis ("Beschluss der
  Eigentuemerversammlung") nicht.
- Vier mitgelieferte Vorlagen bleiben Code und damit unveraenderlich;
  "Als Kopie uebernehmen" legt eine eigene Fassung an. Fuer Rueckkaufswert
  und Kuendigung fuehrt die Oberflaeche weiter zuerst zu den
  fallbezogenen Erzeugern - die rechnen Kuendigungstermine aus, was eine
  Vorlage nicht kann.
- Vorlagen haengen an der betreuten Person, nicht am Benutzer: Sie
  beziehen sich auf ihren Fall, liegen im Papierkorb wie alles andere und
  sind im Vollexport ihrer Akte enthalten.

Der Editor haelt Betreff und Text als eigenen State. Beim Wechsel der
Vorlage ueberlebte der alte Entwurf die Navigation, die Vorlage kam nie
im Textfeld an - ein key auf dem Editor setzt ihn neu auf. Gefunden hat
das erst der End-to-End-Test.

Geprueft: dreizehn Einheitstests fuer die Ersetzung, sechs
End-to-End-Tests fuer Warnung, Kopie, Aufloesung und Papierkorb.

Refs #7
2026-08-23 13:41:51 +02:00

223 lines
6.8 KiB
TypeScript

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',
'schreibvorlage',
'betreute',
] as const
export async function wartungslauf(): Promise<Wartungsergebnis> {
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<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
}