Compare commits

...
50 Commits
Author SHA1 Message Date
florian.wilde c5db3baac0 Merge: Version 1.12.0 2026-09-01 14:40:22 +00:00
Florian Wilde 274d7666ea chore: Version 1.12.0 2026-09-01 16:39:59 +02:00
florian.wilde 890e00fb5c Merge #46: Wo die Daten liegen, und drei Fallen aus dem Betrieb 2026-09-01 14:37:25 +00:00
Florian Wilde 042e63812b Anleitung: wo die Daten liegen, und drei Fallen aus dem Betrieb
Aus einer Fehlersuche am 31.08.2026 sind vier Luecken in der Anleitung
uebriggeblieben. Keine davon war falsch formuliert - alle vier waren
schlicht nicht durchdacht.

1. Es stand nirgends, wo die Daten liegen. Der neue Abschnitt nennt die
   drei Orte, zeigt den Pfad auf der Platte, sagt ausdruecklich, dass dort
   keine anklickbaren PDFs liegen, und beschreibt den Abgleich zwischen
   Akte und Speicher: Zu jedem Eintrag muss eine Datei gehoeren, sonst
   belegt eine Pruefsumme ein Dokument, das es nicht mehr gibt.

2. Der mc-Alias wurde einmalig gesetzt. Er liegt in /root/.mc IM
   Behaelter und ist nach jedem "docker compose up -d minio" weg -
   "mc ls lokal" liefert dann kommentarlos nichts, und man haelt seine
   Dokumente fuer verschwunden. Genau das ist passiert. Jeder Aufruf setzt
   den Alias jetzt selbst, aus den Variablen des Behaelters.

3. Der Bucket-Name stand fest im Befehl, die Anwendung liest ihn aus
   S3_BUCKET. Weichen sie ab, zeigen beide aneinander vorbei.

4. "set -a && source .env" wurde empfohlen, ohne zu sagen, dass die
   exportierten Werte danach jedes "docker compose up" ueberstimmen. Wer
   spaeter die .env aendert und neu startet, sieht keine Wirkung und sucht
   den Fehler ueberall, nur nicht in der eigenen Shell. Der Kniff ist
   nicht mehr noetig - der neue Befehl kommt ohne ihn aus - und die Falle
   steht als Erklaerung daneben.

Dazu docker-compose.override.yml in der .gitignore und in der Anleitung
als vorgesehener Ort fuer Anpassungen, die ein Update ueberleben sollen:
update.sh prueft nur versionierte Dateien und laesst sie in Ruhe.

Die Aussage zur Sicherung ist mitgezogen - sie stimmt seit #41 nicht mehr,
die Dokumente sind jetzt enthalten.

Neun Pruefungen in tests/anleitung.test.ts, gegen vier Mutationen
geprueft: Name fest verdrahtet, Alias nicht gesetzt, source .env zurueck,
Abschnitt entfernt - jede wird rot.

Refs #46
2026-09-01 16:36:48 +02:00
florian.wilde 53dde70f26 Merge #44: Speicherfehler erklären und die Ablage von selbst prüfen 2026-09-01 14:31:51 +00:00
Florian Wilde 92c78a92ff Speicherfehler erklaeren und die Ablage von selbst pruefen
Beim Ablegen eines Schreibens stand in der Oberflaeche "Das Schreiben
konnte nicht abgelegt werden." Bei einer Fehlersuche am 31.08.2026 lagen
hinter genau diesem Satz nacheinander DREI verschiedene Ursachen: ein
fehlender Ablagebereich, ein Endpunkt, der auf einen fremden Anbieter
zeigte, und eine Verschluesselung, die der Speicher nicht anbietet. Ohne
Zugang zum Serverlog waere die Suche aussichtslos gewesen.

Jetzt uebersetzt src/lib/speicher/fehler.ts die bekannten Faelle in
Saetze, die sagen, was zu tun ist - fehlender Ablagebereich mit Verweis
auf Schritt 6 der Installation, abgelehnte Zugangsdaten mit den Namen der
beiden Variablen, nicht unterstuetzte Verschluesselung mit dem Hinweis auf
S3_SSE, und nicht erreichbarer Speicher mit der Zusicherung, dass die
Eingaben bleiben.

Durchgereicht wird dabei nichts. Fremde Fehlertexte tragen Hostnamen,
Eimernamen und Anfragekennungen - das gehoert ins Log, nicht auf den
Bildschirm eines Betreuers. Uebersetzt werden nur die bekannten Faelle;
alles andere bleibt bei der allgemeinen Meldung.

Dazu eine Bereitschaftspruefung: Sie schreibt eine winzige Datei, liest
sie zurueck und raeumt sie weg - und beantwortet damit erreichbar,
Ablagebereich vorhanden und Schreibrecht auf einmal. Das Ergebnis steht in
/api/gesundheit und als Zeile im Log.

## Warum nicht instrumentation.ts

Der naheliegende Ort waere der Starthaken von Next. Der wird aber auch
fuer die Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path
in ein Buendel, in dem es das nicht gibt - der Build schlaegt fehl. Das
war ausprobiert, nicht vermutet.

Die Pruefung haengt deshalb an der Gesundheitsabfrage. Der Healthcheck
ruft sie ab 40 Sekunden nach dem Start alle 30 Sekunden auf; die erste
Pruefung passiert also von selbst kurz nach dem Hochfahren. Eine
Schonfrist von fuenf Minuten verhindert, dass dabei alle 30 Sekunden eine
Probedatei in den Objektspeicher geht, und gemeldet wird nur bei einer
Aenderung.

Ein gestoerter Speicher fuehrt ausdruecklich NICHT zu 503. Sonst startete
der Healthcheck den Behaelter neu und machte die ganze Akte unerreichbar -
wegen eines Teils, den ein Neustart nicht repariert.

Gegen drei Mutationen geprueft: NotImplemented ohne Textpruefung, alte
Reihenfolge bei der Codeermittlung, Erklaerung nicht benutzt - jede wird
rot.

Refs #44
2026-09-01 16:31:03 +02:00
florian.wilde c210f8bff0 Merge #45: Beispielkonfiguration entschärfen 2026-09-01 14:16:46 +00:00
Florian Wilde 5c6fac59b2 Beispielkonfiguration entschaerfen
.env.example belegte S3_ENDPOINT mit der Adresse eines externen
Anbieters. Gedacht war das als Beispiel - in der kopierten .env stand es
aber aktiv, und damit sprach die Anwendung mit einem fremden Host statt
mit dem Objektspeicher nebenan.

Verschaerfend: Die docker-compose.yml hatte laengst einen brauchbaren
Standard, ${S3_ENDPOINT:-http://minio:9000}. Der greift nur bei leerer
Variable - die Beispieldatei hebelte ihn aus. Der Mechanismus, der genau
diesen Fehler verhindern sollte, war durch die Beispieldatei wirkungslos.

Auf dem Livesystem hat das monatelang jede Ablage scheitern lassen, und
die Zugangs-ID des lokalen Speichers ging bei jedem Versuch nach draussen.

Die Zeile ist jetzt auskommentiert, mit einer Erklaerung, wann man sie
braucht.

Nebenbei stand dort SEED_ADMIN_EMAIL mit der Adresse des Betreibers. In
einer mitgelieferten Beispieldatei hat eine persoenliche Adresse nichts
zu suchen - jetzt leer.

Der Test prueft beides und dazu die allgemeine Regel: Kein aktiver Wert
in der Beispieldatei zeigt auf einen fremden Rechner. Er prueft auch,
dass ueberhaupt Zuweisungen gefunden werden - sonst staende er gruen da
und pruefte nichts.

Gegen zwei Mutationen geprueft: Endpunkt wieder aktiv, persoenliche
Adresse wieder eingetragen - beide werden rot.

Refs #45
2026-09-01 16:16:12 +02:00
florian.wilde ea34294fa7 Merge #43: Fehlende Umgebungsvariablen durchreichen 2026-09-01 14:13:08 +00:00
Florian Wilde 810716e4e4 Fehlende Umgebungsvariablen durchreichen - und dauerhaft absichern
Der Dienst "app" zaehlt seine Umgebungsvariablen einzeln auf; ein
env_file gibt es nicht. Drei Werte, die die Anwendung ausliest, standen
dort nicht: ERSTEINRICHTUNG_GEHEIMNIS, VERTRAUE_PROXY (beide seit v1.11.0)
und S3_SSE (seit v1.11.1).

Wer sie laut .env.example und INSTALLATION.md eintrug, brachte sie damit
nicht in den Behaelter - die Anleitung war an dieser Stelle schlicht
falsch. Besonders unangenehm bei VERTRAUE_PROXY: Der Wert fehlt still,
die Anmeldesperre zaehlt dann nur nach E-Mail-Adresse statt zusaetzlich
nach Anschluss, und im Betrieb faellt nichts auf.

VERTRAUE_PROXY bekommt bewusst KEINE Vorgabe "true". Die strengere
Einstellung bleibt die Voreinstellung; wer einen Proxy davorstellt, sagt
es ausdruecklich. Ohne Proxy ist der weitergereichte Kopf frei erfunden.

Der eigentliche Ertrag ist der Test: Er liest, welche Variablen env.ts
ausliest - ueber process.env ebenso wie ueber pflicht(), zahl(),
schalter() und schluessel32() -, vergleicht das mit dem, was die
Compose-Datei dem Dienst mitgibt, und meldet jede Luecke. Ausnahmen sind
aufgezaehlt und begruendet.

Damit wiederholt sich der Fehler beim naechsten neuen Wert nicht: Wer ihn
einfuehrt und das Durchreichen vergisst, sieht es sofort.

Gegen drei Mutationen geprueft: Wert wieder entfernt, Vorgabe "true"
gesetzt, S3_SSE entfernt - jede wird rot.

Refs #43
2026-09-01 16:12:32 +02:00
florian.wilde 88e9202c2f Merge #42: Serverseitige Verschlüsselung optional machen 2026-09-01 14:09:01 +00:00
Florian Wilde c0cb29bf53 Serverseitige Verschluesselung optional machen
Der S3-Treiber setzte bei jedem PutObject fest
ServerSideEncryption: 'AES256'. Das mitgelieferte MinIO laeuft ohne KMS
und lehnt das mit "NotImplemented" ab - es liess sich kein einziges
Dokument ablegen. Wer der INSTALLATION.md folgte, landete zwangslaeufig
dort; es war kein Konfigurationsfehler des Betreibers, sondern der
Auslieferungszustand.

Gebraucht wird die Zeile ohnehin nicht mehr. Seit v1.11.0 verschluesselt
die Anwendung jede Datei selbst, bevor sie den Server verlaesst. Eine
zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
Umschlag nicht schon abdeckt.

Jetzt steuert S3_SSE, ob der Kopf mitgeht. Leer bedeutet aus - auch eine
Zeile mit Leerzeichen, sonst schickte eine leergelassene .env-Zeile einen
leeren Kopf und der Anbieter antwortete mit einem Fehler, den niemand
versteht.

Im Kommentar von v1.11.0 stand, die serverseitige Verschluesselung
"kostet nichts". Das war falsch: Auf MinIO ohne KMS kostete sie die
gesamte Funktion. Der Satz ist korrigiert.

Aufgefallen ist das erst im Betrieb, und der Grund ist derselbe wie beim
Ausloeser: In der Entwicklung laeuft der Dateitreiber, der S3-Pfad wurde
von keinem Test ausgefuehrt. Deshalb pruefen die neuen Tests den Befehl
ab, bevor er das Netz sieht - was hineingeschrieben wird, ist jetzt
belegt, nicht angenommen.

Gegen drei Mutationen geprueft: SSE wieder fest verdrahtet, leere Angabe
nicht abgefangen, Inhalt unverschluesselt - jede wird rot.

Refs #42
2026-09-01 16:08:26 +02:00
florian.wilde dbc24d965d Merge #41: Dokumente mitsichern 2026-09-01 14:04:03 +00:00
Florian Wilde 4d826ef55f Dokumente mitsichern - eine Akte ohne ihre Dateien ist wertlos
Der Sicherungsdienst machte taeglich ein pg_dump und sonst nichts. Nach
einem Plattenschaden waere die Akte vollstaendig wiederhergestellt worden
- mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
der schlechteste denkbare Zustand.

Je Lauf entstehen jetzt zwei verschluesselte Dateien mit demselben
Zeitstempel: der Datenbankstand und ein Archiv des Ablageverzeichnisses.
Beide mit Pruefsumme, beide in der Aufbewahrung, beide im Monatsstand,
beide in der Selbstpruefung.

Schlaegt der Dokumententeil fehl, bricht der Lauf ab und raeumt den
angefangenen Datenbankstand wieder weg. Eine Sicherung, die nur die halbe
Wahrheit enthaelt, aber vollstaendig aussieht, ist schlimmer als gar
keine: Man verlaesst sich darauf und merkt es erst im Ernstfall.

## Abweichung von der urspruenglichen Idee

Im Issue stand "mc mirror statt rohem Verzeichnis". Beim Umsetzen zeigte
sich, dass der MinIO-Client in diesem Behaelter fehlt und bei jedem Lauf
aus dem Netz geholt werden muesste. Eine Internetabhaengigkeit im
Sicherungspfad ist genau das, was man dort nicht will.

Stattdessen wird das Verzeichnis direkt eingepackt. Das ist vertretbar,
weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp schreibt und
dann an seinen Platz umbenennt: Ein laufender Upload liegt nie halbfertig
im Archiv, er fehlt hoechstens. Derselbe Weg deckt den Dateitreiber mit
ab.

## Nur lesend

Der Sicherungsdienst bekommt das Ablageverzeichnis mit ":ro" eingehaengt.
Er laeuft unbeaufsichtigt per cron; ein Fehler im Skript oder ein
kompromittierter Behaelter wuerde sonst Sicherung und Bestand auf einmal
treffen - genau der Fall, gegen den eine Sicherung da ist.

Deshalb schreibt die Wiederherstellung die Dateien auch nicht selbst an
ihren Platz, sondern packt sie nach ./backups aus und gibt den letzten
Handgriff als Einzeiler aus. In ein laufendes MinIO hineinzuschreiben
wuerde dessen Datenbestand ohnehin beschaedigen.

## Geprueft

Die Kernlogik einmal von Hand durchgespielt: packen, verschluesseln,
Selbstpruefung, zurueckholen - der Inhalt kam bitgleich zurueck, der
Klartext war im Archiv nicht auffindbar, und die Selbstpruefung schlug
sowohl bei einem beschaedigten Archiv als auch bei falschem Passwort an.

Dazu elf Pruefungen in tests/skripte.test.ts gegen vier Mutationen:
Dokumententeil entfernt, Abbruch entfernt, Aufraeumen halbiert,
Schreibrecht erteilt - jede wird rot.

Refs #41
2026-09-01 16:03:26 +02:00
florian.wilde 6e6f022234 Merge #40: Ausschlussfristen nur bei Fälligkeit hervorheben 2026-09-01 13:50:59 +00:00
Florian Wilde b3823094d6 Ausschlussfristen nur hervorheben, wenn sie etwas von einem wollen
Auf dem Dashboard standen dauerhaft drei rote Kaesten mit Warndreieck und
Knopf "Frist jetzt bearbeiten": Aufwandspauschalen mit Verfall 2027, 2028
und 2029.

Alle anderen Bloecke des Dashboards filtern nach Dringlichkeit. Dieser
fragte nur, OB etwas eine Ausschlussfrist ist, und nie, WANN sie faellig
wird - eine Frist im Mai 2029 landete damit in derselben Aufmachung wie
eine, die morgen ablaeuft.

Die Daten sind richtig: Pflichtfristen werden bewusst drei Jahre im Voraus
angelegt. Sie gehoeren nur nicht in die Warnung, sondern in die Liste.

Neu ist ein Horizont von 90 Tagen. Er ist grosszuegig gewaehlt, weil die
kuerzeste hier vorkommende Ausschlussfrist sechs Monate laesst. Abgelaufene
warnen weiterhin immer - eine versaeumte Aufwandspauschale bleibt eine
schlechte Nachricht, auch wenn sie lange zurueckliegt.

Der Grund ist nicht Bequemlichkeit: Eine Warnung, die monatelang dasteht
und nichts von einem will, stumpft ab. Man uebersieht die vierte, die
etwas will - und bei § 1878 BGB ist das die Jahrespauschale, die ersatzlos
erlischt.

Ein bestehender Test behauptete "unabhaengig vom Faelligkeitstopf" und
belegte das mit einer Frist 134 Tage in der Zukunft. Die Aussage stimmt
weiterhin, das Beispiel nicht mehr - es liegt jetzt innerhalb des
Horizonts, aber ausserhalb aller Faelligkeitstoepfe.

Gegen drei Mutationen geprueft: Filter entfernt, Abgelaufenes
ausgeschlossen, Horizont um einen Tag verschoben - jede wird rot.

Refs #40
2026-09-01 15:50:17 +02:00
florian.wilde 3525777fb4 Merge #38: Sicherheitsprüfung abarbeiten (v1.11.0) 2026-08-24 10:35:19 +00:00
Florian Wilde 406f7854b7 chore: Version 1.11.0 2026-08-24 10:06:13 +02:00
Florian Wilde e48eda4332 Irrefuehrende Sicherheitszusagen entfernen, OWASP-Durchgang festhalten
Zwei Kommentare versprachen Schutz, den es nicht gab. Das ist schlimmer
als eine fehlende Zusage, weil man sich darauf verlaesst:

- In middleware.ts stand "Ohne Authentifizierungs-Cookie gar nicht erst
  weiterreichen". Die Middleware prueft keine Anmeldung, sie setzt
  Kopfzeilen; die Liste steuert allein den Cache-Control-Kopf. Wer den
  Satz glaubt, verzichtet an einer neuen Seite auf erfordereBenutzer().
  Die Liste heisst jetzt OHNE_CACHE_SPERRE und sagt selbst, was sie ist.

- In datei.ts hiess es, die Pfadpruefung fange "Symlink-Tricks" ab.
  resolve() rechnet mit Zeichenketten und folgt keinem Link - dafuer
  braeuchte es realpath(). Nachgeruestet wird es nicht: Wer dort einen
  Symlink anlegen kann, hat schon Schreibrechte im Datenverzeichnis. Der
  Satz musste trotzdem weg.

Dazu docs/SICHERHEIT.md: Bedrohungsmodell, der Durchgang entlang der
OWASP Top 10 - und ein eigener Abschnitt darueber, was ausdruecklich NICHT
geschuetzt ist.

Aus dem OWASP-Durchgang kamen keine weiteren Funde. Zwei Stellen sahen
danach aus und waren es nicht: Prisma.join() in suche.ts fuegt nur bereits
parametrisierte Fragmente zusammen, und der Suchausschnitt mit den
<mark>-Markern aus ts_headline wird zerlegt und als React-Kinder
gerendert, nicht als HTML - obwohl die Quelle der OCR-Text hochgeladener
Dokumente ist.

Refs #38
2026-08-24 09:53:43 +02:00
Florian Wilde 92ff8efe39 Objektspeicher verschluesseln, statt es dem Anbieter zu ueberlassen
Im S3-Treiber stand nur ServerSideEncryption: 'AES256'. Das ist
Verschluesselung mit dem Schluessel des Anbieters - er entschluesselt beim
Ausliefern selbst. Gegen eine verlorene Platte im Rechenzentrum hilft das,
gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer nicht. In
diesem Eimer liegen Bescheide, Arztbriefe und Kontoauszuege.

Der Dateitreiber hat es von Anfang an richtig gemacht. Der Umschlag zieht
deshalb nach src/lib/speicher/umschlag.ts um und wird von beiden benutzt,
statt ihn ein zweites Mal zu schreiben.

Neu ist die Bindung an den Ablageort: Der Schluessel des Objekts wandert
in die Zusatzdaten. Wer Schreibrechte auf den Eimer hat, kann die Bytes
von Dokument A damit nicht unter dem Namen von Dokument B ablegen - beim
Lesen schlaegt die Pruefung fehl, statt den falschen Bescheid mit
gueltiger Pruefsumme auszuliefern.

Damit fallen die presigned URLs weg. Sie liessen den Browser direkt beim
Anbieter laden; was dort liegt, ist jetzt Geheimtext. Die Auslieferung
geht ueber dieselbe eigene Route wie beim Dateitreiber - die prueft
zusaetzlich die Berechtigung und schreibt einen Audit-Eintrag. Ein
weitergeleiteter Link allein oeffnet also nichts mehr.

Bestehende Dateien bleiben durchgehend lesbar: Der Treiber erkennt am
Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
Auch die alten Umschlaege ohne Ortsbindung gehen weiter auf - sonst waere
die Verbesserung ein Datenverlust. Nachgezogen wird mit

    npm run speicher:verschluesseln            # nur schauen
    npm run speicher:verschluesseln -- --anwenden

Geprueft: 12 Tests gegen drei Mutationen, ein Trockenlauf ueber die 120
Dateien der Entwicklungsablage, und eine Probe mit einer Klartextdatei,
die nach dem Lauf verschluesselt auf der Platte liegt und sich trotzdem
korrekt zurueeklesen laesst.

Refs #38
2026-08-24 09:50:41 +02:00
Florian Wilde 676fbb8ee4 Texterkennung in Grenzen zwingen, die das Dokument nicht selbst setzt
Zwei Wege, auf denen ein hochgeladenes Dokument den Server umbringen
konnte. Beide haben gemeinsam, dass die Groesse aus der Datei kam.

1. Dekompressionsbombe. Die PDF-Stroeme wurden mit inflateSync ohne
   maxOutputLength ausgepackt. Ein Flate-Strom sagt nicht, wie gross er
   ausgepackt wird - ein paar Kilobyte Fuellung blaehen sich auf Gigabyte
   auf. Jetzt ein Deckel je Strom (8 MB) und einer ueber die Summe
   (64 MB); zlib bricht selbst ab, der Strom wird verworfen, der Text ist
   eben unvollstaendig.

2. Riesenseiten. "pdftoppm -r 300" rendert mit fester Aufloesung, aber
   die Seitengroesse steht im PDF. Bei 200 x 200 Zoll ergeben 300 dpi ein
   Bild von 60000 x 60000 Pixeln, und zwar je Seite. Jetzt "-scale-to"
   statt fester dpi: Die laengste Kante bekommt eine feste Pixelzahl,
   unabhaengig davon, was im Dokument als Papierformat steht. Fuer A4
   entspricht das etwa den bisherigen 300 dpi. Dazu ein Deckel auf die
   Gesamtgroesse der gerenderten Dateien.

Der erste Bombentest war wertlos: Die Nutzlast enthielt keine
PDF-Textoperatoren, also war das Ergebnis mit und ohne Grenze leer. Mit
einer Marke in der Nutzlast und einer Gegenprobe knapp unter der Grenze
schlagen beide Mutationen an.

Refs #38
2026-08-24 09:41:35 +02:00
Florian Wilde 5b54d7bc5f Ersteinrichtung schliessen: Geheimnis und eine Sperre statt zweier Schritte
Die Aktion ist die einzige Stelle der Anwendung, an der jemand ohne
Anmeldung schreiben darf. Zwei Dinge fehlten.

Erstens stand die Seite zwischen dem ersten Start und dem Anlegen des
Zugangs fuer jeden offen: Wer sie in diesem Fenster fand, wurde
Administrator einer Anwendung mit Gesundheits- und Finanzdaten. Das
Fenster ist kurz, aber Zufall ist keine Zugriffskontrolle. Jetzt muss
ERSTEINRICHTUNG_GEHEIMNIS aus der .env mit eingegeben werden; verglichen
wird ueber Hashes in gleichbleibender Zeit, und ein falscher Wert
antwortet mit derselben Mindestdauer wie das Anmeldeformular.

Fehlt der Wert, ist die Einrichtung gesperrt statt offen. Das ist
Absicht: Eine Installation, bei der die Zeile vergessen wurde, soll eine
Meldung zeigen und nicht einrichtbar sein.

Zweitens waren Pruefung und Anlegen zwei Schritte. Beide laufen jetzt in
einer Transaktion, und die Sperrzeile mit fester Id entscheidet - eine
zweite gleichzeitige Anfrage scheitert am Primaerschluessel, bevor ein
zweiter Administrator entsteht.

Die Seite liest die Sperrzeile mit: Lag der erste Zugang im Papierkorb,
stand Schritt 1 sonst wieder offen. Der Seed setzt sie ebenfalls.

Refs #38
2026-08-24 08:03:49 +02:00
Florian Wilde 609aa5d27d Anmeldung haerten: Einmalcodes, Anschluss, Host, Passkey-Vorzimmer
Vier Stellen, an denen eine Zusage nicht gehalten wurde.

1. Ein TOTP-Code galt mehrfach. Der Kommentar in totp.ts versprach seit
   dem Anfang, der Aufrufer speichere das getroffene Zeitfenster - getan
   hat es niemand. Damit liess sich ein abgelesener Code innerhalb von 90
   Sekunden noch einmal einloesen. Jetzt geht jede Pruefung durch
   zweitenFaktorEinloesen(), und die Bedingung steht im WHERE von
   updateMany: Zwei gleichzeitige Anmeldungen mit demselben Code treffen
   nicht beide eine Zeile.

2. Der Anschluss kam aus dem ERSTEN Eintrag von "x-forwarded-for". Das
   ist der Teil, den der Aufrufer selbst schreibt. Wer bei jeder Anfrage
   eine andere Zahl hineinschrieb, sah fuer die Anmeldesperre wie tausend
   Anschluesse aus - die Zaehlung war nicht nur wirkungslos, sondern
   irrefuehrend. Jetzt von hinten gelesen, und nur wenn VERTRAUE_PROXY
   gesetzt ist. Ohne Proxy ist der Kopf fremdbestimmt; dann lieber keine
   Zaehlung nach Anschluss als eine, die eine Kopfzeile aushebelt. Beim
   ersten Mal steht eine Zeile im Log, damit das Fehlen auffaellt.

3. trustHost stand fest auf true. Ist AUTH_URL gesetzt - der
   dokumentierte Betrieb setzt es, Passkeys brauchen es -, gibt es keinen
   Grund, die Adresse aus einer Kopfzeile von aussen abzuleiten.
   AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
   nicht stillschweigend uebergangen wird.

4. Passkeys liessen sich ohne erneute Bestaetigung anlegen und entfernen.
   Ein Passkey ist ein vollwertiger Zugang, der einen Passwortwechsel
   ueberlebt - an einem unbeaufsichtigten Rechner waren das zwei Klicks.
   Jetzt dasselbe Vorzimmer wie beim Vollexport. Dabei musste
   alsFehler() lernen, die Weiterleitung von Next durchzulassen: Sonst
   verschwand die Zwischenfrage in "Der Passkey konnte nicht geprueft
   werden".

Refs #38
2026-08-24 08:03:33 +02:00
Florian Wilde 6bd8f7e8a4 Schema: Zeitschritt fuer TOTP und Sperre fuer die Ersteinrichtung
Zwei Felder fuer zwei Luecken, in einer Migration:

benutzer.totpLetzterSchritt haelt das zuletzt akzeptierte TOTP-Fenster
fest. Ohne das gilt ein Code bis zu 90 Sekunden lang beliebig oft.

Die Tabelle ersteinrichtung hat genau eine Zeile mit fester Id. Das ist
kein Protokoll, sondern eine Sperre: Bisher entschied "gibt es schon
einen Benutzer?" und danach wurde angelegt - zwei Anfragen gleichzeitig
kamen beide durch. Jetzt entscheidet der Primaerschluessel.

Enthalten ist eine Datenmigration: Wo schon ein Zugang besteht, wird die
Sperrzeile gesetzt. Ohne sie stuende die Einrichtungsseite auf jedem
bestehenden System nach dem Update wieder offen.

Die DROP-INDEX-Zeilen zu den Trigramm-Indizes hat das Skript aus #36
wieder entfernt; "npm run db:pruefen" bestaetigt, dass beide stehen.

Refs #38
2026-08-24 08:03:32 +02:00
Florian Wilde 133b0314c5 Anmeldesperre gegen Selbstverlaengerung und Schreiblast sichern
Die Sperre hielt sich selbst am Leben. Jeder Aufruf wurde festgehalten,
auch der, der wegen der laufenden Sperre gar nicht geprueft wurde; die
Frist lief zugleich ab dem juengsten Fehlversuch. Wer also alle zehn
Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte
deren Inhaber dauerhaft aus - ohne das Passwort zu kennen. Jeder dieser
Aufrufe hinterliess ausserdem eine Zeile, die erst der Wartungslauf nach
30 Tagen wieder los wurde.

Jetzt zwei getrennte Dinge: Ein geprueft fehlgeschlagener Versuch zaehlt
und wird einzeln festgehalten. Ein waehrend der Sperre abgewiesener
Aufruf zaehlt nicht und wird hoechstens einmal je Sperre vermerkt.

Die Auswertung geht die Versuche von alt nach neu durch und zieht die
Runden nach: Ist das Mass voll, beginnt an diesem Versuch eine Sperre
und der Zaehler faengt von vorn an. Damit laeuft die Frist auch dann ab,
wenn die Fehlversuche noch im Zeitfenster stehen - vorher blieb der
Zugang bis zum Fensterende dicht.

Die Tests sind gegen vier Mutationen geprueft; jede wird rot.

Refs #38
2026-08-24 02:13:04 +02:00
Florian Wilde 2bed78915f Zweiten Faktor zur Grenze machen, nicht nur zur Anzeige
"Der zweite Faktor ist Pflicht" stand im Kopf von auth.ts, durchgesetzt
wurde es aber nur im App-Layout. Ein Layout schuetzt, was durch das
Layout gerendert wird - Server Actions und Route Handler laufen daran
vorbei. Wer eine Passwort-Sitzung hatte, die TOTP-Einrichtung aber nie
abgeschlossen hat, kam an die Daten.

Die Pruefung steckt jetzt in erfordereBenutzer() selbst und damit ueber
erfordereZugang() automatisch in jeder Server Action. Massgeblich ist
der Datenbankstand, nicht das Feld im Sitzungstoken - das laesst sich
ueber session.update() vom Client beeinflussen.

Fuer Route Handler gibt es benutzerFuerRoute(): 401 bzw. 403 statt einer
Umleitung. Eine Anmeldeseite als Inhalt einer Word-Datei waere die
falsche Antwort.

Dabei aufgefallen: Der Dokumentenabruf unter /api/dokumente/datei fragte
nur nach der Anmeldung. Auch geschlossen.

Ausnahmen sind aufgezaehlt und werden von tests/mfa-grenze.test.ts
festgehalten - der Test liest den Quelltext und schlaegt an, sobald eine
weitere dazukommt.

Refs #38
2026-08-24 02:12:50 +02:00
Florian Wilde 2c1b8f4c28 Abhaengigkeiten auf Fassungen ohne bekannte Luecken heben
npm audit meldete 32 Schwachstellen, davon 5 kritische. Der Vorschlag
des Werkzeugs war ein Sprung auf Next.js 16 - noetig war er nicht: next,
next-auth und @auth/core waren nur durchgereicht markiert, ein eigenes
Advisory hatten allein nodemailer, postcss und sharp. Die drei sind
Blattpakete und lassen sich gezielt ueberschreiben.

Nodemailer 9 kollidiert mit einer optionalen Peer-Angabe von next-auth
(hoechstens 8). Das ist folgenlos, weil der E-Mail-Provider von next-auth
hier nicht benutzt wird, sondern Nodemailer direkt.

Danach: 0 Schwachstellen.

Refs #38
2026-08-24 02:12:37 +02:00
florian.wilde ece995bb6a Merge #37: Update brach ohne Meldung ab
Abgeschnittene Leitungen unter pipefail beendeten update.sh stumm und
haetten die Selbstpruefung der Sicherung falsch negativ gemacht. Behoben,
mit ERR-Falle und Test gegen das Muster.
2026-08-23 22:36:53 +00:00
Florian Wilde 107406e3c0 Update brach ohne Meldung ab
Beim Sprung von 1.5.0 auf 1.10.1 endete scripts/update.sh mitten in der
Ausgabe. Kein Fehler, keine Zeile, einfach zurueck zur
Eingabeaufforderung.

Ursache war eine abgeschnittene Leitung:

  git log --oneline "HEAD..$ZIEL" | head -15 | sed ...

head schliesst die Leitung nach 15 Zeilen, git log schreibt weiter und
bekommt SIGPIPE (141). Mit "set -o pipefail" gilt die ganze Kette als
gescheitert, "set -e" beendet das Skript - stumm.

Zeitabhaengig, deshalb monatelang unauffaellig: Solange git log fertig
wurde, bevor head zumachte, ging es gut. Die Schwelle liegt exakt bei
15 Commits, und v1.5.0..v1.10.0 waren die ersten 16.

Behoben, indem der Erzeuger selbst begrenzt (git log -15) statt die
Leitung zu kappen. Dieselbe Behandlung fuer die beiden Tag-Listen.

Dieselbe Falle steckte in der Selbstpruefung von sicherung.sh:

  openssl enc -d ... | head -c 5 | grep -q "PGDMP"

Beide Leser schliessen frueh. Bei kleiner Datenbank steht alles im
Puffer und es faellt nicht auf; mit wachsender Datenbank kippt es - und
dann meldet das Skript eine INTAKTE Sicherung als kaputt und verweigert
das Update. Jetzt liest dd eine feste Menge und endet regulaer.

Damit so etwas nicht wieder stumm passiert:
- Eine ERR-Falle nennt Zeilennummer und Exitcode und erklaert bei 141,
  dass es SIGPIPE ist und ein Fehler im Skript, nicht beim Benutzer.
- Ein Test verbietet "| head" in Skripten mit pipefail und nennt die
  Zeile. Gegenprobe gemacht: Er schlaegt an.
- Bei mehr als 15 Commits steht "... und N weitere" darunter, statt sie
  wortlos wegzulassen.

Nachgestellt: der Ablauf von 1.5.0 auf 1.10.1 laeuft durch.
2026-08-24 00:36:08 +02:00
florian.wilde 9ea78a4d29 Merge #36: Zeilenenden im Repository auf LF
Behebt Issue #26: .gitattributes legt LF fest, 29 Dateien normalisiert.
Nachgewiesen nur Zeilenenden, kein Zeichen Inhalt geaendert.

Closes #26
2026-08-23 22:28:47 +00:00
Florian Wilde 1ed19eb76e Zeilenenden der 24 verbliebenen Dateien normalisieren
Ausschliesslich Zeilenenden. Nachgewiesen mit
"git diff --cached --ignore-cr-at-eol": Der Diff ist leer, es hat sich
kein Zeichen Inhalt geaendert.

Deshalb ein eigener Commit: 7448 geaenderte Zeilen in einem Commit mit
echten Aenderungen zu verstecken, macht die Historie an dieser Stelle
unlesbar. So laesst er sich beim Durchsehen ueberspringen.

Refs #26
2026-08-23 23:37:02 +02:00
Florian Wilde 6a0db51e3b Zeilenenden im Repository auf LF festlegen
Ohne .gitattributes liegen Dateien gemischt vor: 29 von 500 mit CRLF,
der Rest mit LF. Jedes zeilenweise arbeitende Werkzeug schreibt eine
CRLF-Datei dann mit LF zurueck, und der Diff zeigt statt drei
geaenderten Zeilen die ganze Datei - bei Issue #7 waren es 578 statt 8,
kurz vor dem Push bemerkt.

Dieser Commit legt nur die Regel fest. Die Normalisierung der 29 Dateien
kommt getrennt, damit die Historie lesbar bleibt.

Refs #26
2026-08-23 23:34:38 +02:00
florian.wilde 851724774a Merge #35: Doppelt gerenderte Listen dokumentieren
Behebt Issue #28: Kommentar an der Dokumentenliste, Muster und drei
tragfaehige Wege in e2e/hilfen.ts, Begruendung warum die Verdopplung
bleibt.

Closes #28
2026-08-23 21:29:15 +00:00
Florian Wilde 3772e360e1 Doppelt gerenderte Listen dokumentieren
Die Dokumentenliste rendert jeden Eintrag zweimal: als Karte fuers Handy,
als Tabellenzeile ab Tablet. Eine Fassung ist per Breakpoint versteckt,
steht aber im DOM. Beim Testen trifft getByText(...).first() deshalb die
unsichtbare Kopie, und die Pruefung scheitert mit "element is not
visible" an einer Stelle, die augenscheinlich in Ordnung ist. Das hat
mehrfach Zeit gekostet, weil man den Grund nicht sieht.

Nachgemessen an /dokumente mit acht Dokumenten: ein Titel liefert 16
Treffer, davon 8 sichtbar - und first() ist unsichtbar.

- Kommentar an der Komponente, mit dem Verweis auf die Hilfsdatei.
- In e2e/hilfen.ts das Muster beschrieben und drei Wege genannt, die
  tragen: innerText fuer "steht das da", visible=true zum Klicken, die
  Zusammenfassungszeile fuer Anzahlen. Alle drei nachgeprueft, bevor sie
  aufgeschrieben wurden - eine Anleitung mit kaputtem Rezept waere
  schlimmer als keine.

Zur Frage aus dem Issue, ob sich die Verdopplung vermeiden laesst: nein,
nicht ohne Verlust. Eine Tabelle, die zu Karten umbricht, verlangt
display:block auf Zeilen und Zellen - damit ist es keine Tabelle mehr,
Vorlesesoftware verliert den Zusammenhang von Spaltenkopf und Wert, und
genau der macht eine Liste mit acht Spalten erst benutzbar. Die
Begruendung steht jetzt am Code, statt die Frage offen zu lassen.

Betroffen ist uebrigens nur diese eine Stelle, nicht mehrere:
kalender-monat.tsx und das Stammdatenformular nutzen Breakpoints, ohne
Inhalt doppelt zu rendern.

Refs #28
2026-08-23 23:28:22 +02:00
florian.wilde d75eb185d5 Merge #34: Verweise auf fremde Akten an einer Stelle pruefen
Behebt Issue #23: Durchgang durch alle Server Actions, eine Luecke
geschlossen (buchung.vorgangId), die Pruefung in lib/auth/verweise.ts
zusammengefasst.

Closes #23
2026-08-23 21:21:40 +00:00
Florian Wilde 91a9aca0d8 Verweise auf fremde Akten an einer Stelle pruefen
Wenn ein Formular einen Fremdschluessel mitschickt, kommt der aus einer
Auswahlliste im Browser. Der Server darf sich darauf nicht verlassen -
eine veraenderte Anfrage kann jede beliebige Id enthalten.

Alle 22 Dateien unter src/server/actions/ durchgesehen. Das Ergebnis ist
besser als befuerchtet: Bis auf eine Stelle war jeder Verweis geprueft.

  buchung.vorgangId ging ungeprueft durch. Konto, Beleg und Vertrag
  wurden geprueft, der Vorgang nicht.

Die Pruefung liegt jetzt in lib/auth/verweise.ts statt verstreut in den
Aktionen. Die acht erlaubten Ziele sind eine ausgeschriebene Aufzaehlung
und kein dynamischer Tabellenzugriff: So sieht der Typpruefer jede
Abfrage, und welche Tabellen erreichbar sind, ist eine Entscheidung und
keine Nebenwirkung. Mehrere Verweise laufen nebeneinander - bei vier
Verweisen eine Wartezeit statt vier.

Umgestellt: die vier Einzelpruefungen der Buchung, die drei des
Vermoegenspostens, die eigene Hilfsfunktion fuer den Ansprechpartner.
Nicht angefasst wurden die Stellen, die ihre eigene, passendere Form
haben - etwa das Kontaktprotokoll, das beide Verweise parallel laedt und
feldgenaue Meldungen zurueckgibt.

Zum Test: Der erste Entwurf rief die Server Action per fetch nach. Der
waere auch ohne jede Pruefung gruen gewesen, weil Next einen Aufruf ohne
gueltige Action-Id ohnehin ablehnt - er haette nichts bewiesen. Der
jetzige schiebt dem echten Auswahlfeld einen Eintrag mit fremder Id
unter und schickt das Formular normal ab. Gegenprobe mit ausgebauter
Pruefung: faellt um, wie er soll.

Refs #23
2026-08-23 23:20:26 +02:00
florian.wilde 91592cbe88 Merge #33: Ein Jahr Kontobewegungen im Beispielfall
Behebt Issue #22: 107 Buchungen ueber zwoelf Monate im Demofall, mit
gerechnetem Kontostand. Dazu behoben: gGmbH fehlte in der Rechtsformliste
beim Zusammenfassen wiederkehrender Zahlungen.

Closes #22
2026-08-23 19:36:49 +00:00
Florian Wilde 18709a8e49 Ein Jahr Kontobewegungen im Beispielfall
Der Demofall brachte Konten und Kontostaende mit, aber keine einzige
Buchung. Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer - wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.

Neu sind 107 Buchungen ueber zwoelf Monate, erzeugt aus einem Muster
statt einzeln abgetippt: Rente, Betriebsrente und Pflegekasse als
Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag, Telefon und
Kontofuehrung monatlich, dazu Rundfunkbeitrag und Grundsteuer
vierteljaehrlich sowie eine Handvoll Einzelposten.

Was der Fall damit zeigt:
- Das Heimentgelt steht in drei Schreibweisen auf dem Auszug und wird
  trotzdem als eine monatliche Zahlung gefuehrt.
- Genau eine Buchung hat weder Quittung noch Begruendung. Eine, nicht
  zwanzig: Die Liste soll etwas zu zeigen haben und nicht rot leuchten.
- Die Sterbegeld-Lastschriften bleiben unzugeordnet, damit sich der
  Handgriff "zwoelf Zahlungen als belegt zuordnen" ausprobieren laesst.
  Der Beitrag steigt im Mai von 18,40 auf 19,80 - damit auch die
  Abweichungspruefung sichtbar wird.
- Der Kontostand zum 31.07. wird aus Anfangsstand und Buchungen
  gerechnet, nicht behauptet. Ein Beispielfall, der sich selbst
  widerspricht, waere schlimmer als einer ohne Buchungen.

Zwei Funde beim Bauen:

Der Unique-Index auf (kontoId, importReferenz) hat einen echten Fehler in
den Daten gefangen: Zwei Renten am Monatsersten ergaben dieselbe
Referenz. Genau dafuer ist er da.

"gGmbH" fehlte in der Rechtsformliste. Dadurch galten "Seniorenzentrum
St. Katharina" und "... gGmbH" als zwei Empfaenger - und die Gruppe
zerfiel ausgerechnet in dem Fachgebiet, in dem fast jeder Traeger eine
gGmbH ist. Ergaenzt, mit Test.

Geprueft: ein Einheitstest fuer die Rechtsform, vier End-to-End-Tests am
Beispielfall - Umfang und Zeitraum, Kontostand gegen Buchungssumme, die
erkannten Gruppen und das Kassenbuch.

Refs #22
2026-08-23 21:35:46 +02:00
florian.wilde b6b579a04d Merge #32: Absenderanschrift in jedem Schreiben
Behebt Issue #21: Strasse, PLZ und Ort unter "Mein Konto", von dort in
den Absenderblock jedes Schreibens und als Ort in die Datumszeile. Was
fehlt, bleibt weg.

Closes #21
2026-08-23 18:30:23 +00:00
Florian Wilde 848aaf1b9f Absenderanschrift in jedem Schreiben
Jeder erzeugte Brief ging bisher ohne Ruecksendeadresse hinaus: Im
Absenderblock stand nur der Name, in der Datumszeile fehlte der Ort. Bei
einer Versicherung oder Behoerde wirkt das unserioes und macht es der
Gegenstelle unnoetig schwer zu antworten.

Neu unter "Mein Konto" ein Feld fuer Strasse, PLZ und Ort. Ab da stehen
sie im Absenderblock jedes Schreibens, und die Datumszeile lautet
"Bayreuth, den 23.08.2026".

Die Anschrift haengt am Benutzer, nicht an der betreuten Person: Sie
gehoert zu mir und gilt fuer alle Akten.

Nicht die Logik fehlte, sondern die Datenquelle - absenderBlock() und
datumsZeile() konnten die Felder schon, briefkopfBauen() hat sie mit
einem Kommentar auf null gesetzt, weil es sie im Datenmodell nicht gab.

Aufbau:
- Drei Felder am Benutzer, Migration ueber den Wrapper aus #24. Das war
  sein erster echter Einsatz: erzeugte Datei ohne Handarbeit brauchbar,
  DROP-INDEX-Zeilen bereits heraus.
- Ein Absender-Typ statt eines blossen Namens, durchgereicht bis in den
  Serienlauf - der hat bisher {name} gebaut und haette die Anschrift
  sonst still verloren.
- Zwei Platzhalter fuer eigene Vorlagen: betreuer_anschrift,
  betreuer_ort.

Was fehlt, bleibt weg: Jede der drei Angaben ist einzeln freiwillig. Nur
ein Ort ergibt eine Datumszeile mit Ort und einen Absenderblock, der
weiterhin nur aus dem Namen besteht. Eine halb ausgefuellte Adresse
sieht nach Fehler aus, ein blosser Name nach Absicht.

Geprueft: sieben Einheitstests fuer den Absenderblock inklusive jeder
Teilbefuellung und der Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet; vier End-to-End-Tests vom leeren
Zustand ueber das Speichern bis zum fertigen Brief.

Refs #21
2026-08-23 20:29:17 +02:00
florian.wilde 5022a97a6f Merge #31: Datenbankbefehle benennen, welcher Rueckfragen stellt
Behebt Issue #27: Uebersicht der sieben Datenbankbefehle in der README,
db:migrate als einziger interaktiver benannt, Ersteinrichtung auf
npm run setup vereinheitlicht.

Closes #27
2026-08-23 17:36:06 +00:00
Florian Wilde 110b11bef8 Datenbankbefehle benennen, welcher Rueckfragen stellt
"npm run db:migrate" ist "prisma migrate dev" und damit interaktiv: Es
fragt nach einem Namen und bietet an, die Datenbank zurueckzusetzen. In
einem Skript, in der CI oder aus einem Agenten heraus bleibt es haengen.
Aus dem Namen ging das nicht hervor, also lief man einmal hinein.

- Neuer Abschnitt in der README mit allen sieben Datenbankbefehlen,
  wofuer sie da sind und welcher Rueckfragen stellt. Es ist genau einer.
- Der uebliche Weg fuer eine Schemaaenderung steht als Dreizeiler
  daneben - ueber den Wrapper aus #24, nicht ueber migrate dev.
- Die Ersteinrichtung in der README rief "npx prisma migrate dev --name
  fundament" auf, obwohl es dafuer "npm run setup" gibt. Das war der
  Ausgangspunkt der Verwirrung; jetzt steht dort der eine Befehl.
- Der Hinweis unter "Betriebswissen", die SQL-Skripte seien von Hand
  anzustossen, galt nur fuer die Ersteinrichtung. Beim Aktualisieren
  erledigt update.sh das mit - jetzt steht das dabei.

"db:migrate" bleibt bestehen. Der interaktive Ablauf hat seinen Platz,
etwa um eine verkorkste Entwicklungsdatenbank zurueckzusetzen; er ist
nur nicht mehr der Standardweg.

Refs #27
2026-08-23 19:35:20 +02:00
florian.wilde 4aed9824d1 Merge #30: Migrationskommentare zeigen auf die richtige Datei
Behebt Issue #25: prisma/sql/suche.sql gibt es nicht, richtig ist
volltextsuche.sql. Dazu ein Verweis auf npm run db:migration aus #24.

Closes #25
2026-08-23 17:31:35 +00:00
Florian Wilde e19300b33d Migrationskommentare zeigen auf die richtige Datei
Fuenf Migrationen verwiesen fuer die Herkunft der Trigramm-Indizes auf
prisma/sql/suche.sql. Diese Datei gibt es nicht; sie heisst
volltextsuche.sql. Der Hinweis steht genau dort, wo ihn jemand liest,
der die naechste Migration erzeugt - ein falscher Pfad schickt ihn ins
Leere.

Dazu ein Verweis auf das Werkzeug aus #24: "npm run db:migration" nimmt
die Zeilen inzwischen selbst heraus, die Kommentare rieten aber
weiterhin nur zum Nachsehen.

Geprueft, bevor die Dateien angefasst wurden: Prisma speichert zwar eine
Pruefsumme je Migration, "migrate deploy" verifiziert sie fuer bereits
angewendete Migrationen aber nicht. Nachgestellt mit genau der Lage, die
auch auf dem Server entsteht - Pruefsumme in der Datenbank weicht ab,
"migrate status" und "migrate deploy" bleiben zufrieden.

Refs #25
2026-08-23 19:30:46 +02:00
florian.wilde 41e33ad85f Merge #29: Migrations-Wrapper gegen das Loeschen der Trigramm-Indizes
Behebt Issue #24: npm run db:migration erzeugt Migrationen ohne die
bekannten DROP-INDEX-Zeilen und prueft nach dem Anwenden, dass die
Indizes stehen.

Closes #24
2026-08-23 17:25:52 +00:00
Florian Wilde 3153e6179d Migrationen erzeugen, ohne die Suche kaputtzumachen
"prisma migrate diff" kennt die Trigramm-Indizes aus
prisma/sql/volltextsuche.sql nicht und schreibt deshalb in jede
Migration ein DROP INDEX dafuer. Fuenfmal mussten die Zeilen von Hand
raus. Auf dem Server waere es folgenlos geblieben - update.sh legt die
Indizes hinterher neu an -, in der Entwicklung dagegen waere die Suche
still langsam geworden.

  npm run db:migration -- <name> [--anwenden]
  npm run db:pruefen

Aufbau:
- Die Liste der geschuetzten Indizes steht an einer Stelle. Wer in
  prisma/sql/ einen hinzufuegt, traegt ihn dort ein.
- dropZeilenEntfernen() ist bewusst eng: Nur eine DROP-Anweisung, die
  einen geschuetzten Index nennt, faellt weg. Ein DROP INDEX auf einen
  Prisma-eigenen Index bleibt stehen - der soll ja weg. Ein Kommentar,
  der den Namen bloss erwaehnt, ebenfalls.
- Der Kopf der erzeugten Datei sagt, was entfernt wurde und warum.
- Nach --anwenden wird gegen die Datenbank geprueft, ob die Indizes
  wirklich noch stehen. Das ist der Unterschied zwischen "sollte
  gutgegangen sein" und "nachgesehen".

Beim Probelauf gefunden und behoben: Stimmen Schema und Datenbank
ueberein, besteht der ganze Diff aus den beiden bekannten DROP-Zeilen.
Das Skript legte dafuer eine leere Migration an - und eine leere
Migration in der Historie ist schlimmer als keine, weil sie als
angewendet gilt und niemand mehr weiss, wofuer sie gut war. Jetzt bricht
es vorher ab, ohne einen Ordner zu hinterlassen.

Die Typen liegen als .d.mts daneben: Das Skript bleibt .mjs und laeuft
ohne Bauschritt, die Tests laufen trotzdem nicht gegen "any".

Geprueft: zwoelf Einheitstests, dazu von Hand der Lauf mit echter
Schemaaenderung und der Lauf ohne.

Refs #24
2026-08-23 19:25:09 +02:00
florian.wilde 98d1338eae Merge #20: Ansprechpartner am Vorgang hinterlegen
Behebt Issue #9: Kontakt als Ansprechpartner am Vorgang, namentliche
Anrede in der Sachstandsanfrage, Name ueber der Behoerde, Anschrift im
Schreiben vorbelegt. Ohne Ansprechpartner bleibt es bei der allgemeinen
Anrede; geraten wird nie.

Nebenbei behoben: Der Schreiben-Editor verwarf die Empfaengeranschrift
aus dem Entwurf.

Closes #9
2026-08-23 14:55:49 +00:00
Florian Wilde 9e68d10787 Ansprechpartner am Vorgang hinterlegen
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle. Am Vorgang laesst sich jetzt ein
Kontakt aus dem Verzeichnis als Ansprechpartner benennen; die
Sachstandsanfrage redet ihn an, und sein Name steht ueber der Behoerde.

Die Sachstandsanfrage konnte das schon - sie bekam den Namen nur nie
uebergeben. entwurf.ts lud einen der verknuepften Kontakte und verwarf
ihn wieder. Wer angeredet wird, ist jetzt eine Entscheidung und kein
Zufall.

Aufbau:
- Migration: ansprechpartnerId am Vorgang (SetNull - ein Vorgang darf
  nicht daran haengen, dass es den Kontakt noch gibt) und anrede am
  Kontakt.
- Die Anrede als reine Funktion in kontakte/anrede.ts. Geraten wird nie:
  ohne hinterlegte Anrede wird mit vollem Namen angeredet. Ein leerer
  Name mit gesetzter Anrede faellt auf die allgemeine Anrede zurueck -
  "Sehr geehrte Frau ," darf nie herausgehen.
- Der Wechsel des Ansprechpartners steht mit NAMEN im Protokoll. Zwei
  cuids nebeneinander waeren protokolliert, aber nicht nachvollziehbar,
  und nachvollziehbar war die Anforderung.
- Der Ansprechpartner wird gegen die Akte geprueft, bevor er gespeichert
  wird - sein Name steht anschliessend im Briefkopf.

Nebenbei behoben: Der Schreiben-Editor verwarf die empfaengerAnschrift
aus dem Entwurf, sein State startete immer leer. Jetzt kommt die
Anschrift des Ansprechpartners im Formular an.

Geprueft: neun Einheitstests fuer die Anrede, fuenf End-to-End-Tests von
der Auswahl bis zum Protokolleintrag.

Refs #9
2026-08-23 16:31:10 +02:00
florian.wilde 5e7d88cf64 Merge #19: Rueckkaufswert am Vertrag sichtbar machen
Behebt Issue #8: letzter Wert mit Stichtag und Quelle am Vertrag,
Kennzeichnung ab einem Jahr, "noch nicht erfasst" statt leerer Zelle.
Ohne Migration - der Wert bleibt der Wertstand am Vermoegensposten, aus
dem auch das Vermoegensverzeichnis rechnet.

Nebenbei behoben: Zuordnung ueber die Verknuepfung statt ueber die Art
des Postens, und die fehlende Zugehoerigkeitspruefung bei den Verweisen
auf Vertrag und Immobilie.

Closes #8
2026-08-23 13:29:54 +00:00
Florian Wilde 09a4528c4b Rueckkaufswert am Vertrag sichtbar machen
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
gefragt hat. Auf der Vertragsseite steht jetzt oben der zuletzt
mitgeteilte Wert mit Stichtag und Quelle, darunter die Historie; in der
Uebersicht traegt ein Wert, der aelter als ein Jahr ist, ein Abzeichen.

Ohne Migration, und das ist keine Einsparung, sondern der Punkt: Die
Werthistorie gibt es schon als Wertstand am Vermoegensposten, und der
kann auf einen Vertrag zeigen. Ein Feld am Vertrag waere genau die
zweite Wahrheit, die das Issue ausschliesst - dieselbe Zahl steht im
Vermoegensverzeichnis, weil es dieselbe Zahl ist.

Aufbau:
- Die Ein-Jahres-Grenze als eine getestete Regel in
  finanzen/wertalter.ts. Sie stand bisher als "> 365" mitten in der
  Vermoegensseite; jetzt gilt sie am Vertrag genauso.
- wertHistorie() fuehrt die Wertstaende aller mit dem Vertrag
  verknuepften Posten zu einer Reihe zusammen.
- Wo ein Rueckkaufswert zu erwarten waere, aber keiner erfasst ist,
  steht "noch nicht erfasst" statt einer leeren Zelle.

Zwei Fehler dabei behoben:
- Vertrag und Vermoegensposten wurden ueber die ART des Postens
  zusammengefuehrt. Ein von Hand als "Sonstiges" angelegter Posten wurde
  nicht gefunden, und beim ersten Erfassen am Vertrag entstand still ein
  zweiter - Bericht und Vertrag haetten verschiedene Zahlen gezeigt.
  Massgeblich ist jetzt die Verknuepfung.
- Beim Speichern eines Vermoegenspostens wurde geprueft, ob das
  verknuepfte Konto zur betreuten Person gehoert; bei Vertrag und
  Immobilie fehlte die Pruefung, beim Vertragsverweis einer Buchung
  ebenso. Bei mehreren betreuten Personen haette ein manipuliertes
  Formular ueber Aktengrenzen hinweg verknuepfen koennen.

Geprueft: zehn Einheitstests, vier End-to-End-Tests - darunter der neu
eingetragene Wert, der unveraendert im Vermoegensverzeichnis
wiederauftaucht, und der Posten mit abweichender Art.

Refs #8
2026-08-23 15:06:25 +02:00
134 changed files with 16314 additions and 10005 deletions
+55 -5
View File
@@ -15,9 +15,17 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp
# 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel # 32 zufaellige Bytes, base64. Erzeugen: npm run schluessel
AUTH_SECRET= AUTH_SECRET=
# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash). # Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash).
# Hinter einem Proxy die Adresse, die im Browser in der Adresszeile steht -
# nicht die interne. Passkeys pruefen dagegen.
AUTH_URL=http://localhost:3000 AUTH_URL=http://localhost:3000
# Hinter einem Reverse Proxy auf true setzen.
AUTH_TRUST_HOST=true # Darf der Host-Kopf der Anfrage die Adresse bestimmen?
#
# Solange AUTH_URL gesetzt ist: nein, und die Zeile bleibt weg. Die Adresse
# ist dann bekannt und muss nicht aus einer Kopfzeile abgeleitet werden, die
# von aussen kommt. Ein "X-Forwarded-Host: fremde.example" schlaegt sonst in
# erzeugte Adressen durch.
# AUTH_TRUST_HOST=true
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN ------------------------- # --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
# #
@@ -73,15 +81,57 @@ LOGIN_MAX_VERSUCHE=5
LOGIN_FENSTER_SEKUNDEN=900 LOGIN_FENSTER_SEKUNDEN=900
LOGIN_SPERRE_SEKUNDEN=900 LOGIN_SPERRE_SEKUNDEN=900
# Geheimnis fuer die Ersteinrichtung. OHNE DIESEN WERT LAESST SICH DER ERSTE
# ZUGANG NICHT ANLEGEN - das ist Absicht.
#
# Zwischen dem ersten Start und dem Anlegen des Zugangs stand die
# Einrichtungsseite sonst offen: Wer sie in diesem Fenster findet, wird
# Administrator. Erzeugen: npm run schluessel
ERSTEINRICHTUNG_GEHEIMNIS=
# Steht ein Reverse Proxy davor?
#
# Nur mit "true" wird "X-Forwarded-For" geglaubt - und dann von HINTEN
# gelesen, weil der vordere Teil der Liste vom Aufrufer selbst kommt.
# Ohne diese Zeile zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
#
# Der dokumentierte Betrieb (Reverse Proxy davor) braucht sie also:
VERTRAUE_PROXY=true
# Bei mehreren gestaffelten Proxys deren Anzahl. Sonst weglassen.
# VERTRAUE_PROXY_STUFEN=1
# --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------ # --- Objektspeicher (S3-kompatibel, EU!) ------------------------------------
# Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein. # Wird ab Etappe 3 verwendet. Beispiel: Hetzner Object Storage, Falkenstein.
# ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen. # ACHTUNG: Mit dem Anbieter muss ein AV-Vertrag nach Art. 28 DSGVO bestehen.
S3_ENDPOINT=https://fsn1.your-objectstorage.com # Bleibt AUSKOMMENTIERT, solange das mitgelieferte MinIO benutzt wird.
#
# Die docker-compose.yml setzt dann von selbst http://minio:9000 - aber
# nur, wenn die Variable leer ist. Steht hier ein Wert, gewinnt er, und
# die Anwendung spricht mit einem fremden Host statt mit dem Speicher
# nebenan. Genau das ist am 31.08.2026 passiert: Jede Ablage scheiterte
# monatelang mit "NoSuchBucket", und die Zugangs-ID ging bei jedem Versuch
# nach draussen.
#
# Nur setzen, wenn ein EXTERNER Objektspeicher benutzt wird, z.B.:
# S3_ENDPOINT=https://fsn1.your-objectstorage.com
S3_REGION=eu-central S3_REGION=eu-central
S3_BUCKET=betreuungs-cockpit S3_BUCKET=betreuungs-cockpit
S3_ACCESS_KEY_ID= S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY= S3_SECRET_ACCESS_KEY=
# Serverseitige Verschluesselung durch den Anbieter.
#
# Bleibt leer, und das ist die richtige Voreinstellung: Die Anwendung
# verschluesselt jede Datei selbst, bevor sie den Server verlaesst. Eine
# zweite Lage durch den Anbieter schuetzt vor nichts, was der eigene
# Umschlag nicht schon abdeckt.
#
# Das mitgelieferte MinIO kann sie ohnehin nicht - es laeuft ohne KMS und
# lehnt jede Ablage mit "NotImplemented" ab, sobald hier etwas steht.
#
# Nur setzen, wenn ein externer Anbieter sie ausdruecklich verlangt:
# S3_SSE=AES256
# --- E-Mail-Erinnerungen (optional, ab Etappe 2) ---------------------------- # --- E-Mail-Erinnerungen (optional, ab Etappe 2) ----------------------------
SMTP_HOST= SMTP_HOST=
SMTP_PORT=587 SMTP_PORT=587
@@ -91,5 +141,5 @@ SMTP_FROM="Betreuungs-Cockpit <cockpit@example.de>"
# --- Erstanmeldung ---------------------------------------------------------- # --- Erstanmeldung ----------------------------------------------------------
# Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen. # Wird nur vom Seed benutzt, um den ersten Betreuer-Zugang anzulegen.
SEED_ADMIN_EMAIL=fw@wildeflorian.de SEED_ADMIN_EMAIL=
SEED_ADMIN_PASSWORT=bitte-beim-ersten-login-aendern SEED_ADMIN_PASSWORT=
+32
View File
@@ -0,0 +1,32 @@
# Zeilenenden
#
# Im Repository steht ueberall LF, unabhaengig davon, auf welchem System
# gearbeitet wird. Der Grund ist nicht Geschmack, sondern eine Falle:
#
# Solange Dateien gemischt vorliegen, schreibt jedes zeilenweise
# arbeitende Werkzeug - sed, ein Formatierer, ein Skript - die Datei mit
# LF zurueck. Der Diff zeigt dann nicht die drei geaenderten Zeilen,
# sondern die ganze Datei. Genau das ist passiert: 578 Diff-Zeilen statt
# 8, kurz vor einem Push bemerkt.
#
# Nebenwirkung derselben Mischung: "git diff --check" meldet bei
# CRLF-Dateien jede hinzugefuegte Zeile als "trailing whitespace", weil
# es das CR so wertet. Damit ist die Pruefung fuer diese Dateien wertlos.
* text=auto eol=lf
# Shell-Skripte laufen auf dem Server. Ein CRLF im Shebang macht sie
# unbrauchbar ("bad interpreter"), und der Fehler ist von aussen nicht
# anzusehen. Deshalb hier ausdruecklich, nicht nur ueber die Regel oben.
*.sh text eol=lf
# Binaerdateien nie anfassen.
*.png binary
*.jpg binary
*.jpeg binary
*.gif binary
*.ico binary
*.pdf binary
*.woff binary
*.woff2 binary
*.ttf binary
*.zip binary
+8
View File
@@ -11,6 +11,14 @@ dist/
next-env.d.ts next-env.d.ts
*.tsbuildinfo *.tsbuildinfo
# Eigene Anpassungen der Installation.
#
# Docker Compose liest diese Datei automatisch zusaetzlich zur
# docker-compose.yml. Sie ist der vorgesehene Ort fuer Aenderungen, die ein
# Update ueberleben sollen: update.sh prueft nur versionierte Dateien
# (git status --untracked-files=no) und laesst sie deshalb in Ruhe.
docker-compose.override.yml
# Umgebung - enthaelt Schluessel, gehoert NIE ins Repo # Umgebung - enthaelt Schluessel, gehoert NIE ins Repo
.env .env
.env.local .env.local
+425 -1
View File
@@ -26,6 +26,423 @@ Wiederherstellung*.
_Noch nichts._ _Noch nichts._
## [1.12.0] – 2026-09-01
Diese Fassung geht aus einer Fehlersuche im laufenden Betrieb hervor. Ein
Schreiben ließ sich nicht in der Akte ablegen — und hinter derselben
Meldung lagen nacheinander drei verschiedene Ursachen. Beim Nachsehen kam
ein vierter Punkt dazu, der schwerer wiegt als alle anderen zusammen.
### Nach dem Update: einmal die Sicherung prüfen
```bash
docker compose up -d
docker compose exec sicherung /bin/sh /sicherung.sh
```
Erwartet werden drei Zeilen: `Datenbank gesichert`, `Dokumente gesichert`
und `Selbstpruefung bestanden - Datenbank und Dokumente.`
### Behoben
- **Deine Dokumente waren in keiner Sicherung.** Der Sicherungsdienst
machte täglich ein `pg_dump` und sonst nichts. Nach einem Plattenschaden
wäre die Akte vollständig wiederhergestellt worden — mit allen
Einträgen, Verweisen und Prüfsummen, aber ohne eine einzige Datei
dahinter. Die Prüfsumme in der Akte hätte dann ein Dokument belegt, das
es nicht mehr gibt.
Je Lauf entstehen jetzt zwei verschlüsselte Dateien mit demselben
Zeitstempel — der Datenbankstand und ein Archiv der Dokumente. Beide mit
Prüfsumme, beide in der Aufbewahrung, beide in der Selbstprüfung.
Schlägt der Dokumententeil fehl, bricht der Lauf ab und räumt den
angefangenen Datenbankstand weg: Eine Sicherung, die nur die halbe
Wahrheit enthält, aber vollständig aussieht, ist schlimmer als gar
keine.
- **Der mitgelieferte Objektspeicher konnte keine Dokumente aufnehmen.**
Die Anwendung verlangte bei jeder Ablage serverseitige Verschlüsselung,
das mitgelieferte MinIO kann sie ohne Zusatzdienst nicht. Wer der
Installationsanleitung folgte, landete zwangsläufig dort. Sie ist jetzt
abschaltbar und standardmäßig aus — gebraucht wird sie nicht, weil die
Anwendung seit 1.11.0 selbst verschlüsselt.
- **„Das Schreiben konnte nicht abgelegt werden" sagte nichts.** Jetzt
steht dort, was los ist: fehlender Ablagebereich mit Verweis auf den
Installationsschritt, abgelehnte Zugangsdaten, nicht erreichbarer
Speicher. Und in jedem Fall, ob deine Eingaben erhalten bleiben.
- **Zwei Einstellungen erreichten die Anwendung nie.**
`ERSTEINRICHTUNG_GEHEIMNIS` und `VERTRAUE_PROXY` standen zwar in der
Anleitung, wurden aber nicht an den Behälter durchgereicht. Bei
`VERTRAUE_PROXY` fiel das nicht auf: Die Anmeldesperre zählte still eine
Dimension weniger.
- **Die Beispielkonfiguration zeigte auf einen fremden Anbieter.**
`S3_ENDPOINT` war mit einer externen Adresse vorbelegt und hebelte damit
den richtigen Standard aus. Die Zeile ist jetzt auskommentiert.
### Neu
- **Die Anwendung prüft ihre Dateiablage selbst.** Sie schreibt eine
winzige Datei, liest sie zurück und räumt sie weg — und beantwortet
damit auf einmal, ob der Speicher erreichbar ist, der Ablagebereich
existiert und geschrieben werden darf. Das Ergebnis steht unter
`/api/gesundheit` und im Log. Bisher fiel eine kaputte Konfiguration
erst auf, wenn jemand ein Dokument ablegen wollte.
- **Ein neuer Abschnitt in der Anleitung: „Wo deine Daten liegen".**
Die drei Orte, der Pfad auf der Platte, welche Schlüssel unverzichtbar
sind — und wie man prüft, ob zu jedem Akteneintrag auch eine Datei
gehört.
### Geändert
- **Ausschlussfristen warnen nur noch, wenn sie näherrücken.** Auf dem
Dashboard standen dauerhaft drei rote Kästen mit Warndreieck — für
Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Eine Warnung, die
monatelang dasteht und nichts von einem will, stumpft ab; man übersieht
die vierte, die etwas will. Ab drei Monaten vor Ablauf meldet sich eine
Frist wieder, Versäumtes immer. In der Fristenliste stehen sie
unverändert weiter.
## [1.11.0] – 2026-08-24
Diese Fassung geht aus einer Sicherheitsprüfung hervor. Sie ändert nichts
an der Bedienung, schließt aber mehrere Lücken — darunter drei, bei denen
im Code eine Zusage stand, die so nicht galt.
### Vor dem Update: zwei neue Zeilen in der `.env`
```bash
ERSTEINRICHTUNG_GEHEIMNIS=<zufälliger Wert, npm run schluessel erzeugt einen>
VERTRAUE_PROXY=true
```
Beide sind nicht zwingend, aber beide haben Folgen, wenn sie fehlen:
- Ohne `ERSTEINRICHTUNG_GEHEIMNIS` läuft alles weiter — nur der **erste**
Zugang ließe sich nicht mehr anlegen. Auf einer bestehenden Installation
ist das genau richtig; bei einer Neuinstallation brauchst du den Wert.
- Ohne `VERTRAUE_PROXY=true` zählt die Anmeldesperre nur noch nach
E-Mail-Adresse, nicht mehr zusätzlich nach Anschluss. Beim ersten Mal
steht eine erklärende Zeile im Log.
### Nach dem Update, nur mit Objektspeicher
Bereits abgelegte Dateien liegen weiter unverschlüsselt, bis sie einmal
umgeschrieben werden. Lesen funktioniert durchgehend, der Lauf lässt sich
jederzeit abbrechen und wiederholen:
```bash
npm run speicher:verschluesseln # erst schauen
npm run speicher:verschluesseln -- --anwenden
```
Wer beim Dateitreiber bleibt, muss nichts tun — dort wurde immer schon
verschlüsselt.
### Behoben
- **Der zweite Faktor war eine Anzeige, keine Grenze.** „TOTP ist Pflicht"
galt nur für Seiten. Server Actions und Downloads liefen daran vorbei:
Wer sich mit Passwort angemeldet, die Einrichtung aber nie abgeschlossen
hatte, kam an die Daten. Die Prüfung sitzt jetzt an der Stelle, durch die
alles muss.
- **Ein Dokumenten-Download fragte nur nach der Anmeldung.** Dieselbe
Lücke, an einer Stelle, die bei der ersten Durchsicht übersehen wurde.
- **Die Anmeldesperre sperrte dauerhaft aus.** Jeder Versuch während einer
laufenden Sperre verlängerte sie um die volle Dauer. Wer alle zehn
Minuten ein falsches Passwort an eine fremde Adresse schickte, sperrte
deren Inhaber aus — ohne das Passwort zu kennen. Nebenbei wuchs die
Tabelle mit jedem Versuch.
- **Ein Code aus der Authenticator-App galt mehrfach.** Bis zu 90 Sekunden
lang ließ sich derselbe Code erneut einlösen — abgelesen, abgefangen
oder aus dem Browserverlauf. Jetzt gilt jeder genau einmal.
- **Passkeys ließen sich ohne Rückfrage anlegen und entfernen.** An einem
unbeaufsichtigten Rechner waren das zwei Klicks zu einem dauerhaften
eigenen Zugang, der auch einen Passwortwechsel überlebt hätte. Jetzt
fragt die Anwendung vorher nach Passwort und Code — wie beim Export.
- **Die Ersteinrichtung stand offen.** Zwischen dem ersten Start und dem
Anlegen des Zugangs konnte jeder, der die Seite fand, Administrator
werden. Zwei gleichzeitige Anfragen legten außerdem beide einen Zugang
an.
- **Dateien im Objektspeicher lagen mit dem Schlüssel des Anbieters da.**
Gegen eine verlorene Festplatte im Rechenzentrum half das, gegen den
Anbieter und gegen gestohlene Zugangsdaten zum Speicher nicht. Jetzt
liegt derselbe Umschlag darum wie bei der Ablage auf dem eigenen Server,
mit einem Schlüssel, der die Anwendung nie verlässt.
- **Ein präpariertes PDF konnte den Server umbringen.** Beim Auslesen des
eingebetteten Texts gab es keine Obergrenze: Wenige Kilobyte Füllung
blähen sich beim Auspacken auf Gigabyte auf. Ebenso beim Umwandeln in
Bilder — die Seitengröße steht im Dokument, und bei fester Auflösung
ergab eine künstlich große Seite ein Bild von mehreren Gigabyte.
- **32 Schwachstellen in mitgelieferten Paketen, davon 5 kritische.**
Jetzt keine.
### Geändert
- **Downloads laufen jetzt immer über die Anwendung**, auch mit
Objektspeicher. Das ist die Folge der Verschlüsselung — entschlüsseln
kann nur die Anwendung. Nebeneffekt: Jeder Abruf prüft die Berechtigung
und wird protokolliert. Ein weitergeleiteter Link allein öffnet nichts
mehr.
- **`docs/SICHERHEIT.md`** ist neu: Bedrohungsmodell, der Durchgang entlang
der OWASP Top 10 — und ein Abschnitt darüber, was ausdrücklich **nicht**
geschützt ist.
## [1.10.2] – 2026-08-24
### Behoben
- **Das Update brach ohne Meldung ab.** Beim Sprung von 1.5.0 auf 1.10.1
endete `scripts/update.sh` mitten in der Ausgabe — kein Fehler, keine
Zeile, einfach zurück zur Eingabeaufforderung. Ursache war eine
abgeschnittene Leitung:
```
git log --oneline "HEAD..$ZIEL" | head -15 | sed ...
```
`head` schließt die Leitung nach 15 Zeilen, `git log` schreibt weiter
und bekommt SIGPIPE. Weil das Skript mit `set -o pipefail` und
`set -e` läuft, gilt die ganze Kette als gescheitert und das Skript
beendet sich — stumm.
Das ist zeitabhängig und deshalb monatelang nicht aufgefallen: Solange
`git log` fertig war, bevor `head` zumachte, ging alles gut. Genau bei
1.10.0 lagen zum ersten Mal **mehr als 15 Commits** dazwischen.
- **Dieselbe Falle in der Selbstprüfung der Sicherung.** `sicherung.sh`
prüfte mit `openssl … | head -c 5 | grep -q "PGDMP"`, ob sich die
frische Sicherung wieder entschlüsseln lässt. Bei kleiner Datenbank
ging das gut, weil alles im Puffer stand. Mit wachsender Datenbank
hätte es gekippt — und dann hätte das Skript eine **völlig intakte
Sicherung als kaputt gemeldet** und das Update verweigert.
### Geändert
- **Ein stummer Abbruch ist nicht mehr möglich.** `update.sh` meldet
jetzt bei jedem unerwarteten Ende die Zeilennummer und den Exitcode,
und erklärt bei 141 ausdrücklich, dass es sich um SIGPIPE handelt und
um einen Fehler im Skript, nicht um einen Bedienfehler.
- Stehen mehr als 15 Commits an, steht jetzt „… und N weitere" darunter,
statt sie wortlos wegzulassen.
### Geprüft
- Ein Test verbietet das Muster künftig in allen Betriebsskripten:
Wer wieder `| head` in eine Leitung schreibt, bekommt die Zeilennummer
genannt. Gegenprobe gemacht — der Test schlägt an.
- Der Ablauf von 1.5.0 auf 1.10.1 einmal nachgestellt: läuft durch.
## [1.10.1] – 2026-08-23
### Geändert
- **Der Beispielfall hat jetzt ein Jahr Kontobewegungen**
([#22](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/22)).
Bisher brachte er Konten und Kontostände mit, aber keine einzige
Buchung — Kassenbuch, Belegpflicht, Dauerzahlungen und die Erkennung
wiederkehrender Zahlungen liefen darin also leer. Wer die Anwendung zum
ersten Mal ansah, musste erst eine CSV importieren.
Neu sind 107 Buchungen über zwölf Monate: Rente, Betriebsrente und
Pflegekasse als Einnahmen, Heimentgelt, Barbetrag, Sterbegeldbeitrag,
Telefon und Kontoführung als monatliche Ausgaben, dazu Rundfunkbeitrag
und Grundsteuer vierteljährlich sowie eine Handvoll Einzelposten.
Das betrifft nur neue Installationen mit `SEED_DEMO=true` — an einem
bestehenden Bestand ändert sich nichts.
### Behoben
- **„gGmbH" wurde nicht als Rechtsform erkannt.** Beim Zusammenfassen
wiederkehrender Zahlungen galten „Seniorenzentrum St. Katharina" und
„Seniorenzentrum St. Katharina gGmbH" als zwei verschiedene Empfänger —
und damit zerfiel die Gruppe genau dort, wo sie am meisten zählt: Heime,
Sozialdienste und Träger sind fast durchweg gGmbH.
### Was der Beispielfall jetzt zeigt
- Das Heimentgelt steht in **drei Schreibweisen** auf dem Auszug und wird
trotzdem als eine monatliche Zahlung von 2.489 € geführt.
- Genau **eine** Buchung hat weder Quittung noch Begründung — damit die
Belegpflicht etwas zu zeigen hat, aber die Liste nicht rot leuchtet.
- Die Sterbegeld-Lastschriften sind bewusst **nicht** zugeordnet: So lässt
sich der Handgriff „zwölf Zahlungen als belegt zuordnen" ausprobieren.
Der Beitrag steigt im Mai von 18,40 € auf 19,80 €, damit auch die
Abweichungsprüfung sichtbar wird.
- Der Kontostand zum 31.07.2026 wird aus dem Anfangsstand und den
Buchungen **gerechnet**, nicht behauptet. Ein Beispielfall, der sich
selbst widerspricht, wäre schlimmer als einer ohne Buchungen.
### Geprüft
- Ein Einheitstest für die gGmbH-Erkennung.
- Vier End-to-End-Tests am Beispielfall: Umfang und Zeitraum der
Buchungen, die Übereinstimmung von Kontostand und Buchungssumme, die
erkannten Zahlungsgruppen und das Kassenbuch.
## [1.10.0] – 2026-08-23
### Hinzugefügt
- **Deine Anschrift steht jetzt in jedem Schreiben**
([#21](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/21)).
Bisher ging jeder Brief ohne Rücksendeadresse hinaus — im
Absenderblock stand nur dein Name, und in der Datumszeile fehlte der
Ort. Bei einer Versicherung oder Behörde wirkt das unseriös und macht
es der Gegenstelle unnötig schwer zu antworten.
Unter **Mein Konto → Meine Anschrift** einmal Straße, PLZ und Ort
hinterlegen. Ab da stehen sie im Absenderblock jedes erzeugten
Schreibens, und die Datumszeile lautet „Bayreuth, den 23.08.2026".
Betroffen ist alles: Rückkaufswert-Anfrage, Serienabfrage, Kündigung,
Sachstandsanfrage und die eigenen Vorlagen.
- **Zwei neue Platzhalter** für eigene Vorlagen: `{{betreuer_anschrift}}`
und `{{betreuer_ort}}`.
### Eine Zusage
**Was fehlt, bleibt weg.** Jede der drei Angaben ist einzeln freiwillig.
Hinterlegst du nur den Ort, bekommst du die Datumszeile mit Ort und einen
Absenderblock, der weiterhin nur aus deinem Namen besteht — keine leeren
Zeilen, kein Komma ohne Ort. Eine halb ausgefüllte Adresse sieht nach
Fehler aus, ein bloßer Name nach Absicht.
### Datenbank
Eine Migration: drei Felder am Benutzer. Die Anschrift hängt an dir und
nicht an der betreuten Person — sie gilt für alle Akten.
### Geprüft
- Sieben Einheitstests für den Absenderblock, darunter jede
Teilbefüllung und die Gegenprobe, dass nie die Heimadresse der
betreuten Person im Absender landet.
- Vier End-to-End-Tests: der Brief ohne hinterlegte Anschrift, das
Speichern unter *Mein Konto*, der Brief danach und die Gegenprobe am
Rückkaufswert-Schreiben.
## [1.9.0] – 2026-08-23
### Hinzugefügt
- **Ansprechpartner am Vorgang**
([#9](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/9)).
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle — beim Bezirk ist das der
Unterschied zwischen zwei Wochen und zwei Monaten. Am Vorgang lässt
sich jetzt ein Kontakt aus dem Verzeichnis als Ansprechpartner
benennen.
- **Die Sachstandsanfrage redet ihn an.** Aus „Sehr geehrte Damen und
Herren" wird „Sehr geehrte Frau Sachs" — und der Name steht über der
Behörde, damit die Poststelle den Brief zur Person sortiert. Auch das
Schreiben aus dem Editor übernimmt Anrede, Empfänger und Anschrift,
sodass nichts abgetippt werden muss.
- **Anrede am Kontakt.** Damit die Briefanrede stimmt, trägt ein Kontakt
wahlweise „Frau" oder „Herr". **Geraten wird nie:** Ohne Angabe wird
mit vollem Namen angeredet („Sehr geehrte(r) Kim Berger,"). Eine
falsch geratene Anrede fällt dem Empfänger sofort auf und kostet genau
die Aufmerksamkeit, die man gewinnen wollte.
- **Vor dem Anruf weiß man, wen man fragt.** Auf der Vorgangsseite steht
der Ansprechpartner mit Organisation, Durchwahl und E-Mail, verlinkt
auf den Kontakt.
### Zwei Zusagen
**Ohne Ansprechpartner ändert sich nichts.** Ist keiner benannt, bleibt
es bei „Sehr geehrte Damen und Herren" — und die Vorgangsseite sagt das
auch, statt ein leeres Feld zu zeigen. Ein halb gefüllter Name führt nie
zu einer verstümmelten Anrede wie „Sehr geehrte Frau ,".
**Der Wechsel bleibt nachvollziehbar.** Wer den Ansprechpartner
austauscht, findet den Vorgänger im Protokoll — mit **Namen**, nicht mit
der internen Kennung. Zwei Kennungen nebeneinander wären protokolliert,
aber nicht nachvollziehbar.
### Datenbank
Eine Migration: `ansprechpartnerId` am Vorgang und `anrede` am Kontakt.
Wird ein Kontakt gelöscht, bleibt der Vorgang bestehen und verliert nur
den Verweis — ein Vorgang darf nicht daran hängen, dass es den Kontakt
noch gibt.
### Geprüft
- Neun Einheitstests für die Anrede, darunter der Nachname aus „Dr. Anna
Maria Schmitt", der Doppelname und der Fall, der nie passieren darf:
leerer Name mit gesetzter Anrede.
- Fünf End-to-End-Tests: die allgemeine Anrede ohne Ansprechpartner, das
Benennen samt Anzeige mit Durchwahl, die namentliche Anrede in der
Sachstandsanfrage, die vorbelegte Anschrift im Schreiben und der
Wechsel im Protokoll.
## [1.8.0] – 2026-08-23
### Hinzugefügt
- **Der Rückkaufswert steht jetzt beim Vertrag**
([#8](https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/issues/8)).
Am Telefon mit der Gesellschaft braucht man die Zahl dort, wo man sie
gefragt hat — nicht nach einem Umweg über das Vermögensverzeichnis.
Auf der Vertragsseite steht deshalb oben der zuletzt mitgeteilte Wert
mit Stichtag und Quelle, darunter die vollständige Historie.
- **Ein alter Wert sagt, dass er alt ist.** Ist der jüngste Wert älter
als ein Jahr, steht „über ein Jahr alt" daneben — in der
Vertragsübersicht wie auf der Vertragsseite. Ein veralteter Betrag
ohne erkennbares Alter ist schlimmer als kein Betrag: Man rechnet mit
ihm weiter und merkt es erst, wenn der Bezirk nachfragt. Die Grenze von
einem Jahr gilt jetzt überall dieselbe; im Vermögensverzeichnis stand
sie schon, am Vertrag fehlte sie.
- **Verträge ohne Wert bleiben nicht stumm.** Wo ein Rückkaufswert zu
erwarten wäre, aber keiner erfasst ist, steht „noch nicht erfasst"
statt einer leeren Zelle.
### Behoben
- **Ein Vertrag hatte unter Umständen zwei Wertreihen.** Vertrag und
Vermögensposten wurden über die *Art* des Postens zusammengeführt.
Wer einen Posten von Hand anlegte und ihn als etwas anderes als
„Versicherung" führte, bekam beim ersten Eintragen am Vertrag
klammheimlich einen zweiten Posten — und damit zwei Wahrheiten, von
denen der Bericht die eine und der Vertrag die andere zeigte.
Maßgeblich ist jetzt die Verknüpfung mit dem Vertrag, nicht die Art.
- **Fehlende Prüfung bei Verweisen auf Vertrag und Immobilie.** Beim
Speichern eines Vermögenspostens wurde geprüft, ob das verknüpfte
*Konto* zur betreuten Person gehört — bei Vertrag und Immobilie fehlte
dieselbe Prüfung, ebenso beim Vertragsverweis einer Buchung. Bei
mehreren betreuten Personen hätte ein manipuliertes Formular eine
Verknüpfung über Aktengrenzen hinweg anlegen können. Jetzt wird jeder
der drei Verweise gleich behandelt.
### Geprüft
- Zehn Einheitstests: die Ein-Jahres-Grenze (auch der Tag davor und
danach), das Alter taggenau und ohne Uhrzeiteinfluss, ein künftiger
Stichtag, und die Zusammenführung mehrerer Posten zu einer Historie.
- Vier End-to-End-Tests: Wert und Stichtag in der Übersicht, das
Abzeichen am alten Wert, die Historie am Vertrag, und ein neu
eingetragener Wert, der unverändert im Vermögensverzeichnis
wiederauftaucht.
## [1.7.0] – 2026-08-23 ## [1.7.0] – 2026-08-23
### Hinzugefügt ### Hinzugefügt
@@ -928,7 +1345,14 @@ Was davon wann angegangen wird, steht in [`docs/ROADMAP.md`](docs/ROADMAP.md).
--- ---
[Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.12.0...HEAD
[1.12.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.11.0...v1.12.0
[1.11.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.2...v1.11.0
[1.10.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.1...v1.10.2
[1.10.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.10.0...v1.10.1
[1.10.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.9.0...v1.10.0
[1.9.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.8.0...v1.9.0
[1.8.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.7.0...v1.8.0
[1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0 [1.7.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.6.0...v1.7.0
[1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0 [1.6.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.5.0...v1.6.0
[1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0 [1.5.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.4.0...v1.5.0
+35 -4
View File
@@ -97,11 +97,12 @@ DATABASE_URL="postgresql://cockpit:entwicklung@localhost:5433/betreuungs_cockpit
**3. Datenbank einrichten:** **3. Datenbank einrichten:**
```bash ```bash
npx prisma migrate dev --name fundament npm run setup # Migrationen, Volltextsuche, Audit-Schutz, Beispieldaten
npm run db:sql # Volltextsuche und Audit-Schutz
npm run db:seed
``` ```
Das ist die Kurzform für `db:deploy`, `db:sql` und `db:seed` — die drei
Schritte laufen ohne Rückfrage durch.
**4. Starten:** **4. Starten:**
```bash ```bash
@@ -513,6 +514,35 @@ npm run protokoll # Bauprotokoll aus der Git-Historie
npm run version -- patch # Version anheben, Changelog vorbereiten npm run version -- patch # Version anheben, Changelog vorbereiten
``` ```
### Die Datenbankbefehle
Welcher Befehl wofür — und welcher am Terminal Rückfragen stellt. Nur
`db:migrate` ist interaktiv; alle anderen laufen in Skripten, in der CI
und im Container durch.
| Befehl | Wofür | Rückfragen? |
|---|---|---|
| `npm run setup` | Ersteinrichtung: Migrationen, SQL-Skripte, Beispieldaten | nein |
| `npm run db:migration -- <name>` | Schemaänderung während der Entwicklung — erzeugt die Migration und nimmt die `DROP INDEX`-Zeilen zur Volltextsuche heraus | nein |
| `npm run db:deploy` | Vorhandene Migrationen anwenden (Server, CI, Container) | nein |
| `npm run db:migrate` | `prisma migrate dev` — fragt nach dem Namen und bietet an, die Datenbank zurückzusetzen | **ja** |
| `npm run db:sql` | Volltextsuche und Audit-Schutz; läuft nicht mit den Migrationen mit | nein |
| `npm run db:pruefen` | Nachsehen, ob die Trigramm-Indizes noch stehen | nein |
| `npm run db:seed` | Beispieldaten einspielen | nein |
Der übliche Weg für eine Schemaänderung:
```bash
# Schema in prisma/schema.prisma ändern, dann
npm run db:migration -- ansprechpartner # erzeugen und lesen
npm run db:deploy # anwenden
npm run db:pruefen # Indizes nachsehen
```
`db:migrate` braucht man dafür nicht mehr. Es bleibt für die Fälle, in
denen man wirklich den interaktiven Prisma-Ablauf will — etwa um eine
verkorkste Entwicklungsdatenbank zurückzusetzen.
Konventionen — verbindlich für alle Beiträge: Konventionen — verbindlich für alle Beiträge:
[`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md). [`docs/KONVENTIONEN.md`](docs/KONVENTIONEN.md).
Schnittstellen zwischen den Modulen: Schnittstellen zwischen den Modulen:
@@ -545,7 +575,8 @@ Produktion.
über den Abschnitt `environment:`. über den Abschnitt `environment:`.
- Beim ersten Start eines Containers laufen die Migrationen automatisch - Beim ersten Start eines Containers laufen die Migrationen automatisch
mit. Die beiden SQL-Skripte (Volltextsuche, Audit-Schutz) sind mit. Die beiden SQL-Skripte (Volltextsuche, Audit-Schutz) sind
**einmalig von Hand** anzustoßen, siehe oben. **einmalig von Hand** anzustoßen, siehe oben. Beim Aktualisieren
erledigt das `scripts/update.sh` mit — beide Skripte sind idempotent.
### Technische Grundlage ### Technische Grundlage
+23
View File
@@ -93,6 +93,15 @@ services:
AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env} AUTH_SECRET: ${AUTH_SECRET:?AUTH_SECRET fehlt in der .env}
AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env} AUTH_URL: ${AUTH_URL:?AUTH_URL fehlt in der .env}
AUTH_TRUST_HOST: 'true' AUTH_TRUST_HOST: 'true'
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen - siehe
# src/server/actions/ersteinrichtung.ts. Leer heisst: gesperrt.
ERSTEINRICHTUNG_GEHEIMNIS: ${ERSTEINRICHTUNG_GEHEIMNIS:-}
# Bewusst OHNE Vorgabe "true": Die strengere Einstellung bleibt die
# Voreinstellung. Wer einen Reverse Proxy davorstellt - der
# dokumentierte Betrieb tut das -, sagt es in der .env ausdruecklich.
# Ohne den Wert zaehlt die Anmeldesperre nur nach E-Mail-Adresse.
VERTRAUE_PROXY: ${VERTRAUE_PROXY:-}
VERTRAUE_PROXY_STUFEN: ${VERTRAUE_PROXY_STUFEN:-}
ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar} ENC_KEY_V1: ${ENC_KEY_V1:?ENC_KEY_V1 fehlt in der .env - ohne ihn sind die verschluesselten Felder nicht lesbar}
# Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt # Bleibt leer, solange nicht rotiert wird. Die Anwendung ueberspringt
# leere Schluesselvariablen - siehe src/lib/env.ts. # leere Schluesselvariablen - siehe src/lib/env.ts.
@@ -110,6 +119,9 @@ services:
S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit} S3_BUCKET: ${S3_BUCKET:-betreuungs-cockpit}
S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID}
S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY}
# Leer lassen. Das mitgelieferte MinIO laeuft ohne KMS und lehnt jede
# Ablage ab, sobald hier etwas steht - siehe .env.example.
S3_SSE: ${S3_SSE:-}
UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400} UPLOAD_MAX_BYTES: ${UPLOAD_MAX_BYTES:-26214400}
OCR_AKTIV: ${OCR_AKTIV:-true} OCR_AKTIV: ${OCR_AKTIV:-true}
SMTP_HOST: ${SMTP_HOST:-} SMTP_HOST: ${SMTP_HOST:-}
@@ -155,9 +167,20 @@ services:
PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit} PGDATABASE: ${POSTGRES_DB:-betreuungs_cockpit}
SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env} SICHERUNG_PASSWORT: ${SICHERUNG_PASSWORT:?SICHERUNG_PASSWORT fehlt in der .env}
SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30} SICHERUNG_AUFBEWAHRUNG_TAGE: ${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}
SICHERUNG_DOKUMENTE_QUELLE: /dokumente
TZ: Europe/Berlin TZ: Europe/Berlin
volumes: volumes:
- ./backups:/backups - ./backups:/backups
# Die Dokumente, ausdruecklich NUR LESEND.
#
# Der Sicherungsdienst laeuft unbeaufsichtigt per cron. Ein Dienst,
# der die Daten zerstoeren kann, die er sichern soll, ist ein Risiko
# ohne Gegenwert - ein Fehler im Skript oder ein kompromittierter
# Behaelter wuerde beides auf einmal treffen.
#
# Zurueckspielen geschieht deshalb nicht hierher, sondern in ein
# Verzeichnis unter ./backups; siehe scripts/wiederherstellung.sh.
- minio-daten:/dokumente:ro
- ./scripts/sicherung.sh:/sicherung.sh:ro - ./scripts/sicherung.sh:/sicherung.sh:ro
- ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro - ./scripts/wiederherstellung.sh:/wiederherstellung.sh:ro
entrypoint: ['/bin/sh', '-c'] entrypoint: ['/bin/sh', '-c']
+222 -30
View File
@@ -34,6 +34,7 @@ durchgeht — dann weißt du im Fehlerfall genau, wo es hakt.
- [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen) - [Schritt 9 — Sicherung prüfen](#schritt-9--sicherung-prüfen)
- [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten) - [Schritt 10 — Wartungslauf einrichten](#schritt-10--wartungslauf-einrichten)
- [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen) - [Schritt 11 — Schlüssel in Sicherheit bringen](#schritt-11--schlüssel-in-sicherheit-bringen)
- [Wo deine Daten liegen](#wo-deine-daten-liegen)
- [Aktualisieren](#aktualisieren) - [Aktualisieren](#aktualisieren)
- [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt) - [Wenn etwas nicht klappt](#wenn-etwas-nicht-klappt)
- [Abnahmeliste](#abnahmeliste) - [Abnahmeliste](#abnahmeliste)
@@ -131,14 +132,14 @@ cd /opt/betreuungs-cockpit
# Auf die letzte veröffentlichte Fassung wechseln, # Auf die letzte veröffentlichte Fassung wechseln,
# nicht auf den laufenden Entwicklungsstand # nicht auf den laufenden Entwicklungsstand
git checkout v1.7.0 git checkout v1.10.2
``` ```
### Kontrolle ### Kontrolle
```bash ```bash
ls docker-compose.yml Dockerfile .env.example # alle drei da ls docker-compose.yml Dockerfile .env.example # alle drei da
git describe --tags # v1.7.0 git describe --tags # v1.10.2
``` ```
--- ---
@@ -156,6 +157,7 @@ const w = n => b(n).replace(/[+/=]/g,'x');
console.log('AUTH_SECRET=' + b(32)); console.log('AUTH_SECRET=' + b(32));
console.log('ENC_KEY_V1=' + b(32)); console.log('ENC_KEY_V1=' + b(32));
console.log('INDEX_KEY=' + b(32)); console.log('INDEX_KEY=' + b(32));
console.log('ERSTEINRICHTUNG_GEHEIMNIS=' + w(24));
console.log('POSTGRES_PASSWORD=' + w(24)); console.log('POSTGRES_PASSWORD=' + w(24));
console.log('SICHERUNG_PASSWORT=' + w(24)); console.log('SICHERUNG_PASSWORT=' + w(24));
console.log('S3_ACCESS_KEY_ID=' + w(12)); console.log('S3_ACCESS_KEY_ID=' + w(12));
@@ -183,6 +185,13 @@ cp .env.example .env
nano .env nano .env
``` ```
> **Die `.env` allein genügt nicht.** Die `docker-compose.yml` reicht die
> Werte einzeln an den Behälter durch — sie hat kein `env_file:`. Ein
> selbst ergänzter Wert, der dort nicht aufgeführt ist, erreicht die
> Anwendung nicht, auch wenn er in der `.env` steht. Alle hier
> beschriebenen Werte sind eingetragen; ein Test wacht darüber
> (`tests/umgebung-durchreichen.test.ts`).
Diese Werte **musst** du setzen: Diese Werte **musst** du setzen:
```bash ```bash
@@ -192,7 +201,15 @@ POSTGRES_PASSWORD=<aus Schritt 2>
# --- Anmeldung --- # --- Anmeldung ---
AUTH_SECRET=<aus Schritt 2> AUTH_SECRET=<aus Schritt 2>
AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https AUTH_URL=https://cockpit.deine-domain.de # exakt deine Adresse, mit https
AUTH_TRUST_HOST=true
# Ohne diesen Wert laesst sich der erste Zugang nicht anlegen. Absicht:
# sonst steht die Einrichtungsseite bis zum ersten Anlegen fuer jeden offen.
ERSTEINRICHTUNG_GEHEIMNIS=<aus Schritt 2>
# Ein Reverse Proxy steht davor (Schritt 7). Nur damit wird der
# weitergereichte Kopf geglaubt, aus dem die Anmeldesperre den Anschluss
# liest. Ohne die Zeile zaehlt sie nur nach E-Mail-Adresse.
VERTRAUE_PROXY=true
# --- Verschlüsselung --- # --- Verschlüsselung ---
ENC_KEY_V1=<aus Schritt 2> ENC_KEY_V1=<aus Schritt 2>
@@ -202,6 +219,9 @@ INDEX_KEY=<aus Schritt 2>
# --- Objektspeicher --- # --- Objektspeicher ---
S3_ACCESS_KEY_ID=<aus Schritt 2> S3_ACCESS_KEY_ID=<aus Schritt 2>
S3_SECRET_ACCESS_KEY=<aus Schritt 2> S3_SECRET_ACCESS_KEY=<aus Schritt 2>
# S3_ENDPOINT bleibt AUSKOMMENTIERT, solange du das mitgelieferte MinIO
# benutzt. Nur dann setzt die docker-compose.yml von selbst
# http://minio:9000 - steht dort ein Wert, gewinnt er.
# --- Sicherung --- # --- Sicherung ---
SICHERUNG_PASSWORT=<aus Schritt 2> SICHERUNG_PASSWORT=<aus Schritt 2>
@@ -258,12 +278,16 @@ chmod 600 .env
```bash ```bash
# Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte. # Sind alle Pflichtwerte gefüllt? Zeigt nur die Namen, nicht die Werte.
grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \ grep -E '^(POSTGRES_PASSWORD|AUTH_SECRET|AUTH_URL|ERSTEINRICHTUNG_GEHEIMNIS|VERTRAUE_PROXY|ENC_KEY_V1|INDEX_KEY|S3_ACCESS_KEY_ID|S3_SECRET_ACCESS_KEY|SICHERUNG_PASSWORT)=.+' .env \
| sed 's/=.*/ = gesetzt/' | sed 's/=.*/ = gesetzt/'
``` ```
Acht Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht — Zehn Zeilen müssen erscheinen. Fehlt eine, startet die Anwendung nicht —
und sagt dir beim Start auch, welche. und sagt dir beim Start auch, welche. Zwei Ausnahmen: Fehlt
`ERSTEINRICHTUNG_GEHEIMNIS`, startet die Anwendung, aber der erste Zugang
lässt sich nicht anlegen. Fehlt `VERTRAUE_PROXY`, läuft alles — die
Anmeldesperre zählt dann nur nach E-Mail-Adresse und schreibt beim ersten
Mal eine Zeile ins Log.
--- ---
@@ -299,7 +323,8 @@ betreuungs-cockpit-sicherung-1 Up
```bash ```bash
# Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast) # Antwortet die Anwendung? (Port anpassen, falls du APP_PORT gesetzt hast)
curl -s http://127.0.0.1:3000/api/gesundheit curl -s http://127.0.0.1:3000/api/gesundheit
# {"status":"bereit","version":"1.7.0","commit":"..."} # {"status":"bereit","version":"1.12.0","commit":"...",
# "speicher":{"status":"bereit","treiber":"s3"}}
``` ```
Kommt hier nichts, sieh in die Logs: Kommt hier nichts, sieh in die Logs:
@@ -351,20 +376,42 @@ nichts kaputtmacht.
Der Objektspeicher braucht einen Ablagebereich für die Dokumente: Der Objektspeicher braucht einen Ablagebereich für die Dokumente:
```bash ```bash
set -a && source .env && set +a docker compose exec minio sh -c '
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
docker compose exec minio mc alias set lokal \ mc mb "lokal/$S3_BUCKET" 2>/dev/null || true
http://localhost:9000 "$S3_ACCESS_KEY_ID" "$S3_SECRET_ACCESS_KEY" mc ls lokal
'
docker compose exec minio mc mb lokal/betreuungs-cockpit
``` ```
### Kontrolle Es muss eine Zeile mit dem Namen deines Ablagebereichs erscheinen —
standardmäßig `betreuungs-cockpit/`.
```bash > **Warum der Befehl so umständlich aussieht.** Er holt die Zugangsdaten
docker compose exec minio mc ls lokal > aus den Variablen, die im MinIO-Behälter ohnehin stehen. Das hat drei
# [...] betreuungs-cockpit/ > Gründe, und jeder davon hat schon einmal Zeit gekostet:
``` >
> 1. **Kein Passwort in der Befehlszeile** — es stünde sonst in der
> Verlaufsdatei deiner Shell.
> 2. **Kein `source .env`** — exportierte Werte haben Vorrang vor der
> `.env`-Datei und überstimmen danach *jedes* `docker compose up`. Wer
> später die `.env` ändert und neu startet, sieht keine Wirkung und
> sucht den Fehler überall, nur nicht in der eigenen Shell.
> 3. **Der Alias überlebt kein Neuerstellen.** `mc alias set` schreibt
> nach `/root/.mc` **im Behälter**. Nach jedem `docker compose up -d
> minio` ist er weg, und `mc ls lokal` liefert dann kommentarlos nichts
> — man hält seine Dokumente für verschwunden. Deshalb setzt jeder
> Aufruf den Alias selbst.
>
> Der Bucket-Name kommt aus `$S3_BUCKET` und ist nicht fest verdrahtet:
> Sonst zeigen Anwendung und Ablagebereich aneinander vorbei, sobald
> jemand den Namen ändert.
> **Serverseitige Verschlüsselung?** Brauchst du nicht. Die Anwendung
> verschlüsselt jede Datei selbst, bevor sie den Server verlässt — mit
> `ENC_KEY_V1`, der den Anbieter nie erreicht. Das mitgelieferte MinIO
> läuft ohne KMS und würde jede Ablage ablehnen, sobald `S3_SSE` in der
> `.env` steht. Setz den Wert nur, wenn ein externer Anbieter ihn
> ausdrücklich verlangt.
> **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der > **Externer Objektspeicher statt MinIO?** Wenn du einen S3-Dienst in der
> EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den > EU benutzt (z. B. Hetzner Object Storage, Falkenstein), streich den
@@ -375,6 +422,21 @@ docker compose exec minio mc ls lokal
--- ---
> **Der Abschnitt `speicher`** sagt, ob die Dateiablage benutzbar ist:
> erreichbar, Ablagebereich vorhanden, Schreibrecht da. Steht dort
> `gestoert`, lässt sich kein Dokument ablegen — alles andere funktioniert
> weiter. Die Erklärung steht im Log:
>
> ```bash
> docker compose logs app | grep '\[speicher\]'
> ```
>
> Geprüft wird beim ersten Aufruf dieser Adresse und danach höchstens alle
> fünf Minuten. Der Healthcheck ruft sie ohnehin auf, die Zeile steht also
> kurz nach dem Start im Log.
---
## Schritt 7 — In den Reverse Proxy eintragen ## Schritt 7 — In den Reverse Proxy eintragen
Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu Der Proxy steht auf einem **anderen Server**. Damit sind zwei Dinge zu
@@ -649,7 +711,7 @@ Erst **vom Proxy-Server aus** — kommt er überhaupt an?
```bash ```bash
curl -s http://10.0.0.5:3000/api/gesundheit curl -s http://10.0.0.5:3000/api/gesundheit
# {"status":"bereit","version":"1.7.0",...} # {"status":"bereit","version":"1.10.2",...}
``` ```
Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der Kommt hier nichts, liegt es an 7a: falsche `APP_BIND`, Firewall, oder der
@@ -700,7 +762,7 @@ Zum Schluss: **Fristen → Pflichtfristen ableiten**.
- Du kommst mit E-Mail, Passwort und dem Code aus der App herein. - Du kommst mit E-Mail, Passwort und dem Code aus der App herein.
- Unter **Fristen** stehen Einträge mit Paragraphenbezug. - Unter **Fristen** stehen Einträge mit Paragraphenbezug.
- Unter **Protokoll** siehst du deine eigene Anmeldung. - Unter **Protokoll** siehst du deine eigene Anmeldung.
- Unten links in der Seitenleiste steht `Version 1.7.0 · <commit>`. - Unten links in der Seitenleiste steht `Version 1.10.2 · <commit>`.
--- ---
@@ -718,13 +780,19 @@ Erwartete Ausgabe, in dieser Reihenfolge:
``` ```
Sicherung beginnt: /backups/cockpit_2026-08-22_1430.dump.enc Sicherung beginnt: /backups/cockpit_2026-08-22_1430.dump.enc
Sicherung fertig (1.2M) Datenbank gesichert (1.2M)
Selbstpruefung bestanden. Dokumente gesichert (18M)
Selbstpruefung bestanden - Datenbank und Dokumente.
``` ```
Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt Die **Selbstprüfung** ist der eigentliche Punkt: Das Skript entschlüsselt
die eben geschriebene Datei sofort wieder. Schlägt das fehl, endet der beide Dateien sofort wieder und liest sie einmal durch. Schlägt das fehl,
Lauf mit einem Fehler statt mit einem stillen Scheinerfolg. endet der Lauf mit einem Fehler statt mit einem stillen Scheinerfolg.
Fehlt die Zeile `Dokumente gesichert`, ist das Ablageverzeichnis nicht in
den Sicherungsdienst eingehängt. Das Skript bricht dann ab und räumt den
angefangenen Datenbankstand wieder weg — eine Sicherung, die nur die halbe
Wahrheit enthält, aber vollständig aussieht, wäre schlimmer als gar keine.
### Und jetzt einmal zurückspielen ### Und jetzt einmal zurückspielen
@@ -747,6 +815,25 @@ docker compose exec db psql -U cockpit probe \
docker compose exec db dropdb -U cockpit probe docker compose exec db dropdb -U cockpit probe
``` ```
Beim Zurückspielen packt das Skript auch das Dokumentenarchiv aus — nach
`backups/dokumente-wiederhergestellt_<datum>/`. An ihren Platz schreibt es
die Dateien **nicht**: Der Sicherungsdienst hat auf das Ablageverzeichnis
bewusst nur Lesezugriff, und in ein laufendes MinIO hineinzuschreiben würde
dessen Datenbestand beschädigen. Wie der letzte Handgriff aussieht, gibt
das Skript am Ende selbst aus.
Für die Übung genügt ein Blick in das ausgepackte Verzeichnis:
```bash
find backups/dokumente-wiederhergestellt_*/ -type f | head
rm -rf backups/dokumente-wiederhergestellt_*
```
> **Im Ernstfall gehört beides zusammen.** Eine Datenbank ohne die
> zugehörigen Dateien ergibt eine Akte, deren Prüfsummen Dokumente
> belegen, die es nicht mehr gibt. Spiel immer das Paar mit demselben
> Zeitstempel zurück.
### Kontrolle ### Kontrolle
```bash ```bash
@@ -754,12 +841,18 @@ ls -lh backups/
# cockpit_....dump.enc und cockpit_....dump.enc.sha256 # cockpit_....dump.enc und cockpit_....dump.enc.sha256
``` ```
> **Was das Backup nicht enthält:** den Schlüssel `ENC_KEY_V1` und die > **Was das Backup enthält:** die Datenbank *und* die Dokumente. Je Lauf
> Dateien im Objektspeicher. Für die Dateien: > entstehen zwei verschlüsselte Dateien mit demselben Zeitstempel:
> >
> ```bash
> docker compose exec minio mc mirror lokal/betreuungs-cockpit /backups/dokumente
> ``` > ```
> cockpit_2026-09-01_0315.dump.enc <- Akte, Fristen, Buchungen
> dokumente_2026-09-01_0315.tar.gz.enc <- Bescheide, Schreiben, Belege
> ```
>
> **Was es nicht enthält:** den Schlüssel `ENC_KEY_V1`. Ohne ihn bleiben
> auch aus einer vollständig zurückgespielten Sicherung die Diagnosen,
> IBANs, Notizen und Dokumente unlesbar. Er gehört an die zwei Orte aus
> Schritt 11.
> >
> Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup, > Und kopier die Sicherungen regelmäßig **weg vom Server** — ein Backup,
> das neben dem Original liegt, hilft bei einem Serverausfall nicht. > das neben dem Original liegt, hilft bei einem Serverausfall nicht.
@@ -836,6 +929,105 @@ Denk an die letzten Pflichten:
--- ---
## Wo deine Daten liegen
Drei Orte, und keiner davon ist ein Ordner, den du einfach öffnest.
| Was | Wo | In der täglichen Sicherung? |
|---|---|---|
| **Dokumente** (Bescheide, Schreiben, Belege) | Docker-Volume `minio-daten` | ja |
| **Datenbank** (Akte, Fristen, Buchungen) | Docker-Volume `postgres-daten` | ja |
| **Sicherungen** | `./backups` neben der `docker-compose.yml` | — |
### Den Pfad auf der Platte finden
```bash
docker volume inspect betreuungs-cockpit_minio-daten --format '{{.Mountpoint}}'
# /var/lib/docker/volumes/betreuungs-cockpit_minio-daten/_data
```
Heißt dein Projektverzeichnis anders, heißen auch die Volumes anders —
Docker stellt den Verzeichnisnamen voran:
```bash
docker volume ls | grep -E "minio-daten|postgres-daten"
```
> **Dort hineinzusehen bringt nichts.** MinIO legt jedes Dokument als
> eigenes Verzeichnis mit Metadatei und Datenteilen ab, und der Inhalt ist
> verschlüsselt. Du fändest dort keine anklickbaren PDFs, sondern
> Fragmente. Wer die Dokumente sehen will, nimmt die Anwendung.
### Nachsehen, was abgelegt ist
```bash
docker compose exec minio sh -c '
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
mc ls --recursive "lokal/$S3_BUCKET"
'
```
Je Dokument eine Zeile unter `betreute/<id>/dokumente/<uuid>.pdf`.
### Prüfen, ob Akte und Speicher übereinstimmen
Die wichtigste Kontrolle: Zu **jedem** Eintrag in der Akte muss eine Datei
gehören. Fehlt sie, belegt eine Prüfsumme ein Dokument, das es nicht mehr
gibt — gegenüber dem Betreuungsgericht der schlechteste Zustand.
```bash
docker compose exec -T db sh -c 'psql -U "$POSTGRES_USER" -d "$POSTGRES_DB" -At -c "SELECT \"objektSchluessel\" FROM dokument WHERE \"geloeschtAm\" IS NULL ORDER BY 1;"' | sort > /tmp/erwartet.txt
docker compose exec minio sh -c '
mc alias set lokal http://localhost:9000 "$MINIO_ROOT_USER" "$MINIO_ROOT_PASSWORD" >/dev/null &&
mc ls --recursive "lokal/$S3_BUCKET"
' | awk '{print $NF}' | sort > /tmp/vorhanden.txt
echo "In der Akte: $(wc -l < /tmp/erwartet.txt) Im Speicher: $(wc -l < /tmp/vorhanden.txt)"
echo "--- Fehlt im Speicher (Handlungsbedarf): ---"
comm -23 /tmp/erwartet.txt /tmp/vorhanden.txt
echo "--- Verwaist im Speicher (harmlos): ---"
comm -13 /tmp/erwartet.txt /tmp/vorhanden.txt
```
Beide Zahlen gleich und beide Listen leer — dann passt es.
Ein Eintrag unter *„Fehlt im Speicher"* ist ein Akteneintrag ohne Datei.
Er entsteht, wenn eine Ablage scheitert. Solche Einträge löschen und das
Dokument neu anlegen.
### Ohne welche Schlüssel nichts davon lesbar ist
| Schlüssel | Ohne ihn |
|---|---|
| `ENC_KEY_V1` | Diagnosen, IBANs, Notizen und alle Dokumente bleiben unlesbar — auch aus jeder Sicherung |
| `SICHERUNG_PASSWORT` | keine Sicherung lässt sich öffnen |
| `MINIO_KMS_SECRET_KEY` | nur falls gesetzt: MinIO gibt die Objekte nicht mehr heraus |
Alle gehören an die zwei Orte aus Schritt 11, und keiner davon ist dieser
Server.
### Eigene Anpassungen, die ein Update überleben
Änderungen an der `docker-compose.yml` legt `scripts/update.sh` bei jedem
Update per `git stash` beiseite — sie sind versioniert. Wer etwas dauerhaft
ändern will, legt stattdessen eine **`docker-compose.override.yml`**
daneben:
```yaml
services:
app:
environment:
EIGENER_WERT: ${EIGENER_WERT:-}
```
Docker Compose liest sie automatisch mit. `update.sh` prüft nur
versionierte Dateien (`git status --untracked-files=no`) und lässt sie
deshalb unangetastet.
---
## Aktualisieren ## Aktualisieren
```bash ```bash
@@ -875,7 +1067,7 @@ alles so vor wie zuvor.
| Aufruf | Wirkung | | Aufruf | Wirkung |
|---|---| |---|---|
| `./scripts/update.sh` | auf die neueste veröffentlichte Fassung | | `./scripts/update.sh` | auf die neueste veröffentlichte Fassung |
| `./scripts/update.sh v1.7.0` | auf eine bestimmte Fassung | | `./scripts/update.sh v1.10.2` | auf eine bestimmte Fassung |
| `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern | | `./scripts/update.sh --pruefen` | nur nachsehen, nichts ändern |
| `./scripts/update.sh --ja` | ohne Rückfrage (für cron) | | `./scripts/update.sh --ja` | ohne Rückfrage (für cron) |
| `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen | | `./scripts/update.sh --verwerfen` | eigene Änderungen wegwerfen statt beiseitelegen |
@@ -913,7 +1105,7 @@ git fetch --tags
git log --oneline HEAD..origin/main git log --oneline HEAD..origin/main
# 3. Umschalten # 3. Umschalten
git checkout v1.7.0 git checkout v1.10.2
# 4. Neu bauen und starten # 4. Neu bauen und starten
GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build GIT_COMMIT=$(git rev-parse --short HEAD) BUILD_ZEIT=$(date -Iseconds) docker compose up -d --build
+1 -1
View File
@@ -9,7 +9,7 @@ Zeit spart. Er steht weit unten, wenn er nur schön wäre.
Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb Diese Datei ist eine Absichtserklärung, kein Vertrag. Was sich im Betrieb
als wichtiger herausstellt, rutscht nach oben. als wichtiger herausstellt, rutscht nach oben.
Stand: 23.08.2026 · Aktuelle Fassung: **1.7.0** ([CHANGELOG](../CHANGELOG.md)) Stand: 23.08.2026 · Aktuelle Fassung: **1.10.2** ([CHANGELOG](../CHANGELOG.md))
--- ---
+168
View File
@@ -0,0 +1,168 @@
# Sicherheit
Was in dieser Anwendung gegen welche Bedrohung steht — und was
ausdrücklich **nicht**. Der zweite Teil ist der wichtigere: Eine Zusage,
die nicht gilt, ist gefährlicher als eine fehlende, weil man sich auf sie
verlässt.
Stand: v1.11.0. Die Fassung entstand beim Abarbeiten von Issue #38
(Sicherheitsprüfung durch ein zweites Modell) und dem eigenen Durchgang
entlang der OWASP Top 10 (2021).
---
## Das Bedrohungsmodell
Eine ehrenamtliche rechtliche Betreuung. Ein Benutzer, eine betreute
Person, ein kleiner Server hinter einem Reverse Proxy. In der Datenbank
liegen Gesundheitsdaten (Art. 9 DSGVO), Kontobewegungen und
Vermögensverzeichnisse.
Nach Wahrscheinlichkeit geordnet:
| Was | Wie wahrscheinlich | Was dagegen steht |
|---|---|---|
| Offene Sitzung an einem unbeaufsichtigten Rechner | hoch | Abmeldung nach 30 min Leerlauf; erneute Bestätigung vor Export, Löschen, Benutzer- und Passkey-Verwaltung |
| Verlorenes oder gestohlenes Gerät | mittel | Zweiter Faktor ist Pflicht; Passkeys verlangen Nutzerverifikation |
| Kopiertes Backup, falsch gerechtetes Verzeichnis | mittel | Feldverschlüsselung (AES-256-GCM), Dateien im Umschlag, Schlüssel nur in der Umgebung |
| Zugangsdaten zum Objektspeicher abhandengekommen | mittel | Objekte liegen verschlüsselt, an ihren Ablageort gebunden |
| Raten von Passwörtern über das Formular | mittel | Anmeldesperre nach E-Mail **und** Anschluss, Argon2id |
| Einbruch in den Server selbst | gering | Kaum etwas — wer dort Wurzelrechte hat, hat die Schlüssel |
Der letzte Punkt ist keine Nachlässigkeit, sondern eine Grenze: Die
Anwendung braucht die Schlüssel zur Laufzeit. Wer den Prozess kontrolliert,
kommt an sie heran. Dagegen hilft Betriebssicherheit, kein Anwendungscode.
---
## Was ausdrücklich nicht geschützt ist
- **Der Server selbst.** Siehe oben.
- **Metadaten in der Datenbank.** Verschlüsselt sind einzelne Felder —
Diagnosen, IBANs, Verwendungszwecke, Notizen. Dass es einen Vorgang
„Betreuungsgericht" gibt und wann er angelegt wurde, steht im Klartext.
Sonst ließe sich nichts mehr sortieren oder filtern.
- **Symlinks im Ablageverzeichnis.** `resolve()` folgt keinen Links. Wer
dort einen anlegen kann, hat bereits Schreibrechte im Datenverzeichnis —
dann ist der Umweg über einen Link die geringste Sorge. Bis v1.10.2 stand
im Code das Gegenteil; der Satz ist entfernt.
- **Die Middleware prüft keine Anmeldung.** Sie setzt Kopfzeilen, mehr
nicht. Der Zugriffsschutz sitzt in `src/lib/auth/sitzung.ts`, und nur
dort. Bis v1.10.2 behauptete ein Kommentar das Gegenteil.
- **Verfügbarkeit.** Es gibt keinen Schutz gegen jemanden, der den Server
mit Anfragen überzieht. Bei einem Nutzer hinter einem Proxy ist das die
Aufgabe des Proxys.
---
## Der Durchgang entlang der OWASP Top 10 (2021)
### A01 Broken Access Control
Jede Server Action und jede Seite geht durch `erfordereZugang()`, das
zeilenweise gegen die `betreuteId` prüft — nie nur im UI ausgeblendet.
Fremdschlüssel aus Formularen werden über `src/lib/auth/verweise.ts`
geprüft; ein verändertes Auswahlfeld kann keine fremde Akte verknüpfen
(e2e-Test `fremdakte.spec.ts`).
Weiterleitungsziele laufen durch `sicheresZiel()` — kein offener Redirect.
**Behoben in dieser Runde:** Die MFA-Pflicht galt nur für Seiten, nicht
für Server Actions und Route Handler. Sie steckt jetzt in
`erfordereBenutzer()` selbst. `tests/mfa-grenze.test.ts` liest den
Quelltext und schlägt an, sobald eine weitere Umgehung dazukommt.
### A02 Cryptographic Failures
- Felder: AES-256-GCM, Umschlag `bc1.<version>.<iv>.<tag>.<ct>`, Feldname
als Zusatzdaten — ein Geheimtext lässt sich nicht von einem Feld in ein
anderes kopieren.
- Dateien: AES-256-GCM, Umschlag `BCD1`, seit v1.11.0 **beide** Treiber,
mit dem Ablageschlüssel als Zusatzdaten.
- Passwörter: Argon2id, Parameter im Hash, Nachziehen beim Anmelden.
- Schlüsselrotation über `ENC_KEY_V<n>` / `ENC_KEY_AKTIV`; alte Schlüssel
bleiben zum Entschlüsseln stehen.
- Alle Geheimnisvergleiche laufen über `timingSafeEqual` — geprüft für
Reauth-Cookie, Zugriffstoken, ICS-Signatur, Wartungsschlüssel und das
Einrichtungsgeheimnis.
**Behoben in dieser Runde:** Der Objektspeicher verließ sich auf
`ServerSideEncryption: 'AES256'` — den Schlüssel des Anbieters.
### A03 Injection
Prisma parametrisiert. Die fünf Rohabfragen benutzen ausnahmslos
`Prisma.sql`-Templates; `Prisma.join()` in `suche.ts` fügt nur bereits
parametrisierte Fragmente zusammen und ist damit unbedenklich.
Zu XSS: `ts_headline` liefert `<mark>`-Marker im Suchausschnitt, und die
Quelle ist der OCR-Text hochgeladener Dokumente. Gerendert wird er in
`dokument-liste.tsx` aber nicht als HTML, sondern zerlegt und als React-
Kinder ausgegeben — also escaped. Das einzige
`dangerouslySetInnerHTML` der Anwendung setzt eine feste Konstante für die
Themenumschaltung. **Kein Fund.**
### A04 Insecure Design
Erneute Bestätigung vor den folgenschweren Handlungen. Anmeldesperre nach
zwei Dimensionen. Löschen ist ein Papierkorb mit 30 Tagen Frist.
**Behoben in dieser Runde:** Passkeys ließen sich ohne erneute
Bestätigung anlegen und entfernen — ein Passkey ist ein vollwertiger
Zugang, der einen Passwortwechsel überlebt.
### A05 Security Misconfiguration
CSP mit Nonce pro Anfrage und `strict-dynamic` im Produktivbetrieb; dazu
`X-Content-Type-Options`, `X-Frame-Options: DENY`, `Referrer-Policy:
no-referrer`, `Permissions-Policy`, HSTS mit zwei Jahren.
`poweredByHeader` aus.
**Behoben in dieser Runde:** `trustHost` stand fest auf `true`, obwohl
`AUTH_URL` gesetzt ist. `X-Forwarded-For` wurde ohne Proxy geglaubt und
aus dem falschen Ende gelesen.
### A06 Vulnerable and Outdated Components
`npm audit`: 0 Schwachstellen (vorher 32, davon 5 kritisch).
### A07 Identification and Authentication Failures
Passwort + TOTP in einem Schritt, kein Zwischenzustand. Passkeys mit
erzwungener Nutzerverifikation zählen als zwei Faktoren.
`sitzungsGeneration` macht alle Sitzungen auf einen Schlag ungültig.
Antwortzeiten sind angeglichen, damit sich keine E-Mail-Adressen abfragen
lassen.
**Behoben in dieser Runde:** TOTP-Codes galten mehrfach; die Anmeldesperre
verlängerte sich selbst; die Ersteinrichtung stand offen und war nicht
atomar.
### A08 Software and Data Integrity Failures
Zu jedem abgelegten Dokument steht eine SHA-256-Prüfsumme über den
Klartext in der Datenbank — der Nachweis der Unverändertheit gegenüber dem
Gericht. Der Umschlag ist authentifiziert (GCM), Veränderungen fallen beim
Lesen auf.
### A09 Security Logging and Monitoring Failures
`AuditLog` hält Anmeldungen, Fehlversuche, Exporte, Löschungen und
Passkey-Änderungen fest. Anmeldeversuche stehen zusätzlich in
`login_versuch` und werden nach 30 Tagen aufgeräumt.
**Behoben in dieser Runde:** Während einer Sperre entstand pro Anfrage
eine Zeile — die Tabelle ließ sich vollschreiben. Jetzt eine je Sperre.
### A10 Server-Side Request Forgery
Die Anwendung ruft nichts von außen ab: kein `fetch`, keine
HTTP-Bibliothek, keine Webhooks, keine URL-Vorschauen. Ausgehend geht nur
SMTP an den konfigurierten Server. **Keine Angriffsfläche.**
---
## Wenn etwas gefunden wird
Kein öffentliches Issue. Der Weg führt über das Dashboard; dort wird
entschieden, ob und wie es hier landet.
+187 -187
View File
@@ -1,187 +1,187 @@
# Schema-Wünsche aus Etappe 3 „Papier“ # Schema-Wünsche aus Etappe 3 „Papier“
Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde Gesammelt beim Bau der Dokumentenakte. `prisma/schema.prisma` wurde
absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und absprachegemäß **nicht** angefasst; alles hier ist umgangen worden und
funktioniert, wäre aber sauberer, wenn es im Modell stünde. funktioniert, wäre aber sauberer, wenn es im Modell stünde.
Reihenfolge: was am meisten weh tut, steht oben. Reihenfolge: was am meisten weh tut, steht oben.
--- ---
## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen ## 1. `DokumentVerknuepfung`: genau eine Zielspalte — nur in der Anwendung erzwungen
**Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt **Stand:** Zwölf nullbare Fremdschlüsselspalten. Der Kommentar im Schema sagt
„Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau „Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist“ — genau
so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`, so ist es umgesetzt (`ZIEL_SPALTE` in `src/lib/validierung/dokumente.ts`,
Prüfung in `dokumentVerknuepfen`). Prüfung in `dokumentVerknuepfen`).
**Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein **Wunsch:** Ein CHECK-Constraint in der Datenbank, damit auch ein Skript, ein
Seed oder ein späterer Import das nicht verletzen kann: Seed oder ein späterer Import das nicht verletzen kann:
```sql ```sql
ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK ( ALTER TABLE dokument_verknuepfung ADD CONSTRAINT genau_ein_ziel CHECK (
(("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int (("vorgangId" IS NOT NULL)::int + ("fristId" IS NOT NULL)::int
+ ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int + ("buchungId" IS NOT NULL)::int + ("vermoegenspostenId" IS NOT NULL)::int
+ ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int + ("immobilieId" IS NOT NULL)::int + ("vertragId" IS NOT NULL)::int
+ ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int + ("kontaktprotokollId" IS NOT NULL)::int + ("gesundheitsereignisId" IS NOT NULL)::int
+ ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int + ("genehmigungId" IS NOT NULL)::int + ("nachforderungId" IS NOT NULL)::int
+ ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1 + ("bescheidId" IS NOT NULL)::int + ("kontaktId" IS NOT NULL)::int) = 1
); );
``` ```
Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das Ließe sich als eigenes SQL-Skript neben `volltextsuche.sql` legen, ohne das
Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung Prisma-Schema zu ändern. **Warum wichtig:** Eine Akte, in der eine Verknüpfung
auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr auf zwei Dinge gleichzeitig zeigt, ist gegenüber dem Gericht nicht mehr
erklärbar. erklärbar.
**Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen **Zusätzlich:** Es fehlt eine Eindeutigkeit je Paar. Doppelte Verknüpfungen
werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei werden derzeit vor dem Anlegen abgefragt (`findFirst`) — ein Race zwischen zwei
Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte, Klicks kann sie trotzdem erzeugen. Ein partieller Unique-Index je Zielspalte,
z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId") z. B. `CREATE UNIQUE INDEX ... ON dokument_verknuepfung ("dokumentId", "vorgangId")
WHERE "vorgangId" IS NOT NULL;`, würde das schließen. WHERE "vorgangId" IS NOT NULL;`, würde das schließen.
--- ---
## 2. `Dokument.ocrStatus` ist ein freier String ## 2. `Dokument.ocrStatus` ist ein freier String
**Stand:** `ocrStatus String?` mit dem Kommentar **Stand:** `ocrStatus String?` mit dem Kommentar
`"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`. `"ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar"`.
**Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in **Wunsch:** Ein Enum `OcrStatus` mit genau diesen fünf Werten. Der Vertrag in
`docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche `docs/SCHNITTSTELLEN.md` Abschnitt 3 gibt sie ohnehin vor, und die Oberfläche
(`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt (`OcrAbzeichen`) schaltet darauf. Ein Tippfehler beim Zurückschreiben fällt
heute erst auf, wenn das Abzeichen leer bleibt. heute erst auf, wenn das Abzeichen leer bleibt.
--- ---
## 3. Kein Index auf `Dokument.objektSchluessel` ## 3. Kein Index auf `Dokument.objektSchluessel`
**Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das **Stand:** Die Ausliefer-Route `/api/dokumente/datei/[signatur]` sucht das
Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu Dokument über `objektSchluessel`, um Anzeigename und Protokolleintrag zu
bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf. bekommen. Ohne Index ist das ein Seq-Scan bei jedem Dateiabruf.
**Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also **Wunsch:** `@@unique([objektSchluessel])` (der Schlüssel ist eine UUID, also
ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`. ohnehin eindeutig) oder mindestens `@@index([objektSchluessel])`.
--- ---
## 4. Keine Dublettenerkennung über die Prüfsumme ## 4. Keine Dublettenerkennung über die Prüfsumme
**Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer **Stand:** `pruefsumme String?` (SHA-256, wird beim Ablegen gefüllt). Wer
denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze. denselben Bescheid zweimal fotografiert, bekommt zwei Datensätze.
**Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload **Wunsch:** `@@index([betreuteId, pruefsumme])`. Dann ließe sich beim Upload
sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den sagen „dieses Blatt liegt schon in der Akte, angelegt am 12.03.2026“ — ohne den
zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt). zweiten Upload zu verbieten (manchmal ist die zweite Kopie gewollt).
--- ---
## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId` ## 5. `Nachforderung` und `Bescheid` tragen keine `betreuteId`
**Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim **Stand:** Beide hängen nur am `SozialhilfeAntrag`. Die Zugriffsprüfung beim
Verknüpfen muss deshalb über die Beziehung gehen Verknüpfen muss deshalb über die Beziehung gehen
(`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`). (`where: { id, antrag: { betreuteId } }`, siehe `zielGehoertZurAkte`).
**Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen **Wunsch:** Eine denormalisierte `betreuteId` wie bei allen anderen
Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“ Fachentitäten. Die Konvention „jede Abfrage ist auf `betreuteId` eingegrenzt“
ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und ist sonst an genau diesen zwei Stellen nicht wörtlich einhaltbar — und
Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler Ausnahmen von einer Sicherheitsregel sind die Stellen, an denen später Fehler
passieren. passieren.
--- ---
## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel ## 6. Seitenzahl und OCR-Lauf ohne Zeitstempel
**Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein **Stand:** `seitenzahl Int?` wird von der Texterkennung nachgetragen. Wann ein
OCR-Lauf zuletzt versucht wurde, steht nirgends. OCR-Lauf zuletzt versucht wurde, steht nirgends.
**Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in **Wunsch:** `ocrVersuchtAm DateTime?`. `ocrNachziehen()` in
`src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf `src/lib/dokumente/ocr-nachlauf.ts` holt heute alles nach, was auf
`ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein `ausstehend`/`fehlgeschlagen` steht — mit einem Zeitstempel ließe sich ein
dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es dauerhaft kaputtes Dokument nach dem dritten Versuch in Ruhe lassen, statt es
bei jedem Nachziehen erneut durch Tesseract zu schicken. bei jedem Nachziehen erneut durch Tesseract zu schicken.
--- ---
## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien ## 7. Anmerkung zur Verschlüsselung der abgelegten Dateien
Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die Kein Schema-Wunsch, aber eine Abweichung vom Kommentar im Schema, die
festgehalten gehört. Bei `Dokument.ocrText` steht: festgehalten gehört. Bei `Dokument.ocrText` steht:
> „die Datei selbst liegt verschlüsselt im Objektspeicher“ > „die Datei selbst liegt verschlüsselt im Objektspeicher“
Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts` Umgesetzt ist das **nur beim Dateitreiber**: `src/lib/speicher/datei.ts`
verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär- verschlüsselt jede Datei anwendungsseitig mit AES-256-GCM (eigener Binär-
Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus Umschlag `BCD1 | Schlüsselversion | IV | Tag | Geheimtext`, Schlüssel aus
`ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für `ENC_KEY_V*`). Beim S3-Treiber geht das nicht: Der Vertrag verlangt für
`signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert — `signierteUrl()` eine presigned URL, die der Objektspeicher selbst ausliefert —
ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort ein anwendungsseitig verschlüsselter Blob käme dort als Datenmüll an. Dort
läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den läuft der Schutz deshalb über `ServerSideEncryption: AES256` plus den
AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter. AV-Vertrag nach Art. 28 DSGVO mit dem Anbieter.
Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet — Die SHA-256-Prüfsumme wird in **beiden** Fällen über den Klartext gebildet —
sonst wäre sie als Nachweis gegenüber dem Gericht wertlos. sonst wäre sie als Nachweis gegenüber dem Gericht wertlos.
Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()` Wer das vereinheitlichen will, müsste den Vertrag ändern: `signierteUrl()`
würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt, würde auch beim S3-Treiber auf die eigene Route zeigen, die den Blob holt,
entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber entschlüsselt und ausliefert. Das kostet Durchsatz bei großen Scans, wäre aber
konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen konsequenter. Bewusst offengelassen, weil es Abschnitt 1 der Schnittstellen
berührt. berührt.
--- ---
# Anhang: kein Schema-Wunsch, aber dringend # Anhang: kein Schema-Wunsch, aber dringend
## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen ## `@react-pdf/renderer` kann ohne Krücke keinen Buchstaben setzen
Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**, Beim Bau der Belegmappe aufgefallen und **betrifft jedes PDF der Anwendung**,
also auch die Berichte aus Etappe 5. also auch die Berichte aus Etappe 5.
**Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit **Befund:** Jeder Aufruf von `renderToBuffer` scheitert mit
``` ```
TypeError: Cannot read properties of undefined (reading 'unitsPerEm') TypeError: Cannot read properties of undefined (reading 'unitsPerEm')
at @react-pdf/textkit/lib/textkit.js:3090 at @react-pdf/textkit/lib/textkit.js:3090
``` ```
— auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist — auch bei einem Dokument ganz ohne Stilangaben. Nicht die Testumgebung ist
schuld: in reinem Node reproduziert es genauso. schuld: in reinem Node reproduziert es genauso.
**Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6` **Ursache:** In `package.json` ist nur `@react-pdf/renderer` auf `4.1.6`
festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind festgelegt. Seine Unterabhängigkeiten stehen als Caret-Bereiche darin und sind
inzwischen weitergewandert: inzwischen weitergewandert:
| Paket | installiert | von renderer 4.1.6 erwartet | | Paket | installiert | von renderer 4.1.6 erwartet |
|---|---|---| |---|---|---|
| `@react-pdf/font` | 3.1.0 | `^3.0.1` | | `@react-pdf/font` | 3.1.0 | `^3.0.1` |
| `@react-pdf/layout` | 4.7.1 | `^4.2.0` | | `@react-pdf/layout` | 4.7.1 | `^4.2.0` |
| `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) | | `@react-pdf/textkit` | 6.4.1 | (kommt über `layout`) |
`@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher `@react-pdf/font@3.1.0` gibt für die Standardschriften `null` zurück — früher
löste die Schicht darunter den bloßen Namen selbst auf. löste die Schicht darunter den bloßen Namen selbst auf.
`@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit `@react-pdf/textkit@6.4.1` verlangt dagegen ein echtes Schriftobjekt mit
`layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen `layout()` und wirft bei einem Namen ausdrücklich `Invalid font`. Zwischen
beiden klafft eine Lücke. beiden klafft eine Lücke.
**Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende **Was ich getan habe:** `src/lib/pdf/standardschrift.ts` baut das fehlende
Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und Schriftobjekt aus den Metriken, die `@react-pdf/pdfkit` ohnehin mitbringt, und
hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues hängt sich vor die Schriftauflösung — aber nur, wenn sie kaputt ist. Kein neues
Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf, Paket, kein Nachladen aus dem Netz. `pdfErzeugen()` ruft das von selbst auf,
alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test: alle PDF-Wege dieser Anwendung sind damit wieder benutzbar (Test:
„Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`). „Aus einem Bericht und einem Beleg wird eine Mappe" in `tests/dokumente.test.ts`).
**Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier): **Was noch zu tun ist** (außerhalb meiner Pfade, deshalb hier):
`package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen — `package.json` um Festlegungen ergänzen, damit die Pakete zueinander passen —
```jsonc ```jsonc
"overrides": { "overrides": {
"@react-pdf/layout": "4.2.x", "@react-pdf/layout": "4.2.x",
"@react-pdf/textkit": "5.x" "@react-pdf/textkit": "5.x"
} }
``` ```
Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ Die genauen Stände bitte gegen `@react-pdf/renderer@4.1.6` prüfen; alternativ
`@react-pdf/renderer` auf einen aktuellen Stand heben, dessen `@react-pdf/renderer` auf einen aktuellen Stand heben, dessen
Unterabhängigkeiten wieder zusammenpassen. Danach kann Unterabhängigkeiten wieder zusammenpassen. Danach kann
`standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()` `standardschrift.ts` ersatzlos entfallen — `standardschriftenSicherstellen()`
merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus. merkt von selbst, dass es nicht mehr gebraucht wird, und hält sich heraus.
+243 -243
View File
@@ -1,243 +1,243 @@
# Schema-Wünsche aus Etappe 5 „Behörde“ # Schema-Wünsche aus Etappe 5 „Behörde“
Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls Das Schema wurde nicht angefasst. Was beim Bauen des Sozialhilfemoduls
gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem gefehlt hat, steht hier — nach Dringlichkeit sortiert, jeweils mit dem
Behelf, der jetzt an seiner Stelle steht. Behelf, der jetzt an seiner Stelle steht.
--- ---
## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig ## 1. `Heimkostenmonat`: Kostenseite und Deckungsseite überschneiden sich — wichtig
Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus Dieselbe Sache wie Punkt 1 in `docs/schema-wuensche/dashboard.md`, hier aus
Sicht des Moduls, das die Gegenüberstellung baut. Sicht des Moduls, das die Gegenüberstellung baut.
**Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher **Das Problem.** `pflegeEigenanteilCent` heißt „einrichtungseinheitlicher
Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der Eigenanteil". Der ist definitionsgemäß **schon um die Pauschale der
Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile Pflegekasse nach § 43 Abs. 2 SGB XI gekürzt**. Steht in derselben Zeile
zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal zusätzlich `pflegekasseCent` als Deckung, wird dasselbe Geld zweimal
gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei gezählt — die Deckungslücke fällt um die Pauschale zu klein aus, bei
Pflegegrad 3 um 1.319 Euro im Monat. Pflegegrad 3 um 1.319 Euro im Monat.
**Was ich entschieden habe.** Dieses Modul rechnet in der Lesart **Was ich entschieden habe.** Dieses Modul rechnet in der Lesart
`EIGENANTEIL_NETTO` (siehe Kopfkommentar in `EIGENANTEIL_NETTO` (siehe Kopfkommentar in
`src/lib/sozialhilfe/heimkosten.ts`): `src/lib/sozialhilfe/heimkosten.ts`):
``` ```
Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung) Bedarf = pflegeEigenanteil (netto, wie auf der Heimrechnung)
+ unterkunftVerpflegung + investitionskosten + unterkunftVerpflegung + investitionskosten
+ ausbildungsumlage + zusatzleistungen + ausbildungsumlage + zusatzleistungen
+ barbetrag + bekleidungspauschale + barbetrag + bekleidungspauschale
Deckung = leistungszuschlag + rente + sonstigeEinkuenfte Deckung = leistungszuschlag + rente + sonstigeEinkuenfte
+ sozialhilfe + eigenmittel + sozialhilfe + eigenmittel
Deckungslücke = Bedarf − Deckung Deckungslücke = Bedarf − Deckung
``` ```
`pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als `pflegekasseCent` zählt **nicht** als Deckung. Der Wert wird als
`pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich `pflegekasseNachrichtlichCent` mitgeführt und angezeigt; ist er ungleich
null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die null, gibt `heimkostenAuswerten()` einen Hinweis aus, der die
Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor. Doppelzählung erklärt. Das Formular belegt das Feld nicht mehr vor.
`leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der `leistungszuschlagCent` zählt dagegen **sehr wohl** als Deckung: Der
Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht Zuschlag nach § 43c SGB XI mindert den Eigenanteil erst danach und steht
auf der Heimrechnung als eigene Abzugsposition. auf der Heimrechnung als eigene Abzugsposition.
Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart Die Alternative — durchgängig den Bruttosatz erfassen — ist als Lesart
`PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung `PFLEGESATZ_BRUTTO` implementiert und getestet: Bei konsistenter Erfassung
kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der kommen beide Lesarten auf dieselbe Deckungslücke. Benutzt wird sie in der
Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu Oberfläche nicht, weil das Schema keinen Ort hat, um die Wahl zu
speichern. speichern.
**Wunsch, in dieser Reihenfolge:** **Wunsch, in dieser Reihenfolge:**
1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und 1. Den Kommentar am Feld schärfen, damit die Lesart im Schema steht und
nicht nur hier: nicht nur hier:
```prisma ```prisma
/// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der /// NETTO: der einrichtungseinheitliche Eigenanteil, wie er auf der
/// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch /// Heimrechnung steht - bereits um § 43 Abs. 2 SGB XI gekuerzt, noch
/// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT /// nicht um den Leistungszuschlag. pflegekasseCent zaehlt deshalb NICHT
/// als Deckung, sondern nur nachrichtlich. /// als Deckung, sondern nur nachrichtlich.
pflegeEigenanteilCent BigInt @default(0) pflegeEigenanteilCent BigInt @default(0)
``` ```
2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes 2. Wenn beide Lesarten gebraucht werden, ein Feld dafür — sonst muss jedes
Modul raten, was in der Zeile steht: Modul raten, was in der Zeile steht:
```prisma ```prisma
enum Kostenlesart { enum Kostenlesart {
EIGENANTEIL_NETTO EIGENANTEIL_NETTO
PFLEGESATZ_BRUTTO PFLEGESATZ_BRUTTO
} }
lesart Kostenlesart @default(EIGENANTEIL_NETTO) lesart Kostenlesart @default(EIGENANTEIL_NETTO)
``` ```
3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den 3. Alternativ ein `heimrechnungGesamtCent`, das schlicht den
Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und Rechnungsbetrag trägt; die Aufteilung wäre dann nur noch informativ und
die Summe könnte gegen sie geprüft werden. die Summe könnte gegen sie geprüft werden.
**Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul, **Verabredung bis dahin**: Wer `Heimkostenmonat` schreibt — dieses Modul,
das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto- das Dashboard, ein Import — trägt als `pflegeEigenanteilCent` den Netto-
Eigenanteil ein und lässt `pflegekasseCent` auf 0. Eigenanteil ein und lässt `pflegekasseCent` auf 0.
--- ---
## 2. `Nachforderung.checklistenSchluessel` — wichtig ## 2. `Nachforderung.checklistenSchluessel` — wichtig
```prisma ```prisma
/// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts. /// Stabiler Schluessel aus src/lib/sozialhilfe/checkliste.ts.
/// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen. /// Verhindert Dubletten beim erneuten Uebernehmen der Standardunterlagen.
checklistenSchluessel String? checklistenSchluessel String?
@@unique([antragId, checklistenSchluessel]) @@unique([antragId, checklistenSchluessel])
``` ```
**Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der **Warum**: `ausCheckliste: Boolean` merkt sich nur *dass* eine Zeile aus der
Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels Checkliste kam, nicht *welche* Position. Um beim zweiten Aufruf des Panels
nicht dieselbe Unterlage noch einmal anzulegen, vergleicht nicht dieselbe Unterlage noch einmal anzulegen, vergleicht
`nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben, `nochNichtUebernommen()` derzeit über die **Bezeichnung** (kleingeschrieben,
getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es, getrimmt). Das reicht, solange niemand die Bezeichnung ändert — tut er es,
entsteht beim nächsten Übernehmen eine Dublette. entsteht beim nächsten Übernehmen eine Dublette.
**Behelf**: Vergleich über die Bezeichnung in **Behelf**: Vergleich über die Bezeichnung in
`src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`. `src/lib/sozialhilfe/checkliste.ts` und in `checklisteUebernehmen()`.
--- ---
## 3. Rückkaufswert am Vertrag — wichtig ## 3. Rückkaufswert am Vertrag — wichtig
```prisma ```prisma
model Vertrag { model Vertrag {
// ... // ...
rueckkaufswertCent BigInt? rueckkaufswertCent BigInt?
rueckkaufswertStichtag DateTime? @db.Date rueckkaufswertStichtag DateTime? @db.Date
} }
``` ```
**Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge **Warum**: Die Bewertungsansicht stellt Vermögensposten und Verträge
nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`, nebeneinander. Ein Vermögensposten trägt seinen Wert über `Wertstand`,
ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die ein Vertrag hat gar kein Wertfeld — nur `beitragCent`, und das ist die
Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die Prämie, nicht der Wert. Verträge erscheinen deshalb ohne Betrag, und die
Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung Summe „verwertbar“ ist unvollständig, solange nicht zu jeder Versicherung
zusätzlich ein Vermögensposten angelegt wurde. zusätzlich ein Vermögensposten angelegt wurde.
Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der Für das Sozialhilfeverfahren ist genau dieser Wert der entscheidende: Der
Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen. Träger fragt nach Rückkaufswerten, nicht nach Versicherungssummen.
**Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen **Behelf**: Die Bewertungsansicht zeigt nur Verträge, die überhaupt einen
Wert haben können (`MIT_RUECKKAUFSWERT` aus Wert haben können (`MIT_RUECKKAUFSWERT` aus
`src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht `src/lib/vertraege/konstanten.ts`), und markiert sie mit „Wert noch nicht
ermittelt“. Wer den Betrag braucht, legt zusätzlich einen ermittelt“. Wer den Betrag braucht, legt zusätzlich einen
`Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der `Vermoegensposten` mit `vertragId` und einem `Wertstand` an — dann wird der
Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt. Vertrag in der Liste ausgeblendet, damit die Summe nicht doppelt zählt.
--- ---
## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel ## 4. `Bescheid.rechtsbehelfsbelehrungVorhanden` — mittel
```prisma ```prisma
/// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet. /// Ausdrueckliche Einschaetzung, nicht aus der Fristlaenge abgeleitet.
rechtsbehelfsbelehrungVorhanden Boolean @default(true) rechtsbehelfsbelehrungVorhanden Boolean @default(true)
rechtsbehelfsbelehrungBemerkung String? rechtsbehelfsbelehrungBemerkung String?
``` ```
**Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste **Warum**: Ob die Jahresfrist des § 66 Abs. 2 SGG gilt, ist eine bewusste
Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“). Einschätzung mit Begründung („die Belehrung nennt die falsche Behörde“).
Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen — Derzeit wird sie aus `rechtsbehelfsfristMonate === 12` zurückgeschlossen —
das trägt, verliert aber die Begründung, und die bräuchte man genau dann, das trägt, verliert aber die Begründung, und die bräuchte man genau dann,
wenn man sich im Widerspruchsverfahren darauf beruft. wenn man sich im Widerspruchsverfahren darauf beruft.
**Behelf**: `belehrungZuMonaten()` in **Behelf**: `belehrungZuMonaten()` in
`src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf `src/lib/sozialhilfe/rechtsbehelf.ts`. Die Begründung gehört bis auf
Weiteres in `notizenVerschluesselt`. Weiteres in `notizenVerschluesselt`.
--- ---
## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel ## 5. `Bescheid.widerspruchFristId` → `Frist` — mittel
```prisma ```prisma
widerspruchFristId String? @unique widerspruchFristId String? @unique
widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull) widerspruchFrist Frist? @relation(fields: [widerspruchFristId], references: [id], onDelete: SetNull)
``` ```
**Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über **Warum**: Die angelegte Frist „Widerspruch möglich bis“ wird derzeit über
`Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden. `Frist.systemSchluessel = "sozialhilfe-widerspruch-<bescheidId>"` gefunden.
Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist Das funktioniert (`@@unique([betreuteId, systemSchluessel])` trägt es), ist
aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter aber eine Verknüpfung über eine zusammengesetzte Zeichenkette. Ein echter
Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids Fremdschlüssel wäre sauberer und ließe die Frist beim Löschen des Bescheids
mitziehen. Dasselbe gilt für mitziehen. Dasselbe gilt für
`"sozialhilfe-nachforderung-<nachforderungId>"`. `"sozialhilfe-nachforderung-<nachforderungId>"`.
**Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`. **Behelf**: `systemSchluessel` mit festem Präfix, `upsert` statt `create`.
--- ---
## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel ## 6. `Heimkostenmonat` — Feld für die tatsächliche Zahlung — mittel
```prisma ```prisma
/// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid. /// Was tatsaechlich vom Konto ging - fuer den Abgleich mit dem Bescheid.
gezahltCent BigInt? gezahltCent BigInt?
gezahltAm DateTime? @db.Date gezahltAm DateTime? @db.Date
rechnungsnummer String? rechnungsnummer String?
``` ```
**Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was **Warum**: Die Gegenüberstellung rechnet Bedarf gegen Deckung. Was
tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz tatsächlich abgebucht wurde, steht nirgends — und genau die Differenz
zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim zwischen „laut Bescheid“ und „tatsächlich abgebucht“ ist der Anlass, beim
Träger nachzufragen. Träger nachzufragen.
**Behelf**: `bemerkung`. **Behelf**: `bemerkung`.
--- ---
## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein ## 7. `Heimkostenmonat.abwesenheitsminderungCent` — klein
```prisma ```prisma
/// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung. /// Tatsaechliche Minderung laut Heimrechnung, nicht die Schaetzung.
abwesenheitsminderungCent BigInt @default(0) abwesenheitsminderungCent BigInt @default(0)
``` ```
**Warum**: `abwesenheitstage` ist da, die Wirkung schätzt **Warum**: `abwesenheitstage` ist da, die Wirkung schätzt
`abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach `abwesenheitSchaetzen()` nach dem Rahmenvertrag (drei Karenztage, danach
25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die 25 % auf Pflegeanteil sowie Unterkunft und Verpflegung). Was die
Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich Einrichtung wirklich abgezogen hat, steht auf der Rechnung und ließe sich
nicht festhalten. nicht festhalten.
**Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und **Behelf**: Die Schätzung wird ausdrücklich als Schätzung ausgewiesen und
neben die erfasste Lücke gestellt, nicht in sie hineingerechnet. neben die erfasste Lücke gestellt, nicht in sie hineingerechnet.
--- ---
## 8. `Leistung.wiederholungsfristMonat/-tag` — klein ## 8. `Leistung.wiederholungsfristMonat/-tag` — klein
```prisma ```prisma
/// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr. /// Fuer jaehrlich zu wiederholende Antraege: Stichtag im Jahr.
antragsschlussMonat Int? antragsschlussMonat Int?
antragsschlussTag Int? antragsschlussTag Int?
``` ```
**Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden **Warum**: `jaehrlichNeuBeantragen: Boolean` sagt, *dass* wiederholt werden
muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine muss, nicht *bis wann*. Beim Landespflegegeld ist der 31.12. eine
Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten, Ausschlussfrist — daraus ließe sich automatisch eine Frist ableiten,
statt sie von Hand anzulegen. statt sie von Hand anzulegen.
**Behelf**: `antragsschluss` als Text im Katalog **Behelf**: `antragsschluss` als Text im Katalog
(`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der (`src/lib/sozialhilfe/leistungskatalog.ts`) und ein Hinweis in der
Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit Oberfläche. Die Fristableitung übernimmt `pflichtfristenAbleiten()` mit
`landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`). `landespflegegeld-<jahr>` (siehe `docs/SCHNITTSTELLEN.md`).
--- ---
## 9. `Nachforderung.notizenVerschluesselt` — klein ## 9. `Nachforderung.notizenVerschluesselt` — klein
**Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im **Warum**: `erlaeuterung` und `begruendungNichtVorhanden` liegen im
Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht Klartext. Beides ist meist harmlos („Grundbuchauszug beim Amtsgericht
angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten angefordert“), kann aber Gesundheits- oder Vermögensangaben enthalten
(„Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die („Rückkaufswert 12.480 Euro, Police verpfändet“). Wäre mindestens die
Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas. Begründung verschlüsselt, wäre das konsequenter zum Rest des Schemas.
**Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie **Behelf**: keiner. Die Felder werden benutzt, wie sie sind. Falls sie
verschlüsselt werden sollen, gehört `Nachforderung` in verschlüsselt werden sollen, gehört `Nachforderung` in
`VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`. `VERSCHLUESSELTE_FELDER` in `src/lib/crypto/feld.ts`.
+97
View File
@@ -0,0 +1,97 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Die eigene Anschrift im Schreiben — gegen die echte Anwendung.
*
* Der Weg, um den es geht: einmal unter „Mein Konto" hinterlegen, und
* ab da steht sie im Absenderblock und der Ort in der Datumszeile —
* ohne dass man beim Schreiben daran denken muss.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
/** Zurück auf „nichts hinterlegt", damit der Fall auch prüfbar bleibt. */
async function anschriftLeeren(): Promise<void> {
await mitDb(async (db) => {
await db.user.updateMany({ data: { strasse: null, plz: null, ort: null } })
})
}
test.describe('Absenderanschrift', () => {
test.beforeAll(async () => {
await anschriftLeeren()
})
test.afterAll(async () => {
await anschriftLeeren()
})
test('ohne hinterlegte Anschrift steht nur der Name im Brief', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
// Erste Zeile ist der Absender, danach kommt gleich die Leerzeile.
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('')
// Kein Ort in der Datumszeile, also auch kein Komma davor.
expect(text).not.toContain(', den ')
})
test('speichert die Anschrift unter Mein Konto', async ({ page }) => {
await anmelden(page)
await page.goto('/konto')
await page.locator('input#strasse').fill('Cottenbacher Str. 23')
await page.locator('input#plz').fill('95445')
await page.locator('input#ort').fill('Bayreuth')
await page.getByRole('button', { name: 'Anschrift speichern' }).click()
await expect(page.getByText('Deine Anschrift ist gespeichert.')).toBeVisible()
await page.reload()
await expect(page.locator('input#strasse')).toHaveValue('Cottenbacher Str. 23')
})
test('trägt sie in Absenderblock und Datumszeile des Schreibens', async ({ page }) => {
await anmelden(page)
await page.goto('/schreiben/neu?art=FREI')
const text = await page.locator('textarea#text').inputValue()
const zeilen = text.split('\n')
expect(zeilen[0]).toBe('Florian Wilde')
expect(zeilen[1]).toBe('Cottenbacher Str. 23')
expect(zeilen[2]).toBe('95445 Bayreuth')
expect(text).toContain('Bayreuth, den ')
})
test('steht auch im Rückkaufswert-Schreiben zum Vertrag', async ({ page }) => {
const vertragId = await mitDb(async (db) => {
const vertrag = await db.vertrag.findFirst({
where: { art: 'LEBENSVERSICHERUNG', geloeschtAm: null },
select: { id: true },
})
return vertrag?.id ?? ''
})
test.skip(vertragId === '', 'Kein Lebensversicherungsvertrag im Bestand.')
await anmelden(page)
await page.goto(`/schreiben/neu?art=RUECKKAUFSWERT&vertragId=${vertragId}`)
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Cottenbacher Str. 23')
expect(text).toContain('Bayreuth, den ')
})
})
+164
View File
@@ -0,0 +1,164 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Ansprechpartner am Vorgang — gegen die echte Anwendung.
*
* Geprüft wird die Zusage, um die es geht: Ein Schreiben zum Vorgang
* redet die Sachbearbeiterin namentlich an und trägt ihren Namen über
* der Behörde — ohne benannten Ansprechpartner bleibt alles beim Alten.
*/
const KENNUNG = 'Prüfhausen Partner'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.vorgang.deleteMany({ where: { titel: { contains: KENNUNG } } })
await db.kontakt.deleteMany({ where: { name: { contains: KENNUNG } } })
})
}
test.describe('Ansprechpartner am Vorgang', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
await db.kontakt.create({
data: {
betreuteId: person.id,
name: `${KENNUNG} Petra Sachs`,
organisation: `${KENNUNG} Bezirk`,
rolle: 'SACHBEARBEITER_BEZIRK',
anrede: 'FRAU',
telefon: '0921 604-1234',
strasse: 'Cottenbacher Str. 23',
plz: '95445',
ort: 'Bayreuth',
},
})
await db.vorgang.create({
data: {
betreuteId: person.id,
titel: `${KENNUNG} Antrag auf Hilfe zur Pflege`,
kategorie: 'SOZIALHILFE',
status: 'WARTET_AUF_DRITTE',
gegenstelle: `${KENNUNG} Bezirk`,
aktenzeichen: 'HzP 2026/4711',
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('bleibt ohne benannten Ansprechpartner bei der allgemeinen Anrede', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await expect(page.getByText('Keiner benannt', { exact: false }).first()).toBeVisible()
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
const text = await page.locator('textarea#sachstand-text').inputValue()
expect(text).toContain('Sehr geehrte Damen und Herren,')
})
test('lässt einen Kontakt als Ansprechpartner wählen und zeigt ihn an', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await page
.locator('select[name="ansprechpartnerId"]')
.selectOption({ label: `${KENNUNG} Petra Sachs · ${KENNUNG} Bezirk` })
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
await page.reload()
const inhalt = await page.locator('body').innerText()
// Vor dem Anruf weiß man, wen man fragt — samt Durchwahl.
expect(inhalt).toContain(`${KENNUNG} Petra Sachs`)
expect(inhalt).toContain('0921 604-1234')
})
test('redet die Sachstandsanfrage namentlich an', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
await page.getByRole('button', { name: 'Sachstandsanfrage erzeugen' }).click()
const text = await page.locator('textarea#sachstand-text').inputValue()
expect(text).toContain('Sehr geehrte Frau Sachs,')
expect(text).not.toContain('Sehr geehrte Damen und Herren,')
// Der Name steht über der Behörde.
const zeilen = text.split('\n')
expect(zeilen[0]).toContain('Petra Sachs')
})
test('übernimmt Name und Anschrift in das Schreiben zum Vorgang', async ({ page }) => {
const vorgangId = await mitDb(async (db) => {
const vorgang = await db.vorgang.findFirst({
where: { titel: { contains: KENNUNG }, ansprechpartnerId: { not: null } },
select: { id: true },
})
return vorgang?.id ?? ''
})
expect(vorgangId).not.toBe('')
await anmelden(page)
await page.goto(`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgangId}`)
await expect(page.locator('textarea#text')).toBeVisible()
const text = await page.locator('textarea#text').inputValue()
expect(text).toContain('Sehr geehrte Frau Sachs,')
// Empfänger und Anschrift sind vorbelegt, nichts abzutippen.
await expect(page.locator('input[name="empfaengerName"]').first()).toHaveValue(
`${KENNUNG} Petra Sachs`,
)
expect(await page.locator('textarea#empfaengerAnschrift').first().inputValue()).toContain(
'95445 Bayreuth',
)
})
test('hält den Wechsel des Ansprechpartners mit Namen im Protokoll fest', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
await page.getByRole('link', { name: new RegExp(`${KENNUNG} Antrag`) }).first().click()
await page.waitForURL(/\/vorgaenge\/[a-z0-9]+$/)
// Zurück auf „keiner benannt" — der Wechsel muss nachvollziehbar sein.
await page.locator('select[name="ansprechpartnerId"]').selectOption('')
await page.getByRole('button', { name: 'Speichern' }).click()
await expect(page.getByText('Der Vorgang ist gespeichert.')).toBeVisible()
// Im Protokoll steht der Name, nicht die Id: Zwei cuids nebeneinander
// sind protokolliert, aber nicht nachvollziehbar.
await page.goto('/protokoll')
// textContent statt innerText: Die Felderliste steckt in einem
// zugeklappten <details>.
const protokoll = (await page.locator('body').textContent()) ?? ''
expect(protokoll).toContain('ansprechpartner')
expect(protokoll).toContain(`${KENNUNG} Petra Sachs`)
})
})
+138
View File
@@ -0,0 +1,138 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Der Demo-Fall mit Kontobewegungen — gegen die echte Anwendung.
*
* Was hier geprüft wird, ist kein Verhalten der Anwendung, sondern die
* Brauchbarkeit der Beispieldaten: Wer die Anwendung zum ersten Mal
* ansieht, soll auf den Finanzseiten etwas vorfinden und nicht erst
* eine CSV importieren müssen.
*
* Die Prüfungen sind bewusst grob gehalten — sie sollen anschlagen, wenn
* der Demo-Fall leer läuft, und nicht bei jeder geänderten Zahl.
*/
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
test.describe('Demo-Fall', () => {
test('bringt ein Jahr Kontobewegungen mit', async () => {
const zahlen = await mitDb(async (db) => {
const betreute = await db.betreute.findFirst({
where: { vorname: 'Erika', nachname: 'Musterfrau' },
select: { id: true },
})
if (!betreute) return null
const buchungen = await db.buchung.findMany({
where: { betreuteId: betreute.id, geloeschtAm: null },
select: { datum: true },
orderBy: { datum: 'asc' },
})
const ohneNachweis = await db.buchung.count({
where: { betreuteId: betreute.id, belegId: null, belegFehltBegruendung: null },
})
return { buchungen, ohneNachweis }
})
test.skip(zahlen === null, 'Kein Demo-Fall in dieser Datenbank.')
if (!zahlen) return
expect(zahlen.buchungen.length).toBeGreaterThan(50)
// Über mindestens zwölf Monate verteilt, nicht alle an einem Tag.
const erste = zahlen.buchungen[0]!.datum
const letzte = zahlen.buchungen[zahlen.buchungen.length - 1]!.datum
const monate =
(letzte.getUTCFullYear() - erste.getUTCFullYear()) * 12 +
(letzte.getUTCMonth() - erste.getUTCMonth())
expect(monate).toBeGreaterThanOrEqual(11)
// Genau ein Fall ohne Beleg und ohne Begründung — sonst hätte die
// Belegpflicht nichts zu zeigen, oder zu viel.
expect(zahlen.ohneNachweis).toBe(1)
})
test('der Kontostand stimmt mit der Summe der Buchungen überein', async () => {
const ergebnis = await mitDb(async (db) => {
const konto = await db.konto.findFirst({
where: { art: 'GIRO', betreute: { vorname: 'Erika', nachname: 'Musterfrau' } },
select: { id: true },
})
if (!konto) return null
const staende = await db.saldostand.findMany({
where: { kontoId: konto.id },
orderBy: { stichtag: 'asc' },
})
if (staende.length < 2) return null
const [anfang, ende] = [staende[0]!, staende[staende.length - 1]!]
const bewegungen = await db.buchung.findMany({
where: {
kontoId: konto.id,
geloeschtAm: null,
datum: { gt: anfang.stichtag, lte: ende.stichtag },
},
select: { betragCent: true, richtung: true },
})
const summe = bewegungen.reduce(
(wert, b) => (b.richtung === 'EINNAHME' ? wert + b.betragCent : wert - b.betragCent),
0n,
)
return { erwartet: anfang.betragCent + summe, tatsaechlich: ende.betragCent }
})
test.skip(ergebnis === null, 'Kein Demo-Fall mit zwei Kontoständen.')
if (!ergebnis) return
// Ein Demo-Fall, der sich selbst widerspricht, ist schlimmer als
// einer ohne Buchungen: Man rechnet nach und verliert das Vertrauen.
expect(ergebnis.tatsaechlich).toBe(ergebnis.erwartet)
})
test('die Erkennung wiederkehrender Zahlungen findet mehrere Gruppen', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/wiederkehrend')
const inhalt = await page.locator('body').innerText()
// Monatliches und Vierteljährliches, beides erkannt.
expect(inhalt).toContain('Seniorenzentrum')
expect(inhalt).toContain('TeleCare')
expect(inhalt).toContain('Beitragsservice')
expect(inhalt).toContain('monatlich')
expect(inhalt).toContain('vierteljährlich')
// Der eigentliche Vorführeffekt: Das Heim steht in drei Schreibweisen
// auf dem Auszug und wird trotzdem als eine Zahlung geführt.
expect(inhalt).toContain('3 Schreibweisen zusammengefasst')
// Die Sterbekasse steht im zweiten Abschnitt: Zu ihr gibt es einen
// Vertrag, die Zahlungen sind ihm aber noch nicht zugeordnet. Damit
// lässt sich der Handgriff aus der Dauerzahlungs-Zuordnung am
// Demo-Fall ausprobieren, statt ihn nur zu beschreiben.
expect(inhalt).toContain('Schon einem Vertrag zugeordnet')
expect(inhalt).toContain('Nordheim Sterbekasse')
expect(inhalt).toContain('als belegt zuordnen')
})
test('das Kassenbuch zeigt die Buchungen und die fehlende Quittung', async ({ page }) => {
await anmelden(page)
await page.goto('/finanzen/buchungen')
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('Heimentgelt')
// Die eine Buchung ohne Quittung und ohne Begründung.
expect(inhalt).toContain('Modehaus Lindner')
})
})
+116
View File
@@ -0,0 +1,116 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Verweise über Aktengrenzen hinweg — gegen die echte Anwendung.
*
* Die Auswahllisten im Browser zeigen nur die eigene Akte. Darauf darf
* sich der Server nicht verlassen: Wer das Formular verändert, schickt
* jede beliebige Id.
*
* Genau das wird hier nachgestellt — nicht als nachgebauter Aufruf,
* sondern indem dem echten Auswahlfeld ein Eintrag mit einer fremden Id
* untergeschoben und das Formular ganz normal abgeschickt wird. Nur so
* beweist der Test etwas: Er liefe rot, wenn die Prüfung fehlte.
*
* Solange nur eine betreute Person angelegt ist, wäre der Fehler
* folgenlos. Er bliebe es nicht — ein fremder Name stünde im Briefkopf.
*/
const KENNUNG = 'Prüfhausen Fremdakte'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.betreute.deleteMany({ where: { nachname: { contains: 'Fremdakte' } } })
})
}
test.describe('Verweise auf eine fremde Akte', () => {
test.beforeAll(async () => {
await aufraeumen()
})
test.afterAll(async () => {
await aufraeumen()
})
test('weist einen Kontakt aus einer anderen Akte ab', async ({ page }) => {
// Zweite Akte mit einem Kontakt darin — der angemeldete Benutzer hat
// auf sie keinen Zugriff.
const fremderKontakt = await mitDb(async (db) => {
const fremde = await db.betreute.create({
data: {
vorname: 'Otto',
nachname: `${KENNUNG} Fremdakte`,
geburtsdatum: new Date(Date.UTC(1940, 0, 1)),
},
})
const kontakt = await db.kontakt.create({
data: {
betreuteId: fremde.id,
name: `${KENNUNG} Fremder Sachbearbeiter`,
rolle: 'SACHBEARBEITER_BEZIRK',
},
})
return kontakt.id
})
await anmelden(page)
// Irgendeinen Vorgang der eigenen Akte öffnen.
const vorgangId = await mitDb(async (db) => {
const vorgang = await db.vorgang.findFirst({
where: { geloeschtAm: null, betreute: { vorname: 'Erika' } },
select: { id: true },
})
return vorgang?.id ?? ''
})
test.skip(vorgangId === '', 'Kein Vorgang in der eigenen Akte.')
await page.goto(`/vorgaenge/${vorgangId}`)
const auswahl = page.locator('select[name="ansprechpartnerId"]')
await auswahl.waitFor()
// Dem echten Auswahlfeld die fremde Id unterschieben und ganz normal
// abschicken — so, wie es ein verändertes Formular täte.
await auswahl.evaluate((feld, id) => {
const eintrag = document.createElement('option')
eintrag.value = id
eintrag.textContent = 'untergeschoben'
;(feld as HTMLSelectElement).append(eintrag)
;(feld as HTMLSelectElement).value = id
}, fremderKontakt)
await page.getByRole('button', { name: 'Speichern' }).click()
// Der Server lehnt ab, mit einer Meldung, die den Grund nennt.
// Grosszuegige Frist: Im Entwicklungsmodus wird die Server Action beim
// ersten Aufruf uebersetzt, und das dauert laenger als die Vorgabe.
await expect(page.getByText(/gehört nicht zu dieser betreuten Person/).first()).toBeVisible({
timeout: 45_000,
})
// Und vor allem: Die Verknüpfung ist nicht entstanden.
const verknuepft = await mitDb((db) =>
db.vorgang.count({ where: { ansprechpartnerId: fremderKontakt } }),
)
expect(verknuepft).toBe(0)
})
test('die Auswahlliste zeigt den fremden Kontakt gar nicht erst', async ({ page }) => {
await anmelden(page)
await page.goto('/vorgaenge')
const inhalt = await page.locator('body').innerText()
expect(inhalt).not.toContain('Fremder Sachbearbeiter')
})
})
+43
View File
@@ -117,3 +117,46 @@ export function beispielPdf(text = 'Bescheid'): Buffer {
return Buffer.from(pdf, 'latin1') return Buffer.from(pdf, 'latin1')
} }
/**
* Doppelt gerenderte Listen — was beim Suchen schiefgeht.
*
* Die Dokumentenliste (und moeglicherweise kuenftig weitere) rendert
* denselben Inhalt ZWEIMAL: als Karten fuers Handy, als Tabelle ab
* Tablet. Eine der beiden Fassungen ist per Breakpoint versteckt, steht
* aber im DOM.
*
* Die Folge im Test:
*
* await expect(page.getByText('Bescheid').first()).toBeVisible()
* // -> "element is not visible", obwohl die Seite richtig aussieht
*
* `.first()` nimmt, was zuerst im Baum steht - und das ist die Karte,
* also am Schreibtisch die unsichtbare Fassung. Es liegt NICHT an der
* Anwendung.
*
* Nachgemessen an /dokumente mit acht Dokumenten: Ein Titel liefert 16
* Treffer, davon 8 sichtbar - und `.first()` ist unsichtbar.
*
* Drei Wege, die tragen:
*
* 1. Ueber den Seitentext pruefen, wenn es nur um "steht das da?" geht:
*
* const inhalt = await page.locator('body').innerText()
* expect(inhalt).toContain('Bescheid')
*
* innerText liefert nur sichtbaren Text - die versteckte Fassung
* faellt von selbst weg. (Fuer Inhalt in einem zugeklappten
* <details> braucht es dagegen textContent.)
*
* 2. Auf die sichtbare Fassung einschraenken, wenn geklickt wird:
*
* page.getByRole('link', { name: 'Bescheid' }).locator('visible=true')
*
* 3. Die Zusammenfassungszeile pruefen ("12 Dokumente"), wenn es um
* Anzahlen geht - die steht nur einmal.
*
* Was nicht traegt: eine feste Anzahl erwarten. `getByText(...)` findet
* jeden Eintrag zweimal, und wer das mit toHaveCount(1) prueft, schreibt
* einen Test, der bei jeder Layoutaenderung umfaellt.
*/
+176
View File
@@ -0,0 +1,176 @@
import { expect, test } from '@playwright/test'
import { PrismaClient } from '@prisma/client'
import { anmelden } from './hilfen'
/**
* Der Rückkaufswert am Vertrag — gegen die echte Anwendung.
*
* Die Zusage, die hier geprüft wird: Der Wert steht dort, wo man ihn
* braucht, sein Stichtag steht daneben, ein alter Wert ist als alt
* erkennbar — und es ist derselbe Wert, den das Vermögensverzeichnis
* ausweist, nicht ein zweiter danebengelegter.
*/
const KENNUNG = 'Prüfhausen Wert'
async function mitDb<T>(arbeit: (db: PrismaClient) => Promise<T>): Promise<T> {
const db = new PrismaClient()
try {
return await arbeit(db)
} finally {
await db.$disconnect()
}
}
async function aufraeumen(): Promise<void> {
await mitDb(async (db) => {
await db.vermoegensposten.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
await db.vertrag.deleteMany({ where: { bezeichnung: { contains: KENNUNG } } })
})
}
/** Tag vor N Tagen, als reines Datum. */
function vorTagen(tage: number): Date {
const heute = new Date()
const ziel = new Date(Date.UTC(heute.getFullYear(), heute.getMonth(), heute.getDate()))
ziel.setUTCDate(ziel.getUTCDate() - tage)
return ziel
}
test.describe('Rückkaufswert am Vertrag', () => {
test.beforeAll(async () => {
await aufraeumen()
await mitDb(async (db) => {
const betreute = await db.betreute.findMany({
where: { geloeschtAm: null },
select: { id: true },
})
for (const person of betreute) {
// Ein Vertrag mit frischem Wert, einer mit einem zwei Jahre alten,
// einer ohne jeden Wert.
const frisch = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Frisch`,
gesellschaft: `${KENNUNG} Leben AG`,
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
},
})
const alt = await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Alt`,
gesellschaft: `${KENNUNG} Sterbekasse`,
art: 'STERBEGELDVERSICHERUNG',
status: 'AKTIV',
},
})
await db.vertrag.create({
data: {
betreuteId: person.id,
bezeichnung: `${KENNUNG} Ohne`,
gesellschaft: `${KENNUNG} Renten AG`,
art: 'RENTENVERSICHERUNG_PRIVAT',
status: 'AKTIV',
},
})
await db.vermoegensposten.create({
data: {
betreuteId: person.id,
vertragId: frisch.id,
art: 'VERSICHERUNG',
bezeichnung: `${KENNUNG} Frisch`,
wertstaende: {
create: [
{
stichtag: vorTagen(30),
wertCent: 521055n,
wertquelle: 'Rückkaufswertmitteilung',
},
{ stichtag: vorTagen(400), wertCent: 480000n, wertquelle: 'Jahresmitteilung' },
],
},
},
})
// Bewusst als „Sonstiges" verknüpft: Die Zuordnung zum Vertrag
// hängt an der Verknüpfung, nicht an der Art des Postens.
await db.vermoegensposten.create({
data: {
betreuteId: person.id,
vertragId: alt.id,
art: 'SONSTIGES',
bezeichnung: `${KENNUNG} Alt`,
wertstaende: {
create: [
{ stichtag: vorTagen(800), wertCent: 310000n, wertquelle: 'Jahresmitteilung' },
],
},
},
})
}
})
})
test.afterAll(async () => {
await aufraeumen()
})
test('zeigt Wert und Stichtag in der Vertragsübersicht', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('5.210,55')
// Ohne Wert bleibt die Zelle nicht leer, sondern sagt es.
expect(inhalt).toContain('noch nicht erfasst')
})
test('kennzeichnet einen über ein Jahr alten Wert', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
// Der 800 Tage alte Wert des „Alt"-Vertrags trägt das Abzeichen …
await expect(page.getByText('über ein Jahr alt').first()).toBeVisible()
// … und er wird trotz Posten-Art „Sonstiges" überhaupt gefunden.
expect(await page.locator('body').innerText()).toContain('3.100,00')
})
test('zeigt am Vertrag den letzten Wert und die Historie', async ({ page }) => {
await anmelden(page)
await page.goto('/vertraege')
await page.getByRole('link', { name: `${KENNUNG} Frisch` }).click()
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
await expect(page.getByText('Zuletzt mitgeteilt')).toBeVisible()
const inhalt = await page.locator('body').innerText()
expect(inhalt).toContain('Zuletzt mitgeteilt')
expect(inhalt).toContain('5.210,55')
// Der frühere Wert bleibt abrufbar — die Entwicklung ist die Aussage.
expect(inhalt).toContain('4.800,00')
})
test('trägt einen neuen Wert am Vertrag ein — sichtbar auch im Vermögensverzeichnis', async ({
page,
}) => {
await anmelden(page)
await page.goto('/vertraege')
await page.getByRole('link', { name: `${KENNUNG} Ohne` }).click()
await page.waitForURL(/\/vertraege\/[a-z0-9]+$/)
await page.getByRole('button', { name: 'Mitgeteilten Wert eintragen' }).click()
await page.locator('input[name="stichtag"]').last().fill('2026-08-01')
await page.locator('input[name="wert"]').fill('1.234,56')
await page.getByRole('button', { name: 'Wert aufnehmen' }).click()
await expect(page.getByText('Rückkaufswert in die Historie aufgenommen.')).toBeVisible()
expect(await page.locator('body').innerText()).toContain('1.234,56')
// Dieselbe Zahl, eine Quelle: Das Vermögensverzeichnis kennt sie auch.
await page.goto('/finanzen/vermoegen?stichtag=2026-08-31')
expect(await page.locator('body').innerText()).toContain('1.234,56')
})
})
+1563 -2269
View File
File diff suppressed because it is too large Load Diff
+20 -14
View File
@@ -1,6 +1,6 @@
{ {
"name": "betreuungs-cockpit", "name": "betreuungs-cockpit",
"version": "1.7.0", "version": "1.12.0",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "engines": {
@@ -17,6 +17,9 @@
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
"db:deploy": "prisma migrate deploy", "db:deploy": "prisma migrate deploy",
"db:migration": "node scripts/migration-erzeugen.mjs",
"speicher:verschluesseln": "tsx --env-file=.env scripts/speicher-verschluesseln.mts",
"db:pruefen": "node -e \"import('./scripts/migration-erzeugen.mjs').then(m => m.indizesPruefen())\"",
"db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql", "db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql",
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:studio": "prisma studio", "db:studio": "prisma studio",
@@ -35,8 +38,8 @@
"seed": "tsx prisma/seed.ts" "seed": "tsx prisma/seed.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "3.735.0", "@aws-sdk/client-s3": "3.1116.0",
"@aws-sdk/s3-request-presigner": "3.735.0", "@aws-sdk/s3-request-presigner": "3.1116.0",
"@node-rs/argon2": "2.0.2", "@node-rs/argon2": "2.0.2",
"@prisma/client": "6.3.1", "@prisma/client": "6.3.1",
"@radix-ui/react-dialog": "1.1.4", "@radix-ui/react-dialog": "1.1.4",
@@ -52,9 +55,9 @@
"date-fns": "4.1.0", "date-fns": "4.1.0",
"docx": "9.1.1", "docx": "9.1.1",
"lucide-react": "0.474.0", "lucide-react": "0.474.0",
"next": "15.1.6", "next": "15.5.23",
"next-auth": "5.0.0-beta.25", "next-auth": "5.0.0-beta.32",
"nodemailer": "6.10.0", "nodemailer": "9.0.5",
"otpauth": "9.3.6", "otpauth": "9.3.6",
"pdf-lib": "1.17.1", "pdf-lib": "1.17.1",
"qrcode": "1.5.4", "qrcode": "1.5.4",
@@ -67,22 +70,22 @@
"zod": "3.24.1" "zod": "3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "1.50.1", "@playwright/test": "1.62.1",
"@types/archiver": "6.0.3", "@types/archiver": "6.0.3",
"@types/node": "22.13.1", "@types/node": "22.13.1",
"@types/nodemailer": "6.4.17", "@types/nodemailer": "7.0.4",
"@types/qrcode": "1.5.5", "@types/qrcode": "1.5.5",
"@types/react": "19.0.8", "@types/react": "19.0.8",
"@types/react-dom": "19.0.3", "@types/react-dom": "19.0.3",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"eslint": "9.19.0", "eslint": "9.39.1",
"eslint-config-next": "15.1.6", "eslint-config-next": "15.5.23",
"postcss": "8.5.1", "postcss": "8.5.26",
"prisma": "6.3.1", "prisma": "6.3.1",
"tailwindcss": "3.4.17", "tailwindcss": "3.4.17",
"tsx": "4.19.2", "tsx": "4.20.6",
"typescript": "5.7.3", "typescript": "5.7.3",
"vitest": "3.0.5" "vitest": "3.2.6"
}, },
"allowScripts": { "allowScripts": {
"@prisma/client@6.3.1": true, "@prisma/client@6.3.1": true,
@@ -97,6 +100,9 @@
"next-auth": { "next-auth": {
"@simplewebauthn/server": "$@simplewebauthn/server", "@simplewebauthn/server": "$@simplewebauthn/server",
"@simplewebauthn/browser": "$@simplewebauthn/browser" "@simplewebauthn/browser": "$@simplewebauthn/browser"
} },
"nodemailer": "9.0.5",
"postcss": "8.5.26",
"sharp": "0.35.3"
} }
} }
@@ -2,10 +2,11 @@
-- --
-- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte -- ACHTUNG beim Erzeugen weiterer Migrationen: "prisma migrate diff" wollte
-- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und -- hier zusaetzlich die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und -- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt, -- sind Prisma unbekannt - ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus -- ohne dass irgendetwas fehlgeschlagen waere. Solche Zeilen gehoeren aus
-- jeder erzeugten Migration entfernt. -- jeder erzeugten Migration entfernt.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum -- CreateEnum
CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG'); CREATE TYPE "PasskeyAnfrageArt" AS ENUM ('REGISTRIERUNG', 'ANMELDUNG');
@@ -2,10 +2,11 @@
-- --
-- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte -- ACHTUNG, zum zweiten Mal in diesem Projekt: "prisma migrate diff" wollte
-- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und -- hier wieder die Trigramm-Indizes "dokument_absender_trgm" und
-- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/suche.sql und -- "vorgang_titel_trgm" loeschen. Die stammen aus prisma/sql/volltextsuche.sql und
-- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt, -- sind Prisma unbekannt; ein DROP haette die unscharfe Suche stillgelegt,
-- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt. -- ohne dass irgendetwas fehlgeschlagen waere. Die Zeilen sind entfernt.
-- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen. -- Wer die naechste Migration erzeugt: nachsehen und wieder herausnehmen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable -- CreateTable
CREATE TABLE "zahlungsgruppe_geklaert" ( CREATE TABLE "zahlungsgruppe_geklaert" (
@@ -2,10 +2,11 @@
-- --
-- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die -- ACHTUNG, zum dritten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm" -- Trigramm-Indizes "dokument_absender_trgm" und "vorgang_titel_trgm"
-- loeschen. Sie stammen aus prisma/sql/suche.sql und sind Prisma -- loeschen. Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma
-- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas -- unbekannt; ein DROP legt die unscharfe Suche stumm, ohne dass etwas
-- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt: -- fehlschlaegt. Zeilen entfernt. Wer die naechste Migration erzeugt:
-- nachsehen. -- nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- AlterTable -- AlterTable
ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false, ALTER TABLE "buchung" ADD COLUMN "vertragAutomatisch" BOOLEAN NOT NULL DEFAULT false,
@@ -1,8 +1,9 @@
-- Eigene Schreibvorlagen. -- Eigene Schreibvorlagen.
-- --
-- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die -- ACHTUNG, zum vierten Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/suche.sql loeschen. Zeilen entfernt. -- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen. -- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateTable -- CreateTable
CREATE TABLE "schreibvorlage" ( CREATE TABLE "schreibvorlage" (
@@ -0,0 +1,18 @@
-- Ansprechpartner am Vorgang, Anrede am Kontakt.
--
-- ACHTUNG, zum fuenften Mal: "prisma migrate diff" wollte wieder die
-- Trigramm-Indizes aus prisma/sql/volltextsuche.sql loeschen. Zeilen entfernt.
-- Wer die naechste Migration erzeugt: nachsehen.
-- Kuenftig nimmt "npm run db:migration" diese Zeilen selbst heraus.
-- CreateEnum
CREATE TYPE "KontaktAnrede" AS ENUM ('OHNE', 'FRAU', 'HERR');
-- AlterTable
ALTER TABLE "kontakt" ADD COLUMN "anrede" "KontaktAnrede" NOT NULL DEFAULT 'OHNE';
-- AlterTable
ALTER TABLE "vorgang" ADD COLUMN "ansprechpartnerId" TEXT;
-- AddForeignKey
ALTER TABLE "vorgang" ADD CONSTRAINT "vorgang_ansprechpartnerId_fkey" FOREIGN KEY ("ansprechpartnerId") REFERENCES "kontakt"("id") ON DELETE SET NULL ON UPDATE CASCADE;
@@ -0,0 +1,14 @@
-- betreuer_anschrift
--
-- Erzeugt mit "npm run db:migration". Entfernt wurden die
-- DROP-INDEX-Anweisungen zu diesen Indizes:
-- dokument_absender_trgm
-- vorgang_titel_trgm
--
-- Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma unbekannt;
-- ohne sie waere die Volltextsuche still langsam geworden.
-- AlterTable
ALTER TABLE "benutzer" ADD COLUMN "ort" TEXT,
ADD COLUMN "plz" TEXT,
ADD COLUMN "strasse" TEXT;
@@ -0,0 +1,31 @@
-- sicherheitsaudit
--
-- Erzeugt mit "npm run db:migration". Entfernt wurden die
-- DROP-INDEX-Anweisungen zu diesen Indizes:
-- dokument_absender_trgm
-- vorgang_titel_trgm
--
-- Sie stammen aus prisma/sql/volltextsuche.sql und sind Prisma unbekannt;
-- ohne sie waere die Volltextsuche still langsam geworden.
-- AlterTable
ALTER TABLE "benutzer" ADD COLUMN "totpLetzterSchritt" INTEGER;
-- CreateTable
CREATE TABLE "ersteinrichtung" (
"id" TEXT NOT NULL,
"abgeschlossenAm" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
CONSTRAINT "ersteinrichtung_pkey" PRIMARY KEY ("id")
);
-- Bestehende Installationen nachziehen.
--
-- Die Tabelle oben ist die Sperre fuer die Ersteinrichtung. Auf einem
-- System, das schon laeuft, ist die Einrichtung laengst erledigt - ohne
-- diese Zeile stuende die Sperre nach dem Update offen, und der Weg zum
-- Anlegen eines Administrators waere wieder frei.
INSERT INTO "ersteinrichtung" ("id", "userId")
SELECT 'einmalig', (SELECT "id" FROM "benutzer" ORDER BY "erstelltAm" ASC LIMIT 1)
WHERE EXISTS (SELECT 1 FROM "benutzer");
+56 -4
View File
@@ -70,6 +70,13 @@ model User {
// TOTP ist Pflicht. Das Secret liegt verschluesselt, nicht im Klartext. // TOTP ist Pflicht. Das Secret liegt verschluesselt, nicht im Klartext.
totpSecretVerschluesselt String? totpSecretVerschluesselt String?
totpAktiviertAm DateTime? totpAktiviertAm DateTime?
/// Der zuletzt akzeptierte TOTP-Zeitschritt (Unixzeit / 30).
///
/// Ein TOTP-Code gilt 30 Sekunden, mit Toleranz bis zu 90. In dieser Zeit
/// liesse sich derselbe Code ein zweites Mal einloesen - abgelesen ueber
/// die Schulter oder abgefangen. Deshalb wird der Schritt festgehalten
/// und nur noch ein groesserer akzeptiert.
totpLetzterSchritt Int?
// Einmal-Wiederherstellungscodes, einzeln Argon2id-gehasht. // Einmal-Wiederherstellungscodes, einzeln Argon2id-gehasht.
wiederherstellungscodes String[] @default([]) wiederherstellungscodes String[] @default([])
@@ -81,6 +88,13 @@ model User {
// (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung). // (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung).
sitzungsGeneration Int @default(0) sitzungsGeneration Int @default(0)
/// Die eigene Anschrift - Absenderblock und Ort in der Datumszeile
/// jedes erzeugten Schreibens. Sie gehoert zum Betreuer und nicht zur
/// betreuten Person: Sie gilt fuer alle Akten.
strasse String?
plz String?
ort String?
// Oberflaechen-Einstellungen // Oberflaechen-Einstellungen
theme String @default("system") // "system" | "hell" | "dunkel" theme String @default("system") // "system" | "hell" | "dunkel"
@@ -121,6 +135,25 @@ model BetreuungsZugang {
@@map("betreuungs_zugang") @@map("betreuungs_zugang")
} }
/// Haelt fest, dass die Ersteinrichtung stattgefunden hat.
///
/// Die Tabelle hat genau eine Zeile mit fester Id. Das ist kein Protokoll,
/// sondern eine Sperre: Vorher stand im Code "wenn es noch keinen Benutzer
/// gibt, lege einen an" - zwei Anfragen gleichzeitig kamen beide durch die
/// Pruefung und legten beide einen Administrator an. Ueber den
/// Primaerschluessel entscheidet das jetzt die Datenbank, und zwar fuer
/// genau einen Gewinner.
model Ersteinrichtung {
/// Immer "einmalig". Der feste Wert ist die Sperre.
id String @id
abgeschlossenAm DateTime @default(now())
/// Wer den Zugang angelegt hat - nur zum Nachvollziehen.
userId String?
@@map("ersteinrichtung")
}
/// Fehlgeschlagene und erfolgreiche Anmeldeversuche - Grundlage fuer das /// Fehlgeschlagene und erfolgreiche Anmeldeversuche - Grundlage fuer das
/// Rate-Limiting am Login. Wird nach 30 Tagen aufgeraeumt. /// Rate-Limiting am Login. Wird nach 30 Tagen aufgeraeumt.
model LoginVersuch { model LoginVersuch {
@@ -373,6 +406,14 @@ model Vorgang {
gegenstelle String? gegenstelle String?
aktenzeichen String? aktenzeichen String?
/// Die Person bei der Gegenstelle, die den Vorgang bearbeitet. Ein
/// namentlich adressiertes Schreiben landet bei ihr statt in der
/// Poststelle - beim Bezirk ist das der Unterschied zwischen zwei
/// Wochen und zwei Monaten. SetNull: Ein Vorgang darf nicht daran
/// haengen, dass es den Kontakt noch gibt.
ansprechpartnerId String?
ansprechpartner Kontakt? @relation("VorgangAnsprechpartner", fields: [ansprechpartnerId], references: [id], onDelete: SetNull)
verantwortlich String? verantwortlich String?
angelegtVonId String? angelegtVonId String?
angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull) angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull)
@@ -1233,6 +1274,13 @@ model Vertrag {
// Kontakte und Kontaktprotokoll // Kontakte und Kontaktprotokoll
// ============================================================================= // =============================================================================
/// Anrede fuer die Briefanrede eines Kontakts.
enum KontaktAnrede {
OHNE
FRAU
HERR
}
enum KontaktRolle { enum KontaktRolle {
RECHTSPFLEGER RECHTSPFLEGER
RICHTER RICHTER
@@ -1267,7 +1315,10 @@ model Kontakt {
name String name String
organisation String? organisation String?
rolle KontaktRolle @default(SONSTIGES) rolle KontaktRolle @default(SONSTIGES)
/// Fuer die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet -
/// nie geraten, das faellt dem Empfaenger sofort auf.
anrede KontaktAnrede @default(OHNE)
telefon String? telefon String?
mobil String? mobil String?
@@ -1285,9 +1336,10 @@ model Kontakt {
aktualisiertAm DateTime @updatedAt aktualisiertAm DateTime @updatedAt
geloeschtAm DateTime? geloeschtAm DateTime?
protokolle Kontaktprotokoll[] protokolle Kontaktprotokoll[]
vorgaenge Vorgang[] @relation("VorgangKontakte") vorgaenge Vorgang[] @relation("VorgangKontakte")
verknuepfungen DokumentVerknuepfung[] betreuteVorgaenge Vorgang[] @relation("VorgangAnsprechpartner")
verknuepfungen DokumentVerknuepfung[]
@@index([betreuteId, rolle]) @@index([betreuteId, rolle])
@@map("kontakt") @@map("kontakt")
+315 -2
View File
@@ -22,7 +22,7 @@
* *
* Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts. * Der Seed ist idempotent: mehrfaches Ausfuehren aendert nichts.
*/ */
import { PrismaClient } from '@prisma/client' import { PrismaClient, type BuchungKategorie, type Prisma } from '@prisma/client'
import { hash } from '@node-rs/argon2' import { hash } from '@node-rs/argon2'
import { createCipheriv, randomBytes } from 'node:crypto' import { createCipheriv, randomBytes } from 'node:crypto'
@@ -112,6 +112,14 @@ async function ersterZugang() {
}, },
}) })
// Die Ersteinrichtung ist damit erledigt. Ohne diese Zeile stuende die
// Einrichtungsseite nach dem Seed weiter offen.
await db.ersteinrichtung.upsert({
where: { id: 'einmalig' },
update: {},
create: { id: 'einmalig', userId: benutzer.id },
})
console.log(`Zugang ${email} angelegt.`) console.log(`Zugang ${email} angelegt.`)
if (devSecret) { if (devSecret) {
console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.') console.log(' Zweiter Faktor mit dem Entwicklungs-Geheimnis vorbelegt.')
@@ -263,9 +271,11 @@ async function demofall(userId: string) {
art: 'GIRO', art: 'GIRO',
ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'), ibanVerschluesselt: verschluesseln('DE02773501100123456789', 'ibanVerschluesselt'),
saldostaende: { saldostaende: {
// Nur der Anfangsstand. Der spaetere wird weiter unten aus den
// Buchungen gerechnet - ein Demofall, der sich selbst
// widerspricht, waere schlimmer als einer ohne Buchungen.
create: [ create: [
{ stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' }, { stichtag: datum('2026-02-14'), betragCent: cent(4218.36), quelle: 'Kontoauszug 02/2026' },
{ stichtag: datum('2026-07-31'), betragCent: cent(2967.11), quelle: 'Kontoauszug 07/2026' },
], ],
}, },
}, },
@@ -523,6 +533,309 @@ async function demofall(userId: string) {
}) })
} }
// --- Kontobewegungen ----------------------------------------------------
// Zwoelf Monate auf dem Girokonto. Erzeugt aus einem Muster und nicht
// einzeln abgetippt: So bleibt der Fall aenderbar, und die Betraege
// widersprechen sich nicht.
//
// Die Gegenstellen sind absichtlich uneinheitlich geschrieben. Genau
// damit muss die Zusammenfassung wiederkehrender Zahlungen umgehen - auf
// einem Kontoauszug steht dieselbe Stelle auch mal in Grossbuchstaben.
type Buchungsvorlage = {
tag: number
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstellen: string[]
zweck: string
}
const MONATLICH: Buchungsvorlage[] = [
{
tag: 1,
betrag: 1284.17,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: [
'Deutsche Rentenversicherung Bund',
'DRV BUND',
'Deutsche Rentenversich. Bund',
],
zweck: 'Altersrente',
},
{
tag: 1,
betrag: 96.4,
richtung: 'EINNAHME',
kategorie: 'RENTE',
gegenstellen: ['Zusatzversorgungskasse Bayern', 'ZVK Bayern'],
zweck: 'Betriebsrente',
},
{
tag: 3,
betrag: 1262,
richtung: 'EINNAHME',
kategorie: 'PFLEGEKASSE',
gegenstellen: ['Pflegekasse bei der AOK Bayern', 'AOK Bayern - Pflegekasse'],
zweck: 'Leistung vollstationaere Pflege, Pflegegrad 3',
},
{
tag: 5,
betrag: 2489,
richtung: 'AUSGABE',
kategorie: 'HEIMKOSTEN',
gegenstellen: [
'Seniorenzentrum St. Katharina',
'SENIORENZENTRUM ST KATHARINA',
'Seniorenzentrum St. Katharina gGmbH',
],
zweck: 'Heimentgelt',
},
{
tag: 5,
betrag: 135,
richtung: 'AUSGABE',
kategorie: 'BARBETRAG',
// Eigene Gegenstelle, nicht das Heim: Sonst faellt der Barbetrag
// mit dem Heimentgelt in eine Gruppe, und aus zwei sauberen
// monatlichen Zahlungen wird eine "schwankende".
gegenstellen: ['Barauszahlung Verwahrgeldkonto'],
zweck: 'Barbetrag zur persoenlichen Verfuegung',
},
{
tag: 15,
betrag: 18.4,
richtung: 'AUSGABE',
kategorie: 'VERSICHERUNG',
gegenstellen: ['Nordheim Sterbekasse VVaG', 'NORDHEIM STERBEKASSE', 'Nordheim Sterbekasse'],
zweck: 'Beitrag Sterbegeldversicherung SG-220417',
},
{
tag: 20,
betrag: 24.9,
richtung: 'AUSGABE',
kategorie: 'TELEFON_INTERNET',
gegenstellen: ['TeleCare Kommunikation GmbH', 'TELECARE KOMMUNIKATION'],
zweck: 'Telefon und Fernsehen im Zimmer',
},
{
tag: 28,
betrag: 4.9,
richtung: 'AUSGABE',
kategorie: 'BANKGEBUEHREN',
gegenstellen: ['Sparkasse Bayreuth'],
zweck: 'Kontofuehrung',
},
]
/** Zwoelf Monate bis einschliesslich Juli 2026. */
const MONATE = [
'2025-08',
'2025-09',
'2025-10',
'2025-11',
'2025-12',
'2026-01',
'2026-02',
'2026-03',
'2026-04',
'2026-05',
'2026-06',
'2026-07',
]
const buchungen: Prisma.BuchungCreateManyInput[] = []
MONATE.forEach((monat, lauf) => {
MONATLICH.forEach((vorlage, stelle) => {
// Die Sterbegeld-Lastschrift steigt im Mai 2026. Daran wird die
// Pruefung beim Zuordnen einer Dauerzahlung sichtbar: Der Betrag
// weicht vom hinterlegten Beitrag ab.
const betrag =
vorlage.kategorie === 'VERSICHERUNG' && monat >= '2026-05' ? 19.8 : vorlage.betrag
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(`${monat}-${String(vorlage.tag).padStart(2, '0')}`),
betragCent: cent(betrag),
richtung: vorlage.richtung,
kategorie: vorlage.kategorie,
gegenstelle: vorlage.gegenstellen[lauf % vorlage.gegenstellen.length],
verwendungszweckVerschluesselt: verschluesseln(
vorlage.zweck,
'verwendungszweckVerschluesselt',
),
// Wiederkehrende Zahlungen brauchen keinen Einzelbeleg - der
// Bescheid bzw. der Vertrag dahinter ist der Nachweis.
belegFehltBegruendung:
vorlage.richtung === 'EINNAHME'
? 'Laufender Bezug, Nachweis ist der Bescheid.'
: 'Wiederkehrende Zahlung aus laufendem Vertrag.',
// Die Stelle in der Vorlagenliste gehoert in die Referenz: Zwei
// Renten am Monatsersten haetten sonst dieselbe, und der
// Unique-Index gegen Doppelbuchungen schlaegt zu Recht zu.
importReferenz: `demo-${monat}-${stelle}-${vorlage.kategorie}`,
})
})
})
// Vierteljaehrliches und Einmaliges - damit nicht alles im Monatsraster
// liegt und die Belegpflicht etwas zu zeigen hat.
const EINZELN: {
datum: string
betrag: number
richtung: 'EINNAHME' | 'AUSGABE'
kategorie: BuchungKategorie
gegenstelle: string
zweck: string
begruendung?: string
}[] = [
{
datum: '2025-08-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 3. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2025-11-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 4. Quartal 2025',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 1. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-05-15',
betrag: 55.08,
richtung: 'AUSGABE',
kategorie: 'RUNDFUNKBEITRAG',
gegenstelle: 'ARD ZDF Deutschlandradio Beitragsservice',
zweck: 'Rundfunkbeitrag 2. Quartal 2026',
begruendung: 'Wiederkehrende Zahlung, Beitragsbescheid liegt vor.',
},
{
datum: '2026-02-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 1. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-05-16',
betrag: 187.5,
richtung: 'AUSGABE',
kategorie: 'GRUNDSTEUER',
gegenstelle: 'Gemeinde Bindlach',
zweck: 'Grundsteuer B, 2. Rate 2026',
begruendung: 'Bescheid der Gemeinde liegt in der Akte.',
},
{
datum: '2026-01-19',
betrag: 240,
richtung: 'AUSGABE',
kategorie: 'IMMOBILIE_BETRIEBSKOSTEN',
gegenstelle: 'Stadtwerke Bayreuth',
zweck: 'Abschlag Strom und Wasser, Haus Bindlach',
begruendung: 'Jahresabrechnung steht noch aus.',
},
{
datum: '2026-03-11',
betrag: 10,
richtung: 'AUSGABE',
kategorie: 'ZUZAHLUNG',
gegenstelle: 'Apotheke am Markt',
zweck: 'Zuzahlung Rezept',
begruendung: 'Quittung liegt in der Akte.',
},
// Der Fall fuer die Belegpflicht: keine Quittung und keine Begruendung.
// Genau so sieht eine Buchung aus, die noch Arbeit macht.
{
datum: '2026-04-08',
betrag: 89.95,
richtung: 'AUSGABE',
kategorie: 'BEKLEIDUNG',
gegenstelle: 'Modehaus Lindner',
zweck: 'Winterjacke und zwei Nachthemden',
},
{
datum: '2026-06-17',
betrag: 23,
richtung: 'AUSGABE',
kategorie: 'SONSTIGE_AUSGABE',
gegenstelle: 'Friseur im Haus',
zweck: 'Friseur',
begruendung: 'Bar aus dem Barbetrag bezahlt, keine Quittung ausgestellt.',
},
{
datum: '2026-07-02',
betrag: 62.4,
richtung: 'EINNAHME',
kategorie: 'ZINSEN',
gegenstelle: 'Sparkasse Bayreuth',
zweck: 'Zinsgutschrift',
begruendung: 'Der Kontoauszug ist der Nachweis.',
},
]
for (const eintrag of EINZELN) {
buchungen.push({
betreuteId: betreute.id,
kontoId: giro.id,
datum: datum(eintrag.datum),
betragCent: cent(eintrag.betrag),
richtung: eintrag.richtung,
kategorie: eintrag.kategorie,
gegenstelle: eintrag.gegenstelle,
verwendungszweckVerschluesselt: verschluesseln(
eintrag.zweck,
'verwendungszweckVerschluesselt',
),
belegFehltBegruendung: eintrag.begruendung ?? null,
importReferenz: `demo-${eintrag.datum}-${eintrag.kategorie}`,
})
}
await db.buchung.createMany({ data: buchungen })
// Der Kontostand zum 31.07.2026 wird gerechnet, nicht behauptet: Anfangs-
// stand plus alles, was seither ueber das Konto gelaufen ist. Sonst
// widerspraeche der Demofall sich selbst, sobald jemand nachrechnet.
const bewegungCent = buchungen
.filter((eintrag) => {
const tag = eintrag.datum as Date
return tag > datum('2026-02-14') && tag <= datum('2026-07-31')
})
.reduce((summe, eintrag) => {
const betrag = eintrag.betragCent as bigint
return eintrag.richtung === 'EINNAHME' ? summe + betrag : summe - betrag
}, 0n)
await db.saldostand.create({
data: {
kontoId: giro.id,
stichtag: datum('2026-07-31'),
betragCent: cent(4218.36) + bewegungCent,
quelle: 'Kontoauszug 07/2026',
},
})
console.log('Demofall "Erika Musterfrau" angelegt.') console.log('Demofall "Erika Musterfrau" angelegt.')
} }
+32
View File
@@ -0,0 +1,32 @@
/**
* Typen fuer das Werkzeugskript.
*
* Das Skript selbst bleibt .mjs - es laeuft ohne Bauschritt, auch auf
* einem Rechner, auf dem nur Node steht. Die Typen daneben sorgen dafuer,
* dass die Tests trotzdem geprueft werden und nicht stillschweigend
* gegen "any" laufen.
*/
export type GeschuetzterIndex = {
name: string
quelle: string
}
export declare const GESCHUETZTE_INDIZES: GeschuetzterIndex[]
export declare function dropZeilenEntfernen(
sql: string,
geschuetzt?: GeschuetzterIndex[],
): { sql: string; entfernt: string[] }
export declare function hatAnweisungen(sql: string): boolean
export declare function kopfBauen(
name: string,
entfernt: string[],
geschuetzt?: GeschuetzterIndex[],
): string
export declare function ordnernameBauen(name: string, jetzt?: Date): string
export declare function indizesPruefen(): void
+296
View File
@@ -0,0 +1,296 @@
#!/usr/bin/env node
/**
* Eine Migration erzeugen, ohne die Volltextsuche kaputtzumachen.
*
* npm run db:migration -- ansprechpartner
* npm run db:migration -- ansprechpartner --anwenden
*
* Das Problem, das dieses Skript loest: "prisma migrate diff" kennt nur
* das, was im Prisma-Schema steht. Die Trigramm-Indizes der Suche stehen
* aber in prisma/sql/volltextsuche.sql und sind Prisma unbekannt - also
* haelt es sie fuer ueberfluessig und schreibt in JEDE erzeugte Migration:
*
* DROP INDEX "dokument_absender_trgm";
* DROP INDEX "vorgang_titel_trgm";
*
* Fuenfmal ist das passiert, fuenfmal mussten die Zeilen von Hand raus.
* Auf dem Server waere es nicht aufgefallen, weil update.sh die Indizes
* hinterher ohnehin neu anlegt - in der Entwicklung dagegen waere die
* Suche still langsam geworden, ohne dass etwas fehlschlaegt.
*
* Was das Skript macht:
* 1. Migration mit "migrate diff" erzeugen.
* 2. DROP-Zeilen zu geschuetzten Indizes entfernen und im Kopf
* vermerken, welche das waren.
* 3. Auf Wunsch (--anwenden) "migrate deploy" laufen lassen und danach
* pruefen, dass die geschuetzten Indizes wirklich noch stehen.
*
* Nicht automatisch angewendet wird die Migration ohne --anwenden: Erst
* liest man, was da erzeugt wurde.
*/
import { execFileSync } from 'node:child_process'
import { mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
const WURZEL = join(dirname(fileURLToPath(import.meta.url)), '..')
/**
* Indizes, die es nur in prisma/sql/ gibt und die Prisma deshalb nicht
* kennt. Wer dort einen hinzufuegt, traegt ihn hier ein - sonst faellt er
* der naechsten Migration zum Opfer.
*/
export const GESCHUETZTE_INDIZES = [
{ name: 'dokument_absender_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
{ name: 'vorgang_titel_trgm', quelle: 'prisma/sql/volltextsuche.sql' },
]
/**
* Entfernt DROP-INDEX-Anweisungen zu geschuetzten Indizes.
*
* Bewusst eng gefasst: Nur eine Anweisung, die genau einen geschuetzten
* Index nennt, faellt weg. Ein DROP INDEX auf einen Prisma-eigenen Index
* bleibt stehen - der soll ja weg. Der Kommentar "-- DropIndex", den
* Prisma darueber setzt, wird mitgenommen, damit keine verwaisten
* Ueberschriften zurueckbleiben.
*
* @param {string} sql
* @param {{name: string}[]} geschuetzt
* @returns {{sql: string, entfernt: string[]}}
*/
export function dropZeilenEntfernen(sql, geschuetzt = GESCHUETZTE_INDIZES) {
const namen = geschuetzt.map((eintrag) => eintrag.name)
const zeilen = sql.split('\n')
const behalten = []
const entfernt = []
for (let i = 0; i < zeilen.length; i++) {
const zeile = zeilen[i]
const treffer = namen.find(
(name) => /^\s*DROP\s+INDEX\b/i.test(zeile) && zeile.includes(name),
)
if (!treffer) {
behalten.push(zeile)
continue
}
entfernt.push(treffer)
// Die "-- DropIndex"-Ueberschrift darueber mitnehmen, samt der
// Leerzeile davor - sonst bleiben Luecken im Text stehen.
while (behalten.length > 0 && /^\s*--\s*DropIndex\s*$/i.test(behalten[behalten.length - 1])) {
behalten.pop()
while (behalten.length > 0 && behalten[behalten.length - 1].trim() === '') behalten.pop()
}
// Leerzeilen, die der entfernten Anweisung folgen, ebenfalls.
while (i + 1 < zeilen.length && zeilen[i + 1].trim() === '') i++
}
return { sql: behalten.join('\n').replace(/\n{3,}/g, '\n\n').trimStart(), entfernt }
}
/**
* Steht in dem SQL ueberhaupt noch eine Anweisung?
*
* Wichtig fuer den Normalfall: Wenn Schema und Datenbank uebereinstimmen,
* besteht der ganze Diff aus den beiden bekannten DROP-INDEX-Zeilen. Nach
* dem Entfernen bleibt nichts uebrig - und eine leere Migration anzulegen
* waere schlimmer als gar keine, weil sie als angewendet in der Historie
* steht und niemand mehr weiss, wofuer sie gut war.
*
* @param {string} sql
* @returns {boolean}
*/
export function hatAnweisungen(sql) {
return sql
.split('\n')
.some((zeile) => zeile.trim() !== '' && !zeile.trim().startsWith('--'))
}
/** Der Kopfkommentar der erzeugten Datei. */
export function kopfBauen(name, entfernt, geschuetzt = GESCHUETZTE_INDIZES) {
const zeilen = [`-- ${name}`, '--']
if (entfernt.length === 0) {
zeilen.push('-- Erzeugt mit "npm run db:migration". Nichts zu entfernen.')
} else {
const quellen = [
...new Set(
entfernt.map(
(eintrag) => geschuetzt.find((g) => g.name === eintrag)?.quelle ?? 'prisma/sql/',
),
),
]
zeilen.push(
'-- Erzeugt mit "npm run db:migration". Entfernt wurden die',
'-- DROP-INDEX-Anweisungen zu diesen Indizes:',
...entfernt.map((eintrag) => `-- ${eintrag}`),
'--',
`-- Sie stammen aus ${quellen.join(', ')} und sind Prisma unbekannt;`,
'-- ohne sie waere die Volltextsuche still langsam geworden.',
)
}
zeilen.push('')
return zeilen.join('\n')
}
/** Zeitstempel im Prisma-Format, aus der lokalen Uhr. */
export function ordnernameBauen(name, jetzt = new Date()) {
const zwei = (zahl) => String(zahl).padStart(2, '0')
const stempel = [
jetzt.getFullYear(),
zwei(jetzt.getMonth() + 1),
zwei(jetzt.getDate()),
zwei(jetzt.getHours()),
zwei(jetzt.getMinutes()),
zwei(jetzt.getSeconds()),
].join('')
return `${stempel}_${name}`
}
// --- Ab hier: Ausfuehrung ----------------------------------------------------
/**
* Prisma direkt ueber sein Eintrittsskript aufrufen, nicht ueber npx.
*
* Mit "shell: true" wuerde Node warnen (Argumente werden dann nur
* aneinandergehaengt statt maskiert), und ohne Shell findet Windows das
* npx-Skript nicht. Der Weg ueber node loest beides und ist derselbe,
* den scripts/update.sh schon nimmt.
*/
function prisma(argumente, optionen = {}) {
const eintritt = join(WURZEL, 'node_modules', 'prisma', 'build', 'index.js')
return execFileSync(process.execPath, [eintritt, ...argumente], {
cwd: WURZEL,
encoding: 'utf8',
...optionen,
})
}
function abbruch(meldung) {
console.error(`\n Abbruch: ${meldung}\n`)
process.exit(1)
}
function hauptlauf() {
const argumente = process.argv.slice(2)
const anwenden = argumente.includes('--anwenden')
const name = argumente.find((eintrag) => !eintrag.startsWith('--'))
if (!name) {
abbruch(
'Bitte einen Namen angeben: npm run db:migration -- <name> [--anwenden]\n' +
' Beispiel: npm run db:migration -- ansprechpartner',
)
}
if (!/^[a-z0-9_]+$/.test(name)) {
abbruch(`"${name}" taugt nicht als Ordnername. Erlaubt: a-z, 0-9 und _`)
}
console.log(`\n Migration "${name}" erzeugen\n`)
let roh
try {
roh = prisma([
'migrate',
'diff',
'--from-schema-datasource',
'prisma/schema.prisma',
'--to-schema-datamodel',
'prisma/schema.prisma',
'--script',
])
} catch (fehler) {
abbruch(`"prisma migrate diff" ist fehlgeschlagen:\n${fehler.message}`)
}
if (roh.trim() === '' || /^--\s*This is an empty migration/im.test(roh)) {
abbruch('Schema und Datenbank stimmen bereits ueberein - es gibt nichts zu migrieren.')
}
const { sql, entfernt } = dropZeilenEntfernen(roh)
if (!hatAnweisungen(sql)) {
const grund =
entfernt.length > 0
? `Uebrig blieben nur die bekannten DROP-INDEX-Zeilen (${entfernt.join(', ')}).`
: 'Der Vergleich hat keine Anweisungen ergeben.'
abbruch(
`Es gibt nichts zu migrieren. ${grund}\n` +
' Es wurde keine Migration angelegt.',
)
}
const ordner = join(WURZEL, 'prisma', 'migrations', ordnernameBauen(name))
mkdirSync(ordner, { recursive: true })
const datei = join(ordner, 'migration.sql')
writeFileSync(datei, `${kopfBauen(name, entfernt)}\n${sql.trimEnd()}\n`, 'utf8')
console.log(` Geschrieben: ${datei.replace(WURZEL, '.')}`)
if (entfernt.length > 0) {
console.log('\n Entfernte DROP-INDEX-Anweisungen:')
for (const eintrag of entfernt) console.log(` - ${eintrag}`)
} else {
console.log(' Keine DROP-INDEX-Anweisung zu schuetzenden Indizes gefunden.')
}
if (!anwenden) {
console.log('\n Jetzt: die Datei lesen, dann anwenden mit')
console.log(' npm run db:deploy && npm run db:pruefen\n')
return
}
console.log('\n Anwenden ...')
try {
prisma(['migrate', 'deploy'], { stdio: 'inherit' })
} catch {
abbruch('"prisma migrate deploy" ist fehlgeschlagen. Die Datei bleibt liegen.')
}
indizesPruefen()
}
/**
* Stehen die geschuetzten Indizes noch?
*
* Der Sinn des ganzen Skripts haengt an dieser Pruefung: Sie ist der
* Unterschied zwischen "sollte gutgegangen sein" und "nachgesehen".
*/
export function indizesPruefen() {
const bedingung = GESCHUETZTE_INDIZES.map((eintrag) => `'${eintrag.name}'`).join(', ')
const sql = `DO $$
BEGIN
IF (SELECT count(*) FROM pg_indexes WHERE indexname IN (${bedingung})) <> ${GESCHUETZTE_INDIZES.length} THEN
RAISE EXCEPTION 'Es fehlen geschuetzte Indizes';
END IF;
END $$;`
// In den Temp-Ordner, nicht ins Repository: Die Datei ist Werkzeug,
// kein Bestandteil des Projekts.
const datei = join(tmpdir(), `indizes-pruefung-${process.pid}.sql`)
writeFileSync(datei, sql, 'utf8')
try {
prisma(['db', 'execute', '--schema', 'prisma/schema.prisma', '--file', datei], {
stdio: 'pipe',
})
console.log('\n Geschuetzte Indizes stehen:')
for (const eintrag of GESCHUETZTE_INDIZES) console.log(` - ${eintrag.name}`)
console.log('')
} catch {
abbruch(
'Nach der Migration fehlen geschuetzte Indizes.\n' +
' Wiederherstellen mit: npm run db:sql',
)
} finally {
rmSync(datei, { force: true })
}
}
if (process.argv[1] && process.argv[1].endsWith('migration-erzeugen.mjs')) {
hauptlauf()
}
+1
View File
@@ -18,6 +18,7 @@ AUTH_SECRET=${b64(32)}
ENC_KEY_V1=${b64(32)} ENC_KEY_V1=${b64(32)}
ENC_KEY_AKTIV=1 ENC_KEY_AKTIV=1
INDEX_KEY=${b64(32)} INDEX_KEY=${b64(32)}
ERSTEINRICHTUNG_GEHEIMNIS=${b64(24)}
POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')} POSTGRES_PASSWORD=${b64(24).replace(/[+/=]/g, 'x')}
# WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen. # WICHTIG: ENC_KEY_V1 geht NICHT verloren gehen duerfen.
+93 -20
View File
@@ -1,6 +1,6 @@
#!/bin/sh #!/bin/sh
# ============================================================================= # =============================================================================
# Taegliche Sicherung der Datenbank # Taegliche Sicherung: Datenbank UND Dokumente
# ============================================================================= # =============================================================================
# #
# Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand: # Laeuft im Behaelter "sicherung" per cron um 03:15 Uhr. Von Hand:
@@ -10,17 +10,44 @@
# Was passiert: # Was passiert:
# 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen # 1. pg_dump im eigenen Format (-Fc), damit einzelne Tabellen
# wiederherstellbar bleiben. # wiederherstellbar bleiben.
# 2. Verschluesselung mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT. # 2. Die abgelegten Dokumente als Archiv.
# 3. Beides verschluesselt mit AES-256 und PBKDF2 aus SICHERUNG_PASSWORT.
# Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere # Das Backup liegt oft auf fremdem Speicher - unverschluesselt waere
# es ein Datenleck mit Ansage. # es ein Datenleck mit Ansage.
# 3. SHA-256-Pruefsumme daneben. # 4. SHA-256-Pruefsumme je Datei.
# 4. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen. # 5. Staende, die aelter sind als SICHERUNG_AUFBEWAHRUNG_TAGE, entfernen.
# Der jeweils erste Stand eines Monats bleibt stehen. # Der jeweils erste Stand eines Monats bleibt stehen.
# #
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder, aber NICHT # ## Warum die Dokumente dazugehoeren
# den Schluessel ENC_KEY_V1. Ohne ihn sind Diagnosen, IBANs und Notizen #
# auch aus einer wiederhergestellten Datenbank nicht lesbar. Den Schluessel # Bis v1.11.0 sicherte dieses Skript nur die Datenbank. Nach einem
# getrennt sichern - Passwortmanager oder ausgedruckt im Ordner. # Plattenschaden waere die Akte vollstaendig wiederhergestellt worden -
# mit allen Eintraegen, Verweisen und Pruefsummen, aber ohne eine einzige
# Datei dahinter. Die Pruefsumme in der Akte haette dann ein Dokument
# belegt, das es nicht mehr gibt. Gegenueber dem Betreuungsgericht ist das
# der schlechteste denkbare Zustand.
#
# ## Warum das Ablageverzeichnis roh eingepackt wird
#
# Der naheliegende Weg waere "mc mirror" aus dem MinIO-Client. Der ist in
# diesem Behaelter aber nicht vorhanden und muesste bei jedem Lauf aus dem
# Netz geholt werden - eine Internetabhaengigkeit im Sicherungspfad ist
# genau das, was man dort nicht will.
#
# Stattdessen wird das Datenverzeichnis direkt eingepackt. Das ist
# vertretbar, weil MinIO ein Objekt erst vollstaendig unter .minio.sys/tmp
# schreibt und dann an seinen Platz umbenennt: Ein laufender Upload liegt
# also nie halbfertig im Archiv, er fehlt hoechstens. Bei einem Lauf um
# 03:15 Uhr auf einem Einzelplatzsystem ist auch das unwahrscheinlich.
#
# Derselbe Weg deckt den Dateitreiber mit ab - dort liegen die Dateien
# ebenfalls einfach im Verzeichnis.
#
# WICHTIG: Dieses Backup enthaelt die verschluesselten Felder und die
# verschluesselten Dateien, aber NICHT den Schluessel ENC_KEY_V1. Ohne ihn
# sind Diagnosen, IBANs, Notizen und Dokumente auch aus einer
# wiederhergestellten Sicherung nicht lesbar. Den Schluessel getrennt
# sichern - Passwortmanager oder ausgedruckt im Ordner.
# ============================================================================= # =============================================================================
set -eu set -eu
@@ -30,6 +57,11 @@ AUFBEWAHRUNG="${SICHERUNG_AUFBEWAHRUNG_TAGE:-30}"
ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)" ZEITSTEMPEL="$(date +%Y-%m-%d_%H%M)"
TAG_IM_MONAT="$(date +%d)" TAG_IM_MONAT="$(date +%d)"
DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc" DATEI="$ZIEL/cockpit_${ZEITSTEMPEL}.dump.enc"
DOKUMENTE_DATEI="$ZIEL/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
# Wo die Dokumente liegen. Der Behaelter bekommt das Ablageverzeichnis
# eingehaengt - siehe docker-compose.yml, Dienst "sicherung".
DOKUMENTE_QUELLE="${SICHERUNG_DOKUMENTE_QUELLE:-/dokumente}"
if [ -z "${SICHERUNG_PASSWORT:-}" ]; then if [ -z "${SICHERUNG_PASSWORT:-}" ]; then
echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2 echo "$(date -Iseconds) FEHLER: SICHERUNG_PASSWORT ist nicht gesetzt. Abbruch." >&2
@@ -50,17 +82,39 @@ pg_dump --format=custom --compress=6 --no-owner --no-privileges \
GROESSE="$(du -h "$DATEI" | cut -f1)" GROESSE="$(du -h "$DATEI" | cut -f1)"
sha256sum "$DATEI" > "$DATEI.sha256" sha256sum "$DATEI" > "$DATEI.sha256"
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt echo "$(date -Iseconds) Datenbank gesichert ($GROESSE)"
# beim Aufraeumen verschont.
if [ "$TAG_IM_MONAT" = "01" ]; then # --- Dokumente ---------------------------------------------------------------
touch "$DATEI.monatsstand" # Schlaegt dieser Teil fehl, bricht der ganze Lauf ab (set -e). Das ist
# Absicht: Eine Sicherung, die nur die halbe Wahrheit enthaelt, aber
# vollstaendig aussieht, ist schlimmer als gar keine - man verlaesst sich
# darauf und merkt es erst im Ernstfall.
if [ ! -d "$DOKUMENTE_QUELLE" ]; then
echo "$(date -Iseconds) FEHLER: Das Ablageverzeichnis $DOKUMENTE_QUELLE ist nicht eingehaengt." >&2
echo " Ohne die Dokumente ist die Sicherung unvollstaendig." >&2
echo " Siehe docker-compose.yml, Dienst \"sicherung\"." >&2
rm -f "$DATEI" "$DATEI.sha256"
exit 1
fi fi
echo "$(date -Iseconds) Sicherung fertig ($GROESSE)" # "tar" ohne Leitung an einen Verbraucher, der frueh schliesst - sonst
# SIGPIPE, siehe die Anmerkung zur Selbstpruefung weiter unten.
tar -czf - -C "$DOKUMENTE_QUELLE" . | openssl enc -aes-256-cbc -pbkdf2 -iter 200000 -salt -pass env:SICHERUNG_PASSWORT -out "$DOKUMENTE_DATEI"
DOK_GROESSE="$(du -h "$DOKUMENTE_DATEI" | cut -f1)"
sha256sum "$DOKUMENTE_DATEI" > "$DOKUMENTE_DATEI.sha256"
echo "$(date -Iseconds) Dokumente gesichert ($DOK_GROESSE)"
# Der jeweils erste Stand eines Monats bekommt eine Markierung und bleibt
# beim Aufraeumen verschont. Beide Teile, sonst faellt die Haelfte weg.
if [ "$TAG_IM_MONAT" = "01" ]; then
touch "$DATEI.monatsstand" "$DOKUMENTE_DATEI.monatsstand"
fi
# --- Aufraeumen -------------------------------------------------------------- # --- Aufraeumen --------------------------------------------------------------
ENTFERNT=0 ENTFERNT=0
for alt in "$ZIEL"/cockpit_*.dump.enc; do for alt in "$ZIEL"/cockpit_*.dump.enc "$ZIEL"/dokumente_*.tar.gz.enc; do
[ -e "$alt" ] || continue [ -e "$alt" ] || continue
[ -e "$alt.monatsstand" ] && continue [ -e "$alt.monatsstand" ] && continue
@@ -75,11 +129,30 @@ done
# --- Selbstpruefung ---------------------------------------------------------- # --- Selbstpruefung ----------------------------------------------------------
# Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup. # Ein Backup, das sich nicht entschluesseln laesst, ist kein Backup.
# Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist. # Deshalb wird sofort geprueft, ob die Datei wieder lesbar ist.
if openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \ # Kein "| head -c 5 | grep -q": Beide schliessen die Leitung, sobald sie
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \ # genug haben. openssl schreibt dann in ein geschlossenes Rohr, bekommt
| head -c 5 | grep -q "PGDMP"; then # SIGPIPE - und mit "set -o pipefail" gilt die ganze Kette als
echo "$(date -Iseconds) Selbstpruefung bestanden." # gescheitert. Bei einer kleinen Datenbank faellt das nicht auf, weil
else # alles schon im Puffer steht; mit wachsender Datenbank kippt es, und
echo "$(date -Iseconds) FEHLER: Die Sicherung laesst sich nicht entschluesseln!" >&2 # dann meldet die Selbstpruefung eine INTAKTE Sicherung als kaputt.
#
# "dd" liest eine feste Menge und beendet sich regulaer.
KOPF="$(openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DATEI" 2>/dev/null \
| dd bs=1 count=5 2>/dev/null || true)"
if [ "$KOPF" != "PGDMP" ]; then
echo "$(date -Iseconds) FEHLER: Der Datenbankstand laesst sich nicht entschluesseln!" >&2
exit 1 exit 1
fi fi
# Dasselbe fuer die Dokumente: entschluesseln und das Archiv auflisten
# lassen. "tar -tzf" liest die ganze Datei und meldet jeden Schaden - das
# ist bei den Groessenordnungen hier bezahlbar und sagt mehr aus als ein
# Blick auf die ersten Bytes.
if ! openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 -pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" 2>/dev/null | tar -tzf - >/dev/null 2>&1; then
echo "$(date -Iseconds) FEHLER: Das Dokumentenarchiv laesst sich nicht lesen!" >&2
exit 1
fi
echo "$(date -Iseconds) Selbstpruefung bestanden - Datenbank und Dokumente."
+184
View File
@@ -0,0 +1,184 @@
/**
* Bestehende Dateien nachtraeglich in den Umschlag stecken.
*
* npm run speicher:verschluesseln # nur schauen
* npm run speicher:verschluesseln -- --anwenden
*
* Warum es diesen Lauf gibt: Der Objektspeicher-Treiber hat bis v1.10.2
* unverschluesselt abgelegt - es stand nur ServerSideEncryption: 'AES256'
* darauf, also Verschluesselung mit dem Schluessel des Anbieters. Seit
* v1.11.0 liegt derselbe Umschlag darum wie beim Dateitreiber. Neue Dateien
* bekommen ihn beim Ablegen; die alten liegen weiter im Klartext, bis sie
* hier einmal umgeschrieben werden.
*
* Lesen funktioniert waehrenddessen durchgehend: Der Treiber erkennt am
* Kopf, ob ein Umschlag da ist, und reicht alles andere unveraendert durch.
* Der Lauf laesst sich also jederzeit starten, abbrechen und wiederholen.
*
* Er ist absichtlich langsam und einfach: ein Objekt nach dem anderen,
* lesen, pruefen, zurueckschreiben. Bei ein paar hundert Dokumenten dauert
* das Sekunden, und ein Fehler betrifft immer nur eine Datei.
*
* OHNE --anwenden wird nichts geschrieben.
*/
import { createRequire } from 'node:module'
import { readFile, writeFile } from 'node:fs/promises'
/**
* "server-only" fuer diesen Lauf neutralisieren.
*
* Das Paket wirft beim Import ausserhalb einer Server-Umgebung - genau
* dafuer ist es da. Hier laufen wir aber bewusst ausserhalb von Next, und
* die Alternative waere, den Umschlag im Skript noch einmal zu schreiben.
* Zwei Fassungen derselben Krypto sind die schlechtere Wahl: Wenn eine
* davon abweicht, merkt es niemand, bis eine Datei nicht mehr aufgeht.
*
* Deshalb wird der Modullader vorher umgebogen und danach dynamisch
* importiert - statische Importe wuerden vorgezogen und liefen in den
* Fehler.
*/
const verlangen = createRequire(import.meta.url)
const modulLader = verlangen('node:module') as {
_load: (anfrage: string, ...rest: unknown[]) => unknown
}
const echterLader = modulLader._load
modulLader._load = function (anfrage: string, ...rest: unknown[]) {
if (anfrage === 'server-only') return {}
return echterLader.call(this, anfrage, ...rest)
}
const { env } = await import('../src/lib/env')
const { istVerschluesselt, verschluesselnBinaer } = await import('../src/lib/speicher/umschlag')
const anwenden = process.argv.includes('--anwenden')
type Bilanz = { geprueft: number; umgeschrieben: number; schonGut: number; fehler: number }
function zeile(text = ''): void {
console.log(text)
}
/** Objektspeicher: auflisten, lesen, bei Bedarf zurueckschreiben. */
async function s3Durchgehen(): Promise<Bilanz> {
const s3 = await import('../src/lib/speicher/s3')
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
const schluessel = await s3.objekteAuflisten()
zeile(` ${schluessel.length} Objekte im Eimer "${env.s3.bucket}".`)
zeile()
for (const eintrag of schluessel) {
bilanz.geprueft += 1
try {
const roh = await s3.rohLesen(eintrag)
if (istVerschluesselt(roh)) {
bilanz.schonGut += 1
continue
}
if (anwenden) {
// schreiben() legt den Umschlag selbst an - der Inhalt geht also
// als Klartext hinein und kommt verschluesselt im Eimer an.
await s3.schreiben(eintrag, roh, 'application/octet-stream')
}
bilanz.umgeschrieben += 1
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${eintrag}`)
} catch (fehler) {
bilanz.fehler += 1
zeile(` FEHLER bei ${eintrag}: ${fehler instanceof Error ? fehler.message : fehler}`)
}
}
return bilanz
}
/** Dateitreiber: dasselbe fuer das Ablageverzeichnis. */
async function dateienDurchgehen(): Promise<Bilanz> {
const { readdir, stat } = await import('node:fs/promises')
const { join, relative, sep } = await import('node:path')
const treiber = await import('../src/lib/speicher/datei')
const bilanz: Bilanz = { geprueft: 0, umgeschrieben: 0, schonGut: 0, fehler: 0 }
const wurzel = treiber.ablageverzeichnis()
zeile(` Verzeichnis ${wurzel}`)
zeile()
async function absteigen(verzeichnis: string): Promise<void> {
let eintraege: string[]
try {
eintraege = await readdir(verzeichnis)
} catch {
return // gibt es noch nicht
}
for (const name of eintraege) {
const pfad = join(verzeichnis, name)
if ((await stat(pfad)).isDirectory()) {
await absteigen(pfad)
continue
}
bilanz.geprueft += 1
try {
const roh = await readFile(pfad)
if (istVerschluesselt(roh)) {
bilanz.schonGut += 1
continue
}
// Der Ablageschluessel ist der Pfad unterhalb der Wurzel, mit
// Schraegstrichen - genau das, was der Treiber beim Schreiben
// benutzt. Er gehoert in die Zusatzdaten, sonst waere die Datei
// hinterher zwar verschluesselt, aber nicht an ihren Platz
// gebunden, und liesse sich unter fremdem Namen wieder einspielen.
const ablageschluessel = relative(wurzel, pfad).split(sep).join('/')
if (anwenden) {
await writeFile(pfad, verschluesselnBinaer(roh, ablageschluessel), { mode: 0o600 })
}
bilanz.umgeschrieben += 1
zeile(` ${anwenden ? 'umgeschrieben' : 'waere umzuschreiben'}: ${pfad}`)
} catch (fehler) {
bilanz.fehler += 1
zeile(` FEHLER bei ${pfad}: ${fehler instanceof Error ? fehler.message : fehler}`)
}
}
}
await absteigen(wurzel)
return bilanz
}
async function main(): Promise<void> {
zeile()
zeile(' Bestehende Dateien in den Umschlag stecken')
zeile(' ' + '-'.repeat(50))
zeile()
if (!anwenden) {
zeile(' TROCKENLAUF - es wird nichts geschrieben.')
zeile(' Zum Anwenden: npm run speicher:verschluesseln -- --anwenden')
zeile()
}
const treiber = env.speicherTreiber
zeile(` Treiber: ${treiber}`)
zeile()
const bilanz = treiber === 's3' ? await s3Durchgehen() : await dateienDurchgehen()
zeile()
zeile(` Geprueft: ${bilanz.geprueft}`)
zeile(` Schon im Umschlag: ${bilanz.schonGut}`)
zeile(` ${anwenden ? 'Umgeschrieben' : 'Umzuschreiben'}: ${bilanz.umgeschrieben}`)
if (bilanz.fehler > 0) zeile(` Fehler: ${bilanz.fehler}`)
zeile()
if (bilanz.fehler > 0) process.exitCode = 1
}
main().catch((fehler) => {
console.error(fehler)
process.exitCode = 1
})
+45 -5
View File
@@ -52,6 +52,32 @@ gut() { printf ' %s%s%s\n' "$GRUEN" "$1" "$AUS"; }
warnung() { printf ' %s%s%s\n' "$GELB" "$1" "$AUS"; } warnung() { printf ' %s%s%s\n' "$GELB" "$1" "$AUS"; }
abbruch() { printf '\n%sAbbruch: %s%s\n\n' "$ROT" "$1" "$AUS" >&2; exit 1; } abbruch() { printf '\n%sAbbruch: %s%s\n\n' "$ROT" "$1" "$AUS" >&2; exit 1; }
# --- Unerwarteter Abbruch ----------------------------------------------------
#
# "set -e" beendet das Skript bei jedem Befehl, der nicht 0 zurueckgibt -
# stumm. Auf dem Bildschirm bricht die Ausgabe dann einfach ab, und man
# sucht an der falschen Stelle.
#
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert: Eine Leitung
# der Form "git log ... | head -15" hat den Erzeuger mit SIGPIPE beendet,
# "pipefail" hat das an die Kette weitergereicht, und das Skript war weg.
#
# Diese Falle sagt jetzt wenigstens, WO es passiert ist. Exitcode 141
# heisst SIGPIPE - dann wurde irgendwo wieder eine Leitung zu frueh
# geschlossen.
unerwartet() {
code=$1
zeile=$2
printf '%sAbbruch in Zeile %s (Exitcode %s).%s\n' "$ROT" "$zeile" "$code" "$AUS" >&2
if [ "$code" = "141" ]; then
printf '%sExitcode 141 ist SIGPIPE: eine Leitung wurde zu frueh geschlossen,%s\n' "$ROT" "$AUS" >&2
printf '%setwa durch "| head". Das ist ein Fehler im Skript, nicht bei dir.%s\n' "$ROT" "$AUS" >&2
fi
printf '%sKam der Abbruch vor Schritt 5, wurde nichts veraendert.%s\n' "$ROT" "$AUS" >&2
}
trap 'unerwartet $? $LINENO' ERR
# --- Aufrufparameter --------------------------------------------------------- # --- Aufrufparameter ---------------------------------------------------------
ZIEL="" ZIEL=""
@@ -113,7 +139,11 @@ if ! git fetch --tags --quiet origin; then
fi fi
JETZT="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)" JETZT="$(git describe --tags --exact-match 2>/dev/null || git rev-parse --short HEAD)"
NEUESTE="$(git tag --list 'v*' --sort=-v:refname | head -1)" # Kein "| head -1": Siehe den Hinweis zu SIGPIPE weiter unten. Die erste
# Zeile holt die Shell selbst.
ALLE_TAGS="$(git tag --list 'v*' --sort=-v:refname)"
NEUESTE="${ALLE_TAGS%%$'
'*}"
[ -n "$NEUESTE" ] || abbruch "Es gibt keine veroeffentlichte Fassung (kein Tag)." [ -n "$NEUESTE" ] || abbruch "Es gibt keine veroeffentlichte Fassung (kein Tag)."
@@ -123,7 +153,8 @@ fi
git rev-parse --verify --quiet "$ZIEL^{commit}" >/dev/null \ git rev-parse --verify --quiet "$ZIEL^{commit}" >/dev/null \
|| abbruch "Die Fassung '$ZIEL' gibt es nicht. Vorhanden: || abbruch "Die Fassung '$ZIEL' gibt es nicht. Vorhanden:
$(git tag --list 'v*' --sort=-v:refname | head -5 | sed 's/^/ /')" $(printf '%s
' "$ALLE_TAGS" | sed -n '1,5p' | sed 's/^/ /')"
printf ' Jetzt: %s\n' "$JETZT" printf ' Jetzt: %s\n' "$JETZT"
printf ' Ziel: %s%s%s\n' "$FETT" "$ZIEL" "$AUS" printf ' Ziel: %s%s%s\n' "$FETT" "$ZIEL" "$AUS"
@@ -152,14 +183,23 @@ if [ -n "$CHANGELOG" ]; then
if (nummer == jetzt) exit if (nummer == jetzt) exit
drucken = (nummer != "Unveröffentlicht") drucken = (nummer != "Unveröffentlicht")
} }
drucken { print " " $0 } drucken && zeilen < 60 { print " " $0; zeilen++ }
' | head -60 '
else else
warnung "Kein CHANGELOG.md in $ZIEL gefunden." warnung "Kein CHANGELOG.md in $ZIEL gefunden."
fi fi
printf '\n' printf '\n'
git log --oneline --no-decorate "HEAD..$ZIEL" | head -15 | sed 's/^/ /' # "-15" statt "| head -15": Bei mehr Commits als der Grenze schliesst head
# die Leitung, git bekommt SIGPIPE, und mit "set -o pipefail" gilt die ganze
# Kette als gescheitert - "set -e" beendet das Skript dann OHNE Meldung.
# Genau das ist beim Sprung von v1.5.0 auf v1.10.1 passiert.
ANZAHL="$(git rev-list --count "HEAD..$ZIEL")"
git log --oneline --no-decorate -15 "HEAD..$ZIEL" | sed 's/^/ /'
if [ "$ANZAHL" -gt 15 ]; then
printf ' ... und %s weitere
' "$((ANZAHL - 15))"
fi
if [ "$NUR_PRUEFEN" = "1" ]; then if [ "$NUR_PRUEFEN" = "1" ]; then
printf '\n' printf '\n'
+64 -4
View File
@@ -9,7 +9,7 @@
# ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben # ausdruecklich nach, weil dabei der aktuelle Datenbestand ueberschrieben
# wird. # wird.
# #
# ZWEI DINGE, DIE MAN VORHER WISSEN MUSS: # DREI DINGE, DIE MAN VORHER WISSEN MUSS:
# #
# 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder, # 1. Die Sicherung enthaelt die anwendungsseitig verschluesselten Felder,
# aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env # aber NICHT den Schluessel. Ohne den passenden ENC_KEY_Vn aus der .env
@@ -20,6 +20,14 @@
# das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im # das hier einmal im Jahr auf einer Testdatenbank aus, bevor du es im
# Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt # Ernstfall zum ersten Mal brauchst. Siehe README, Abschnitt
# "Wiederherstellung ueben". # "Wiederherstellung ueben".
#
# 3. Die Dokumente werden NICHT selbsttaetig an ihren Platz geschrieben.
# Dieses Skript packt sie nach ./backups aus und sagt, wie es
# weitergeht. Grund: Der Sicherungsdienst hat auf das Ablageverzeichnis
# bewusst nur Lesezugriff (siehe docker-compose.yml), und in ein
# laufendes MinIO hineinzuschreiben wuerde dessen Datenbestand
# beschaedigen. Der letzte Handgriff bleibt beim Menschen - er ist ein
# Einzeiler und im Klartext beschrieben.
# ============================================================================= # =============================================================================
set -eu set -eu
@@ -87,7 +95,44 @@ openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
| pg_restore --clean --if-exists --no-owner --no-privileges \ | pg_restore --clean --if-exists --no-owner --no-privileges \
--dbname="${PGDATABASE:-postgres}" --exit-on-error --dbname="${PGDATABASE:-postgres}" --exit-on-error
echo "$(date -Iseconds) Wiederherstellung abgeschlossen." echo "$(date -Iseconds) Datenbank wiederhergestellt."
# --- Dokumente ---------------------------------------------------------------
# Zu "cockpit_2026-09-01_0315.dump.enc" gehoert
# "dokumente_2026-09-01_0315.tar.gz.enc" - gleicher Zeitstempel, anderer
# Name. Fehlt sie, stammt die Sicherung aus einer Fassung vor v1.12.0.
ZEITSTEMPEL="$(basename "$DATEI" | sed -n 's/^cockpit_\(.*\)\.dump\.enc$/\1/p')"
DOKUMENTE_DATEI="$(dirname "$DATEI")/dokumente_${ZEITSTEMPEL}.tar.gz.enc"
AUSPACKZIEL="$(dirname "$DATEI")/dokumente-wiederhergestellt_${ZEITSTEMPEL}"
if [ ! -f "$DOKUMENTE_DATEI" ]; then
echo ""
echo "WARNUNG: Zu dieser Sicherung gibt es kein Dokumentenarchiv."
echo " Erwartet wurde: $DOKUMENTE_DATEI"
echo " Sicherungen aus Fassungen vor v1.12.0 enthalten nur die"
echo " Datenbank. Die Akte ist dann vollstaendig, die Dateien"
echo " dahinter fehlen."
else
if [ -f "$DOKUMENTE_DATEI.sha256" ]; then
echo "Pruefsumme des Dokumentenarchivs wird geprueft ..."
if sha256sum -c "$DOKUMENTE_DATEI.sha256" >/dev/null 2>&1; then
echo " in Ordnung."
else
echo "FEHLER: Die Pruefsumme des Dokumentenarchivs stimmt nicht." >&2
exit 1
fi
fi
mkdir -p "$AUSPACKZIEL"
openssl enc -d -aes-256-cbc -pbkdf2 -iter 200000 \
-pass env:SICHERUNG_PASSWORT -in "$DOKUMENTE_DATEI" \
| tar -xzf - -C "$AUSPACKZIEL"
ANZAHL="$(find "$AUSPACKZIEL" -type f | wc -l)"
echo "$(date -Iseconds) Dokumente ausgepackt: $ANZAHL Dateien nach"
echo " $AUSPACKZIEL"
fi
echo "" echo ""
echo "Naechste Schritte:" echo "Naechste Schritte:"
echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):" echo " 1. Die SQL-Skripte erneut ausfuehren (Volltextsuche und Audit-Schutz):"
@@ -95,6 +140,21 @@ echo " docker compose exec app npm run db:sql"
echo " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -" echo " 2. Pruefen, ob ENC_KEY_V1 in der .env zu diesem Bestand passt -"
echo " ohne den richtigen Schluessel bleiben die verschluesselten" echo " ohne den richtigen Schluessel bleiben die verschluesselten"
echo " Felder unlesbar." echo " Felder unlesbar."
echo " 3. In der Anwendung eine betreute Person oeffnen und nachsehen, ob" echo " 3. Die Dokumente an ihren Platz bringen. Dazu MinIO anhalten, das"
echo " die Diagnosen im Klartext erscheinen. Erst dann ist die" echo " Datenverzeichnis ersetzen und wieder starten - auf dem Host,"
echo " nicht in diesem Behaelter:"
echo ""
echo " docker compose stop minio app"
echo " docker run --rm \\"
echo " -v betreuungs-cockpit_minio-daten:/ziel \\"
echo " -v \"\$PWD/backups:/quelle:ro\" alpine \\"
echo " sh -c 'rm -rf /ziel/* /ziel/..?* && cp -a /quelle/dokumente-wiederhergestellt_*/. /ziel/'"
echo " docker compose up -d"
echo ""
echo " Steht mehr als ein ausgepacktes Verzeichnis unter ./backups,"
echo " vorher das richtige auswaehlen statt des Sternchens."
echo ""
echo " 4. In der Anwendung eine betreute Person oeffnen und nachsehen, ob"
echo " die Diagnosen im Klartext erscheinen. Dann ein Dokument"
echo " herunterladen. Erst wenn beides klappt, ist die"
echo " Wiederherstellung wirklich gelungen." echo " Wiederherstellung wirklich gelungen."
+6 -2
View File
@@ -1,6 +1,6 @@
import { Readable } from 'node:stream' import { Readable } from 'node:stream'
import type { NextRequest } from 'next/server' import type { NextRequest } from 'next/server'
import { aktiveBetreute, erfordereBenutzer, reauthGueltig } from '@/lib/auth/sitzung' import { aktiveBetreute, benutzerFuerRoute, reauthGueltig } from '@/lib/auth/sitzung'
import { darf } from '@/lib/auth/rollen' import { darf } from '@/lib/auth/rollen'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
@@ -33,7 +33,11 @@ function abgelehnt(nachricht: string, status: number): Response {
} }
export async function GET(anfrage: NextRequest): Promise<Response> { export async function GET(anfrage: NextRequest): Promise<Response> {
const benutzer = await erfordereBenutzer('/berichte/export') // Eine Route antwortet mit einem Statuscode, nicht mit einer
// Anmeldeseite als Dateiinhalt.
const angemeldet = await benutzerFuerRoute()
if (!angemeldet.ok) return angemeldet.antwort
const benutzer = angemeldet.benutzer
const gewuenscht = anfrage.nextUrl.searchParams.get('betreute') const gewuenscht = anfrage.nextUrl.searchParams.get('betreute')
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
+163 -163
View File
@@ -1,163 +1,163 @@
import Link from 'next/link' import Link from 'next/link'
import { import {
Archive, Archive,
CalendarClock, CalendarClock,
FolderArchive, FolderArchive,
Paperclip, Paperclip,
ScrollText, ScrollText,
} from 'lucide-react' } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { BerichtsListe } from '@/components/berichte/berichts-liste' import { BerichtsListe } from '@/components/berichte/berichts-liste'
import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext' import { berichtskontext, berichtsZeilenLaden } from '@/components/berichte/kontext'
import { HinweisKarten } from '@/components/hinweise/HinweisKarten' import { HinweisKarten } from '@/components/hinweise/HinweisKarten'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum, relativeTage, tageBis } from '@/lib/utils' import { formatDatum, relativeTage, tageBis } from '@/lib/utils'
export const metadata = { title: 'Berichte' } export const metadata = { title: 'Berichte' }
/** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */ /** Fristen, hinter denen ein Bericht steht - danach sucht die Übersicht. */
const BERICHTSFRISTEN = [ const BERICHTSFRISTEN = [
'anfangsbericht', 'anfangsbericht',
'jahresbericht', 'jahresbericht',
'vermoegensverzeichnis', 'vermoegensverzeichnis',
'vermoegensuebersicht', 'vermoegensuebersicht',
'rechnungslegung', 'rechnungslegung',
] ]
const WEGE = [ const WEGE = [
{ {
href: '/berichte/jahresbericht', href: '/berichte/jahresbericht',
titel: 'Jahresbericht', titel: 'Jahresbericht',
beschreibung: beschreibung:
'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.', 'Textentwurf aus Kontaktprotokollen, Gesundheitsereignissen und Vorgängen – zum Überarbeiten, nicht zum blinden Abgeben.',
icon: ScrollText, icon: ScrollText,
}, },
{ {
href: '/berichte/belegmappe', href: '/berichte/belegmappe',
titel: 'Belegmappe', titel: 'Belegmappe',
beschreibung: beschreibung:
'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.', 'Alle Belege eines Zeitraums oder eines Vorgangs als ein PDF mit Deckblatt und Verzeichnis.',
icon: FolderArchive, icon: FolderArchive,
}, },
{ {
href: '/berichte/unterlagenpaket', href: '/berichte/unterlagenpaket',
titel: 'Unterlagen für den Träger', titel: 'Unterlagen für den Träger',
beschreibung: beschreibung:
'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.', 'Die nachgeforderten Unterlagen als ein PDF mit Anschreiben, laufender Nummer und Inhaltsverzeichnis.',
icon: Paperclip, icon: Paperclip,
}, },
{ {
href: '/berichte/export', href: '/berichte/export',
titel: 'Vollexport', titel: 'Vollexport',
beschreibung: beschreibung:
'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.', 'Alles, was gespeichert ist, als ZIP – damit du auch ohne diese Anwendung weiterarbeiten kannst.',
icon: Archive, icon: Archive,
}, },
] ]
/** /**
* Die Berichtsuebersicht beantwortet zwei Fragen: * Die Berichtsuebersicht beantwortet zwei Fragen:
* "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?". * "Was ist demnaechst faellig?" und "Was habe ich wann abgegeben?".
* *
* Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen * Die erste Frage kommt aus den Fristen - dort stehen die gesetzlichen
* Pflichten mit Datum. Die zweite aus den protokollierten Berichten. * Pflichten mit Datum. Die zweite aus den protokollierten Berichten.
*/ */
export default async function BerichteSeite() { export default async function BerichteSeite() {
const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen') const { betreuteId, darfBerichteSchreiben } = await berichtskontext('lesen')
const [fristen, zeilen] = await Promise.all([ const [fristen, zeilen] = await Promise.all([
db.frist.findMany({ db.frist.findMany({
where: { where: {
betreuteId, betreuteId,
status: 'OFFEN', status: 'OFFEN',
...nichtGeloescht, ...nichtGeloescht,
OR: BERICHTSFRISTEN.map((schluessel) => ({ OR: BERICHTSFRISTEN.map((schluessel) => ({
systemSchluessel: { startsWith: schluessel }, systemSchluessel: { startsWith: schluessel },
})), })),
}, },
orderBy: { datum: 'asc' }, orderBy: { datum: 'asc' },
take: 10, take: 10,
}), }),
berichtsZeilenLaden(betreuteId, darfBerichteSchreiben), berichtsZeilenLaden(betreuteId, darfBerichteSchreiben),
]) ])
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel="Berichte" titel="Berichte"
pfad={[{ titel: 'Berichte' }]} pfad={[{ titel: 'Berichte' }]}
beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast." beschreibung="Was das Betreuungsgericht und der Sozialhilfeträger von dir sehen wollen – und was du ihnen wann vorgelegt hast."
/> />
<section className="space-y-3" aria-label="Demnächst fällig"> <section className="space-y-3" aria-label="Demnächst fällig">
<h2 className="text-base font-semibold">Demnächst fällig</h2> <h2 className="text-base font-semibold">Demnächst fällig</h2>
{fristen.length === 0 ? ( {fristen.length === 0 ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '} Zurzeit steht keine Berichtsfrist offen. Prüfe unter{' '}
<Link href="/fristen" className="underline underline-offset-4"> <Link href="/fristen" className="underline underline-offset-4">
Fristen Fristen
</Link> </Link>
, ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind. , ob die Pflichtfristen aus dem Bestellungsdatum schon abgeleitet sind.
</p> </p>
) : ( ) : (
<ul className="divide-y rounded-md border"> <ul className="divide-y rounded-md border">
{fristen.map((frist) => { {fristen.map((frist) => {
const tage = tageBis(frist.datum) const tage = tageBis(frist.datum)
const variante = const variante =
tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen' tage < 0 ? 'rot' : tage <= 7 ? 'orange' : tage <= 31 ? 'gelb' : 'gruen'
return ( return (
<li key={frist.id} className="flex flex-wrap items-center gap-3 p-3"> <li key={frist.id} className="flex flex-wrap items-center gap-3 p-3">
<CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" /> <CalendarClock className="size-5 shrink-0 text-muted-foreground" aria-hidden="true" />
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block font-medium">{frist.titel}</span> <span className="block font-medium">{frist.titel}</span>
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
{formatDatum(frist.datum)} {formatDatum(frist.datum)}
{frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''} {frist.rechtsgrundlage ? ` · ${frist.rechtsgrundlage}` : ''}
</span> </span>
</span> </span>
{frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>} {frist.ausschlussfrist && <Badge variant="rot">Ausschlussfrist</Badge>}
<Badge variant={variante}> <Badge variant={variante}>
{tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)} {tage < 0 ? `überfällig, ${relativeTage(tage)}` : relativeTage(tage)}
</Badge> </Badge>
</li> </li>
) )
})} })}
</ul> </ul>
)} )}
</section> </section>
<section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?"> <section className="grid gap-4 md:grid-cols-2" aria-label="Was möchtest du erzeugen?">
{WEGE.map((weg) => { {WEGE.map((weg) => {
const Icon = weg.icon const Icon = weg.icon
return ( return (
<Card key={weg.href}> <Card key={weg.href}>
<CardHeader className="flex flex-row items-start gap-3 space-y-0"> <CardHeader className="flex flex-row items-start gap-3 space-y-0">
<Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" /> <Icon className="mt-0.5 size-5 shrink-0 text-primary" aria-hidden="true" />
<div className="min-w-0"> <div className="min-w-0">
<CardTitle className="text-lg"> <CardTitle className="text-lg">
<Link href={weg.href} className="underline-offset-4 hover:underline"> <Link href={weg.href} className="underline-offset-4 hover:underline">
{weg.titel} {weg.titel}
</Link> </Link>
</CardTitle> </CardTitle>
<CardDescription className="mt-1">{weg.beschreibung}</CardDescription> <CardDescription className="mt-1">{weg.beschreibung}</CardDescription>
</div> </div>
</CardHeader> </CardHeader>
</Card> </Card>
) )
})} })}
</section> </section>
<section className="space-y-3" aria-label="Erzeugte Berichte"> <section className="space-y-3" aria-label="Erzeugte Berichte">
<h2 className="text-base font-semibold">Was ich abgegeben habe</h2> <h2 className="text-base font-semibold">Was ich abgegeben habe</h2>
<BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} /> <BerichtsListe zeilen={zeilen} darfSchreiben={darfBerichteSchreiben} />
</section> </section>
<HinweisKarten kontext={['BERICHTE']} /> <HinweisKarten kontext={['BERICHTE']} />
<Rechtshinweis rechtsgrundlage="§ 1863 BGB" /> <Rechtshinweis rechtsgrundlage="§ 1863 BGB" />
</div> </div>
) )
} }
+2 -1
View File
@@ -18,6 +18,7 @@ import {
VERMOEGENS_ART_EINZAHL, VERMOEGENS_ART_EINZAHL,
} from '@/lib/finanzen/kategorien' } from '@/lib/finanzen/kategorien'
import { vermoegenLaden } from '@/lib/berichte/gemeinsam' import { vermoegenLaden } from '@/lib/berichte/gemeinsam'
import { wertVeraltet } from '@/lib/finanzen/wertalter'
import type { SozialhilfeBewertung } from '@prisma/client' import type { SozialhilfeBewertung } from '@prisma/client'
export const metadata = { title: 'Vermögen' } export const metadata = { title: 'Vermögen' }
@@ -235,7 +236,7 @@ function Postentabelle({
</TableCell> </TableCell>
<TableCell className="whitespace-nowrap text-sm text-muted-foreground"> <TableCell className="whitespace-nowrap text-sm text-muted-foreground">
{formatDatum(eintrag.bewertetAm)} {formatDatum(eintrag.bewertetAm)}
{eintrag.alterTage > 365 && ( {wertVeraltet(eintrag.alterTage) && (
<Badge variant="gelb" className="ml-2"> <Badge variant="gelb" className="ml-2">
über ein Jahr alt über ein Jahr alt
</Badge> </Badge>
@@ -47,6 +47,7 @@ export default async function KontaktBearbeitenSeite({
name: kontakt.name, name: kontakt.name,
organisation: kontakt.organisation, organisation: kontakt.organisation,
rolle: kontakt.rolle, rolle: kontakt.rolle,
anrede: kontakt.anrede,
telefon: kontakt.telefon, telefon: kontakt.telefon,
mobil: kontakt.mobil, mobil: kontakt.mobil,
fax: kontakt.fax, fax: kontakt.fax,
@@ -0,0 +1,89 @@
'use client'
import { useActionState } from 'react'
import { useFormStatus } from 'react-dom'
import { AlertCircle, CheckCircle2, Loader2 } from 'lucide-react'
import { anschriftSpeichern, type AnschriftZustand } from '@/server/actions/konto'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Feld } from '@/components/feld'
function Absenden() {
const { pending } = useFormStatus()
return (
<Button type="submit" disabled={pending}>
{pending && <Loader2 className="animate-spin" aria-hidden="true" />}
{pending ? 'Wird gespeichert …' : 'Anschrift speichern'}
</Button>
)
}
export function AnschriftFormular({
strasse,
plz,
ort,
}: {
strasse: string | null
plz: string | null
ort: string | null
}) {
const [zustand, aktion] = useActionState<AnschriftZustand, FormData>(anschriftSpeichern, {})
return (
<form action={aktion} className="space-y-4" noValidate>
{zustand.fehler && (
<Alert variant="fehler">
<AlertCircle aria-hidden="true" />
<AlertDescription>{zustand.fehler}</AlertDescription>
</Alert>
)}
{zustand.erfolg && (
<Alert variant="erfolg">
<CheckCircle2 aria-hidden="true" />
<AlertDescription>{zustand.erfolg}</AlertDescription>
</Alert>
)}
<Feld id="strasse" beschriftung="Straße und Hausnummer" fehler={zustand.felder?.strasse}>
<Input
id="strasse"
name="strasse"
defaultValue={strasse ?? ''}
maxLength={150}
autoComplete="street-address"
/>
</Feld>
<div className="grid gap-4 sm:grid-cols-[8rem_1fr]">
<Feld id="plz" beschriftung="PLZ" fehler={zustand.felder?.plz}>
<Input
id="plz"
name="plz"
defaultValue={plz ?? ''}
maxLength={10}
autoComplete="postal-code"
inputMode="numeric"
/>
</Feld>
<Feld
id="ort"
beschriftung="Ort"
erklaerung="Steht außerdem in der Datumszeile: „Bayreuth, den 23.08.2026“."
fehler={zustand.felder?.ort}
>
<Input
id="ort"
name="ort"
defaultValue={ort ?? ''}
maxLength={100}
autoComplete="address-level2"
/>
</Feld>
</div>
<Absenden />
</form>
)
}
+27 -2
View File
@@ -1,6 +1,6 @@
import type { Metadata } from 'next' import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { Fingerprint, KeyRound, ShieldCheck } from 'lucide-react' import { Fingerprint, KeyRound, MapPin, ShieldCheck } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@@ -11,6 +11,7 @@ import { PasskeyVerwaltung } from '@/components/konto/passkey-verwaltung'
import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen' import { ROLLEN_BESCHRIFTUNG, ROLLEN_ERKLAERUNG } from '@/lib/auth/rollen'
import { formatDatumZeit } from '@/lib/utils' import { formatDatumZeit } from '@/lib/utils'
import { PasswortFormular } from './passwort-formular' import { PasswortFormular } from './passwort-formular'
import { AnschriftFormular } from './anschrift-formular'
export const metadata: Metadata = { title: 'Mein Konto' } export const metadata: Metadata = { title: 'Mein Konto' }
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -22,7 +23,10 @@ export default async function KontoSeite() {
return ( return (
<div className="max-w-2xl space-y-6"> <div className="max-w-2xl space-y-6">
<SeitenKopf titel="Mein Konto" beschreibung="Zugangsdaten, zweiter Faktor und Passkeys." /> <SeitenKopf
titel="Mein Konto"
beschreibung="Zugangsdaten, eigene Anschrift, zweiter Faktor und Passkeys."
/>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
@@ -55,6 +59,27 @@ export default async function KontoSeite() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<MapPin className="size-5" aria-hidden="true" />
Meine Anschrift
</CardTitle>
<CardDescription>
Steht im Absenderblock jedes Schreibens, das hier entsteht, und liefert den Ort für
die Datumszeile. Ohne sie geht ein förmlicher Brief ohne Rücksendeadresse hinaus.
Jede Angabe ist einzeln freiwillig — was fehlt, bleibt im Brief einfach weg.
</CardDescription>
</CardHeader>
<CardContent>
<AnschriftFormular
strasse={benutzer.strasse}
plz={benutzer.plz}
ort={benutzer.ort}
/>
</CardContent>
</Card>
<Card> <Card>
<CardHeader className="pb-3"> <CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base"> <CardTitle className="flex items-center gap-2 text-base">
+2 -1
View File
@@ -50,7 +50,7 @@ export default async function SchreibenSeite({
try { try {
entwurf = await entwurfBauen({ entwurf = await entwurfBauen({
betreuteId: aktive.id, betreuteId: aktive.id,
benutzerName: benutzer.name, benutzer,
art, art,
vertragId: text('vertragId') || null, vertragId: text('vertragId') || null,
vorgangId: text('vorgangId') || null, vorgangId: text('vorgangId') || null,
@@ -126,6 +126,7 @@ export default async function SchreibenSeite({
betreff: entwurf.betreff, betreff: entwurf.betreff,
text: entwurf.text, text: entwurf.text,
empfaengerName: entwurf.empfaengerName, empfaengerName: entwurf.empfaengerName,
empfaengerAnschrift: entwurf.empfaengerAnschrift,
wiedervorlageAm: entwurf.wiedervorlageAm, wiedervorlageAm: entwurf.wiedervorlageAm,
}} }}
kontakte={kontakte} kontakte={kontakte}
@@ -1,149 +1,149 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { Trash2 } from 'lucide-react' import { Trash2 } from 'lucide-react'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine' import { AktionMitRueckfrage } from '@/components/sozialhilfe/bausteine'
import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular' import { BescheidFormular } from '@/components/sozialhilfe/bescheid-formular'
import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung' import { BescheidPruefung } from '@/components/sozialhilfe/bescheid-pruefung'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide' import { BESCHEID_ART_BESCHRIFTUNG } from '@/lib/sozialhilfe/bescheide'
import { betragFormatieren } from '@/lib/finanzen/betrag' import { betragFormatieren } from '@/lib/finanzen/betrag'
import { import {
fristAmpel, fristAmpel,
rechtsbehelfsfristBerechnen, rechtsbehelfsfristBerechnen,
zugangsvermutung, zugangsvermutung,
} from '@/lib/sozialhilfe/rechtsbehelf' } from '@/lib/sozialhilfe/rechtsbehelf'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { bescheidLoeschen } from '@/server/actions/sozialhilfe' import { bescheidLoeschen } from '@/server/actions/sozialhilfe'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** /**
* Bescheid-Prüfung. * Bescheid-Prüfung.
* *
* Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die * Der Rechenweg der Widerspruchsfrist steht ausgeschrieben da - die
* Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen * Anwendung kann sich irren, und wer eine Frist verpasst, soll sehen
* können, wo die Rechnung abgebogen ist. * können, wo die Rechnung abgebogen ist.
*/ */
export default async function BescheidSeite({ export default async function BescheidSeite({
params, params,
}: { }: {
params: Promise<{ antragId: string; bescheidId: string }> params: Promise<{ antragId: string; bescheidId: string }>
}) { }) {
const { antragId, bescheidId } = await params const { antragId, bescheidId } = await params
const { betreuteId, darfSchreiben } = await sozialhilfeKontext() const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const bescheid = await db.bescheid.findFirst({ const bescheid = await db.bescheid.findFirst({
where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht }, where: { id: bescheidId, antragId, antrag: { betreuteId }, ...nichtGeloescht },
include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } }, include: { antrag: { select: { id: true, traeger: true, aktenzeichen: true } } },
}) })
if (!bescheid) notFound() if (!bescheid) notFound()
const offen = oeffnen('Bescheid', bescheid) const offen = oeffnen('Bescheid', bescheid)
const heute = heuteAlsTag() const heute = heuteAlsTag()
const ergebnis = rechtsbehelfsfristBerechnen({ const ergebnis = rechtsbehelfsfristBerechnen({
zugegangenAm: bescheid.zugegangenAm, zugegangenAm: bescheid.zugegangenAm,
bescheidVom: bescheid.bescheidVom, bescheidVom: bescheid.bescheidVom,
fristMonate: bescheid.rechtsbehelfsfristMonate, fristMonate: bescheid.rechtsbehelfsfristMonate,
heute, heute,
}) })
const vorschlag = zugangsvermutung(bescheid.bescheidVom) const vorschlag = zugangsvermutung(bescheid.bescheidVom)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`} titel={`${BESCHEID_ART_BESCHRIFTUNG[bescheid.art]} vom ${formatDatum(bescheid.bescheidVom)}`}
beschreibung={ beschreibung={
<> <>
{bescheid.antrag.traeger} {bescheid.antrag.traeger}
{bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '} {bescheid.antrag.aktenzeichen ? ` · Az. ${bescheid.antrag.aktenzeichen}` : ''} · Zugang{' '}
{formatDatum(bescheid.zugegangenAm)} {formatDatum(bescheid.zugegangenAm)}
</> </>
} }
pfad={[ pfad={[
{ titel: 'Start', href: '/heute' }, { titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' }, { titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` }, { titel: 'Antragsakte', href: `/sozialhilfe/${antragId}` },
{ titel: 'Bescheid' }, { titel: 'Bescheid' },
]} ]}
/> />
<BescheidPruefung <BescheidPruefung
bescheidId={bescheid.id} bescheidId={bescheid.id}
pruefStatus={bescheid.pruefStatus} pruefStatus={bescheid.pruefStatus}
widerspruchEingelegtAm={ widerspruchEingelegtAm={
bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : '' bescheid.widerspruchEingelegtAm ? alsIsoTag(bescheid.widerspruchEingelegtAm) : ''
} }
darfSchreiben={darfSchreiben} darfSchreiben={darfSchreiben}
rechenweg={{ rechenweg={{
berechenbar: ergebnis.berechenbar, berechenbar: ergebnis.berechenbar,
widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis widerspruchMoeglichBis: ergebnis.widerspruchMoeglichBis
? alsIsoTag(ergebnis.widerspruchMoeglichBis) ? alsIsoTag(ergebnis.widerspruchMoeglichBis)
: null, : null,
rechnerischesEnde: ergebnis.rechnerischesEnde rechnerischesEnde: ergebnis.rechnerischesEnde
? alsIsoTag(ergebnis.rechnerischesEnde) ? alsIsoTag(ergebnis.rechnerischesEnde)
: null, : null,
verschoben: ergebnis.verschoben, verschoben: ergebnis.verschoben,
tageVerbleibend: ergebnis.tageVerbleibend, tageVerbleibend: ergebnis.tageVerbleibend,
abgelaufen: ergebnis.abgelaufen, abgelaufen: ergebnis.abgelaufen,
ampel: fristAmpel(ergebnis), ampel: fristAmpel(ergebnis),
schritte: ergebnis.schritte, schritte: ergebnis.schritte,
hinweise: ergebnis.hinweise, hinweise: ergebnis.hinweise,
}} }}
/> />
{offen.notizen && ( {offen.notizen && (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Notizen</CardTitle> <CardTitle>Notizen</CardTitle>
<CardDescription>Verschlüsselt gespeichert.</CardDescription> <CardDescription>Verschlüsselt gespeichert.</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="whitespace-pre-line text-sm">{offen.notizen}</p> <p className="whitespace-pre-line text-sm">{offen.notizen}</p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{darfSchreiben && ( {darfSchreiben && (
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-semibold">Bescheid bearbeiten</h2> <h2 className="text-lg font-semibold">Bescheid bearbeiten</h2>
<BescheidFormular <BescheidFormular
antragId={antragId} antragId={antragId}
zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }} zugangsvorschlag={{ datum: alsIsoTag(vorschlag.datum), hinweis: vorschlag.hinweis }}
werte={{ werte={{
id: bescheid.id, id: bescheid.id,
art: bescheid.art, art: bescheid.art,
bescheidVom: alsIsoTag(bescheid.bescheidVom), bescheidVom: alsIsoTag(bescheid.bescheidVom),
zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '', zugegangenAm: bescheid.zugegangenAm ? alsIsoTag(bescheid.zugegangenAm) : '',
zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '', zeitraumVon: bescheid.zeitraumVon ? alsIsoTag(bescheid.zeitraumVon) : '',
zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '', zeitraumBis: bescheid.zeitraumBis ? alsIsoTag(bescheid.zeitraumBis) : '',
betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent), betrag: bescheid.betragCent === null ? '' : betragFormatieren(bescheid.betragCent),
rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate, rechtsbehelfsfristMonate: bescheid.rechtsbehelfsfristMonate,
widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm widerspruchEingelegtAm: bescheid.widerspruchEingelegtAm
? alsIsoTag(bescheid.widerspruchEingelegtAm) ? alsIsoTag(bescheid.widerspruchEingelegtAm)
: '', : '',
notizen: offen.notizen ?? '', notizen: offen.notizen ?? '',
}} }}
/> />
<div className="border-t pt-6"> <div className="border-t pt-6">
<AktionMitRueckfrage <AktionMitRueckfrage
aktion={bescheidLoeschen.bind(null, bescheid.id)} aktion={bescheidLoeschen.bind(null, bescheid.id)}
frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage." frage="Diesen Bescheid in den Papierkorb legen? Er bleibt dort 30 Tage."
variant="outline" variant="outline"
size="default" size="default"
> >
<Trash2 aria-hidden="true" /> <Trash2 aria-hidden="true" />
Bescheid löschen Bescheid löschen
</AktionMitRueckfrage> </AktionMitRueckfrage>
</div> </div>
</section> </section>
)} )}
</div> </div>
) )
} }
+232 -232
View File
@@ -1,232 +1,232 @@
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste' import { Vermoegensliste, type ListenPosten } from '@/components/sozialhilfe/vermoegensliste'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { alsIsoTag } from '@/lib/fristen/datum' import { alsIsoTag } from '@/lib/fristen/datum'
import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien' import { VERMOEGENS_ART_BESCHRIFTUNG } from '@/lib/finanzen/kategorien'
import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' import { MIT_RUECKKAUFSWERT, VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen' import { BEWERTUNG_BESCHRIFTUNG } from '@/lib/sozialhilfe/schonvermoegen'
import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht' import { freibetragVergleichen, uebersichtBilden } from '@/lib/sozialhilfe/vermoegensuebersicht'
import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang' import { sozialhilfeKontext } from '@/lib/sozialhilfe/zugang'
import { formatCent } from '@/lib/utils' import { formatCent } from '@/lib/utils'
import { AlertCircle } from 'lucide-react' import { AlertCircle } from 'lucide-react'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** /**
* Vermögens-Bewertungsansicht. * Vermögens-Bewertungsansicht.
* *
* Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was * Sie zählt zusammen und stellt gegenüber - sie entscheidet nichts. Was
* geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht. * geschützt ist, sagt der Träger; bleibt man uneins, das Sozialgericht.
* Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung". * Deshalb heißt die Spalte "Einschätzung" und nicht "Bewertung".
*/ */
export default async function VermoegensbewertungSeite() { export default async function VermoegensbewertungSeite() {
const { betreuteId, darfSchreiben } = await sozialhilfeKontext() const { betreuteId, darfSchreiben } = await sozialhilfeKontext()
const [posten, vertraege] = await Promise.all([ const [posten, vertraege] = await Promise.all([
db.vermoegensposten.findMany({ db.vermoegensposten.findMany({
where: { betreuteId, ...nichtGeloescht }, where: { betreuteId, ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
orderBy: { bezeichnung: 'asc' }, orderBy: { bezeichnung: 'asc' },
}), }),
db.vertrag.findMany({ db.vertrag.findMany({
where: { where: {
betreuteId, betreuteId,
// Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein // Nur Vertraege, die ueberhaupt einen Wert darstellen koennen. Ein
// Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen. // Stromvertrag hat keinen Rueckkaufswert und wuerde die Liste zumuellen.
art: { in: [...MIT_RUECKKAUFSWERT] }, art: { in: [...MIT_RUECKKAUFSWERT] },
...nichtGeloescht, ...nichtGeloescht,
}, },
orderBy: { bezeichnung: 'asc' }, orderBy: { bezeichnung: 'asc' },
}), }),
]) ])
// Verträge, die schon als Vermögensposten geführt werden, nicht doppelt // Verträge, die schon als Vermögensposten geführt werden, nicht doppelt
// zeigen - sonst wäre die Summe zweimal drin. // zeigen - sonst wäre die Summe zweimal drin.
const bereitsAlsPosten = new Set( const bereitsAlsPosten = new Set(
posten.map((p) => p.vertragId).filter((id): id is string => id !== null), posten.map((p) => p.vertragId).filter((id): id is string => id !== null),
) )
const zeilen: ListenPosten[] = [ const zeilen: ListenPosten[] = [
...posten.map((p) => { ...posten.map((p) => {
const geoeffnet = oeffnen('Vermoegensposten', p) const geoeffnet = oeffnen('Vermoegensposten', p)
const stand = p.wertstaende[0] const stand = p.wertstaende[0]
return { return {
id: p.id, id: p.id,
bezeichnung: p.bezeichnung, bezeichnung: p.bezeichnung,
herkunft: 'VERMOEGEN' as const, herkunft: 'VERMOEGEN' as const,
art: p.art, art: p.art,
artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art], artBeschriftung: VERMOEGENS_ART_BESCHRIFTUNG[p.art],
bewertung: p.bewertungSozialhilfe, bewertung: p.bewertungSozialhilfe,
wertCent: stand ? centZuZahl(stand.wertCent) : null, wertCent: stand ? centZuZahl(stand.wertCent) : null,
wertStichtag: stand ? alsIsoTag(stand.stichtag) : null, wertStichtag: stand ? alsIsoTag(stand.stichtag) : null,
begruendung: geoeffnet.bewertungBegruendung ?? '', begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: stand ? stand.wertquelle : null, zusatz: stand ? stand.wertquelle : null,
} }
}), }),
...vertraege ...vertraege
.filter((v) => !bereitsAlsPosten.has(v.id)) .filter((v) => !bereitsAlsPosten.has(v.id))
.map((v) => { .map((v) => {
const geoeffnet = oeffnen('Vertrag', v) const geoeffnet = oeffnen('Vertrag', v)
return { return {
id: v.id, id: v.id,
bezeichnung: v.bezeichnung, bezeichnung: v.bezeichnung,
herkunft: 'VERTRAG' as const, herkunft: 'VERTRAG' as const,
art: v.art, art: v.art,
artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art], artBeschriftung: VERTRAG_ART_BESCHRIFTUNG[v.art],
bewertung: v.bewertungSozialhilfe, bewertung: v.bewertungSozialhilfe,
// Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird // Der Rückkaufswert steht am Vertrag nicht als Betrag; er wird
// als Vermögensposten mit Wertstand geführt. Bis dahin: offen. // als Vermögensposten mit Wertstand geführt. Bis dahin: offen.
wertCent: null, wertCent: null,
wertStichtag: null, wertStichtag: null,
begruendung: geoeffnet.bewertungBegruendung ?? '', begruendung: geoeffnet.bewertungBegruendung ?? '',
zusatz: v.gesellschaft, zusatz: v.gesellschaft,
} }
}), }),
] ]
const uebersicht = uebersichtBilden( const uebersicht = uebersichtBilden(
zeilen.map((z) => ({ zeilen.map((z) => ({
id: z.id, id: z.id,
bezeichnung: z.bezeichnung, bezeichnung: z.bezeichnung,
herkunft: z.herkunft, herkunft: z.herkunft,
artBeschriftung: z.artBeschriftung, artBeschriftung: z.artBeschriftung,
bewertung: z.bewertung, bewertung: z.bewertung,
wertCent: z.wertCent === null ? null : BigInt(z.wertCent), wertCent: z.wertCent === null ? null : BigInt(z.wertCent),
wertStichtag: null, wertStichtag: null,
begruendung: z.begruendung, begruendung: z.begruendung,
href: '#', href: '#',
})), })),
) )
const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent) const vergleich = freibetragVergleichen(uebersicht.jeBewertung.VERWERTBAR.summeCent)
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<SeitenKopf <SeitenKopf
titel="Vermögen bewerten" titel="Vermögen bewerten"
beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter." beschreibung="Jeder Posten bekommt eine Einschätzung und — das ist der wichtige Teil — eine Begründung. Ohne Begründung hilft die Einordnung im Widerspruchsverfahren nicht weiter."
pfad={[ pfad={[
{ titel: 'Start', href: '/heute' }, { titel: 'Start', href: '/heute' },
{ titel: 'Sozialhilfe', href: '/sozialhilfe' }, { titel: 'Sozialhilfe', href: '/sozialhilfe' },
{ titel: 'Vermögen' }, { titel: 'Vermögen' },
]} ]}
/> />
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR} titel={BEWERTUNG_BESCHRIFTUNG.VERWERTBAR}
betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent} betrag={uebersicht.jeBewertung.VERWERTBAR.summeCent}
anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl} anzahl={uebersicht.jeBewertung.VERWERTBAR.anzahl}
ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert} ohneWert={uebersicht.jeBewertung.VERWERTBAR.ohneWert}
farbe="text-ampel-rot" farbe="text-ampel-rot"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT} titel={BEWERTUNG_BESCHRIFTUNG.GESCHUETZT}
betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent} betrag={uebersicht.jeBewertung.GESCHUETZT.summeCent}
anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl} anzahl={uebersicht.jeBewertung.GESCHUETZT.anzahl}
ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert} ohneWert={uebersicht.jeBewertung.GESCHUETZT.ohneWert}
farbe="text-ampel-gruen" farbe="text-ampel-gruen"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG} titel={BEWERTUNG_BESCHRIFTUNG.STRITTIG}
betrag={uebersicht.jeBewertung.STRITTIG.summeCent} betrag={uebersicht.jeBewertung.STRITTIG.summeCent}
anzahl={uebersicht.jeBewertung.STRITTIG.anzahl} anzahl={uebersicht.jeBewertung.STRITTIG.anzahl}
ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert} ohneWert={uebersicht.jeBewertung.STRITTIG.ohneWert}
farbe="text-ampel-orange" farbe="text-ampel-orange"
/> />
<Summenkachel <Summenkachel
titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT} titel={BEWERTUNG_BESCHRIFTUNG.UNGEPRUEFT}
betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent} betrag={uebersicht.jeBewertung.UNGEPRUEFT.summeCent}
anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl} anzahl={uebersicht.jeBewertung.UNGEPRUEFT.anzahl}
ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert} ohneWert={uebersicht.jeBewertung.UNGEPRUEFT.ohneWert}
farbe="text-muted-foreground" farbe="text-muted-foreground"
/> />
</div> </div>
<Card> <Card>
<CardContent className="space-y-2 pt-5"> <CardContent className="space-y-2 pt-5">
<div className="flex flex-wrap items-baseline justify-between gap-3"> <div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground"> <span className="text-sm text-muted-foreground">
Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII) Vermögensfreibetrag (§ 90 Abs. 2 Nr. 9 SGB XII)
</span> </span>
<span className="text-lg font-semibold tabular-nums"> <span className="text-lg font-semibold tabular-nums">
{formatCent(vergleich.freibetragCent)} {formatCent(vergleich.freibetragCent)}
</span> </span>
</div> </div>
<div className="flex flex-wrap items-baseline justify-between gap-3"> <div className="flex flex-wrap items-baseline justify-between gap-3">
<span className="text-sm text-muted-foreground">Über dem Freibetrag</span> <span className="text-sm text-muted-foreground">Über dem Freibetrag</span>
<span <span
className={`text-lg font-semibold tabular-nums ${ className={`text-lg font-semibold tabular-nums ${
vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen' vergleich.ausgeschoepft ? 'text-ampel-rot' : 'text-ampel-gruen'
}`} }`}
> >
{formatCent(vergleich.ueberFreibetragCent)} {formatCent(vergleich.ueberFreibetragCent)}
</span> </span>
</div> </div>
<p className="text-sm text-muted-foreground">{vergleich.hinweis}</p> <p className="text-sm text-muted-foreground">{vergleich.hinweis}</p>
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz /> <Rechtshinweis rechtsgrundlage="§ 90 SGB XII" kurz />
</CardContent> </CardContent>
</Card> </Card>
{uebersicht.ungeprueft.length > 0 && ( {uebersicht.ungeprueft.length > 0 && (
<Alert variant="warnung"> <Alert variant="warnung">
<AlertCircle aria-hidden="true" /> <AlertCircle aria-hidden="true" />
<AlertTitle> <AlertTitle>
{uebersicht.ungeprueft.length}{' '} {uebersicht.ungeprueft.length}{' '}
{uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft {uebersicht.ungeprueft.length === 1 ? 'Posten ist' : 'Posten sind'} noch ungeprüft
</AlertTitle> </AlertTitle>
<AlertDescription> <AlertDescription>
Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das Vor dem Antrag sollte hier nichts mehr stehen — der Träger fragt sonst nach, und das
kostet Wochen. kostet Wochen.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
{uebersicht.ohneBegruendung.length > 0 && ( {uebersicht.ohneBegruendung.length > 0 && (
<Alert variant="warnung"> <Alert variant="warnung">
<AlertCircle aria-hidden="true" /> <AlertCircle aria-hidden="true" />
<AlertTitle>Bewertung ohne Begründung</AlertTitle> <AlertTitle>Bewertung ohne Begründung</AlertTitle>
<AlertDescription> <AlertDescription>
{uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf {uebersicht.ohneBegruendung.map((p) => p.bezeichnung).join(', ')} — trag nach, worauf
sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist. sich die Einschätzung stützt. Im Widerspruchsverfahren zählt nur, was begründet ist.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} /> <Vermoegensliste posten={zeilen} darfSchreiben={darfSchreiben} />
<Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" /> <Rechtshinweis rechtsgrundlage="§ 90 SGB XII, § 91 SGB XII" />
</div> </div>
) )
} }
function Summenkachel({ function Summenkachel({
titel, titel,
betrag, betrag,
anzahl, anzahl,
ohneWert, ohneWert,
farbe, farbe,
}: { }: {
titel: string titel: string
betrag: bigint betrag: bigint
anzahl: number anzahl: number
ohneWert: number ohneWert: number
farbe: string farbe: string
}) { }) {
return ( return (
<Card> <Card>
<CardContent className="pt-5"> <CardContent className="pt-5">
<p className="text-sm text-muted-foreground">{titel}</p> <p className="text-sm text-muted-foreground">{titel}</p>
<p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p> <p className={`text-xl font-semibold tabular-nums ${farbe}`}>{formatCent(betrag)}</p>
<p className="mt-1 text-xs text-muted-foreground"> <p className="mt-1 text-xs text-muted-foreground">
{anzahl} {anzahl === 1 ? 'Posten' : 'Posten'} {anzahl} {anzahl === 1 ? 'Posten' : 'Posten'}
{ohneWert > 0 && `, davon ${ohneWert} ohne Wert`} {ohneWert > 0 && `, davon ${ohneWert} ohne Wert`}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
) )
} }
@@ -1,77 +1,77 @@
import { notFound, redirect } from 'next/navigation' import { notFound, redirect } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { VertragFormular } from '@/components/vertraege/vertrag-formular' import { VertragFormular } from '@/components/vertraege/vertrag-formular'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag } from '@/lib/fristen/datum' import { alsIsoTag } from '@/lib/fristen/datum'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
/** Cent als Eingabetext: 481255 wird zu "4812,55". */ /** Cent als Eingabetext: 481255 wird zu "4812,55". */
function centAlsText(cent: bigint | null): string { function centAlsText(cent: bigint | null): string {
if (cent === null) return '' if (cent === null) return ''
const negativ = cent < 0n const negativ = cent < 0n
const betrag = negativ ? -cent : cent const betrag = negativ ? -cent : cent
const euro = betrag / 100n const euro = betrag / 100n
const rest = betrag % 100n const rest = betrag % 100n
return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}` return `${negativ ? '-' : ''}${euro},${rest.toString().padStart(2, '0')}`
} }
export default async function VertragBearbeiten({ export default async function VertragBearbeiten({
params, params,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`) const benutzer = await erfordereBenutzer(`/vertraege/${id}/bearbeiten`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`) if (!darfSchreiben('VERTRAEGE')) redirect(`/vertraege/${id}`)
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}) })
if (!vertrag) notFound() if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag) const offen = oeffnen('Vertrag', vertrag)
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
titel={`${vertrag.bezeichnung} bearbeiten`} titel={`${vertrag.bezeichnung} bearbeiten`}
beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert." beschreibung="Änderungen werden protokolliert. Die Vertragsnummer wird verschlüsselt gespeichert."
pfad={[ pfad={[
{ titel: 'Übersicht', href: '/heute' }, { titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' }, { titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` }, { titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Bearbeiten' }, { titel: 'Bearbeiten' },
]} ]}
/> />
<VertragFormular <VertragFormular
betreuteId={aktive.id} betreuteId={aktive.id}
werte={{ werte={{
id: vertrag.id, id: vertrag.id,
bezeichnung: vertrag.bezeichnung, bezeichnung: vertrag.bezeichnung,
gesellschaft: vertrag.gesellschaft ?? '', gesellschaft: vertrag.gesellschaft ?? '',
art: vertrag.art, art: vertrag.art,
status: vertrag.status, status: vertrag.status,
vertragsnummer: offen.vertragsnummer ?? '', vertragsnummer: offen.vertragsnummer ?? '',
beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '', beginnAm: vertrag.beginnAm ? alsIsoTag(vertrag.beginnAm) : '',
endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '', endeAm: vertrag.endeAm ? alsIsoTag(vertrag.endeAm) : '',
kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '', kuendigungsfristMonate: vertrag.kuendigungsfristMonate?.toString() ?? '',
kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '', kuendigungZum: vertrag.kuendigungZum ? alsIsoTag(vertrag.kuendigungZum) : '',
gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '', gekuendigtAm: vertrag.gekuendigtAm ? alsIsoTag(vertrag.gekuendigtAm) : '',
beitrag: centAlsText(vertrag.beitragCent), beitrag: centAlsText(vertrag.beitragCent),
rhythmus: vertrag.rhythmus ?? '', rhythmus: vertrag.rhythmus ?? '',
bewertungSozialhilfe: vertrag.bewertungSozialhilfe, bewertungSozialhilfe: vertrag.bewertungSozialhilfe,
bewertungBegruendung: offen.bewertungBegruendung ?? '', bewertungBegruendung: offen.bewertungBegruendung ?? '',
notizen: offen.notizen ?? '', notizen: offen.notizen ?? '',
}} }}
/> />
</div> </div>
) )
} }
+96 -96
View File
@@ -1,96 +1,96 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent' import { KuendigungsAssistent } from '@/components/vertraege/kuendigungs-assistent'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent' import { kuendigungsassistent } from '@/lib/vertraege/kuendigungsassistent'
import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung' import { laufzeitAusVertrag } from '@/lib/vertraege/kuendigung'
import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten' import { VERTRAG_ART_BESCHRIFTUNG } from '@/lib/vertraege/konstanten'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
export default async function VertragKuendigen({ export default async function VertragKuendigen({
params, params,
}: { }: {
params: Promise<{ id: string }> params: Promise<{ id: string }>
}) { }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`) const benutzer = await erfordereBenutzer(`/vertraege/${id}/kuendigen`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) return null if (!aktive) return null
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
const schreiben = darfSchreiben('VERTRAEGE') const schreiben = darfSchreiben('VERTRAEGE')
const vertrag = await db.vertrag.findFirst({ const vertrag = await db.vertrag.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}) })
if (!vertrag) notFound() if (!vertrag) notFound()
const offen = oeffnen('Vertrag', vertrag) const offen = oeffnen('Vertrag', vertrag)
const heute = heuteAlsTag() const heute = heuteAlsTag()
const paket = kuendigungsassistent({ const paket = kuendigungsassistent({
kopf: briefkopfBauen({ betreute: aktive, benutzer }), kopf: briefkopfBauen({ betreute: aktive, benutzer }),
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer, vertragsnummer: offen.vertragsnummer,
art: vertrag.art, art: vertrag.art,
laufzeit: { laufzeit: {
beginnAm: vertrag.beginnAm, beginnAm: vertrag.beginnAm,
endeAm: vertrag.endeAm, endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate, kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
}, },
heute, heute,
}) })
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<SeitenKopf <SeitenKopf
titel="Kündigungs-Assistent" titel="Kündigungs-Assistent"
beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`} beschreibung={`${vertrag.bezeichnung} — Frist prüfen, Genehmigungsfrage stellen, Schreiben erzeugen, Versandnachweis sichern.`}
pfad={[ pfad={[
{ titel: 'Übersicht', href: '/heute' }, { titel: 'Übersicht', href: '/heute' },
{ titel: 'Verträge', href: '/vertraege' }, { titel: 'Verträge', href: '/vertraege' },
{ titel: vertrag.bezeichnung, href: `/vertraege/${id}` }, { titel: vertrag.bezeichnung, href: `/vertraege/${id}` },
{ titel: 'Kündigen' }, { titel: 'Kündigen' },
]} ]}
/> />
<KuendigungsAssistent <KuendigungsAssistent
betreuteId={aktive.id} betreuteId={aktive.id}
vertragId={id} vertragId={id}
darfSchreiben={schreiben} darfSchreiben={schreiben}
heuteIso={alsIsoTag(heute)} heuteIso={alsIsoTag(heute)}
daten={{ daten={{
schreiben: paket.schreiben, schreiben: paket.schreiben,
warnungen: paket.warnungen, warnungen: paket.warnungen,
genehmigung: paket.genehmigung, genehmigung: paket.genehmigung,
versandnachweis: { versandnachweis: {
moeglichkeiten: [...paket.versandnachweis.moeglichkeiten], moeglichkeiten: [...paket.versandnachweis.moeglichkeiten],
hinweis: paket.versandnachweis.hinweis, hinweis: paket.versandnachweis.hinweis,
}, },
plan: { plan: {
berechenbar: paket.plan.berechenbar, berechenbar: paket.plan.berechenbar,
naechsterTermin: paket.plan.naechsterTermin naechsterTermin: paket.plan.naechsterTermin
? alsIsoTag(paket.plan.naechsterTermin) ? alsIsoTag(paket.plan.naechsterTermin)
: null, : null,
zugangSpaetestensAm: paket.plan.zugangSpaetestensAm zugangSpaetestensAm: paket.plan.zugangSpaetestensAm
? alsIsoTag(paket.plan.zugangSpaetestensAm) ? alsIsoTag(paket.plan.zugangSpaetestensAm)
: null, : null,
spaetesterAbsendetag: paket.plan.spaetesterAbsendetag spaetesterAbsendetag: paket.plan.spaetesterAbsendetag
? alsIsoTag(paket.plan.spaetesterAbsendetag) ? alsIsoTag(paket.plan.spaetesterAbsendetag)
: null, : null,
tageBisAbsendetag: paket.plan.tageBisAbsendetag, tageBisAbsendetag: paket.plan.tageBisAbsendetag,
verpasst: paket.plan.verpasst, verpasst: paket.plan.verpasst,
hinweise: paket.plan.hinweise, hinweise: paket.plan.hinweise,
}, },
}} }}
/> />
</div> </div>
) )
} }
+275 -266
View File
@@ -1,266 +1,275 @@
import Link from 'next/link' import Link from 'next/link'
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import { FileX2, Pencil, Shield } from 'lucide-react' import { FileX2, Pencil, Shield } from 'lucide-react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { Datenzeile } from '@/components/substanz-formular' import { Datenzeile } from '@/components/substanz-formular'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert' import { Rueckkaufswert } from '@/components/vertraege/rueckkaufswert'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag } from '@/lib/fristen/datum'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen } from '@/lib/vertraege/briefkopf'
import { import {
kuendigungsampel, kuendigungsampel,
kuendigungsplan, kuendigungsplan,
laufzeitAusVertrag, laufzeitAusVertrag,
} from '@/lib/vertraege/kuendigung' } from '@/lib/vertraege/kuendigung'
import { anfrageStatus, rueckkaufswertAnschreiben } from '@/lib/vertraege/rueckkaufswert' import {
import { anfrageStatus,
BEWERTUNG_BESCHRIFTUNG, rueckkaufswertAnschreiben,
BEWERTUNG_ERKLAERUNG, wertHistorie,
jahresbeitragCent, } from '@/lib/vertraege/rueckkaufswert'
kannRueckkaufswertHaben, import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
RHYTHMUS_BESCHRIFTUNG, import {
VERTRAG_ART_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, BEWERTUNG_ERKLAERUNG,
} from '@/lib/vertraege/konstanten' jahresbeitragCent,
import { formatCent, formatDatum } from '@/lib/utils' kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG,
export const dynamic = 'force-dynamic' VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) { } from '@/lib/vertraege/konstanten'
const { id } = await params import { formatCent, formatDatum } from '@/lib/utils'
const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer) export const dynamic = 'force-dynamic'
if (!aktive) return null
export default async function VertragDetail({ params }: { params: Promise<{ id: string }> }) {
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen') const { id } = await params
const schreiben = darfSchreiben('VERTRAEGE') const benutzer = await erfordereBenutzer(`/vertraege/${id}`)
const aktive = await aktiveBetreute(benutzer)
const vertrag = await db.vertrag.findFirst({ if (!aktive) return null
where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: { const { darfSchreiben } = await erfordereZugang(aktive.id, 'VERTRAEGE', 'lesen')
vermoegensposten: { const schreiben = darfSchreiben('VERTRAEGE')
where: { art: 'VERSICHERUNG', ...nichtGeloescht },
include: { wertstaende: { orderBy: { stichtag: 'desc' } } }, const vertrag = await db.vertrag.findFirst({
}, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
}, include: {
}) // Ueber die Verknuepfung, nicht ueber die Art - siehe wertHistorie().
if (!vertrag) notFound() vermoegensposten: {
where: nichtGeloescht,
const offen = oeffnen('Vertrag', vertrag) include: { wertstaende: { orderBy: { stichtag: 'desc' } } },
const heute = heuteAlsTag() },
},
const plan = kuendigungsplan({ })
beginnAm: vertrag.beginnAm, if (!vertrag) notFound()
endeAm: vertrag.endeAm,
laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm), const offen = oeffnen('Vertrag', vertrag)
kuendigungsfristMonate: vertrag.kuendigungsfristMonate, const heute = heuteAlsTag()
heute,
}) const plan = kuendigungsplan({
const ampel = kuendigungsampel(plan) beginnAm: vertrag.beginnAm,
const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute) endeAm: vertrag.endeAm,
const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) laufzeitJahre: laufzeitAusVertrag(vertrag.beginnAm, vertrag.endeAm),
kuendigungsfristMonate: vertrag.kuendigungsfristMonate,
const kopf = briefkopfBauen({ betreute: aktive, benutzer }) heute,
const anschreiben = rueckkaufswertAnschreiben({ })
kopf, const ampel = kuendigungsampel(plan)
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null }, const anfrage = anfrageStatus(vertrag.rueckkaufswertAngefragtAm, heute)
vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`, const jahresbeitrag = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
vertragsnummer: offen.vertragsnummer,
stichtag: heute, const kopf = briefkopfBauen({ betreute: aktive, benutzer })
heute, const anschreiben = rueckkaufswertAnschreiben({
}) kopf,
empfaenger: { gesellschaft: vertrag.gesellschaft, anschrift: null },
const wertstaende = vertrag.vermoegensposten[0]?.wertstaende ?? [] vertragsbezeichnung: `${VERTRAG_ART_BESCHRIFTUNG[vertrag.art]} — ${vertrag.bezeichnung}`,
vertragsnummer: offen.vertragsnummer,
return ( stichtag: heute,
<div className="space-y-6"> heute,
<SeitenKopf })
titel={vertrag.bezeichnung}
beschreibung={ const wertstaende = wertHistorie(vertrag.vermoegensposten)
[vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
.filter(Boolean) return (
.join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art] <div className="space-y-6">
} <SeitenKopf
pfad={[ titel={vertrag.bezeichnung}
{ titel: 'Übersicht', href: '/heute' }, beschreibung={
{ titel: 'Verträge', href: '/vertraege' }, [vertrag.gesellschaft, VERTRAG_ART_BESCHRIFTUNG[vertrag.art]]
{ titel: vertrag.bezeichnung }, .filter(Boolean)
]} .join(' · ') || VERTRAG_ART_BESCHRIFTUNG[vertrag.art]
aktionen={ }
schreiben && ( pfad={[
<div className="flex flex-wrap gap-2"> { titel: 'Übersicht', href: '/heute' },
<Button asChild variant="outline"> { titel: 'Verträge', href: '/vertraege' },
<Link href={`/vertraege/${id}/kuendigen`}> { titel: vertrag.bezeichnung },
<FileX2 aria-hidden="true" /> ]}
Kündigen aktionen={
</Link> schreiben && (
</Button> <div className="flex flex-wrap gap-2">
<Button asChild> <Button asChild variant="outline">
<Link href={`/vertraege/${id}/bearbeiten`}> <Link href={`/vertraege/${id}/kuendigen`}>
<Pencil aria-hidden="true" /> <FileX2 aria-hidden="true" />
Bearbeiten Kündigen
</Link> </Link>
</Button> </Button>
</div> <Button asChild>
) <Link href={`/vertraege/${id}/bearbeiten`}>
} <Pencil aria-hidden="true" />
/> Bearbeiten
</Link>
<Card> </Button>
<CardHeader> </div>
<CardTitle className="flex items-center gap-2"> )
<Shield className="size-5" aria-hidden="true" /> }
Vertragsdaten />
</CardTitle>
<CardDescription> <Card>
Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt. <CardHeader>
</CardDescription> <CardTitle className="flex items-center gap-2">
</CardHeader> <Shield className="size-5" aria-hidden="true" />
<CardContent className="grid gap-x-8 sm:grid-cols-2"> Vertragsdaten
<dl className="divide-y"> </CardTitle>
<Datenzeile beschriftung="Vertragsnummer"> <CardDescription>
{offen.vertragsnummer ?? '–'} Die Vertragsnummer wird verschlüsselt gespeichert und hier nur zum Lesen angezeigt.
</Datenzeile> </CardDescription>
<Datenzeile beschriftung="Status"> </CardHeader>
{VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]} <CardContent className="grid gap-x-8 sm:grid-cols-2">
</Datenzeile> <dl className="divide-y">
<Datenzeile beschriftung="Laufzeitbeginn"> <Datenzeile beschriftung="Vertragsnummer">
{formatDatum(vertrag.beginnAm)} {offen.vertragsnummer ?? '–'}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Vereinbartes Ende"> <Datenzeile beschriftung="Status">
{formatDatum(vertrag.endeAm)} {VERTRAG_STATUS_BESCHRIFTUNG[vertrag.status]}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Kündigungsfrist"> <Datenzeile beschriftung="Laufzeitbeginn">
{vertrag.kuendigungsfristMonate !== null {formatDatum(vertrag.beginnAm)}
? `${vertrag.kuendigungsfristMonate} Monate` </Datenzeile>
: 'nicht erfasst'} <Datenzeile beschriftung="Vereinbartes Ende">
</Datenzeile> {formatDatum(vertrag.endeAm)}
</dl> </Datenzeile>
<dl className="divide-y"> <Datenzeile beschriftung="Kündigungsfrist">
<Datenzeile beschriftung="Beitrag"> {vertrag.kuendigungsfristMonate !== null
{formatCent(vertrag.beitragCent)} ? `${vertrag.kuendigungsfristMonate} Monate`
{vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''} : 'nicht erfasst'}
</Datenzeile> </Datenzeile>
<Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile> </dl>
<Datenzeile beschriftung="Gekündigt am"> <dl className="divide-y">
{formatDatum(vertrag.gekuendigtAm)} <Datenzeile beschriftung="Beitrag">
</Datenzeile> {formatCent(vertrag.beitragCent)}
<Datenzeile beschriftung="Gekündigt zum"> {vertrag.rhythmus ? ` (${RHYTHMUS_BESCHRIFTUNG[vertrag.rhythmus]})` : ''}
{formatDatum(vertrag.kuendigungZum)} </Datenzeile>
</Datenzeile> <Datenzeile beschriftung="Jahresbeitrag">{formatCent(jahresbeitrag)}</Datenzeile>
<Datenzeile beschriftung="Rückkaufswert angefragt"> <Datenzeile beschriftung="Gekündigt am">
{formatDatum(vertrag.rueckkaufswertAngefragtAm)} {formatDatum(vertrag.gekuendigtAm)}
</Datenzeile> </Datenzeile>
</dl> <Datenzeile beschriftung="Gekündigt zum">
{formatDatum(vertrag.kuendigungZum)}
{offen.notizen && ( </Datenzeile>
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2"> <Datenzeile beschriftung="Rückkaufswert angefragt">
<p className="whitespace-pre-line text-sm">{offen.notizen}</p> {formatDatum(vertrag.rueckkaufswertAngefragtAm)}
</div> </Datenzeile>
)} </dl>
</CardContent>
</Card> {offen.notizen && (
<div className="mt-4 rounded-md border bg-muted/40 p-3 sm:col-span-2">
<Card> <p className="whitespace-pre-line text-sm">{offen.notizen}</p>
<CardHeader> </div>
<CardTitle>Kündigungsfrist</CardTitle> )}
<CardDescription> </CardContent>
Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der </Card>
Absendetag früher als der Fristablauf.
</CardDescription> <Card>
</CardHeader> <CardHeader>
<CardContent className="space-y-4"> <CardTitle>Kündigungsfrist</CardTitle>
{plan.berechenbar ? ( <CardDescription>
<> Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ist der
<dl className="grid gap-3 sm:grid-cols-3"> Absendetag früher als der Fristablauf.
<div className="rounded-md border p-3"> </CardDescription>
<dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt> </CardHeader>
<dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd> <CardContent className="space-y-4">
</div> {plan.berechenbar ? (
<div className="rounded-md border p-3"> <>
<dt className="text-sm text-muted-foreground">Zugang spätestens</dt> <dl className="grid gap-3 sm:grid-cols-3">
<dd className="text-lg font-semibold"> <div className="rounded-md border p-3">
{formatDatum(plan.zugangSpaetestensAm)} <dt className="text-sm text-muted-foreground">Nächster Kündigungstermin</dt>
</dd> <dd className="text-lg font-semibold">{formatDatum(plan.naechsterTermin)}</dd>
</div> </div>
<div className="rounded-md border p-3"> <div className="rounded-md border p-3">
<dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt> <dt className="text-sm text-muted-foreground">Zugang spätestens</dt>
<dd className="text-lg font-semibold"> <dd className="text-lg font-semibold">
{formatDatum(plan.spaetesterAbsendetag)} {formatDatum(plan.zugangSpaetestensAm)}
</dd> </dd>
{ampel && ( </div>
<dd className="mt-1"> <div className="rounded-md border p-3">
<Badge variant={ampel.variante}>{ampel.text}</Badge> <dt className="text-sm text-muted-foreground">Brief spätestens absenden</dt>
</dd> <dd className="text-lg font-semibold">
)} {formatDatum(plan.spaetesterAbsendetag)}
</div> </dd>
</dl> {ampel && (
<ul className="space-y-1.5 text-sm text-muted-foreground"> <dd className="mt-1">
{plan.hinweise.map((hinweis) => ( <Badge variant={ampel.variante}>{ampel.text}</Badge>
<li key={hinweis}>{hinweis}</li> </dd>
))} )}
</ul> </div>
</> </dl>
) : ( <ul className="space-y-1.5 text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground"> {plan.hinweise.map((hinweis) => (
{plan.hinweise[0] ?? <li key={hinweis}>{hinweis}</li>
'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'} ))}
</p> </ul>
)} </>
</CardContent> ) : (
</Card> <p className="text-sm text-muted-foreground">
{plan.hinweise[0] ??
{kannRueckkaufswertHaben(vertrag.art) && ( 'Ohne Laufzeitbeginn lässt sich kein Kündigungstermin berechnen.'}
<Rueckkaufswert </p>
betreuteId={aktive.id} )}
vertragId={id} </CardContent>
darfSchreiben={schreiben} </Card>
angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
erinnerungFaellig={anfrage.erinnerungFaellig} {kannRueckkaufswertHaben(vertrag.art) && (
tageSeitAnfrage={anfrage.tageSeitAnfrage} <Rueckkaufswert
anschreiben={anschreiben} betreuteId={aktive.id}
stichtagVorschlag={alsIsoTag(heute)} vertragId={id}
werte={wertstaende.map((w) => ({ darfSchreiben={schreiben}
id: w.id, angefragtAm={vertrag.rueckkaufswertAngefragtAm?.toISOString() ?? null}
stichtag: w.stichtag.toISOString(), erinnerungFaellig={anfrage.erinnerungFaellig}
wertCent: centZuZahl(w.wertCent) ?? 0, tageSeitAnfrage={anfrage.tageSeitAnfrage}
wertquelle: w.wertquelle, anschreiben={anschreiben}
bemerkung: w.bemerkung, stichtagVorschlag={alsIsoTag(heute)}
}))} alterHinweis={
/> wertalterBestimmen(wertstaende[0]?.stichtag ?? null, heute)?.hinweis ?? null
)} }
werte={wertstaende.map((w) => ({
<Card> id: w.id,
<CardHeader> stichtag: w.stichtag.toISOString(),
<CardTitle>Bewertung fürs Sozialamt</CardTitle> wertCent: centZuZahl(w.wertCent) ?? 0,
<CardDescription> wertquelle: w.wertquelle,
Meine Einordnung und meine Begründung — keine Rechtsauskunft. bemerkung: w.bemerkung,
</CardDescription> }))}
</CardHeader> />
<CardContent className="space-y-3"> )}
<Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
<p className="text-sm text-muted-foreground"> <Card>
{BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]} <CardHeader>
</p> <CardTitle>Bewertung fürs Sozialamt</CardTitle>
{offen.bewertungBegruendung ? ( <CardDescription>
<p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm"> Meine Einordnung und meine Begründung — keine Rechtsauskunft.
{offen.bewertungBegruendung} </CardDescription>
</p> </CardHeader>
) : ( <CardContent className="space-y-3">
<p className="text-sm text-muted-foreground"> <Badge variant="outline">{BEWERTUNG_BESCHRIFTUNG[vertrag.bewertungSozialhilfe]}</Badge>
Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer <p className="text-sm text-muted-foreground">
Aufstellung an den Bezirk auftaucht. {BEWERTUNG_ERKLAERUNG[vertrag.bewertungSozialhilfe]}
</p> </p>
)} {offen.bewertungBegruendung ? (
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz /> <p className="whitespace-pre-line rounded-md border bg-muted/40 p-3 text-sm">
</CardContent> {offen.bewertungBegruendung}
</Card> </p>
</div> ) : (
) <p className="text-sm text-muted-foreground">
} Noch keine Begründung hinterlegt. Sie gehört dazu, bevor der Vertrag in einer
Aufstellung an den Bezirk auftaucht.
</p>
)}
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
</CardContent>
</Card>
</div>
)
}
+27 -10
View File
@@ -22,10 +22,13 @@ import {
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
jahresbeitragCent, jahresbeitragCent,
kannRueckkaufswertHaben,
RHYTHMUS_BESCHRIFTUNG, RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten' } from '@/lib/vertraege/konstanten'
import { juengsterWert } from '@/lib/vertraege/rueckkaufswert'
import { wertalterBestimmen } from '@/lib/finanzen/wertalter'
import { formatCent, formatDatum } from '@/lib/utils' import { formatCent, formatDatum } from '@/lib/utils'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -48,8 +51,10 @@ export default async function VertraegeUebersicht() {
const vertraege = await db.vertrag.findMany({ const vertraege = await db.vertrag.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht }, where: { betreuteId: aktive.id, ...nichtGeloescht },
include: { include: {
// Ueber die Verknuepfung, nicht ueber die Art: Ein von Hand als
// "Sonstiges" angelegter Posten meint denselben Vertrag.
vermoegensposten: { vermoegensposten: {
where: { art: 'VERSICHERUNG', ...nichtGeloescht }, where: nichtGeloescht,
include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } }, include: { wertstaende: { orderBy: { stichtag: 'desc' }, take: 1 } },
}, },
}, },
@@ -71,6 +76,8 @@ export default async function VertraegeUebersicht() {
}) })
const ampel = kuendigungsampel(plan) const ampel = kuendigungsampel(plan)
const letzterWert = juengsterWert(vertrag.vermoegensposten)
const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus) const jahr = jahresbeitragCent(vertrag.beitragCent, vertrag.rhythmus)
if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr if (jahr && vertrag.status === 'AKTIV') jahresbeitragSumme += jahr
@@ -97,11 +104,10 @@ export default async function VertraegeUebersicht() {
beitragCent: centZuZahl(vertrag.beitragCent), beitragCent: centZuZahl(vertrag.beitragCent),
rhythmus: vertrag.rhythmus, rhythmus: vertrag.rhythmus,
bewertung: vertrag.bewertungSozialhilfe, bewertung: vertrag.bewertungSozialhilfe,
rueckkaufswertCent: centZuZahl( rueckkaufswertCent: centZuZahl(letzterWert?.wertCent ?? null),
vertrag.vermoegensposten[0]?.wertstaende[0]?.wertCent ?? null, rueckkaufswertStichtag: letzterWert?.stichtag.toISOString() ?? null,
), rueckkaufswertAlter: wertalterBestimmen(letzterWert?.stichtag ?? null, heute),
rueckkaufswertStichtag: wertErwartet: kannRueckkaufswertHaben(vertrag.art),
vertrag.vermoegensposten[0]?.wertstaende[0]?.stichtag.toISOString() ?? null,
ampel, ampel,
} }
}) })
@@ -201,10 +207,21 @@ export default async function VertraegeUebersicht() {
)} )}
</TableCell> </TableCell>
<TableCell className="text-right tabular-nums"> <TableCell className="text-right tabular-nums">
{formatCent(zeile.rueckkaufswertCent)} {zeile.rueckkaufswertStichtag ? (
{zeile.rueckkaufswertStichtag && ( <>
<span className="block text-xs text-muted-foreground"> {formatCent(zeile.rueckkaufswertCent)}
{formatDatum(zeile.rueckkaufswertStichtag)} <span className="block text-xs text-muted-foreground">
{formatDatum(zeile.rueckkaufswertStichtag)}
</span>
{zeile.rueckkaufswertAlter?.veraltet && (
<Badge variant="gelb" className="mt-1">
{zeile.rueckkaufswertAlter.hinweis}
</Badge>
)}
</>
) : (
<span className="text-sm font-normal text-muted-foreground">
{zeile.wertErwartet ? 'noch nicht erfasst' : '–'}
</span> </span>
)} )}
</TableCell> </TableCell>
+438 -387
View File
@@ -1,387 +1,438 @@
import { notFound } from 'next/navigation' import { notFound } from 'next/navigation'
import Link from 'next/link' import Link from 'next/link'
import { ArrowLeft, FileDown, Clock } from 'lucide-react' import { ArrowLeft, FileDown, Clock } from 'lucide-react'
import type { VorgangStatus } from '@prisma/client' import type { VorgangStatus } from '@prisma/client'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { SeitenKopf } from '@/components/seiten-kopf' import { SeitenKopf } from '@/components/seiten-kopf'
import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste' import { AufgabenListe, type AufgabenZeile } from '@/components/vorgaenge/aufgaben-liste'
import { Faelligkeit } from '@/components/vorgaenge/faelligkeit' import { Faelligkeit } from '@/components/vorgaenge/faelligkeit'
import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen' import { GenehmigungAnlegen } from '@/components/vorgaenge/genehmigung-anlegen'
import { import {
GenehmigungsKette, GenehmigungsKette,
type GenehmigungsAnzeige, type GenehmigungsAnzeige,
} from '@/components/vorgaenge/genehmigungs-kette' } from '@/components/vorgaenge/genehmigungs-kette'
import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf' import { SachstandsanfrageKnopf } from '@/components/vorgaenge/sachstandsanfrage-knopf'
import { StatusWechsler } from '@/components/vorgaenge/status-wechsler' import { StatusWechsler } from '@/components/vorgaenge/status-wechsler'
import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen' import { VorgangLoeschen } from '@/components/vorgaenge/vorgang-loeschen'
import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular' import { VorgangsFormular } from '@/components/vorgaenge/vorgangs-formular'
import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige' import { ZeitstrahlAnzeige } from '@/components/vorgaenge/zeitstrahl-anzeige'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { centZuZahl, db, nichtGeloescht } from '@/lib/db' import { centZuZahl, db, nichtGeloescht } from '@/lib/db'
import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld' import { entschluesselnWennMoeglich, oeffnen } from '@/lib/crypto/feld'
import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge' import { KATEGORIE_BESCHRIFTUNG, PRIORITAET_BESCHRIFTUNG } from '@/lib/validierung/vorgaenge'
import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage' import { sachstandsanfrageErzeugen } from '@/lib/vorgaenge/sachstandsanfrage'
import { import {
VORGANG_STATUS_BESCHRIFTUNG, VORGANG_STATUS_BESCHRIFTUNG,
zeitstrahlBauen, zeitstrahlBauen,
type ZeitstrahlStatuswechsel, type ZeitstrahlStatuswechsel,
} from '@/lib/vorgaenge/zeitstrahl' } from '@/lib/vorgaenge/zeitstrahl'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
/** /**
* Die Fallakte. * Die Fallakte.
* *
* Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff; * Kopf mit Status, Gegenstelle, Aktenzeichen und dem nächsten Handgriff;
* darunter die Aufgabenliste, das Genehmigungsverfahren und der * darunter die Aufgabenliste, das Genehmigungsverfahren und der
* Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist. * Zeitstrahl, der alles zusammenführt, was in dieser Sache passiert ist.
*/ */
export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) { export default async function VorgangSeite({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params const { id } = await params
const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`) const benutzer = await erfordereBenutzer(`/vorgaenge/${id}`)
const aktive = await aktiveBetreute(benutzer) const aktive = await aktiveBetreute(benutzer)
if (!aktive) notFound() if (!aktive) notFound()
const vorgang = await db.vorgang.findFirst({ const vorgang = await db.vorgang.findFirst({
where: { id, betreuteId: aktive.id, ...nichtGeloescht }, where: { id, betreuteId: aktive.id, ...nichtGeloescht },
include: { include: {
aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] }, aufgaben: { orderBy: [{ reihenfolge: 'asc' }, { erstelltAm: 'asc' }] },
genehmigungen: { genehmigungen: {
where: nichtGeloescht, where: nichtGeloescht,
include: { beschlussDokument: { select: { id: true, titel: true } } }, include: { beschlussDokument: { select: { id: true, titel: true } } },
orderBy: { erstelltAm: 'asc' }, orderBy: { erstelltAm: 'asc' },
}, },
fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } }, fristen: { where: nichtGeloescht, orderBy: { datum: 'asc' } },
buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, buchungen: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } }, kontaktprotokolle: { where: nichtGeloescht, orderBy: { datum: 'desc' } },
verknuepfungen: { include: { dokument: true } }, verknuepfungen: { include: { dokument: true } },
}, ansprechpartner: true,
}) },
if (!vorgang) notFound() })
if (!vorgang) notFound()
const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE') const { darfSchreiben } = await erfordereZugang(aktive.id, 'VORGAENGE', 'lesen')
const schreiben = darfSchreiben('VORGAENGE')
const offen = oeffnen('Vorgang', vorgang)
const offen = oeffnen('Vorgang', vorgang)
// --- Statuswechsel aus dem Audit-Log --------------------------------------
// Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die // Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
// Statuschronik zu lesen, spart eine zusaetzliche Tabelle. const kontakte = await db.kontakt.findMany({
const protokolle = await db.auditLog.findMany({ where: { betreuteId: aktive.id, ...nichtGeloescht },
where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' }, select: { id: true, name: true, organisation: true },
orderBy: { zeitpunkt: 'asc' }, orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 200, take: 300,
}) })
const statuswechsel: ZeitstrahlStatuswechsel[] = [] // --- Statuswechsel aus dem Audit-Log --------------------------------------
for (const eintrag of protokolle) { // Das Log ist ohnehin der Nachweis gegenueber dem Gericht; daraus die
const roh = entschluesselnWennMoeglich( // Statuschronik zu lesen, spart eine zusaetzliche Tabelle.
eintrag.aenderungVerschluesselt, const protokolle = await db.auditLog.findMany({
'aenderungVerschluesselt', where: { entitaet: 'Vorgang', entitaetId: vorgang.id, aktion: 'GEAENDERT' },
) orderBy: { zeitpunkt: 'asc' },
if (!roh) continue take: 200,
try { })
const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
const status = gelesen.status const statuswechsel: ZeitstrahlStatuswechsel[] = []
if (!status || typeof status.neu !== 'string') continue for (const eintrag of protokolle) {
statuswechsel.push({ const roh = entschluesselnWennMoeglich(
id: eintrag.id, eintrag.aenderungVerschluesselt,
zeitpunkt: eintrag.zeitpunkt, 'aenderungVerschluesselt',
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null, )
nach: status.neu as VorgangStatus, if (!roh) continue
wer: eintrag.userName, try {
}) const gelesen = JSON.parse(roh) as Record<string, { alt: unknown; neu: unknown }>
} catch { const status = gelesen.status
// Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren. if (!status || typeof status.neu !== 'string') continue
continue statuswechsel.push({
} id: eintrag.id,
} zeitpunkt: eintrag.zeitpunkt,
von: typeof status.alt === 'string' ? (status.alt as VorgangStatus) : null,
// --- Zeitstrahl ----------------------------------------------------------- nach: status.neu as VorgangStatus,
const zeitstrahl = zeitstrahlBauen({ wer: eintrag.userName,
vorgang: { })
titel: vorgang.titel, } catch {
erstelltAm: vorgang.erstelltAm, // Ein unlesbarer Protokolleintrag darf die Akte nicht blockieren.
erledigtAm: vorgang.erledigtAm, continue
}, }
dokumente: vorgang.verknuepfungen }
.map((v) => v.dokument)
.filter((d) => d.geloeschtAm === null) // --- Zeitstrahl -----------------------------------------------------------
.map((d) => ({ const zeitstrahl = zeitstrahlBauen({
id: d.id, vorgang: {
titel: d.titel, titel: vorgang.titel,
eingangAm: d.eingangAm, erstelltAm: vorgang.erstelltAm,
versandAm: d.versandAm, erledigtAm: vorgang.erledigtAm,
erstelltAm: d.erstelltAm, },
absender: d.absender, dokumente: vorgang.verknuepfungen
empfaenger: d.empfaenger, .map((v) => v.dokument)
})), .filter((d) => d.geloeschtAm === null)
kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => { .map((d) => ({
const klar = oeffnen('Kontaktprotokoll', p) id: d.id,
return { titel: d.titel,
id: p.id, eingangAm: d.eingangAm,
datum: p.datum, versandAm: d.versandAm,
art: p.art, erstelltAm: d.erstelltAm,
gespraechspartner: p.gespraechspartner, absender: d.absender,
mitBetreuter: p.mitBetreuter, empfaenger: d.empfaenger,
inhalt: klar.inhalt ?? null, })),
} kontaktprotokolle: vorgang.kontaktprotokolle.map((p) => {
}), const klar = oeffnen('Kontaktprotokoll', p)
fristen: vorgang.fristen.map((f) => ({ return {
id: f.id, id: p.id,
titel: f.titel, datum: p.datum,
datum: f.datum, art: p.art,
erledigtAm: f.erledigtAm, gespraechspartner: p.gespraechspartner,
ausschlussfrist: f.ausschlussfrist, mitBetreuter: p.mitBetreuter,
rechtsgrundlage: f.rechtsgrundlage, inhalt: klar.inhalt ?? null,
})), }
buchungen: vorgang.buchungen.map((b) => { }),
const klar = oeffnen('Buchung', b) fristen: vorgang.fristen.map((f) => ({
return { id: f.id,
id: b.id, titel: f.titel,
datum: b.datum, datum: f.datum,
betragCent: centZuZahl(b.betragCent) ?? 0, erledigtAm: f.erledigtAm,
richtung: b.richtung, ausschlussfrist: f.ausschlussfrist,
gegenstelle: b.gegenstelle, rechtsgrundlage: f.rechtsgrundlage,
verwendungszweck: klar.verwendungszweck ?? null, })),
} buchungen: vorgang.buchungen.map((b) => {
}), const klar = oeffnen('Buchung', b)
genehmigungen: vorgang.genehmigungen.map((g) => ({ return {
id: g.id, id: b.id,
titel: g.titel, datum: b.datum,
rechtsgrundlage: g.rechtsgrundlage, betragCent: centZuZahl(b.betragCent) ?? 0,
status: g.status, richtung: b.richtung,
mitteilungAm: g.mitteilungAm, gegenstelle: b.gegenstelle,
antragAm: g.antragAm, verwendungszweck: klar.verwendungszweck ?? null,
anhoerungAm: g.anhoerungAm, }
beschlussAm: g.beschlussAm, }),
rechtskraftAm: g.rechtskraftAm, genehmigungen: vorgang.genehmigungen.map((g) => ({
vollzugAm: g.vollzugAm, id: g.id,
})), titel: g.titel,
statuswechsel, rechtsgrundlage: g.rechtsgrundlage,
}) status: g.status,
mitteilungAm: g.mitteilungAm,
// --- Sachstandsanfrage ---------------------------------------------------- antragAm: g.antragAm,
// Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der anhoerungAm: g.anhoerungAm,
// Knopf nur Zierde. beschlussAm: g.beschlussAm,
const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE' rechtskraftAm: g.rechtskraftAm,
const anfrage = wartetAufDritte vollzugAm: g.vollzugAm,
? sachstandsanfrageErzeugen({ })),
vorgangTitel: vorgang.titel, statuswechsel,
gegenstelle: vorgang.gegenstelle, })
aktenzeichen: vorgang.aktenzeichen,
letzterKontaktAm: vorgang.letzterKontaktAm, // --- Sachstandsanfrage ----------------------------------------------------
betreute: { // Nur, wenn tatsaechlich auf jemanden gewartet wird - sonst waere der
vorname: aktive.vorname, // Knopf nur Zierde.
nachname: aktive.nachname, const wartetAufDritte = vorgang.status === 'WARTET_AUF_DRITTE'
geburtsdatum: aktive.geburtsdatum, const anfrage = wartetAufDritte
}, ? sachstandsanfrageErzeugen({
betreuer: { vorgangTitel: vorgang.titel,
name: benutzer.name, gegenstelle: vorgang.gegenstelle,
aktenzeichenGericht: aktive.aktenzeichenGericht, aktenzeichen: vorgang.aktenzeichen,
betreuungsgericht: aktive.betreuungsgericht, letzterKontaktAm: vorgang.letzterKontaktAm,
}, betreute: {
}) vorname: aktive.vorname,
: null nachname: aktive.nachname,
geburtsdatum: aktive.geburtsdatum,
// Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument. },
const dokumente = vorgang.genehmigungen.length betreuer: {
? await db.dokument.findMany({ name: benutzer.name,
where: { betreuteId: aktive.id, ...nichtGeloescht }, aktenzeichenGericht: aktive.aktenzeichenGericht,
select: { id: true, titel: true }, betreuungsgericht: aktive.betreuungsgericht,
orderBy: { erstelltAm: 'desc' }, },
take: 100, ansprechpartner: vorgang.ansprechpartner
}) ? {
: [] name: vorgang.ansprechpartner.name,
anrede: vorgang.ansprechpartner.anrede,
const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({ }
id: a.id, : null,
titel: a.titel, })
beschreibung: a.beschreibung, : null
erledigt: a.erledigt,
faelligAm: a.faelligAm, // Dokumente der betreuten Person - Auswahl fuer das Beschlussdokument.
})) const dokumente = vorgang.genehmigungen.length
? await db.dokument.findMany({
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({ where: { betreuteId: aktive.id, ...nichtGeloescht },
id: g.id, select: { id: true, titel: true },
titel: g.titel, orderBy: { erstelltAm: 'desc' },
rechtsgrundlage: g.rechtsgrundlage, take: 100,
status: g.status, })
mitteilungAm: g.mitteilungAm, : []
antragAm: g.antragAm,
anhoerungAm: g.anhoerungAm, const aufgaben: AufgabenZeile[] = vorgang.aufgaben.map((a) => ({
beschlussAm: g.beschlussAm, id: a.id,
rechtskraftAm: g.rechtskraftAm, titel: a.titel,
vollzugAm: g.vollzugAm, beschreibung: a.beschreibung,
beschlussDokumentId: g.beschlussDokumentId, erledigt: a.erledigt,
beschlussDokumentTitel: g.beschlussDokument?.titel ?? null, faelligAm: a.faelligAm,
dokumente, }))
}))
const genehmigungen: GenehmigungsAnzeige[] = vorgang.genehmigungen.map((g) => ({
return ( id: g.id,
<div className="space-y-8"> titel: g.titel,
<SeitenKopf rechtsgrundlage: g.rechtsgrundlage,
titel={vorgang.titel} status: g.status,
beschreibung={offen.beschreibung ?? undefined} mitteilungAm: g.mitteilungAm,
pfad={[ antragAm: g.antragAm,
{ titel: 'Start', href: '/heute' }, anhoerungAm: g.anhoerungAm,
{ titel: 'Vorgänge', href: '/vorgaenge' }, beschlussAm: g.beschlussAm,
{ titel: vorgang.titel }, rechtskraftAm: g.rechtskraftAm,
]} vollzugAm: g.vollzugAm,
aktionen={ beschlussDokumentId: g.beschlussDokumentId,
<> beschlussDokumentTitel: g.beschlussDokument?.titel ?? null,
<Button variant="ghost" asChild> dokumente,
<Link href="/vorgaenge"> }))
<ArrowLeft aria-hidden="true" />
Zurück return (
</Link> <div className="space-y-8">
</Button> <SeitenKopf
{anfrage && ( titel={vorgang.titel}
<SachstandsanfrageKnopf beschreibung={offen.beschreibung ?? undefined}
vorgangId={vorgang.id} pfad={[
betreff={anfrage.betreff} { titel: 'Start', href: '/heute' },
text={anfrage.text} { titel: 'Vorgänge', href: '/vorgaenge' },
darfSchreiben={schreiben} { titel: vorgang.titel },
/> ]}
)} aktionen={
{schreiben && ( <>
<Button asChild variant="outline"> <Button variant="ghost" asChild>
<Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}> <Link href="/vorgaenge">
<FileDown aria-hidden="true" /> <ArrowLeft aria-hidden="true" />
Als Word-Datei und in die Akte Zurück
</Link> </Link>
</Button> </Button>
)} {anfrage && (
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />} <SachstandsanfrageKnopf
</> vorgangId={vorgang.id}
} betreff={anfrage.betreff}
/> text={anfrage.text}
darfSchreiben={schreiben}
{/* --- Kopf der Akte ---------------------------------------------- */} />
<Card> )}
<CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3"> {schreiben && (
<div className="space-y-1"> <Button asChild variant="outline">
<p className="text-sm text-muted-foreground">Status</p> <Link href={`/schreiben/neu?art=SACHSTAND&vorgangId=${vorgang.id}`}>
<p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p> <FileDown aria-hidden="true" />
{schreiben && ( Als Word-Datei und in die Akte
<StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein /> </Link>
)} </Button>
</div> )}
{schreiben && <VorgangLoeschen vorgangId={vorgang.id} titel={vorgang.titel} />}
<div className="space-y-1"> </>
<p className="text-sm text-muted-foreground">Gegenstelle</p> }
<p className="font-medium">{vorgang.gegenstelle ?? '–'}</p> />
{vorgang.letzterKontaktAm && (
<p className="flex items-center gap-1.5 text-sm text-muted-foreground"> {/* --- Kopf der Akte ---------------------------------------------- */}
<Clock className="size-3.5" aria-hidden="true" /> <Card>
Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)} <CardContent className="grid gap-5 p-5 sm:grid-cols-2 lg:grid-cols-3">
</p> <div className="space-y-1">
)} <p className="text-sm text-muted-foreground">Status</p>
</div> <p className="font-medium">{VORGANG_STATUS_BESCHRIFTUNG[vorgang.status]}</p>
{schreiben && (
<div className="space-y-1"> <StatusWechsler vorgangId={vorgang.id} status={vorgang.status} klein />
<p className="text-sm text-muted-foreground">Aktenzeichen</p> )}
<p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p> </div>
</div>
<div className="space-y-1">
<div className="space-y-1 sm:col-span-2"> <p className="text-sm text-muted-foreground">Gegenstelle</p>
<p className="text-sm text-muted-foreground">Nächster Schritt</p> <p className="font-medium">{vorgang.gegenstelle ?? '–'}</p>
<p className="font-medium"> {vorgang.letzterKontaktAm && (
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'} <p className="flex items-center gap-1.5 text-sm text-muted-foreground">
</p> <Clock className="size-3.5" aria-hidden="true" />
</div> Letzter Kontakt: {formatDatum(vorgang.letzterKontaktAm)}
</p>
<div className="space-y-2"> )}
<p className="text-sm text-muted-foreground">Einordnung</p> </div>
<div className="flex flex-wrap gap-2">
<Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge> <div className="space-y-1">
<Badge variant="secondary"> <p className="text-sm text-muted-foreground">Aktenzeichen</p>
{PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'} <p className="font-medium">{vorgang.aktenzeichen ?? '–'}</p>
</Badge> </div>
<Faelligkeit datum={vorgang.faelligAm} />
</div> {/* Wen frage ich, bevor ich anrufe? Durchwahl und Adresse
</div> stehen dabei - sonst schlaegt man sie doch wieder nach. */}
</CardContent> <div className="space-y-1">
</Card> <p className="text-sm text-muted-foreground">Ansprechpartner</p>
{vorgang.ansprechpartner ? (
{/* --- Aufgaben ----------------------------------------------------- */} <>
<section aria-labelledby="aufgaben" className="space-y-4"> <p className="font-medium">
<h2 id="aufgaben" className="text-xl font-semibold"> <Link
Aufgaben href={`/kontakte/${vorgang.ansprechpartner.id}`}
</h2> className="hover:underline"
<AufgabenListe >
vorgangId={vorgang.id} {vorgang.ansprechpartner.name}
aufgaben={aufgaben} </Link>
darfSchreiben={schreiben} </p>
/> {vorgang.ansprechpartner.organisation && (
</section> <p className="text-sm text-muted-foreground">
{vorgang.ansprechpartner.organisation}
{/* --- Genehmigungen ------------------------------------------------ */} </p>
{(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && ( )}
<section aria-labelledby="genehmigungen" className="space-y-6"> {(vorgang.ansprechpartner.telefon || vorgang.ansprechpartner.email) && (
<h2 id="genehmigungen" className="text-xl font-semibold"> <p className="text-sm text-muted-foreground">
Genehmigung des Betreuungsgerichts {[vorgang.ansprechpartner.telefon, vorgang.ansprechpartner.email]
</h2> .filter(Boolean)
.join(' · ')}
{genehmigungen.map((genehmigung) => ( </p>
<GenehmigungsKette )}
key={genehmigung.id} </>
genehmigung={genehmigung} ) : (
darfSchreiben={schreiben} <p className="text-sm text-muted-foreground">
/> Keiner benannt — Schreiben gehen an „Sehr geehrte Damen und Herren“.
))} </p>
)}
{schreiben && genehmigungen.length === 0 && ( </div>
<GenehmigungAnlegen vorgangId={vorgang.id} />
)} <div className="space-y-1 sm:col-span-2">
</section> <p className="text-sm text-muted-foreground">Nächster Schritt</p>
)} <p className="font-medium">
{vorgang.naechsterSchritt ?? 'Kein offener Handgriff notiert.'}
{/* --- Zeitstrahl --------------------------------------------------- */} </p>
<section aria-labelledby="zeitstrahl" className="space-y-4"> </div>
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl <div className="space-y-2">
</h2> <p className="text-sm text-muted-foreground">Einordnung</p>
<p className="text-sm text-muted-foreground"> <div className="flex flex-wrap gap-2">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste <Badge variant="outline">{KATEGORIE_BESCHRIFTUNG[vorgang.kategorie]}</Badge>
zuerst. <Badge variant="secondary">
</p> {PRIORITAET_BESCHRIFTUNG[vorgang.prioritaet] ?? 'Normal'}
<ZeitstrahlAnzeige eintraege={zeitstrahl} /> </Badge>
</section> <Faelligkeit datum={vorgang.faelligAm} />
</div>
{/* --- Bearbeiten --------------------------------------------------- */} </div>
{schreiben && ( </CardContent>
<section aria-labelledby="bearbeiten" className="space-y-4"> </Card>
<Card>
<CardHeader> {/* --- Aufgaben ----------------------------------------------------- */}
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle> <section aria-labelledby="aufgaben" className="space-y-4">
</CardHeader> <h2 id="aufgaben" className="text-xl font-semibold">
<CardContent> Aufgaben
<VorgangsFormular </h2>
modus="aendern" <AufgabenListe
betreuteId={aktive.id} vorgangId={vorgang.id}
vorgangId={vorgang.id} aufgaben={aufgaben}
abbrechenHref={`/vorgaenge/${vorgang.id}`} darfSchreiben={schreiben}
werte={{ />
titel: vorgang.titel, </section>
kategorie: vorgang.kategorie,
status: vorgang.status, {/* --- Genehmigungen ------------------------------------------------ */}
prioritaet: vorgang.prioritaet, {(vorgang.genehmigungspflichtig || genehmigungen.length > 0) && (
beschreibung: offen.beschreibung ?? null, <section aria-labelledby="genehmigungen" className="space-y-6">
gegenstelle: vorgang.gegenstelle, <h2 id="genehmigungen" className="text-xl font-semibold">
aktenzeichen: vorgang.aktenzeichen, Genehmigung des Betreuungsgerichts
verantwortlich: vorgang.verantwortlich, </h2>
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm, {genehmigungen.map((genehmigung) => (
letzterKontaktAm: vorgang.letzterKontaktAm, <GenehmigungsKette
genehmigungspflichtig: vorgang.genehmigungspflichtig, key={genehmigung.id}
}} genehmigung={genehmigung}
/> darfSchreiben={schreiben}
</CardContent> />
</Card> ))}
</section>
)} {schreiben && genehmigungen.length === 0 && (
</div> <GenehmigungAnlegen vorgangId={vorgang.id} />
) )}
} </section>
)}
{/* --- Zeitstrahl --------------------------------------------------- */}
<section aria-labelledby="zeitstrahl" className="space-y-4">
<h2 id="zeitstrahl" className="text-xl font-semibold">
Zeitstrahl
</h2>
<p className="text-sm text-muted-foreground">
Dokumente, Gespräche, Fristen, Zahlungen und Statuswechsel in einer Spalte - neueste
zuerst.
</p>
<ZeitstrahlAnzeige eintraege={zeitstrahl} />
</section>
{/* --- Bearbeiten --------------------------------------------------- */}
{schreiben && (
<section aria-labelledby="bearbeiten" className="space-y-4">
<Card>
<CardHeader>
<CardTitle id="bearbeiten">Angaben bearbeiten</CardTitle>
</CardHeader>
<CardContent>
<VorgangsFormular
modus="aendern"
betreuteId={aktive.id}
vorgangId={vorgang.id}
abbrechenHref={`/vorgaenge/${vorgang.id}`}
kontakte={kontakte}
werte={{
titel: vorgang.titel,
kategorie: vorgang.kategorie,
status: vorgang.status,
prioritaet: vorgang.prioritaet,
beschreibung: offen.beschreibung ?? null,
gegenstelle: vorgang.gegenstelle,
aktenzeichen: vorgang.aktenzeichen,
ansprechpartnerId: vorgang.ansprechpartnerId,
verantwortlich: vorgang.verantwortlich,
naechsterSchritt: vorgang.naechsterSchritt,
faelligAm: vorgang.faelligAm,
letzterKontaktAm: vorgang.letzterKontaktAm,
genehmigungspflichtig: vorgang.genehmigungspflichtig,
}}
/>
</CardContent>
</Card>
</section>
)}
</div>
)
}
+11
View File
@@ -10,6 +10,7 @@ import { VorlagenKacheln } from '@/components/vorgaenge/vorlagen-kacheln'
import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung' import { aktiveBetreute, erfordereBenutzer, erfordereZugang } from '@/lib/auth/sitzung'
import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette' import { GENEHMIGUNG_WARNUNG } from '@/lib/vorgaenge/genehmigungskette'
import { vorlageFinden } from '@/lib/vorgaenge/vorlagen' import { vorlageFinden } from '@/lib/vorgaenge/vorlagen'
import { db, nichtGeloescht } from '@/lib/db'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
import { addDays } from 'date-fns' import { addDays } from 'date-fns'
@@ -31,6 +32,14 @@ export default async function NeuerVorgangSeite({
await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben') await erfordereZugang(aktive.id, 'VORGAENGE', 'schreiben')
// Kontakte dieser Akte fuer die Wahl des Ansprechpartners.
const kontakte = await db.kontakt.findMany({
where: { betreuteId: aktive.id, ...nichtGeloescht },
select: { id: true, name: true, organisation: true },
orderBy: [{ organisation: 'asc' }, { name: 'asc' }],
take: 300,
})
const auswahl = await searchParams const auswahl = await searchParams
const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined const vorlage = auswahl.vorlage ? vorlageFinden(auswahl.vorlage) : undefined
const leer = auswahl.leer === '1' const leer = auswahl.leer === '1'
@@ -155,6 +164,7 @@ export default async function NeuerVorgangSeite({
betreuteId={aktive.id} betreuteId={aktive.id}
vorlageSchluessel={vorlage?.schluessel ?? null} vorlageSchluessel={vorlage?.schluessel ?? null}
abbrechenHref="/vorgaenge" abbrechenHref="/vorgaenge"
kontakte={kontakte}
werte={{ werte={{
titel: vorlage?.titel ?? '', titel: vorlage?.titel ?? '',
kategorie: vorlage?.kategorie ?? 'SONSTIGES', kategorie: vorlage?.kategorie ?? 'SONSTIGES',
@@ -163,6 +173,7 @@ export default async function NeuerVorgangSeite({
beschreibung: vorlage?.beschreibung ?? null, beschreibung: vorlage?.beschreibung ?? null,
gegenstelle: null, gegenstelle: null,
aktenzeichen: null, aktenzeichen: null,
ansprechpartnerId: null,
verantwortlich: benutzer.name, verantwortlich: benutzer.name,
naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null, naechsterSchritt: vorlage?.aufgaben[0]?.titel ?? null,
faelligAm: null, faelligAm: null,
+9 -1
View File
@@ -21,8 +21,16 @@ export const dynamic = 'force-dynamic'
export default async function ErsteinrichtungSeite() { export default async function ErsteinrichtungSeite() {
const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } }) const anzahlBenutzer = await db.user.count({ where: { geloeschtAm: null } })
// Die Sperrzeile ist die eigentliche Auskunft darueber, ob eingerichtet
// wurde. Der Benutzerzaehler allein genuegt nicht: Liegt der erste Zugang
// im Papierkorb, stuende Schritt 1 sonst wieder offen.
const sperre = await db.ersteinrichtung.findUnique({
where: { id: 'einmalig' },
select: { id: true },
})
// --- Schritt 1: Es gibt noch keinen Zugang ------------------------------- // --- Schritt 1: Es gibt noch keinen Zugang -------------------------------
if (anzahlBenutzer === 0) { if (anzahlBenutzer === 0 && !sperre) {
return ( return (
<> <>
<Card> <Card>
@@ -61,6 +61,23 @@ export function ZugangFormular() {
/> />
</Feld> </Feld>
<Feld
id="geheimnis"
beschriftung="Einrichtungsgeheimnis"
erklaerung="Der Wert von ERSTEINRICHTUNG_GEHEIMNIS aus der .env. Er verhindert, dass jemand
anders diesen Zugang anlegt, solange die Seite noch offensteht."
fehler={zustand.felder?.geheimnis}
pflicht
>
<Input
name="geheimnis"
type="password"
autoComplete="off"
spellCheck={false}
required
/>
</Feld>
<Feld <Feld
id="passwort" id="passwort"
beschriftung="Passwort" beschriftung="Passwort"
@@ -2,7 +2,7 @@ import { NextResponse } from 'next/server'
import type { Modul } from '@prisma/client' import type { Modul } from '@prisma/client'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { darf } from '@/lib/auth/rollen' import { darf } from '@/lib/auth/rollen'
import { aktuellerBenutzer } from '@/lib/auth/sitzung' import { benutzerFuerRoute } from '@/lib/auth/sitzung'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { typErkennen } from '@/lib/speicher/dateityp' import { typErkennen } from '@/lib/speicher/dateityp'
import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher' import { betreuteIdAusSchluessel, lesen } from '@/lib/speicher'
@@ -55,7 +55,10 @@ function nurText(status: number, meldung: string): NextResponse {
export async function GET( export async function GET(
_anfrage: Request, _anfrage: Request,
{ params }: { params: Promise<{ signatur: string }> }, { params }: { params: Promise<{ signatur: string }> },
): Promise<NextResponse> { // Response statt NextResponse: Die Abweisung ohne zweiten Faktor kommt
// aus benutzerFuerRoute() und ist eine schlichte Response. NextResponse
// erbt davon, die uebrigen Rueckgaben passen also weiterhin.
): Promise<Response> {
const { signatur } = await params const { signatur } = await params
// 1. Signatur und Ablauf // 1. Signatur und Ablauf
@@ -70,9 +73,14 @@ export async function GET(
} }
const schluessel = geprueft.schluessel const schluessel = geprueft.schluessel
// 2. Angemeldet? // 2. Angemeldet - und mit abgeschlossenem zweitem Faktor.
const benutzer = await aktuellerBenutzer() //
if (!benutzer) return nurText(401, 'Bitte melde dich an.') // Frueher stand hier nur die Frage nach der Anmeldung. Damit kam jemand,
// der die TOTP-Einrichtung nie abgeschlossen hatte, an jedes Dokument -
// die MFA-Pflicht galt nur fuer Seiten, nicht fuer diesen Abruf.
const angemeldet = await benutzerFuerRoute()
if (!angemeldet.ok) return angemeldet.antwort
const benutzer = angemeldet.benutzer
// 3. Zugriff auf genau diese betreute Person // 3. Zugriff auf genau diese betreute Person
const betreuteId = betreuteIdAusSchluessel(schluessel) const betreuteId = betreuteIdAusSchluessel(schluessel)
+26 -5
View File
@@ -1,12 +1,18 @@
import { NextResponse } from 'next/server' import { NextResponse } from 'next/server'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { COMMIT, VERSION } from '@/lib/version' import { COMMIT, VERSION } from '@/lib/version'
import { speicherZustand } from '@/lib/speicher/pruefung'
/** /**
* Betriebsbereitschaft fuer den Healthcheck in docker-compose. * Betriebsbereitschaft fuer den Healthcheck in docker-compose.
* *
* Bewusst ohne Anmeldung, aber ohne jede Auskunft ueber Inhalte: geprueft * Bewusst ohne Anmeldung, aber ohne jede Auskunft ueber Inhalte: geprueft
* wird nur, ob die Datenbank antwortet. * wird, ob Datenbank und Dateiablage antworten.
*
* Nur die Datenbank entscheidet ueber 503. Ein gestoerter Speicher wird
* gemeldet, laesst den Behaelter aber laufen - sonst wuerde der
* Healthcheck ihn neu starten, und die ganze Akte waere weg wegen eines
* Teils, den ein Neustart nicht repariert.
* *
* Die Version steht dabei: sie wird beim Aktualisieren gebraucht, um zu * Die Version steht dabei: sie wird beim Aktualisieren gebraucht, um zu
* sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft * sehen, ob der neue Stand tatsaechlich laeuft. Das ist keine Auskunft
@@ -19,14 +25,29 @@ export const dynamic = 'force-dynamic'
export async function GET() { export async function GET() {
try { try {
await db.$queryRaw`SELECT 1` await db.$queryRaw`SELECT 1`
return NextResponse.json(
{ status: 'bereit', version: VERSION, commit: COMMIT || undefined },
{ headers: { 'Cache-Control': 'no-store' } },
)
} catch { } catch {
return NextResponse.json( return NextResponse.json(
{ status: 'datenbank nicht erreichbar', version: VERSION }, { status: 'datenbank nicht erreichbar', version: VERSION },
{ status: 503, headers: { 'Cache-Control': 'no-store' } }, { status: 503, headers: { 'Cache-Control': 'no-store' } },
) )
} }
// Der Speicher wird mitgeprueft, aber er entscheidet NICHT ueber die
// Betriebsbereitschaft: Faellt er aus, funktioniert alles ausser dem
// Ablegen von Dateien. Ein 503 wuerde den Behaelter neu starten lassen
// und die ganze Akte unerreichbar machen - wegen eines Teils, den ein
// Neustart nicht repariert.
const speicher = await speicherZustand()
return NextResponse.json(
{
status: 'bereit',
version: VERSION,
commit: COMMIT || undefined,
speicher: speicher.ok
? { status: 'bereit', treiber: speicher.treiber }
: { status: 'gestoert', treiber: speicher.treiber, art: speicher.art },
},
{ headers: { 'Cache-Control': 'no-store' } },
)
} }
+19 -33
View File
@@ -3,15 +3,17 @@ import Credentials from 'next-auth/providers/credentials'
import type { Rolle } from '@prisma/client' import type { Rolle } from '@prisma/client'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { hashVeraltet, passwortHashen, passwortPruefen, wiederherstellungscodeFinden } from '@/lib/crypto/hash' import { hashVeraltet, passwortHashen, passwortPruefen } from '@/lib/crypto/hash'
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp'
import { import {
anmeldungErlaubt, anmeldungErlaubt,
aufMindestdauerWarten, aufMindestdauerWarten,
fehlversucheZuruecksetzen, fehlversucheZuruecksetzen,
sperreVermerken,
versuchFesthalten, versuchFesthalten,
} from '@/lib/auth/ratelimit' } from '@/lib/auth/ratelimit'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor'
import { anschlussAus } from '@/lib/auth/anschluss'
import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey' import { PasskeyFehler, anmeldungPruefen } from '@/lib/auth/passkey'
import type { AuthenticationResponseJSON } from '@simplewebauthn/server' import type { AuthenticationResponseJSON } from '@simplewebauthn/server'
@@ -64,12 +66,13 @@ export class PasskeyAnmeldeFehler extends CredentialsSignin {
function ipAusAnfrage(anfrage: Request | undefined): string | null { function ipAusAnfrage(anfrage: Request | undefined): string | null {
if (!anfrage) return null if (!anfrage) return null
const weitergeleitet = anfrage.headers.get('x-forwarded-for')?.split(',')[0]?.trim() return anschlussAus(anfrage.headers)
return weitergeleitet || anfrage.headers.get('x-real-ip') || null
} }
export const { handlers, signIn, signOut, auth } = NextAuth({ export const { handlers, signIn, signOut, auth } = NextAuth({
trustHost: true, // Siehe env.vertraueHost: Ist AUTH_URL gesetzt, wird dem Host-Kopf nicht
// geglaubt. Vorher stand hier fest "true".
trustHost: env.vertraueHost,
secret: process.env.AUTH_SECRET, secret: process.env.AUTH_SECRET,
session: { session: {
@@ -110,7 +113,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// 1. Sperre pruefen, bevor teuer gerechnet wird. // 1. Sperre pruefen, bevor teuer gerechnet wird.
const sperre = await anmeldungErlaubt(email, ip) const sperre = await anmeldungErlaubt(email, ip)
if (sperre.gesperrt) { if (sperre.gesperrt) {
await versuchFesthalten(email, ip, false, 'gesperrt') await sperreVermerken(email, ip)
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
} }
@@ -141,33 +144,13 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
throw new AnmeldeFehler('konto-inaktiv') throw new AnmeldeFehler('konto-inaktiv')
} }
// 3. Zweiter Faktor. Ohne eingerichtetes TOTP kommt der Benutzer // 3. Zweiter Faktor. Ist er noch nicht eingerichtet, kommt der
// zwar herein, das App-Layout schickt ihn aber sofort zur // Benutzer nur bis zur Einrichtungsseite - erzwungen in
// Einrichtung - er kann nichts anderes tun. // erfordereBenutzer(), nicht erst im Layout.
if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) { if (benutzer.totpSecretVerschluesselt && benutzer.totpAktiviertAm) {
const codeOhneTrenner = code.replace(/[\s-]/g, '') const eingeloest = await zweitenFaktorEinloesen(benutzer, code)
let zweiterFaktorOk = false
if (/^\d{6}$/.test(codeOhneTrenner)) { if (!eingeloest.ok) {
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
zweiterFaktorOk = totpPruefen(secret, benutzer.email, codeOhneTrenner).gueltig
} else if (codeOhneTrenner.length >= 12) {
// Wiederherstellungscode - wird nach Gebrauch entfernt.
const treffer = await wiederherstellungscodeFinden(
benutzer.wiederherstellungscodes,
code,
)
if (treffer >= 0) {
zweiterFaktorOk = true
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
await db.user.update({
where: { id: benutzer.id },
data: { wiederherstellungscodes: rest },
})
}
}
if (!zweiterFaktorOk) {
await versuchFesthalten(email, ip, false, 'code-falsch') await versuchFesthalten(email, ip, false, 'code-falsch')
await protokollieren({ await protokollieren({
aktion: 'ANMELDUNG_FEHLGESCHLAGEN', aktion: 'ANMELDUNG_FEHLGESCHLAGEN',
@@ -175,7 +158,10 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
entitaetId: benutzer.id, entitaetId: benutzer.id,
userEmail: email, userEmail: email,
userId: benutzer.id, userId: benutzer.id,
bezeichnung: 'Zweiter Faktor falsch', bezeichnung:
eingeloest.grund === 'wiederverwendet'
? 'Zweiter Faktor bereits verbraucht'
: 'Zweiter Faktor falsch',
}) })
await aufMindestdauerWarten(begonnen) await aufMindestdauerWarten(begonnen)
throw new AnmeldeFehler('code-falsch') throw new AnmeldeFehler('code-falsch')
@@ -274,7 +260,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// anmeldet - erraten laesst sich ein Passkey ohnehin nicht. // anmeldet - erraten laesst sich ein Passkey ohnehin nicht.
const sperre = await anmeldungErlaubt(benutzer.email, ip) const sperre = await anmeldungErlaubt(benutzer.email, ip)
if (sperre.gesperrt) { if (sperre.gesperrt) {
await versuchFesthalten(benutzer.email, ip, false, 'gesperrt') await sperreVermerken(benutzer.email, ip)
throw new AnmeldeFehler('gesperrt', sperre.nochSekunden) throw new AnmeldeFehler('gesperrt', sperre.nochSekunden)
} }
@@ -17,6 +17,21 @@ import {
* *
* Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle * Am Handy eine Karte je Dokument, ab Tablet eine Tabelle - die Tabelle
* scrollt dabei im eigenen Rahmen, nie die ganze Seite. * scrollt dabei im eigenen Rahmen, nie die ganze Seite.
*
* ACHTUNG, jeder Inhalt steht ZWEIMAL im DOM: einmal als Karte, einmal
* als Tabellenzeile. Eine der beiden Fassungen ist per Breakpoint
* versteckt, steht aber trotzdem im Baum.
*
* Fuer Tests heisst das: getByText(...).first() trifft womoeglich die
* unsichtbare Kopie, und die Pruefung scheitert mit "element is not
* visible" an einer Stelle, die augenscheinlich in Ordnung ist. Der Weg,
* der traegt, steht in e2e/hilfen.ts unter "Doppelt gerenderte Listen".
*
* Warum nicht eine Fassung fuer beide? Eine Tabelle, die sich zu Karten
* umbricht, verlangt display:block auf Zeilen und Zellen. Damit ist es
* keine Tabelle mehr - Vorlesesoftware verliert den Zusammenhang von
* Spaltenkopf und Wert, und genau der macht eine Dokumentenliste mit
* acht Spalten erst benutzbar. Die Verdopplung ist der Preis dafuer.
*/ */
export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) { export function DokumentListe({ eintraege }: { eintraege: Listeneintrag[] }) {
return ( return (
+22 -1
View File
@@ -9,7 +9,12 @@ import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { KONTAKT_ROLLEN, KONTAKT_ROLLE_BESCHRIFTUNG } from '@/lib/validierung/kontakte' import {
KONTAKT_ANREDEN,
KONTAKT_ROLLEN,
KONTAKT_ROLLE_BESCHRIFTUNG,
} from '@/lib/validierung/kontakte'
import { ANREDE_BESCHRIFTUNG } from '@/lib/kontakte/anrede'
import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte' import { kontaktSpeichern, type KontaktZustand } from '@/server/actions/kontakte'
/** /**
@@ -24,6 +29,7 @@ export type KontaktWerte = {
name: string name: string
organisation: string | null organisation: string | null
rolle: string rolle: string
anrede: string
telefon: string | null telefon: string | null
mobil: string | null mobil: string | null
fax: string | null fax: string | null
@@ -91,6 +97,21 @@ export function KontaktFormular({
</Select> </Select>
</Feld> </Feld>
<Feld
id="anrede"
beschriftung="Anrede"
erklaerung="Für die Briefanrede. Ohne Angabe wird mit vollem Namen angeredet — geraten wird nie."
fehler={zustand.felder?.anrede}
>
<Select name="anrede" defaultValue={wert('anrede') || 'OHNE'}>
{KONTAKT_ANREDEN.map((anrede) => (
<option key={anrede} value={anrede}>
{ANREDE_BESCHRIFTUNG[anrede]}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen dort" beschriftung="Aktenzeichen dort"
+10 -2
View File
@@ -59,7 +59,13 @@ export function SchreibenEditor({
}: { }: {
betreuteId: string betreuteId: string
art: SchreibenArt art: SchreibenArt
entwurf: { betreff: string; text: string; empfaengerName: string; wiedervorlageAm: string } entwurf: {
betreff: string
text: string
empfaengerName: string
empfaengerAnschrift: string
wiedervorlageAm: string
}
kontakte: KontaktAuswahl[] kontakte: KontaktAuswahl[]
vertragId: string | null vertragId: string | null
vorgangId: string | null vorgangId: string | null
@@ -69,7 +75,9 @@ export function SchreibenEditor({
const [text, setText] = React.useState(entwurf.text) const [text, setText] = React.useState(entwurf.text)
const [betreff, setBetreff] = React.useState(entwurf.betreff) const [betreff, setBetreff] = React.useState(entwurf.betreff)
const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName) const [empfaengerName, setEmpfaengerName] = React.useState(entwurf.empfaengerName)
const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState('') const [empfaengerAnschrift, setEmpfaengerAnschrift] = React.useState(
entwurf.empfaengerAnschrift,
)
const [empfaengerEmail, setEmpfaengerEmail] = React.useState('') const [empfaengerEmail, setEmpfaengerEmail] = React.useState('')
const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF') const [versandweg, setVersandweg] = React.useState<'BRIEF' | 'EMAIL'>('BRIEF')
const [betreffKopiert, setBetreffKopiert] = React.useState(false) const [betreffKopiert, setBetreffKopiert] = React.useState(false)
+310 -310
View File
@@ -1,310 +1,310 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine' import { Absenden, Meldung } from '@/components/sozialhilfe/bausteine'
import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten' import { MONATSNAMEN } from '@/lib/sozialhilfe/heimkosten'
import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe' import { heimkostenSpeichern, type Formularzustand } from '@/server/actions/sozialhilfe'
export type HeimkostenWerte = { export type HeimkostenWerte = {
jahr: number jahr: number
monat: number monat: number
pflegeEigenanteil: string pflegeEigenanteil: string
unterkunftVerpflegung: string unterkunftVerpflegung: string
investitionskosten: string investitionskosten: string
ausbildungsumlage: string ausbildungsumlage: string
zusatzleistungen: string zusatzleistungen: string
pflegekasse: string pflegekasse: string
leistungszuschlagProzent: number leistungszuschlagProzent: number
leistungszuschlag: string leistungszuschlag: string
rente: string rente: string
sonstigeEinkuenfte: string sonstigeEinkuenfte: string
sozialhilfe: string sozialhilfe: string
eigenmittel: string eigenmittel: string
barbetrag: string barbetrag: string
bekleidungspauschale: string bekleidungspauschale: string
abwesenheitstage: number abwesenheitstage: number
bemerkung: string bemerkung: string
} }
/** /**
* Ein Monat der Gegenüberstellung. * Ein Monat der Gegenüberstellung.
* *
* Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der * Die Reihenfolge folgt der Heimrechnung: erst die vier Bausteine der
* Kosten, dann die Deckungsquellen, dann das, was der betreuten Person * Kosten, dann die Deckungsquellen, dann das, was der betreuten Person
* verbleiben muss. Wer von oben nach unten abtippt, findet alles an der * verbleiben muss. Wer von oben nach unten abtippt, findet alles an der
* Stelle, an der es auf der Rechnung steht. * Stelle, an der es auf der Rechnung steht.
*/ */
export function HeimkostenFormular({ export function HeimkostenFormular({
werte, werte,
jahre, jahre,
hinweisPflegekasse, hinweisPflegekasse,
vorschlagBarbetrag, vorschlagBarbetrag,
}: { }: {
werte: HeimkostenWerte werte: HeimkostenWerte
jahre: number[] jahre: number[]
/** /**
* Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN * Erklaerung zum Feld "Leistung der Pflegekasse". Bewusst KEIN
* Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der * Uebernehmen-Knopf: Unter der Lesart dieses Moduls ist der
* Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert * Eigenanteil bereits um die Pauschale gekuerzt, das Feld gehoert
* deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts. * deshalb auf 0. Siehe src/lib/sozialhilfe/heimkosten.ts.
*/ */
hinweisPflegekasse: string hinweisPflegekasse: string
vorschlagBarbetrag: { betrag: string; text: string } vorschlagBarbetrag: { betrag: string; text: string }
}) { }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {}) const [zustand, aktion] = useActionState<Formularzustand, FormData>(heimkostenSpeichern, {})
const [barbetrag, setBarbetrag] = useState(werte.barbetrag) const [barbetrag, setBarbetrag] = useState(werte.barbetrag)
return ( return (
<form action={aktion} className="space-y-6"> <form action={aktion} className="space-y-6">
<Meldung zustand={zustand} /> <Meldung zustand={zustand} />
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>Monat erfassen</CardTitle> <CardTitle>Monat erfassen</CardTitle>
<CardDescription> <CardDescription>
Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird Beträge wie auf der Heimrechnung, z.B. 1.234,56. Ein bereits erfasster Monat wird
überschrieben. überschrieben.
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-6"> <CardContent className="space-y-6">
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}> <Feld id="jahr" beschriftung="Jahr" pflicht fehler={zustand.felder?.jahr}>
<Select name="jahr" defaultValue={String(werte.jahr)}> <Select name="jahr" defaultValue={String(werte.jahr)}>
{jahre.map((j) => ( {jahre.map((j) => (
<option key={j} value={j}> <option key={j} value={j}>
{j} {j}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}> <Feld id="monat" beschriftung="Monat" pflicht fehler={zustand.felder?.monat}>
<Select name="monat" defaultValue={String(werte.monat)}> <Select name="monat" defaultValue={String(werte.monat)}>
{MONATSNAMEN.map((name, i) => ( {MONATSNAMEN.map((name, i) => (
<option key={name} value={i + 1}> <option key={name} value={i + 1}>
{name} {name}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
</div> </div>
<Feldgruppe <Feldgruppe
titel="Kosten" titel="Kosten"
beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen." beschreibung="Die vier Bausteine der Heimrechnung, dazu Zusatzleistungen."
> >
<Feld <Feld
id="pflegeEigenanteil" id="pflegeEigenanteil"
beschriftung="Einrichtungseinheitlicher Eigenanteil" beschriftung="Einrichtungseinheitlicher Eigenanteil"
fehler={zustand.felder?.pflegeEigenanteil} fehler={zustand.felder?.pflegeEigenanteil}
erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag." erklaerung="Der pflegebedingte Anteil (EEE), genau wie er auf der Heimrechnung steht — also bereits abzüglich der Pauschale der Pflegekasse (§ 43 Abs. 2 SGB XI). In jedem Pflegegrad gleich hoch. Nur auf ihn wirkt der Leistungszuschlag."
> >
<Input <Input
name="pflegeEigenanteil" name="pflegeEigenanteil"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.pflegeEigenanteil} defaultValue={werte.pflegeEigenanteil}
/> />
</Feld> </Feld>
<Feld <Feld
id="unterkunftVerpflegung" id="unterkunftVerpflegung"
beschriftung="Unterkunft und Verpflegung" beschriftung="Unterkunft und Verpflegung"
fehler={zustand.felder?.unterkunftVerpflegung} fehler={zustand.felder?.unterkunftVerpflegung}
> >
<Input <Input
name="unterkunftVerpflegung" name="unterkunftVerpflegung"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.unterkunftVerpflegung} defaultValue={werte.unterkunftVerpflegung}
/> />
</Feld> </Feld>
<Feld <Feld
id="investitionskosten" id="investitionskosten"
beschriftung="Investitionskosten" beschriftung="Investitionskosten"
fehler={zustand.felder?.investitionskosten} fehler={zustand.felder?.investitionskosten}
erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter." erklaerung="Gebäude und Instandhaltung. Laufen auch bei Abwesenheit weiter."
> >
<Input <Input
name="investitionskosten" name="investitionskosten"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.investitionskosten} defaultValue={werte.investitionskosten}
/> />
</Feld> </Feld>
<Feld <Feld
id="ausbildungsumlage" id="ausbildungsumlage"
beschriftung="Ausbildungsumlage" beschriftung="Ausbildungsumlage"
fehler={zustand.felder?.ausbildungsumlage} fehler={zustand.felder?.ausbildungsumlage}
> >
<Input <Input
name="ausbildungsumlage" name="ausbildungsumlage"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.ausbildungsumlage} defaultValue={werte.ausbildungsumlage}
/> />
</Feld> </Feld>
<Feld <Feld
id="zusatzleistungen" id="zusatzleistungen"
beschriftung="Zusatzleistungen" beschriftung="Zusatzleistungen"
fehler={zustand.felder?.zusatzleistungen} fehler={zustand.felder?.zusatzleistungen}
erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab." erklaerung="Friseur, Komfortleistungen. Der Träger übernimmt sie in aller Regel nicht — sie gehen vom Barbetrag ab."
> >
<Input <Input
name="zusatzleistungen" name="zusatzleistungen"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.zusatzleistungen} defaultValue={werte.zusatzleistungen}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?"> <Feldgruppe titel="Deckung" beschreibung="Wer zahlt welchen Teil?">
<Feld <Feld
id="pflegekasse" id="pflegekasse"
beschriftung="Leistung der Pflegekasse (nachrichtlich)" beschriftung="Leistung der Pflegekasse (nachrichtlich)"
fehler={zustand.felder?.pflegekasse} fehler={zustand.felder?.pflegekasse}
erklaerung={hinweisPflegekasse} erklaerung={hinweisPflegekasse}
> >
<Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} /> <Input name="pflegekasse" inputMode="decimal" defaultValue={werte.pflegekasse} />
</Feld> </Feld>
<Feld <Feld
id="leistungszuschlagProzent" id="leistungszuschlagProzent"
beschriftung="Leistungszuschlag in Prozent" beschriftung="Leistungszuschlag in Prozent"
fehler={zustand.felder?.leistungszuschlagProzent} fehler={zustand.felder?.leistungszuschlagProzent}
erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung." erklaerung="§ 43c SGB XI: 15 / 30 / 50 / 75 Prozent nach Verweildauer. Steht auf der Heimrechnung."
> >
<Input <Input
name="leistungszuschlagProzent" name="leistungszuschlagProzent"
type="number" type="number"
min={0} min={0}
max={100} max={100}
defaultValue={werte.leistungszuschlagProzent} defaultValue={werte.leistungszuschlagProzent}
/> />
</Feld> </Feld>
<Feld <Feld
id="leistungszuschlag" id="leistungszuschlag"
beschriftung="Leistungszuschlag in Euro" beschriftung="Leistungszuschlag in Euro"
fehler={zustand.felder?.leistungszuschlag} fehler={zustand.felder?.leistungszuschlag}
erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung." erklaerung="Nur auf den pflegebedingten Eigenanteil, nicht auf die ganze Rechnung."
> >
<Input <Input
name="leistungszuschlag" name="leistungszuschlag"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.leistungszuschlag} defaultValue={werte.leistungszuschlag}
/> />
</Feld> </Feld>
<Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}> <Feld id="rente" beschriftung="Rente" fehler={zustand.felder?.rente}>
<Input name="rente" inputMode="decimal" defaultValue={werte.rente} /> <Input name="rente" inputMode="decimal" defaultValue={werte.rente} />
</Feld> </Feld>
<Feld <Feld
id="sonstigeEinkuenfte" id="sonstigeEinkuenfte"
beschriftung="Sonstige Einkünfte" beschriftung="Sonstige Einkünfte"
fehler={zustand.felder?.sonstigeEinkuenfte} fehler={zustand.felder?.sonstigeEinkuenfte}
erklaerung="Betriebsrente, Mieteinnahmen, Zinsen." erklaerung="Betriebsrente, Mieteinnahmen, Zinsen."
> >
<Input <Input
name="sonstigeEinkuenfte" name="sonstigeEinkuenfte"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.sonstigeEinkuenfte} defaultValue={werte.sonstigeEinkuenfte}
/> />
</Feld> </Feld>
<Feld <Feld
id="sozialhilfe" id="sozialhilfe"
beschriftung="Anteil der Sozialhilfe" beschriftung="Anteil der Sozialhilfe"
fehler={zustand.felder?.sozialhilfe} fehler={zustand.felder?.sozialhilfe}
erklaerung="Was der Bezirk laut Bescheid übernimmt." erklaerung="Was der Bezirk laut Bescheid übernimmt."
> >
<Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} /> <Input name="sozialhilfe" inputMode="decimal" defaultValue={werte.sozialhilfe} />
</Feld> </Feld>
<Feld <Feld
id="eigenmittel" id="eigenmittel"
beschriftung="Eigenmittel" beschriftung="Eigenmittel"
fehler={zustand.felder?.eigenmittel} fehler={zustand.felder?.eigenmittel}
erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde." erklaerung="Was aus dem eigenen Vermögen zugeschossen wurde."
> >
<Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} /> <Input name="eigenmittel" inputMode="decimal" defaultValue={werte.eigenmittel} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Was verbleiben muss" titel="Was verbleiben muss"
beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest." beschreibung="Barbetrag und Bekleidungspauschale sind Teil des Bedarfs, nicht Rest."
> >
<Feld <Feld
id="barbetrag" id="barbetrag"
beschriftung="Barbetrag" beschriftung="Barbetrag"
fehler={zustand.felder?.barbetrag} fehler={zustand.felder?.barbetrag}
erklaerung={ erklaerung={
<> <>
{vorschlagBarbetrag.text}{' '} {vorschlagBarbetrag.text}{' '}
<button <button
type="button" type="button"
onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)} onClick={() => setBarbetrag(vorschlagBarbetrag.betrag)}
className="font-medium text-primary underline-offset-4 hover:underline" className="font-medium text-primary underline-offset-4 hover:underline"
> >
Wert übernehmen Wert übernehmen
</button> </button>
</> </>
} }
> >
<Input <Input
name="barbetrag" name="barbetrag"
inputMode="decimal" inputMode="decimal"
value={barbetrag} value={barbetrag}
onChange={(e) => setBarbetrag(e.target.value)} onChange={(e) => setBarbetrag(e.target.value)}
/> />
</Feld> </Feld>
<Feld <Feld
id="bekleidungspauschale" id="bekleidungspauschale"
beschriftung="Bekleidungspauschale" beschriftung="Bekleidungspauschale"
fehler={zustand.felder?.bekleidungspauschale} fehler={zustand.felder?.bekleidungspauschale}
erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid." erklaerung="Höhe legt der Träger fest (§ 27b Abs. 1 SGB XII) — übernimm den Betrag aus dem Bescheid."
> >
<Input <Input
name="bekleidungspauschale" name="bekleidungspauschale"
inputMode="decimal" inputMode="decimal"
defaultValue={werte.bekleidungspauschale} defaultValue={werte.bekleidungspauschale}
/> />
</Feld> </Feld>
<Feld <Feld
id="abwesenheitstage" id="abwesenheitstage"
beschriftung="Abwesenheitstage" beschriftung="Abwesenheitstage"
fehler={zustand.felder?.abwesenheitstage} fehler={zustand.felder?.abwesenheitstage}
erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung." erklaerung="Krankenhaus, Reha, Urlaub. Die ersten drei Tage sind voll zu zahlen; ab dem vierten mindert sich das Entgelt nach dem Rahmenvertrag — die Anwendung schätzt das nur, maßgeblich ist die Heimrechnung."
> >
<Input <Input
name="abwesenheitstage" name="abwesenheitstage"
type="number" type="number"
min={0} min={0}
max={31} max={31}
defaultValue={werte.abwesenheitstage} defaultValue={werte.abwesenheitstage}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}> <Feld id="bemerkung" beschriftung="Bemerkung" fehler={zustand.felder?.bemerkung}>
<Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} /> <Textarea name="bemerkung" defaultValue={werte.bemerkung} rows={2} />
</Feld> </Feld>
</CardContent> </CardContent>
</Card> </Card>
<Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" /> <Rechtshinweis rechtsgrundlage="§ 43 SGB XI, § 43c SGB XI, § 27b SGB XII" />
<Absenden>Monat speichern</Absenden> <Absenden>Monat speichern</Absenden>
</form> </form>
) )
} }
+30 -3
View File
@@ -4,6 +4,7 @@ import Link from 'next/link'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import { Coins, FileDown, Mail, Plus } from 'lucide-react' import { Coins, FileDown, Mail, Plus } from 'lucide-react'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
@@ -36,6 +37,7 @@ export function Rueckkaufswert({
anschreiben, anschreiben,
stichtagVorschlag, stichtagVorschlag,
darfSchreiben, darfSchreiben,
alterHinweis,
}: { }: {
betreuteId: string betreuteId: string
vertragId: string vertragId: string
@@ -46,6 +48,8 @@ export function Rueckkaufswert({
anschreiben: string anschreiben: string
stichtagVorschlag: string stichtagVorschlag: string
darfSchreiben: boolean darfSchreiben: boolean
/** Kurztext, wenn der juengste Wert zu alt ist - sonst null. */
alterHinweis: string | null
}) { }) {
const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>( const [erfassen, erfassenAktion] = useActionState<Formularzustand, FormData>(
rueckkaufswertErfassen, rueckkaufswertErfassen,
@@ -55,6 +59,9 @@ export function Rueckkaufswert({
rueckkaufswertAnfordern, rueckkaufswertAnfordern,
{}, {},
) )
// Die Liste kommt bereits juengster Wert zuerst von der Seite.
const letzter = werte[0] ?? null
const [briefOffen, setBriefOffen] = useState(false) const [briefOffen, setBriefOffen] = useState(false)
const [erfassenOffen, setErfassenOffen] = useState(false) const [erfassenOffen, setErfassenOffen] = useState(false)
@@ -88,7 +95,7 @@ export function Rueckkaufswert({
</Alert> </Alert>
)} )}
{werte.length === 0 ? ( {!letzter ? (
<Leerzustand <Leerzustand
titel="Noch kein Rückkaufswert erfasst" titel="Noch kein Rückkaufswert erfasst"
beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben." beschreibung="Fordere ihn bei der Gesellschaft an. Frag im selben Schreiben nach der beitragsfreien Versicherungssumme — sonst musst du für den Vergleich ein zweites Mal schreiben."
@@ -103,7 +110,26 @@ export function Rueckkaufswert({
} }
/> />
) : ( ) : (
<Table> <>
{/* Der zuletzt mitgeteilte Wert gehoert nach oben: Wer beim
Telefonat mit der Gesellschaft danach gefragt wird, hat
keine Zeit, eine Historie zu lesen. */}
<div className="rounded-md border p-4">
<p className="text-sm text-muted-foreground">Zuletzt mitgeteilt</p>
<p className="text-2xl font-semibold tabular-nums">
{formatCent(letzter.wertCent)}
</p>
<p className="text-sm text-muted-foreground">
zum Stichtag {formatDatum(letzter.stichtag)} · {letzter.wertquelle}
</p>
{alterHinweis && (
<Badge variant="gelb" className="mt-2">
{alterHinweis} — vor dem nächsten Bericht neu anfragen
</Badge>
)}
</div>
<Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Stichtag</TableHead> <TableHead>Stichtag</TableHead>
@@ -126,7 +152,8 @@ export function Rueckkaufswert({
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
</>
)} )}
{darfSchreiben && ( {darfSchreiben && (
+237 -237
View File
@@ -1,237 +1,237 @@
'use client' 'use client'
import { useActionState, useState } from 'react' import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client' import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select' import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld' import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis' import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular' import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import { import {
BEWERTUNG_BESCHRIFTUNG, BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG, BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG, RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG, VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG, VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten' } from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege' import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege' import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz' import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = { export type VertragWerte = {
id?: string id?: string
bezeichnung: string bezeichnung: string
gesellschaft: string gesellschaft: string
art: string art: string
status: string status: string
vertragsnummer: string vertragsnummer: string
beginnAm: string beginnAm: string
endeAm: string endeAm: string
kuendigungsfristMonate: string kuendigungsfristMonate: string
kuendigungZum: string kuendigungZum: string
gekuendigtAm: string gekuendigtAm: string
beitrag: string beitrag: string
rhythmus: string rhythmus: string
bewertungSozialhilfe: string bewertungSozialhilfe: string
bewertungBegruendung: string bewertungBegruendung: string
notizen: string notizen: string
} }
export const LEERER_VERTRAG: VertragWerte = { export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '', bezeichnung: '',
gesellschaft: '', gesellschaft: '',
art: 'LEBENSVERSICHERUNG', art: 'LEBENSVERSICHERUNG',
status: 'AKTIV', status: 'AKTIV',
vertragsnummer: '', vertragsnummer: '',
beginnAm: '', beginnAm: '',
endeAm: '', endeAm: '',
kuendigungsfristMonate: '3', kuendigungsfristMonate: '3',
kuendigungZum: '', kuendigungZum: '',
gekuendigtAm: '', gekuendigtAm: '',
beitrag: '', beitrag: '',
rhythmus: 'MONATLICH', rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT', bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '', bewertungBegruendung: '',
notizen: '', notizen: '',
} }
export function VertragFormular({ export function VertragFormular({
betreuteId, betreuteId,
werte, werte,
}: { }: {
betreuteId: string betreuteId: string
werte: VertragWerte werte: VertragWerte
}) { }) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {}) const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe) const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id const istNeu = !werte.id
return ( return (
<form action={aktion} className="space-y-8" noValidate> <form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} /> <input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />} {werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} /> <Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer."> <Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld <Feld
id="bezeichnung" id="bezeichnung"
beschriftung="Bezeichnung" beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst." erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung} fehler={zustand.felder?.bezeichnung}
pflicht pflicht
> >
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} /> <Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld> </Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}> <Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} /> <Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld> </Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht> <Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}> <Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => ( {VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}> <option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]} {VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}> <Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}> <Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => ( {VERTRAG_STATUS.map((s) => (
<option key={s} value={s}> <option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]} {VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld <Feld
id="vertragsnummer" id="vertragsnummer"
beschriftung="Vertragsnummer" beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu." erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer} fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2" className="sm:col-span-2"
> >
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} /> <Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Laufzeit und Kündigung" titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag." beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
> >
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}> <Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} /> <Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld> </Feld>
<Feld <Feld
id="endeAm" id="endeAm"
beschriftung="Vereinbartes Ende" beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet." erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung. Steht hier ein Datum, ergibt sich daraus zugleich die Laufzeit, um die sich der Vertrag verlängert — sonst wird mit einem Jahr gerechnet."
fehler={zustand.felder?.endeAm} fehler={zustand.felder?.endeAm}
> >
<Input name="endeAm" type="date" defaultValue={werte.endeAm} /> <Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld> </Feld>
<Feld <Feld
id="kuendigungsfristMonate" id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten" beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate." erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate} fehler={zustand.felder?.kuendigungsfristMonate}
> >
<Input <Input
name="kuendigungsfristMonate" name="kuendigungsfristMonate"
inputMode="numeric" inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate} defaultValue={werte.kuendigungsfristMonate}
/> />
</Feld> </Feld>
<Feld <Feld
id="kuendigungZum" id="kuendigungZum"
beschriftung="Gekündigt zum" beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum} fehler={zustand.felder?.kuendigungZum}
> >
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} /> <Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld> </Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}> <Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} /> <Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht."> <Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}> <Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" /> <Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld> </Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}> <Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}> <Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option> <option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => ( {ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}> <option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]} {RHYTHMUS_BESCHRIFTUNG[r]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feldgruppe <Feldgruppe
titel="Bewertung fürs Sozialamt" titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft." beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
> >
<Feld <Feld
id="bewertungSozialhilfe" id="bewertungSozialhilfe"
beschriftung="Einordnung" beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]} erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe} fehler={zustand.felder?.bewertungSozialhilfe}
> >
<Select <Select
name="bewertungSozialhilfe" name="bewertungSozialhilfe"
value={bewertung} value={bewertung}
onChange={(e) => setBewertung(e.target.value)} onChange={(e) => setBewertung(e.target.value)}
> >
{BEWERTUNGEN.map((b) => ( {BEWERTUNGEN.map((b) => (
<option key={b} value={b}> <option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]} {BEWERTUNG_BESCHRIFTUNG[b]}
</option> </option>
))} ))}
</Select> </Select>
</Feld> </Feld>
<Feld <Feld
id="bewertungBegruendung" id="bewertungBegruendung"
beschriftung="Begründung" beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren." erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung} fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'} pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2" className="sm:col-span-2"
> >
<Textarea <Textarea
name="bewertungBegruendung" name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung} defaultValue={werte.bewertungBegruendung}
rows={4} rows={4}
maxLength={4000} maxLength={4000}
/> />
</Feld> </Feld>
</Feldgruppe> </Feldgruppe>
<Feld <Feld
id="notizen" id="notizen"
beschriftung="Notizen" beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert." erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen} fehler={zustand.felder?.notizen}
> >
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} /> <Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld> </Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz /> <Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf> <Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form> </form>
) )
} }
@@ -36,6 +36,7 @@ export type VorgangsFormularWerte = {
beschreibung: string | null beschreibung: string | null
gegenstelle: string | null gegenstelle: string | null
aktenzeichen: string | null aktenzeichen: string | null
ansprechpartnerId: string | null
verantwortlich: string | null verantwortlich: string | null
naechsterSchritt: string | null naechsterSchritt: string | null
faelligAm: Date | null faelligAm: Date | null
@@ -50,6 +51,7 @@ export function VorgangsFormular({
vorlageSchluessel, vorlageSchluessel,
werte, werte,
abbrechenHref, abbrechenHref,
kontakte,
}: { }: {
modus: 'anlegen' | 'aendern' modus: 'anlegen' | 'aendern'
betreuteId: string betreuteId: string
@@ -57,6 +59,8 @@ export function VorgangsFormular({
vorlageSchluessel?: string | null vorlageSchluessel?: string | null
werte: VorgangsFormularWerte werte: VorgangsFormularWerte
abbrechenHref: string abbrechenHref: string
/** Kontakte dieser Akte, zur Wahl des Ansprechpartners. */
kontakte: { id: string; name: string; organisation: string | null }[]
}) { }) {
const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>( const [zustand, aktion, laeuft] = useActionState<Formularzustand, FormData>(
modus === 'anlegen' ? vorgangAnlegen : vorgangAendern, modus === 'anlegen' ? vorgangAnlegen : vorgangAendern,
@@ -146,6 +150,22 @@ export function VorgangsFormular({
/> />
</Feld> </Feld>
<Feld
id="ansprechpartnerId"
beschriftung="Ansprechpartner"
erklaerung="Wer dort die Sache bearbeitet. Schreiben zu diesem Vorgang reden ihn namentlich an — das bringt sie an der Poststelle vorbei."
fehler={zustand.felder?.ansprechpartnerId}
>
<Select name="ansprechpartnerId" defaultValue={werte.ansprechpartnerId ?? ''}>
<option value="">– keiner benannt –</option>
{kontakte.map((kontakt) => (
<option key={kontakt.id} value={kontakt.id}>
{[kontakt.name, kontakt.organisation].filter(Boolean).join(' · ')}
</option>
))}
</Select>
</Feld>
<Feld <Feld
id="aktenzeichen" id="aktenzeichen"
beschriftung="Aktenzeichen" beschriftung="Aktenzeichen"
+92
View File
@@ -0,0 +1,92 @@
import 'server-only'
import { env } from '@/lib/env'
/**
* Von welchem Anschluss kommt diese Anfrage?
*
* Gebraucht wird das nur fuer eines: die zweite Dimension der Anmeldesperre.
* Gezaehlt wird nach E-Mail UND nach Anschluss, damit es weder hilft,
* dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
* Adressen von einem.
*
* ## Warum das vorher nichts geschuetzt hat
*
* Der Wert kam aus "x-forwarded-for", und zwar aus dem ERSTEN Eintrag:
*
* kopf.get('x-forwarded-for')?.split(',')[0]?.trim()
*
* Dieser Kopf ist eine Liste, an die jeder Zwischenknoten hinten anhaengt.
* Der erste Eintrag ist deshalb der, den der Aufrufer selbst geschickt hat -
* frei erfunden. Wer bei jeder Anfrage eine andere Zahl hineinschrieb, sah
* fuer die Sperre wie tausend verschiedene Anschluesse aus. Die Zaehlung
* nach Anschluss war damit nicht nur wirkungslos, sondern irrefuehrend: Im
* Protokoll standen tausend Anschluesse, wo einer war.
*
* Richtig ist das andere Ende: Was der vertraute Proxy anhaengt, hat er
* selbst gesehen und nicht der Aufrufer geschrieben.
*
* ## Und wenn kein Proxy davor steht
*
* Dann ist der Kopf vollstaendig fremdbestimmt und es gibt hier nichts zu
* holen - also null. Keine Zaehlung nach Anschluss ist besser als eine, die
* sich mit einer Kopfzeile aushebeln laesst. Deshalb muss der Betrieb
* ausdruecklich sagen, dass ein Proxy davorsteht (VERTRAUE_PROXY=true).
*/
let gewarnt = false
/**
* Warnt einmal, wenn weitergereichte Koepfe ankommen, ihnen aber nicht
* geglaubt wird. Genau dann fehlt die Einstellung im Betrieb.
*/
function einmalWarnen(): void {
if (gewarnt) return
gewarnt = true
console.warn(
'[anschluss] Es kommen "x-forwarded-for"-Koepfe an, aber VERTRAUE_PROXY ist nicht gesetzt. ' +
'Die Anmeldesperre zaehlt deshalb nur nach E-Mail-Adresse, nicht nach Anschluss. ' +
'Steht ein Reverse Proxy davor, setze VERTRAUE_PROXY=true (und, bei mehreren, ' +
'VERTRAUE_PROXY_STUFEN=<Anzahl>). Steht keiner davor, ist das hier richtig so.',
)
}
/**
* Liest die Adresse des Aufrufers aus den Koepfen.
*
* @param kopf Die Kopfzeilen der Anfrage.
* @returns Die Adresse, oder null, wenn sie sich nicht vertrauenswuerdig
* bestimmen laesst.
*/
export function anschlussAus(kopf: Headers): string | null {
const weitergereicht = kopf.get('x-forwarded-for')
const echterIp = kopf.get('x-real-ip')
if (!env.vertraueProxy) {
if (weitergereicht || echterIp) einmalWarnen()
return null
}
if (weitergereicht) {
const kette = weitergereicht
.split(',')
.map((teil) => teil.trim())
.filter((teil) => teil.length > 0)
// Von hinten zaehlen: Der letzte Eintrag stammt vom naechsten
// Zwischenknoten, der zweitletzte von dem davor. Bei einer Stufe ist
// der letzte Eintrag der Aufrufer, wie der Proxy ihn gesehen hat.
if (kette.length > 0) {
const stelle = kette.length - env.vertraueProxyStufen
return kette[Math.max(0, stelle)] ?? null
}
}
// Manche Proxys setzen nur diesen Kopf. Er enthaelt genau eine Adresse,
// also gibt es hier nichts abzuzaehlen.
return echterIp?.trim() || null
}
/** Nur fuer Tests: setzt die einmalige Warnung zurueck. */
export function warnungZuruecksetzen(): void {
gewarnt = false
}
+82 -14
View File
@@ -14,8 +14,32 @@ import { ipHashen } from '@/lib/crypto/hash'
* Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es * Gezaehlt wird nach E-Mail UND nach Anschluss (IP-Hash). Damit hilft es
* weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele * weder, dieselbe Adresse von vielen Anschluessen zu probieren, noch viele
* Adressen von einem Anschluss. * Adressen von einem Anschluss.
*
* ## Warum abgewiesene Versuche nicht mitzaehlen
*
* Frueher wurde jeder Aufruf festgehalten, auch der, der wegen einer
* laufenden Sperre gar nicht geprueft wurde. Zusammen mit einer Frist, die
* vom juengsten Fehlversuch aus gerechnet wurde, ergab das eine
* Rueckkopplung: Jeder abgewiesene Aufruf schob die Frist um die volle
* Sperrdauer nach hinten. Wer alle zehn Minuten ein falsches Passwort an
* eine fremde Adresse schickte, sperrte deren Inhaber dauerhaft aus - ohne
* das Passwort zu kennen. Und jeder dieser Aufrufe hinterliess eine Zeile,
* die erst der Wartungslauf nach 30 Tagen wieder los wurde.
*
* Deshalb jetzt zwei getrennte Dinge:
*
* - Ein *geprueft* fehlgeschlagener Versuch (falsches Passwort, falscher
* Code, unbekannte Adresse) zaehlt und wird einzeln festgehalten.
* - Ein *abgewiesener* Aufruf waehrend der Sperre zaehlt nicht und wird
* hoechstens einmal je Sperre vermerkt.
*
* Damit steht die Frist fest, sobald sie einmal gesetzt ist, und die
* Tabelle waechst nicht mit der Zahl der Angriffsversuche.
*/ */
/** Grund fuer eine Zeile, die nur den abgewiesenen Aufruf dokumentiert. */
const GRUND_ABGEWIESEN = 'gesperrt'
export type SperrErgebnis = export type SperrErgebnis =
| { gesperrt: false; verbleibendeVersuche: number } | { gesperrt: false; verbleibendeVersuche: number }
| { gesperrt: true; nochSekunden: number } | { gesperrt: true; nochSekunden: number }
@@ -29,33 +53,49 @@ function fensterStart(): Date {
* Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst * Passwortvergleich aufgerufen - ein gesperrter Zugang soll gar nicht erst
* Rechenzeit fuer Argon2 kosten. * Rechenzeit fuer Argon2 kosten.
*/ */
export async function anmeldungErlaubt( export async function anmeldungErlaubt(email: string, ip: string | null): Promise<SperrErgebnis> {
email: string, const jetzt = Date.now()
ip: string | null,
): Promise<SperrErgebnis> {
const seit = fensterStart()
const kennung = email.trim().toLowerCase() const kennung = email.trim().toLowerCase()
const ipH = ipHashen(ip) const ipH = ipHashen(ip)
const fehlversuche = await db.loginVersuch.findMany({ const fehlversuche = await db.loginVersuch.findMany({
where: { where: {
erfolg: false, erfolg: false,
zeitpunkt: { gte: seit }, // Abgewiesene Aufrufe zaehlen nicht mit - sonst haelt sich die Sperre
// selbst am Leben, siehe Kopf dieser Datei.
grund: { not: GRUND_ABGEWIESEN },
zeitpunkt: { gte: new Date(jetzt - env.loginFensterSekunden * 1000) },
OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])], OR: [{ email: kennung }, ...(ipH ? [{ ipHash: ipH }] : [])],
}, },
orderBy: { zeitpunkt: 'desc' }, orderBy: { zeitpunkt: 'asc' },
select: { zeitpunkt: true }, select: { zeitpunkt: true },
take: env.loginMaxVersuche + 1, // Deckel gegen eine ausufernde Abfrage. Mehr als eine Handvoll Runden
// passt ohnehin nicht in ein Zeitfenster, in dem jede Runde gesperrt
// wird.
take: 200,
}) })
if (fehlversuche.length >= env.loginMaxVersuche) { // Von alt nach neu durchgehen und die Runden nachvollziehen: Sobald das
const juengster = fehlversuche[0]!.zeitpunkt.getTime() // Mass voll ist, beginnt an genau diesem Versuch eine Sperre, und der
const frei = juengster + env.loginSperreSekunden * 1000 // Zaehler faengt von vorn an. Ist ihre Frist inzwischen abgelaufen, sind
const nochSekunden = Math.max(1, Math.ceil((frei - Date.now()) / 1000)) // die Versuche davor abgegolten - sonst bliebe der Zugang bis zum Ende
return { gesperrt: true, nochSekunden } // des Zeitfensters dicht, obwohl die Sperre laengst um ist.
let zaehler = 0
let sperreBis = 0
for (const versuch of fehlversuche) {
zaehler += 1
if (zaehler >= env.loginMaxVersuche) {
sperreBis = versuch.zeitpunkt.getTime() + env.loginSperreSekunden * 1000
zaehler = 0
}
} }
return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - fehlversuche.length } if (sperreBis > jetzt) {
return { gesperrt: true, nochSekunden: Math.max(1, Math.ceil((sperreBis - jetzt) / 1000)) }
}
return { gesperrt: false, verbleibendeVersuche: env.loginMaxVersuche - zaehler }
} }
/** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */ /** Haelt einen Versuch fest. Der Grund hilft spaeter beim Nachvollziehen. */
@@ -75,6 +115,34 @@ export async function versuchFesthalten(
}) })
} }
/**
* Vermerkt einen abgewiesenen Aufruf - aber nur den ersten je Sperre.
*
* Dass eine Sperre gegriffen hat, will man im Protokoll sehen. Wie oft
* dagegen geklopft wurde, ist die Information nicht wert: Sie kostet eine
* Zeile pro Anfrage, und genau daraus laesst sich die Tabelle vollschreiben.
* Eine Zeile je Sperre reicht, um den Vorgang zu erkennen.
*
* @returns true, wenn tatsaechlich vermerkt wurde.
*/
export async function sperreVermerken(email: string, ip: string | null): Promise<boolean> {
const kennung = email.trim().toLowerCase()
const vorhanden = await db.loginVersuch.findFirst({
where: {
email: kennung,
erfolg: false,
grund: GRUND_ABGEWIESEN,
zeitpunkt: { gte: fensterStart() },
},
select: { id: true },
})
if (vorhanden) return false
await versuchFesthalten(kennung, ip, false, GRUND_ABGEWIESEN)
return true
}
/** /**
* Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser * Nach einer erfolgreichen Anmeldung werden die Fehlversuche dieser
* Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen. * Adresse geloescht - sonst bliebe der Zaehler bis zum Fensterende stehen.
+68 -4
View File
@@ -46,8 +46,46 @@ export async function aktuellerBenutzer(): Promise<User | null> {
return benutzer return benutzer
} }
/** Wie aktuellerBenutzer, leitet aber zum Anmeldeformular um. */ /** Hat dieser Benutzer den zweiten Faktor abgeschlossen? */
export function zweiterFaktorFertig(benutzer: User): boolean {
return Boolean(benutzer.totpAktiviertAm && benutzer.totpSecretVerschluesselt)
}
/**
* Der angemeldete Benutzer - mit abgeschlossenem zweitem Faktor.
*
* Die MFA-Pflicht steckt bewusst HIER und nicht im App-Layout. Ein Layout
* schuetzt nur, was durch das Layout gerendert wird; Server Actions und
* Route Handler laufen daran vorbei. Wer eine Passwort-Sitzung hat, aber
* die TOTP-Einrichtung nie abgeschlossen hat, konnte sie frueher direkt
* aufrufen - "MFA ist Pflicht" war damit eine Aussage ueber die
* Oberflaeche, keine Sicherheitsgrenze.
*
* Massgeblich ist der Datenbankstand aus aktuellerBenutzer(), nicht das
* Feld im Sitzungstoken: Das laesst sich ueber session.update() vom
* Client beeinflussen.
*
* Die Ausnahmen sind klein und aufgezaehlt - Einrichtung des zweiten
* Faktors und Abmelden. Wer eine weitere braucht, nimmt bewusst
* erfordereBenutzerOhneZweitenFaktor() und begruendet es dort.
*/
export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise<User> { export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise<User> {
const benutzer = await erfordereBenutzerOhneZweitenFaktor(zielNachAnmeldung)
if (!zweiterFaktorFertig(benutzer)) {
redirect('/zwei-faktor/einrichten')
}
return benutzer
}
/**
* Nur fuer die Handvoll Stellen, die VOR abgeschlossener MFA erreichbar
* sein muessen: die Einrichtung selbst und das Abmelden. Jede weitere
* Verwendung ist ein Loch in der MFA-Pflicht und braucht eine
* Begruendung im Code.
*/
export async function erfordereBenutzerOhneZweitenFaktor(
zielNachAnmeldung?: string,
): Promise<User> {
const benutzer = await aktuellerBenutzer() const benutzer = await aktuellerBenutzer()
if (!benutzer) { if (!benutzer) {
const ziel = zielNachAnmeldung ? `?weiter=${encodeURIComponent(zielNachAnmeldung)}` : '' const ziel = zielNachAnmeldung ? `?weiter=${encodeURIComponent(zielNachAnmeldung)}` : ''
@@ -57,11 +95,37 @@ export async function erfordereBenutzer(zielNachAnmeldung?: string): Promise<Use
} }
/** /**
* Erzwingt den eingerichteten zweiten Faktor. Wird im App-Layout * Fuer Route Handler: Statt umzuleiten wird abgewiesen.
* aufgerufen: ohne TOTP kommt niemand an Fachdaten. *
* Ein Redirect ist bei einer Datei- oder Kalenderantwort die falsche
* Antwort - der Aufrufer will Daten, nicht eine Anmeldeseite als Inhalt.
*/
export async function benutzerFuerRoute(): Promise<
{ ok: true; benutzer: User } | { ok: false; antwort: Response }
> {
const benutzer = await aktuellerBenutzer()
if (!benutzer) {
return { ok: false, antwort: new Response('Nicht angemeldet.', { status: 401 }) }
}
if (!zweiterFaktorFertig(benutzer)) {
return {
ok: false,
antwort: new Response('Zweiter Faktor ist nicht eingerichtet.', { status: 403 }),
}
}
return { ok: true, benutzer }
}
/**
* Erzwingt den eingerichteten zweiten Faktor.
*
* Bleibt als eigener Aufruf im App-Layout bestehen, obwohl
* erfordereBenutzer() das inzwischen selbst tut: Zwei Sperren an
* derselben Stelle kosten nichts und die aeussere ist die, die man beim
* Lesen des Layouts sieht.
*/ */
export async function erfordereZweitenFaktor(benutzer: User): Promise<void> { export async function erfordereZweitenFaktor(benutzer: User): Promise<void> {
if (!benutzer.totpAktiviertAm || !benutzer.totpSecretVerschluesselt) { if (!zweiterFaktorFertig(benutzer)) {
redirect('/zwei-faktor/einrichten') redirect('/zwei-faktor/einrichten')
} }
} }
+121
View File
@@ -0,0 +1,121 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Gehoert ein verknuepfter Datensatz zu dieser Akte?
*
* Wenn ein Formular einen Fremdschluessel mitschickt - Konto, Vertrag,
* Immobilie, Dokument, Vorgang, Kontakt -, dann kommt der aus einer
* Auswahlliste im Browser. Der Server darf sich darauf nicht verlassen:
* Eine veraenderte Anfrage kann jede beliebige Id enthalten.
*
* Ohne die Pruefung entstuende eine Verknuepfung ueber Aktengrenzen
* hinweg. Was daran gefaehrlich ist, sieht man erst an den Folgen: Der
* Rueckkaufswert der einen Akte haengt am Vertrag der anderen, ein
* fremder Name steht im Briefkopf, ein fremder Beschluss in der
* Genehmigungskette.
*
* Solange nur eine betreute Person angelegt ist, ist all das folgenlos.
* Die Pruefung gehoert trotzdem hin - sie kostet eine Abfrage, und die
* zweite betreute Person kommt frueher, als man denkt.
*
* Bewusst KEINE Ausnahme geworfen: Formularaktionen geben Fehler zurueck,
* damit die Eingaben des Benutzers nicht verlorengehen.
*/
/** Die Tabellen, auf die ein Formular verweisen darf. */
export type Verweisziel =
| 'konto'
| 'vertrag'
| 'immobilie'
| 'dokument'
| 'vorgang'
| 'kontakt'
| 'vermoegensposten'
| 'buchung'
type Sucher = (id: string, betreuteId: string) => Promise<{ id: string } | null>
/**
* Je Ziel eine Abfrage, ausgeschrieben statt dynamisch zusammengesetzt.
*
* Das ist ein paar Zeilen laenger als ein Zugriff ueber den Tabellennamen -
* dafuer ist die Liste der erlaubten Ziele eine bewusste Aufzaehlung und
* keine Nebenwirkung, und der Typpruefer sieht jede Abfrage.
*/
const SUCHER: Record<Verweisziel, Sucher> = {
konto: (id, betreuteId) =>
db.konto.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
vertrag: (id, betreuteId) =>
db.vertrag.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
immobilie: (id, betreuteId) =>
db.immobilie.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
dokument: (id, betreuteId) =>
db.dokument.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
vorgang: (id, betreuteId) =>
db.vorgang.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
kontakt: (id, betreuteId) =>
db.kontakt.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
vermoegensposten: (id, betreuteId) =>
db.vermoegensposten.findFirst({
where: { id, betreuteId, ...nichtGeloescht },
select: { id: true },
}),
buchung: (id, betreuteId) =>
db.buchung.findFirst({ where: { id, betreuteId, ...nichtGeloescht }, select: { id: true } }),
}
const BESCHRIFTUNG: Record<Verweisziel, string> = {
konto: 'Dieses Konto',
vertrag: 'Dieser Vertrag',
immobilie: 'Diese Immobilie',
dokument: 'Dieses Dokument',
vorgang: 'Dieser Vorgang',
kontakt: 'Dieser Kontakt',
vermoegensposten: 'Dieser Vermögensposten',
buchung: 'Diese Buchung',
}
/**
* Prueft einen einzelnen Verweis.
*
* Ein leerer Verweis ist in Ordnung - "keine Verknuepfung" ist ein
* gueltiger Zustand und keine Verletzung.
*
* @returns null, wenn alles stimmt; sonst die fertige Fehlermeldung.
*/
export async function verweisPruefen(
ziel: Verweisziel,
id: string | null | undefined,
betreuteId: string,
): Promise<string | null> {
if (!id) return null
const treffer = await SUCHER[ziel](id, betreuteId)
return treffer ? null : `${BESCHRIFTUNG[ziel]} gehört nicht zu dieser betreuten Person.`
}
/**
* Prueft mehrere Verweise auf einmal und meldet den ersten Verstoss.
*
* Die Abfragen laufen nebeneinander: Bei vier Verweisen ist das eine
* Wartezeit statt vier.
*
* @example
* const fehler = await verweisePruefen(betreuteId, {
* konto: werte.kontoId,
* vertrag: werte.vertragId,
* })
* if (fehler) return { fehler }
*/
export async function verweisePruefen(
betreuteId: string,
verweise: Partial<Record<Verweisziel, string | null | undefined>>,
): Promise<string | null> {
const eintraege = Object.entries(verweise) as [Verweisziel, string | null | undefined][]
const ergebnisse = await Promise.all(
eintraege.map(([ziel, id]) => verweisPruefen(ziel, id, betreuteId)),
)
return ergebnisse.find((meldung) => meldung !== null) ?? null
}
+93
View File
@@ -0,0 +1,93 @@
import 'server-only'
import type { User } from '@prisma/client'
import { db } from '@/lib/db'
import { wiederherstellungscodeFinden } from '@/lib/crypto/hash'
import { totpPruefen, totpSchritt, totpSecretEntschluesseln } from '@/lib/crypto/totp'
/**
* Einloesen des zweiten Faktors - einmal und nur einmal.
*
* Ein TOTP-Code gilt 30 Sekunden, mit der Toleranz von einem Fenster bis zu
* 90. Wer ihn in dieser Zeit noch einmal einreicht, kommt ohne die
* Toleranz ein zweites Mal herein: abgelesen ueber die Schulter,
* mitgeschrieben von einem Zwischenglied, oder aus einem Formular, das im
* Browserverlauf steht. Der Kommentar in totp.ts versprach seit dem Anfang,
* der Aufrufer speichere das getroffene Fenster - getan hat es niemand.
*
* Deshalb hier: Der Schritt wird festgehalten, und angenommen wird nur ein
* groesserer. Damit ist jeder Code genau einmal gut.
*
* ## Warum updateMany und nicht update
*
* Die Bedingung steht im WHERE, nicht in einem if davor. Zwei Anfragen mit
* demselben Code, gleichzeitig abgeschickt, wuerden sonst beide den
* Vergleich bestehen, bevor eine von ihnen schreibt. So entscheidet die
* Datenbank: Genau eine trifft eine Zeile, die andere keine.
*/
export type ZweiterFaktorErgebnis =
| { ok: true; art: 'totp' | 'wiederherstellungscode' }
| { ok: false; grund: 'falsch' | 'wiederverwendet' }
/** Ab dieser Laenge wird die Eingabe als Wiederherstellungscode gelesen. */
const CODE_MINDESTLAENGE = 12
/**
* Prueft die Eingabe und verbraucht sie.
*
* Nimmt sowohl einen sechsstelligen TOTP-Code als auch einen
* Wiederherstellungscode. Letzterer wird nach Gebrauch aus der Liste
* entfernt - auch das eine Einmalverwendung, nur mit anderem Mechanismus.
*
* @param benutzer Der Datensatz aus der Datenbank, nicht aus der Sitzung.
* @param eingabe Was im Formular stand, ungereinigt.
*/
export async function zweitenFaktorEinloesen(
benutzer: User,
eingabe: string,
): Promise<ZweiterFaktorErgebnis> {
const ohneTrenner = eingabe.replace(/[\s-]/g, '')
if (/^\d{6}$/.test(ohneTrenner)) {
if (!benutzer.totpSecretVerschluesselt) return { ok: false, grund: 'falsch' }
const secret = totpSecretEntschluesseln(benutzer.totpSecretVerschluesselt)
const { gueltig, delta } = totpPruefen(secret, benutzer.email, ohneTrenner)
if (!gueltig || delta === null) return { ok: false, grund: 'falsch' }
const schritt = totpSchritt(delta)
const { count } = await db.user.updateMany({
where: {
id: benutzer.id,
OR: [{ totpLetzterSchritt: null }, { totpLetzterSchritt: { lt: schritt } }],
},
data: { totpLetzterSchritt: schritt },
})
// Keine Zeile getroffen: Dieser Schritt war schon einmal dran. Der Code
// ist rechnerisch richtig und trotzdem verbraucht.
if (count === 0) return { ok: false, grund: 'wiederverwendet' }
return { ok: true, art: 'totp' }
}
if (ohneTrenner.length >= CODE_MINDESTLAENGE) {
const treffer = await wiederherstellungscodeFinden(benutzer.wiederherstellungscodes, eingabe)
if (treffer < 0) return { ok: false, grund: 'falsch' }
// Auch hier die Bedingung in die Abfrage: Sonst koennten zwei
// gleichzeitige Anmeldungen denselben Code einloesen, weil beide noch
// die alte Liste sehen.
const rest = benutzer.wiederherstellungscodes.filter((_, i) => i !== treffer)
const { count } = await db.user.updateMany({
where: { id: benutzer.id, wiederherstellungscodes: { equals: benutzer.wiederherstellungscodes } },
data: { wiederherstellungscodes: rest },
})
if (count === 0) return { ok: false, grund: 'wiederverwendet' }
return { ok: true, art: 'wiederherstellungscode' }
}
return { ok: false, grund: 'falsch' }
}
+213 -213
View File
@@ -1,213 +1,213 @@
import 'server-only' import 'server-only'
import { import {
AlignmentType, AlignmentType,
BorderStyle, BorderStyle,
Document, Document,
Footer, Footer,
Header, Header,
HeadingLevel, HeadingLevel,
PageNumber, PageNumber,
Packer, Packer,
Paragraph, Paragraph,
TextRun, TextRun,
convertMillimetersToTwip, convertMillimetersToTwip,
} from 'docx' } from 'docx'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf' import { textZuAbschnitten } from '@/lib/berichte/jahresbericht.entwurf'
/** /**
* DOCX-Ausgabe. * DOCX-Ausgabe.
* *
* Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal * Warum zusaetzlich zum PDF? Weil ein Jahresbericht selten beim ersten Mal
* fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen * fertig ist. Das Gericht schickt Rueckfragen, der Rechtspfleger will einen
* Absatz anders, im naechsten Jahr will man den Vorjahresbericht als * Absatz anders, im naechsten Jahr will man den Vorjahresbericht als
* Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine * Grundlage nehmen. Ein PDF laesst sich dafuer nicht anfassen, eine
* .docx-Datei schon - in Word, LibreOffice oder Google Docs. * .docx-Datei schon - in Word, LibreOffice oder Google Docs.
* *
* Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die * Das PDF bleibt die Fassung, die abgegeben wird; die .docx ist die
* Arbeitsfassung. * Arbeitsfassung.
* *
* Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus * Der Kopf wird hier bewusst als eigener Typ beschrieben und nicht aus
* src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann * src/lib/pdf/rahmen.tsx importiert: Die DOCX-Ausgabe soll auch dann
* funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind * funktionieren, wenn am PDF-Rahmen gerade gearbeitet wird. Die Felder sind
* dieselben, ein Berichtskopf passt ohne Umbau hinein. * dieselben, ein Berichtskopf passt ohne Umbau hinein.
*/ */
export type DocxKopf = { export type DocxKopf = {
titel: string titel: string
betreute: { name: string; geburtsdatum: Date | null } betreute: { name: string; geburtsdatum: Date | null }
behoerde?: string behoerde?: string
aktenzeichen?: string aktenzeichen?: string
zeitraumVon?: Date | null zeitraumVon?: Date | null
zeitraumBis?: Date | null zeitraumBis?: Date | null
stichtag?: Date | null stichtag?: Date | null
rechtsgrundlage?: string rechtsgrundlage?: string
erstelltAm: Date erstelltAm: Date
erstelltVon: string erstelltVon: string
} }
const SCHRIFT = 'Arial' const SCHRIFT = 'Arial'
function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) { function zeile(text: string, optionen: { fett?: boolean; grau?: boolean; groesse?: number } = {}) {
return new TextRun({ return new TextRun({
text, text,
bold: optionen.fett ?? false, bold: optionen.fett ?? false,
color: optionen.grau ? '555555' : '000000', color: optionen.grau ? '555555' : '000000',
size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten size: (optionen.groesse ?? 11) * 2, // docx rechnet in halben Punkten
font: SCHRIFT, font: SCHRIFT,
}) })
} }
function kopfzeilen(kopf: DocxKopf): string[] { function kopfzeilen(kopf: DocxKopf): string[] {
const zeilen: string[] = [ const zeilen: string[] = [
`Betreute Person: ${kopf.betreute.name}` + `Betreute Person: ${kopf.betreute.name}` +
(kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''), (kopf.betreute.geburtsdatum ? `, geboren am ${formatDatum(kopf.betreute.geburtsdatum)}` : ''),
] ]
if (kopf.behoerde) zeilen.push(kopf.behoerde) if (kopf.behoerde) zeilen.push(kopf.behoerde)
if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`) if (kopf.aktenzeichen) zeilen.push(`Aktenzeichen: ${kopf.aktenzeichen}`)
if (kopf.zeitraumVon || kopf.zeitraumBis) { if (kopf.zeitraumVon || kopf.zeitraumBis) {
zeilen.push( zeilen.push(
`Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`, `Berichtszeitraum: ${formatDatum(kopf.zeitraumVon)} bis ${formatDatum(kopf.zeitraumBis)}`,
) )
} }
if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`) if (kopf.stichtag) zeilen.push(`Stichtag: ${formatDatum(kopf.stichtag)}`)
if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`) if (kopf.rechtsgrundlage) zeilen.push(`Rechtsgrundlage: ${kopf.rechtsgrundlage}`)
return zeilen return zeilen
} }
function unterschriftenblock(kopf: DocxKopf): Paragraph[] { function unterschriftenblock(kopf: DocxKopf): Paragraph[] {
return [ return [
new Paragraph({ children: [zeile('')], spacing: { before: 600 } }), new Paragraph({ children: [zeile('')], spacing: { before: 600 } }),
new Paragraph({ new Paragraph({
children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)], children: [zeile(`Ort, Datum: ${formatDatum(kopf.erstelltAm)}`)],
spacing: { after: 500 }, spacing: { after: 500 },
}), }),
new Paragraph({ new Paragraph({
children: [zeile('')], children: [zeile('')],
border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } }, border: { top: { style: BorderStyle.SINGLE, size: 6, color: '000000', space: 2 } },
spacing: { after: 60 }, spacing: { after: 60 },
}), }),
new Paragraph({ new Paragraph({
children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })], children: [zeile(`${kopf.erstelltVon}, rechtliche Betreuerin bzw. rechtlicher Betreuer`, { groesse: 9, grau: true })],
}), }),
] ]
} }
/** /**
* Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften * Setzt den (ueberarbeiteten) Entwurfstext als Word-Dokument. Ueberschriften
* erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am * erkennt der Satz an der fuehrenden Nummer, Aufzaehlungen am
* Aufzaehlungszeichen - dieselbe Regel wie im PDF. * Aufzaehlungszeichen - dieselbe Regel wie im PDF.
*/ */
export function docxAusEntwurf(kopf: DocxKopf, text: string): Document { export function docxAusEntwurf(kopf: DocxKopf, text: string): Document {
const abschnitte = textZuAbschnitten(text) const abschnitte = textZuAbschnitten(text)
const inhalt: Paragraph[] = [ const inhalt: Paragraph[] = [
new Paragraph({ new Paragraph({
children: [zeile(kopf.titel, { fett: true, groesse: 16 })], children: [zeile(kopf.titel, { fett: true, groesse: 16 })],
spacing: { after: 160 }, spacing: { after: 160 },
}), }),
...kopfzeilen(kopf).map( ...kopfzeilen(kopf).map(
(text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }), (text_) => new Paragraph({ children: [zeile(text_, { groesse: 10, grau: true })] }),
), ),
new Paragraph({ new Paragraph({
children: [zeile('')], children: [zeile('')],
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } }, border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: 'AAAAAA', space: 2 } },
spacing: { after: 200 }, spacing: { after: 200 },
}), }),
] ]
for (const abschnitt of abschnitte) { for (const abschnitt of abschnitte) {
if (abschnitt.titel) { if (abschnitt.titel) {
inhalt.push( inhalt.push(
new Paragraph({ new Paragraph({
children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })], children: [zeile(abschnitt.titel, { fett: true, groesse: 12 })],
heading: HeadingLevel.HEADING_2, heading: HeadingLevel.HEADING_2,
spacing: { before: 260, after: 100 }, spacing: { before: 260, after: 100 },
}), }),
) )
} }
for (const absatz of abschnitt.absaetze) { for (const absatz of abschnitt.absaetze) {
const istAufzaehlung = absatz.startsWith('•') const istAufzaehlung = absatz.startsWith('•')
inhalt.push( inhalt.push(
new Paragraph({ new Paragraph({
children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)], children: [zeile(istAufzaehlung ? absatz.replace(/^•\s*/, '') : absatz)],
bullet: istAufzaehlung ? { level: 0 } : undefined, bullet: istAufzaehlung ? { level: 0 } : undefined,
alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED, alignment: istAufzaehlung ? AlignmentType.LEFT : AlignmentType.JUSTIFIED,
spacing: { after: 120, line: 300 }, spacing: { after: 120, line: 300 },
}), }),
) )
} }
} }
inhalt.push(...unterschriftenblock(kopf)) inhalt.push(...unterschriftenblock(kopf))
return new Document({ return new Document({
creator: kopf.erstelltVon, creator: kopf.erstelltVon,
title: kopf.titel, title: kopf.titel,
description: `${kopf.titel} für ${kopf.betreute.name}`, description: `${kopf.titel} für ${kopf.betreute.name}`,
sections: [ sections: [
{ {
properties: { properties: {
page: { page: {
margin: { margin: {
top: convertMillimetersToTwip(25), top: convertMillimetersToTwip(25),
bottom: convertMillimetersToTwip(20), bottom: convertMillimetersToTwip(20),
left: convertMillimetersToTwip(25), left: convertMillimetersToTwip(25),
right: convertMillimetersToTwip(20), right: convertMillimetersToTwip(20),
}, },
}, },
}, },
headers: { headers: {
default: new Header({ default: new Header({
children: [ children: [
new Paragraph({ new Paragraph({
alignment: AlignmentType.RIGHT, alignment: AlignmentType.RIGHT,
children: [ children: [
zeile( zeile(
`${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`, `${kopf.betreute.name}${kopf.aktenzeichen ? ` · ${kopf.aktenzeichen}` : ''}`,
{ groesse: 8, grau: true }, { groesse: 8, grau: true },
), ),
], ],
}), }),
], ],
}), }),
}, },
footers: { footers: {
default: new Footer({ default: new Footer({
children: [ children: [
new Paragraph({ new Paragraph({
alignment: AlignmentType.CENTER, alignment: AlignmentType.CENTER,
children: [ children: [
zeile('Seite ', { groesse: 8, grau: true }), zeile('Seite ', { groesse: 8, grau: true }),
new TextRun({ new TextRun({
children: [PageNumber.CURRENT], children: [PageNumber.CURRENT],
size: 16, size: 16,
color: '555555', color: '555555',
font: SCHRIFT, font: SCHRIFT,
}), }),
zeile(' von ', { groesse: 8, grau: true }), zeile(' von ', { groesse: 8, grau: true }),
new TextRun({ new TextRun({
children: [PageNumber.TOTAL_PAGES], children: [PageNumber.TOTAL_PAGES],
size: 16, size: 16,
color: '555555', color: '555555',
font: SCHRIFT, font: SCHRIFT,
}), }),
zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, { zeile(` · erstellt am ${formatDatum(kopf.erstelltAm)}`, {
groesse: 8, groesse: 8,
grau: true, grau: true,
}), }),
], ],
}), }),
], ],
}), }),
}, },
children: inhalt, children: inhalt,
}, },
], ],
}) })
} }
/** Fertige .docx-Datei als Buffer. */ /** Fertige .docx-Datei als Buffer. */
export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> { export async function docxErzeugen(kopf: DocxKopf, text: string): Promise<Buffer> {
return Packer.toBuffer(docxAusEntwurf(kopf, text)) return Packer.toBuffer(docxAusEntwurf(kopf, text))
} }
+19 -2
View File
@@ -20,6 +20,9 @@ const ZIFFERN = 6
const PERIODE = 30 const PERIODE = 30
const TOLERANZ_FENSTER = 1 const TOLERANZ_FENSTER = 1
/** Laenge eines TOTP-Fensters in Sekunden - fuer den Wiederverwendungsschutz. */
export const TOTP_PERIODE = PERIODE
function totpBauen(secret: string, kennung: string): TOTP { function totpBauen(secret: string, kennung: string): TOTP {
return new TOTP({ return new TOTP({
issuer: AUSSTELLER, issuer: AUSSTELLER,
@@ -54,10 +57,24 @@ export async function totpQrCode(secret: string, kennung: string): Promise<strin
}) })
} }
/**
* Der absolute Zeitschritt, auf den ein Code passt.
*
* validate() gibt nur die Abweichung zum aktuellen Fenster zurueck (delta).
* Zum Festhalten braucht es eine Zahl, die nicht davon abhaengt, wann
* geprueft wurde - sonst waere ein spaeter geprueftes Fenster kleiner als
* ein frueher geprueftes.
*/
export function totpSchritt(delta: number, jetztMs = Date.now()): number {
return Math.floor(jetztMs / 1000 / PERIODE) + delta
}
/** /**
* Prueft einen eingegebenen Sechsstelligen gegen das Geheimnis. * Prueft einen eingegebenen Sechsstelligen gegen das Geheimnis.
* Gibt zusaetzlich das getroffene Zeitfenster zurueck - der Aufrufer *
* speichert es, um eine Wiederverwendung desselben Codes zu verhindern. * Gibt zusaetzlich das getroffene Zeitfenster zurueck. Wer damit anmeldet,
* muss es ueber zweitenFaktorEinloesen() festhalten - sonst gilt derselbe
* Code bis zu 90 Sekunden lang ein zweites Mal.
*/ */
export function totpPruefen( export function totpPruefen(
secret: string, secret: string,
+37 -1
View File
@@ -24,6 +24,42 @@ export const WARTEZEIT_DRINGEND_TAGE = 21
/** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */ /** Wie viele Ausschlussfristen zeigt die Warnung hoechstens? */
const AUSSCHLUSSFRISTEN_MAX = 3 const AUSSCHLUSSFRISTEN_MAX = 3
/**
* Wie weit im Voraus eine Ausschlussfrist die Warnung ausloest.
*
* Frueher gab es diese Grenze nicht: Der Warnblock fragte nur, OB etwas
* eine Ausschlussfrist ist, nie WANN sie faellig wird. Auf dem Dashboard
* standen deshalb dauerhaft drei rote Kaesten mit Warndreieck - fuer
* Aufwandspauschalen, die 2027, 2028 und 2029 ablaufen. Die Pflichtfristen
* werden bewusst drei Jahre im Voraus angelegt (jahreVoraus in
* pflichtfristen.ts), und alle landeten ungefiltert in der Warnung.
*
* Das ist nicht nur laestig, sondern gefaehrlich: Eine Warnung, die
* monatelang dasteht und nichts von einem will, stumpft ab. Man uebersieht
* die vierte, die etwas will - und bei § 1878 BGB ist das die
* Jahrespauschale, die ersatzlos erlischt.
*
* 90 Tage, weil die kuerzeste hier vorkommende Ausschlussfrist sechs
* Monate Zeit laesst. Drei Monate Vorlauf sind reichlich, und solange
* nichts ansteht, bleibt der Kasten leer - dann bedeutet er etwas.
*
* Verschwinden tut nichts: Was ausserhalb des Horizonts liegt, steht
* weiterhin in der Fristenliste.
*/
const AUSSCHLUSSFRIST_HORIZONT_TAGE = 90
/**
* Gehoert diese Frist in den hervorgehobenen Warnblock?
*
* Abgelaufenes immer - eine versaeumte Ausschlussfrist bleibt eine
* schlechte Nachricht, auch wenn sie lange zurueckliegt. Sonst nur, was
* innerhalb des Horizonts faellig wird.
*/
function warntJetzt(eintrag: { ausschlussfrist: boolean; tage: number }): boolean {
if (!eintrag.ausschlussfrist) return false
return eintrag.tage < 0 || eintrag.tage <= AUSSCHLUSSFRIST_HORIZONT_TAGE
}
/** /**
* Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der * Datumsfelder ohne Uhrzeit (@db.Date) kommen als UTC-Mitternacht aus der
* Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht, * Datenbank. Wer sie unbesehen mit einem lokalen "heute" vergleicht,
@@ -126,7 +162,7 @@ export function fristenEinordnen(fristen: FristRoh[], heute: Date): FristenAmpel
dieseWoche: eintraege.filter((e) => e.stufe === 'orange'), dieseWoche: eintraege.filter((e) => e.stufe === 'orange'),
diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'), diesenMonat: eintraege.filter((e) => e.stufe === 'gelb'),
weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length, weitereOffen: eintraege.filter((e) => e.stufe === 'gruen').length,
ausschlussfristen: eintraege.filter((e) => e.ausschlussfrist).slice(0, AUSSCHLUSSFRISTEN_MAX), ausschlussfristen: eintraege.filter(warntJetzt).slice(0, AUSSCHLUSSFRISTEN_MAX),
gesamtOffen: eintraege.length, gesamtOffen: eintraege.length,
} }
} }
+91
View File
@@ -31,6 +31,18 @@ function zahl(name: string, standard: number): number {
return n return n
} }
/**
* Ein Ja/Nein-Schalter. Bewusst nur "true"/"1"/"ja" als Ja - alles andere,
* auch ein Tippfehler, gilt als Nein. Bei einem Sicherheitsschalter ist das
* die richtige Richtung: Wer sich verschreibt, bekommt die strengere
* Einstellung, nicht die laxere.
*/
function schalter(name: string, standard = false): boolean {
const wert = process.env[name]?.trim().toLowerCase()
if (wert === undefined || wert === '') return standard
return wert === 'true' || wert === '1' || wert === 'ja'
}
function schluessel32(name: string): Buffer { function schluessel32(name: string): Buffer {
const roh = pflicht(name) const roh = pflicht(name)
const buf = Buffer.from(roh, 'base64') const buf = Buffer.from(roh, 'base64')
@@ -151,6 +163,26 @@ export const env = {
bucket: pflicht('S3_BUCKET'), bucket: pflicht('S3_BUCKET'),
accessKeyId: pflicht('S3_ACCESS_KEY_ID'), accessKeyId: pflicht('S3_ACCESS_KEY_ID'),
secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'), secretAccessKey: pflicht('S3_SECRET_ACCESS_KEY'),
/**
* Serverseitige Verschluesselung - nur, wenn ausdruecklich gewuenscht.
*
* Frueher stand im Treiber fest ServerSideEncryption: 'AES256'. Das
* mitgelieferte MinIO laeuft ohne KMS und lehnt das mit
* "NotImplemented" ab: Es liess sich kein einziges Dokument ablegen,
* und wer der INSTALLATION.md folgte, landete zwangslaeufig dort.
*
* Gebraucht wird sie ohnehin nicht mehr. Seit v1.11.0 verschluesselt
* die Anwendung jede Datei selbst, bevor sie den Server verlaesst
* (src/lib/speicher/umschlag.ts). Serverseitige Verschluesselung
* schuetzt vor nichts, was der eigene Umschlag nicht schon abdeckt -
* sie ist hoechstens eine zweite Lage fuer Anbieter, die sie
* anbieten.
*
* Leer bedeutet aus. Wer sie will, setzt S3_SSE=AES256 oder den Wert,
* den sein Anbieter erwartet.
*/
serverseitigeVerschluesselung: process.env.S3_SSE?.trim() || null,
} }
}, },
@@ -214,6 +246,65 @@ export const env = {
} }
}, },
/**
* Darf der Host-Kopf der Anfrage die oeffentliche Adresse bestimmen?
*
* Standard: nur dann, wenn AUTH_URL nicht gesetzt ist. Ist die Adresse
* konfiguriert - der dokumentierte Betrieb setzt sie, Passkeys brauchen
* sie ohnehin -, gibt es keinen Grund, sie aus einer Kopfzeile von aussen
* abzuleiten. Vorher stand in auth.ts fest "true", und ein
* "X-Forwarded-Host: fremde.example" haette in erzeugte Adressen
* durchgeschlagen.
*
* AUTH_TRUST_HOST bleibt wirksam, damit die dokumentierte Einstellung
* nicht stillschweigend uebergangen wird.
*/
get vertraueHost() {
return schalter('AUTH_TRUST_HOST', !process.env.AUTH_URL)
},
/**
* Geheimnis fuer die Ersteinrichtung.
*
* Zwischen dem ersten Start und dem Anlegen des Zugangs steht die Seite
* offen: Wer sie in diesem Fenster findet, legt sich selbst einen
* Administratorzugang an. Das Fenster ist kurz, aber es ist da - und bei
* einer Anwendung mit Gesundheits- und Finanzdaten ist "kurz" kein
* Argument.
*
* Ohne diesen Wert ist die Ersteinrichtung gesperrt. Das ist Absicht:
* Eine Installation, bei der das Feld vergessen wurde, soll nicht
* einrichtbar sein, sondern eine Meldung zeigen.
*/
get ersteinrichtungGeheimnis() {
return process.env.ERSTEINRICHTUNG_GEHEIMNIS?.trim() ?? ''
},
/**
* Steht ein Reverse Proxy davor, dem die weitergereichten Koepfe zu
* glauben sind?
*
* Standard ist nein. Das ist die unbequeme, aber richtige Voreinstellung:
* Ohne Proxy ist "x-forwarded-for" frei erfunden, und eine Sperre, die
* darauf zaehlt, laesst sich mit einer Kopfzeile aushebeln. Wer einen
* Proxy davorstellt - der dokumentierte Betrieb tut das -, sagt es hier
* ausdruecklich. Siehe src/lib/auth/anschluss.ts.
*/
get vertraueProxy() {
return schalter('VERTRAUE_PROXY', false)
},
/**
* Wie viele Zwischenknoten vertraut werden. Bei einem Proxy: 1.
*
* Die Zahl sagt, wie weit von hinten in der Kette der Aufrufer steht.
* Zu hoch gesetzt liest man wieder fremdbestimmte Eintraege, deshalb
* bleibt es bei eins, solange nichts anderes dasteht.
*/
get vertraueProxyStufen() {
return Math.max(1, zahl('VERTRAUE_PROXY_STUFEN', 1))
},
get istProduktion() { get istProduktion() {
return process.env.NODE_ENV === 'production' return process.env.NODE_ENV === 'production'
}, },
+52
View File
@@ -0,0 +1,52 @@
import { tageZwischen } from '@/lib/finanzen/datum'
/**
* Wie alt ist ein Wert - und ab wann ist das ein Problem?
*
* Ein Rueckkaufswert ohne Datum ist schlimmer als kein Rueckkaufswert:
* Man rechnet mit einer Zahl, die drei Jahre alt sein kann, und merkt es
* erst, wenn der Bezirk nachfragt. Deshalb steht neben jedem Betrag der
* Stichtag, und ab einem Jahr steht daneben, dass er alt ist.
*
* Die Grenze liegt bei 365 Tagen, weil der Bezirk jaehrlich prueft und
* das Betreuungsgericht jaehrlich Rechnung legen laesst. Ein Wert, der
* den letzten Jahresbericht ueberlebt hat, gehoert erneuert.
*
* Bewusst eine eigene Datei und nicht in vermoegen.ts: Die Regel gilt am
* Vertrag genauso wie im Vermoegensverzeichnis, und sie soll an beiden
* Stellen dieselbe sein - nicht zweimal als `> 365` hingeschrieben.
*/
export const WERT_VERALTET_TAGE = 365
export type Wertalter = {
/** Tage zwischen Stichtag und heute. Negativ bei kuenftigem Stichtag. */
alterTage: number
veraltet: boolean
/** Kurztext fuer ein Abzeichen neben dem Betrag, sonst null. */
hinweis: string | null
}
/** Ist ein Wert dieses Alters erneuerungsbeduerftig? */
export function wertVeraltet(alterTage: number): boolean {
return alterTage > WERT_VERALTET_TAGE
}
/**
* Das Alter eines Werts zum Stichtag.
*
* Ohne Stichtag gibt es kein Alter - dann null, und die Oberflaeche
* zeigt gar nichts an, statt eine Null zu behaupten.
*/
export function wertalterBestimmen(stichtag: Date | null, heute: Date): Wertalter | null {
if (!stichtag) return null
const alterTage = tageZwischen(stichtag, heute)
const veraltet = wertVeraltet(alterTage)
return {
alterTage,
veraltet,
hinweis: veraltet ? 'über ein Jahr alt' : null,
}
}
+4
View File
@@ -122,6 +122,10 @@ export type Zahlungsgruppe = {
const RECHTSFORMEN = [ const RECHTSFORMEN = [
'gmbh & co kg', 'gmbh & co kg',
'gmbh und co kg', 'gmbh und co kg',
// Die haeufigste Rechtsform in diesem Fachgebiet ueberhaupt: Heime,
// Sozialdienste und Traeger sind fast durchweg gGmbH. Fehlte sie,
// zerfiele die Zusammenfassung genau dort, wo sie gebraucht wird.
'ggmbh',
'gmbh', 'gmbh',
'mbh', 'mbh',
'ohg', 'ohg',
+61
View File
@@ -0,0 +1,61 @@
import type { KontaktAnrede } from '@prisma/client'
/**
* Die Briefanrede eines Ansprechpartners.
*
* Ein namentlich adressiertes Schreiben landet bei der Person, die den
* Vorgang kennt, statt in der Poststelle. Beim Bezirk Oberfranken ist das
* der Unterschied zwischen zwei Wochen und zwei Monaten.
*
* Zwei Regeln, die hier nicht verhandelbar sind:
*
* 1. Es wird nie geraten. Ohne hinterlegte Anrede wird mit dem vollen
* Namen angeredet ("Sehr geehrte(r) Anna Schmitt,"). Eine falsch
* geratene Anrede faellt dem Empfaenger sofort auf und kostet genau
* die Aufmerksamkeit, die man gewinnen wollte.
* 2. Ohne Ansprechpartner bleibt es bei "Sehr geehrte Damen und Herren,".
* Ein halb gefuellter Name darf nie zu "Sehr geehrte Frau ," werden -
* lieber unpersoenlich als offensichtlich automatisch.
*/
export const ANREDE_BESCHRIFTUNG: Record<KontaktAnrede, string> = {
OHNE: 'keine Angabe',
FRAU: 'Frau',
HERR: 'Herr',
}
export const ALLGEMEINE_ANREDE = 'Sehr geehrte Damen und Herren,'
/**
* Der Nachname aus einem vollen Namen.
*
* Titel und Vornamen fallen weg: "Dr. Anna Schmitt" wird zu "Schmitt".
* Doppelnamen mit Bindestrich bleiben ganz, weil sie ein Wort sind.
*/
export function nachnameAus(name: string): string {
const teile = name.trim().split(/\s+/).filter(Boolean)
return teile.length > 0 ? teile[teile.length - 1]! : ''
}
export type Ansprechpartner = {
name: string
anrede: KontaktAnrede
}
/**
* Die Anredezeile fuer ein Schreiben, einschliesslich Komma.
*
* Ohne Ansprechpartner - oder mit einem, dessen Name leer ist - die
* allgemeine Anrede.
*/
export function anredeZeile(ansprechpartner: Ansprechpartner | null): string {
if (!ansprechpartner) return ALLGEMEINE_ANREDE
const name = ansprechpartner.name.trim()
if (!name) return ALLGEMEINE_ANREDE
if (ansprechpartner.anrede === 'FRAU') return `Sehr geehrte Frau ${nachnameAus(name)},`
if (ansprechpartner.anrede === 'HERR') return `Sehr geehrter Herr ${nachnameAus(name)},`
return `Sehr geehrte(r) ${name},`
}
+50 -5
View File
@@ -1,6 +1,6 @@
import 'server-only' import 'server-only'
import { execFile } from 'node:child_process' import { execFile } from 'node:child_process'
import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
import { promisify } from 'node:util' import { promisify } from 'node:util'
@@ -43,6 +43,26 @@ const MAX_SEITEN = 30
/** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */ /** Mehr als das braucht keine Volltextsuche - und die Spalte bleibt handhabbar. */
const MAX_ZEICHEN = 400_000 const MAX_ZEICHEN = 400_000
/**
* Laengste Kante einer gerenderten Seite in Pixeln.
*
* Vorher wurde mit festen 300 dpi gerendert. Bei einer normalen A4-Seite
* sind das rund 2480 x 3510 Pixel - unproblematisch. Die Seitengroesse
* steht aber im PDF und kann beliebig gross angegeben werden: Bei einer
* Seite von 200 x 200 Zoll ergeben 300 dpi ein Bild von 60000 x 60000
* Pixeln, also mehrere Gigabyte auf der Platte, und das je Seite. Ein
* Dokument, das jemand in die Akte legt, haette so den Server vollschreiben
* koennen.
*
* "-scale-to" haengt die Aufloesung an die Pixelzahl statt an die
* angegebene Papiergroesse. 3500 Pixel entsprechen bei A4 etwa 300 dpi -
* fuer den Regelfall bleibt also alles wie vorher.
*/
const MAX_KANTE_PIXEL = 3500
/** Wie viel die gerenderten Seiten zusammen belegen duerfen. */
const MAX_BILDER_BYTES = 512 * 1024 * 1024
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Verfuegbarkeit // Verfuegbarkeit
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
@@ -146,14 +166,39 @@ async function tesseractAufDatei(pfad: string): Promise<{ text: string; meldung?
async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise<string[]> { async function pdfInBilder(verzeichnis: string, pdfPfad: string): Promise<string[]> {
await ausfuehren( await ausfuehren(
'pdftoppm', 'pdftoppm',
['-r', '300', '-gray', '-png', '-l', String(MAX_SEITEN), pdfPfad, join(verzeichnis, 'seite')], [
{ timeout: ZEITGRENZE_JE_SEITE_MS * 4, windowsHide: true }, // Statt fester dpi: Die laengste Kante wird auf eine feste Pixelzahl
// gebracht. Siehe MAX_KANTE_PIXEL - eine im PDF angegebene Riesenseite
// ergibt sonst ein Riesenbild.
'-scale-to',
String(MAX_KANTE_PIXEL),
'-gray',
'-png',
'-l',
String(MAX_SEITEN),
pdfPfad,
join(verzeichnis, 'seite'),
],
{ timeout: ZEITGRENZE_JE_SEITE_MS * 4, maxBuffer: 1024 * 1024, windowsHide: true },
) )
const dateien = await readdir(verzeichnis)
return dateien const dateien = (await readdir(verzeichnis))
.filter((name) => name.startsWith('seite') && name.endsWith('.png')) .filter((name) => name.startsWith('seite') && name.endsWith('.png'))
.sort() .sort()
.map((name) => join(verzeichnis, name)) .map((name) => join(verzeichnis, name))
// Zweiter Deckel, hinterher: Was pdftoppm trotz -scale-to abgelegt hat,
// wird nur bis zu einer Gesamtgroesse weiterverarbeitet. Der Rest bleibt
// liegen und wird mit dem Arbeitsverzeichnis geloescht.
const genommen: string[] = []
let gesamt = 0
for (const pfad of dateien) {
const { size } = await stat(pfad)
if (gesamt + size > MAX_BILDER_BYTES) break
gesamt += size
genommen.push(pfad)
}
return genommen
} }
/** /**
+55 -7
View File
@@ -15,8 +15,33 @@ import { inflateSync, inflateRawSync, unzipSync } from 'node:zlib'
* "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein. * "nicht_verfuegbar", und der Nutzer traegt die Metadaten von Hand ein.
* *
* Rein, ohne Seiteneffekte, ohne Netz. * Rein, ohne Seiteneffekte, ohne Netz.
*
* ## Grenzen, und warum es sie braucht
*
* Ein Flate-Strom sagt nicht, wie gross er ausgepackt wird. Ein paar
* Kilobyte Nullen komprimieren auf fast nichts und blaehen sich beim
* Auspacken auf Gigabyte auf - die klassische Dekompressionsbombe. Vorher
* lief das Auspacken ohne Obergrenze, und ein Dokument, das jemand in die
* Akte legt, haette den Server damit aus dem Speicher werfen koennen.
*
* Deshalb drei Deckel: einer je Strom, einer fuer die Summe, und die
* bestehende Obergrenze von 2000 Stroemen. Wird einer erreicht, bricht das
* Auspacken ab und der Text ist eben unvollstaendig - besser ein
* "nicht_verfuegbar" als ein Serverneustart.
*/ */
/**
* Wie gross ein einzelner ausgepackter Strom werden darf.
*
* Acht Megabyte sind fuer eine Seite Text absurd viel - ein
* Behoerdenbescheid liegt bei einigen Kilobyte. Die Grenze ist bewusst
* grosszuegig: Sie soll Bomben abfangen, nicht ungewoehnliche PDFs.
*/
const MAX_STROM_BYTES = 8 * 1024 * 1024
/** Und wie viel es ueber alle Stroeme zusammen sein darf. */
const MAX_GESAMT_BYTES = 64 * 1024 * 1024
/** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */ /** Oktal-Escapes und Standard-Escapes in PDF-Literalstrings. */
function literalEntpacken(roh: string): string { function literalEntpacken(roh: string): string {
let ergebnis = '' let ergebnis = ''
@@ -108,8 +133,9 @@ function stroemeAuspacken(pdf: Buffer): string[] {
const roh = pdf.toString('latin1') const roh = pdf.toString('latin1')
const stroeme: string[] = [] const stroeme: string[] = []
let suchAb = 0 let suchAb = 0
let gesamt = 0
while (stroeme.length < 2000) { while (stroeme.length < 2000 && gesamt < MAX_GESAMT_BYTES) {
const beginn = roh.indexOf('stream', suchAb) const beginn = roh.indexOf('stream', suchAb)
if (beginn < 0) break if (beginn < 0) break
@@ -131,28 +157,50 @@ function stroemeAuspacken(pdf: Buffer): string[] {
} }
if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) { if (/\/Filter\s*(\/FlateDecode|\[\s*\/FlateDecode)/.test(kopf)) {
const entpackt = flateVersuchen(daten) const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
if (entpackt) stroeme.push(entpackt.toString('latin1')) if (entpackt) {
gesamt += entpackt.length
stroeme.push(entpackt.toString('latin1'))
}
continue continue
} }
if (!/\/Filter/.test(kopf)) { if (!/\/Filter/.test(kopf)) {
// Unkomprimiert: Hier gibt es nichts, was sich aufblaehen koennte -
// die Bytes stehen so in der Datei. Sie zaehlen trotzdem mit, damit
// die Summe stimmt.
gesamt += daten.length
stroeme.push(daten.toString('latin1')) stroeme.push(daten.toString('latin1'))
continue continue
} }
// Unbekannter Filter: trotzdem einen Flate-Versuch wagen. // Unbekannter Filter: trotzdem einen Flate-Versuch wagen.
const entpackt = flateVersuchen(daten) const entpackt = flateVersuchen(daten, MAX_GESAMT_BYTES - gesamt)
if (entpackt) stroeme.push(entpackt.toString('latin1')) if (entpackt) {
gesamt += entpackt.length
stroeme.push(entpackt.toString('latin1'))
}
} }
return stroeme return stroeme
} }
function flateVersuchen(daten: Buffer): Buffer | null { /**
* Packt einen Strom aus - mit Deckel.
*
* maxOutputLength laesst zlib abbrechen, sobald die Ausgabe die Grenze
* ueberschreitet; der Fehler landet im catch wie jeder andere. Ohne diese
* Angabe entpackt Node so lange, wie Speicher da ist.
*
* @param rest Wie viele Bytes ueber alle Stroeme noch uebrig sind.
*/
function flateVersuchen(daten: Buffer, rest: number): Buffer | null {
const grenze = Math.min(MAX_STROM_BYTES, rest)
if (grenze <= 0) return null
for (const fn of [inflateSync, unzipSync, inflateRawSync]) { for (const fn of [inflateSync, unzipSync, inflateRawSync]) {
try { try {
return fn(daten) return fn(daten, { maxOutputLength: grenze })
} catch { } catch {
// naechster Versuch // naechster Versuch
} }
+214 -214
View File
@@ -1,214 +1,214 @@
import 'server-only' import 'server-only'
import type * as React from 'react' import type * as React from 'react'
import { renderToBuffer } from '@react-pdf/renderer' import { renderToBuffer } from '@react-pdf/renderer'
import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib' import { PDFDocument, StandardFonts, degrees, rgb } from 'pdf-lib'
import { typErkennen } from '@/lib/speicher/dateityp' import { typErkennen } from '@/lib/speicher/dateityp'
import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift' import { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
/** /**
* PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen. * PDF-Werkzeuge, auf die alle Ausgabemodule aufsetzen.
* *
* Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2. * Vertrag: docs/SCHNITTSTELLEN.md, Abschnitt 2.
* *
* Zwei Wege fuehren hier zusammen: * Zwei Wege fuehren hier zusammen:
* - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte) * - pdfErzeugen() rendert ein react-pdf-Dokument (eigene Berichte)
* - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander * - pdfsZusammenfuehren() klebt fremde PDFs und Fotos aneinander
* (Belegmappe, Unterlagenpaket fuer das Gericht) * (Belegmappe, Unterlagenpaket fuer das Gericht)
* und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das * und paginieren() legt am Ende eine durchgehende Zaehlung darueber - das
* ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann. * ist die Zahl, auf die sich ein Inhaltsverzeichnis berufen kann.
*/ */
/** A4 in PDF-Punkten (72 dpi). */ /** A4 in PDF-Punkten (72 dpi). */
export const A4_BREITE = 595.28 export const A4_BREITE = 595.28
export const A4_HOEHE = 841.89 export const A4_HOEHE = 841.89
const RAND = 36 const RAND = 36
/** Rendert ein react-pdf-Dokument zu einem Buffer. */ /** Rendert ein react-pdf-Dokument zu einem Buffer. */
export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> { export async function pdfErzeugen(dokument: React.ReactElement): Promise<Buffer> {
// Muss vor dem ersten Rendern laufen: repariert bei Bedarf die // Muss vor dem ersten Rendern laufen: repariert bei Bedarf die
// Aufloesung der eingebauten Standardschriften. Idempotent und ab dem // Aufloesung der eingebauten Standardschriften. Idempotent und ab dem
// zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts. // zweiten Aufruf kostenlos - siehe src/lib/pdf/standardschrift.ts.
standardschriftenSicherstellen() standardschriftenSicherstellen()
// renderToBuffer erwartet ein <Document>-Element; die Typen von // renderToBuffer erwartet ein <Document>-Element; die Typen von
// @react-pdf/renderer sind hier enger als React.ReactElement. // @react-pdf/renderer sind hier enger als React.ReactElement.
const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0]) const roh = await renderToBuffer(dokument as Parameters<typeof renderToBuffer>[0])
return Buffer.from(roh) return Buffer.from(roh)
} }
/** /**
* Wandelt ein Bild in ein einseitiges PDF. * Wandelt ein Bild in ein einseitiges PDF.
* *
* JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die * JPEG und PNG kann pdf-lib direkt einbetten. WEBP, TIFF und HEIC (die
* Formate moderner Handykameras) gehen vorher durch sharp - sonst waere * Formate moderner Handykameras) gehen vorher durch sharp - sonst waere
* jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren. * jedes iPhone-Foto eines Bescheids fuer die Belegmappe verloren.
*/ */
export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> { export async function bildAlsPdf(inhalt: Buffer, beschriftung?: string): Promise<Buffer> {
const typ = typErkennen(inhalt) const typ = typErkennen(inhalt)
if (!typ || !typ.istBild) { if (!typ || !typ.istBild) {
throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.') throw new Error('Diese Datei ist kein Bild, das sich in ein PDF wandeln ließe.')
} }
let daten = inhalt let daten = inhalt
let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png' let format: 'jpg' | 'png' = typ.endung === 'jpg' ? 'jpg' : 'png'
if (typ.endung !== 'jpg' && typ.endung !== 'png') { if (typ.endung !== 'jpg' && typ.endung !== 'png') {
const sharp = (await import('sharp')).default const sharp = (await import('sharp')).default
daten = await sharp(inhalt).rotate().png().toBuffer() daten = await sharp(inhalt).rotate().png().toBuffer()
format = 'png' format = 'png'
} }
const pdf = await PDFDocument.create() const pdf = await PDFDocument.create()
const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten) const bild = format === 'jpg' ? await pdf.embedJpg(daten) : await pdf.embedPng(daten)
const beschriftungshoehe = beschriftung ? 16 : 0 const beschriftungshoehe = beschriftung ? 16 : 0
const nutzbareBreite = A4_BREITE - 2 * RAND const nutzbareBreite = A4_BREITE - 2 * RAND
const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe const nutzbareHoehe = A4_HOEHE - 2 * RAND - beschriftungshoehe
// Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde. // Hochkant lassen, aber quer drehen, wenn das Blatt sonst winzig wuerde.
const querformat = bild.width > bild.height const querformat = bild.width > bild.height
const seitenBreite = querformat ? A4_HOEHE : A4_BREITE const seitenBreite = querformat ? A4_HOEHE : A4_BREITE
const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE const seitenHoehe = querformat ? A4_BREITE : A4_HOEHE
const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite const platzBreite = querformat ? nutzbareHoehe : nutzbareBreite
const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe const platzHoehe = querformat ? nutzbareBreite : nutzbareHoehe
const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1) const massstab = Math.min(platzBreite / bild.width, platzHoehe / bild.height, 1)
const breite = bild.width * massstab const breite = bild.width * massstab
const hoehe = bild.height * massstab const hoehe = bild.height * massstab
const seite = pdf.addPage([seitenBreite, seitenHoehe]) const seite = pdf.addPage([seitenBreite, seitenHoehe])
seite.drawImage(bild, { seite.drawImage(bild, {
x: (seitenBreite - breite) / 2, x: (seitenBreite - breite) / 2,
y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2, y: (seitenHoehe - hoehe) / 2 + beschriftungshoehe / 2,
width: breite, width: breite,
height: hoehe, height: hoehe,
}) })
if (beschriftung) { if (beschriftung) {
const schrift = await pdf.embedFont(StandardFonts.Helvetica) const schrift = await pdf.embedFont(StandardFonts.Helvetica)
seite.drawText(kuerzenFuerSchrift(beschriftung, 110), { seite.drawText(kuerzenFuerSchrift(beschriftung, 110), {
x: RAND, x: RAND,
y: RAND / 2, y: RAND / 2,
size: 8, size: 8,
font: schrift, font: schrift,
color: rgb(0.3, 0.3, 0.3), color: rgb(0.3, 0.3, 0.3),
}) })
} }
return Buffer.from(await pdf.save()) return Buffer.from(await pdf.save())
} }
/** /**
* Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket). * Fuegt mehrere PDFs zu einem zusammen (Belegmappe, Unterlagenpaket).
* Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib. * Bilder werden vorher in einseitige PDFs gewandelt. Nutzt pdf-lib.
*/ */
export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> { export async function pdfsZusammenfuehren(teile: Buffer[]): Promise<Buffer> {
const ziel = await PDFDocument.create() const ziel = await PDFDocument.create()
ziel.setProducer('Betreuungs-Cockpit') ziel.setProducer('Betreuungs-Cockpit')
ziel.setCreator('Betreuungs-Cockpit') ziel.setCreator('Betreuungs-Cockpit')
for (const teil of teile) { for (const teil of teile) {
const typ = typErkennen(teil) const typ = typErkennen(teil)
const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil const alsPdf = typ && typ.istBild ? await bildAlsPdf(teil) : teil
const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true }) const quelle = await PDFDocument.load(alsPdf, { ignoreEncryption: true })
const seiten = await ziel.copyPages(quelle, quelle.getPageIndices()) const seiten = await ziel.copyPages(quelle, quelle.getPageIndices())
for (const seite of seiten) ziel.addPage(seite) for (const seite of seiten) ziel.addPage(seite)
} }
if (ziel.getPageCount() === 0) { if (ziel.getPageCount() === 0) {
// Ein leeres PDF laesst sich weder oeffnen noch abheften. // Ein leeres PDF laesst sich weder oeffnen noch abheften.
ziel.addPage([A4_BREITE, A4_HOEHE]) ziel.addPage([A4_BREITE, A4_HOEHE])
} }
return Buffer.from(await ziel.save()) return Buffer.from(await ziel.save())
} }
/** /**
* Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der * Wie viele Seiten hat dieses PDF? Wird fuer das Inhaltsverzeichnis der
* Belegmappe gebraucht, bevor zusammengefuehrt wird. * Belegmappe gebraucht, bevor zusammengefuehrt wird.
*/ */
export async function seitenzahl(pdf: Buffer): Promise<number> { export async function seitenzahl(pdf: Buffer): Promise<number> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
return dokument.getPageCount() return dokument.getPageCount()
} }
/** /**
* Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen. * Seitenzahlen und Fusszeile ueber ein zusammengefuehrtes PDF legen.
* *
* Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest, * Wichtig fuer die Belegmappe: erst nach dem Zusammenfuehren steht fest,
* auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die * auf welcher Seite ein Beleg liegt. Die Zaehlung hier ist die, auf die
* sich das Inhaltsverzeichnis und spaeter das Gericht beziehen. * sich das Inhaltsverzeichnis und spaeter das Gericht beziehen.
*/ */
export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> { export async function paginieren(pdf: Buffer, fusszeile: string): Promise<Buffer> {
const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true }) const dokument = await PDFDocument.load(pdf, { ignoreEncryption: true })
const schrift = await dokument.embedFont(StandardFonts.Helvetica) const schrift = await dokument.embedFont(StandardFonts.Helvetica)
const seiten = dokument.getPages() const seiten = dokument.getPages()
const gesamt = seiten.length const gesamt = seiten.length
seiten.forEach((seite, i) => { seiten.forEach((seite, i) => {
const { width, height } = seite.getSize() const { width, height } = seite.getSize()
const drehung = seite.getRotation().angle % 360 const drehung = seite.getRotation().angle % 360
const zaehlung = `Seite ${i + 1} von ${gesamt}` const zaehlung = `Seite ${i + 1} von ${gesamt}`
const text = kuerzenFuerSchrift(fusszeile, 120) const text = kuerzenFuerSchrift(fusszeile, 120)
const groesse = 7.5 const groesse = 7.5
// Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls // Gedrehte Seiten (Querformat-Scans) brauchen die Fusszeile ebenfalls
// gedreht, sonst steht sie quer im Blatt. // gedreht, sonst steht sie quer im Blatt.
if (drehung === 90 || drehung === 270) { if (drehung === 90 || drehung === 270) {
seite.drawText(`${text} · ${zaehlung}`, { seite.drawText(`${text} · ${zaehlung}`, {
x: drehung === 90 ? 18 : width - 18, x: drehung === 90 ? 18 : width - 18,
y: drehung === 90 ? 20 : height - 20, y: drehung === 90 ? 20 : height - 20,
size: groesse, size: groesse,
font: schrift, font: schrift,
color: rgb(0.35, 0.35, 0.35), color: rgb(0.35, 0.35, 0.35),
rotate: degrees(drehung), rotate: degrees(drehung),
}) })
return return
} }
seite.drawText(text, { seite.drawText(text, {
x: 36, x: 36,
y: 18, y: 18,
size: groesse, size: groesse,
font: schrift, font: schrift,
color: rgb(0.35, 0.35, 0.35), color: rgb(0.35, 0.35, 0.35),
}) })
const breite = schrift.widthOfTextAtSize(zaehlung, groesse) const breite = schrift.widthOfTextAtSize(zaehlung, groesse)
seite.drawText(zaehlung, { seite.drawText(zaehlung, {
x: width - 36 - breite, x: width - 36 - breite,
y: 18, y: 18,
size: groesse, size: groesse,
font: schrift, font: schrift,
color: rgb(0.35, 0.35, 0.35), color: rgb(0.35, 0.35, 0.35),
}) })
}) })
return Buffer.from(await dokument.save()) return Buffer.from(await dokument.save())
} }
/** /**
* Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa * Helvetica in pdf-lib kann nur WinAnsi. Zeichen ausserhalb davon (etwa
* ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen - * ein Gedankenstrich aus einem OCR-Text) wuerden das Speichern abbrechen -
* deshalb hier ersetzen statt riskieren. * deshalb hier ersetzen statt riskieren.
*/ */
export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift' export { standardschriftenSicherstellen } from '@/lib/pdf/standardschrift'
export function fuerStandardschrift(text: string): string { export function fuerStandardschrift(text: string): string {
return text return text
.replace(/[\u2013\u2014]/g, '-') .replace(/[\u2013\u2014]/g, '-')
.replace(/[\u2018\u2019\u201a]/g, "'") .replace(/[\u2018\u2019\u201a]/g, "'")
.replace(/[\u201c\u201d\u201e]/g, '"') .replace(/[\u201c\u201d\u201e]/g, '"')
.replace(/\u2026/g, '...') .replace(/\u2026/g, '...')
.replace(/\u20ac/g, 'EUR') .replace(/\u20ac/g, 'EUR')
.replace(/[\u00a0\u2007\u202f]/g, ' ') .replace(/[\u00a0\u2007\u202f]/g, ' ')
// Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das // Alles ausserhalb von WinAnsi (Latin-1) wird ersetzt, statt das
// Speichern des PDFs scheitern zu lassen. // Speichern des PDFs scheitern zu lassen.
// eslint-disable-next-line no-control-regex // eslint-disable-next-line no-control-regex
.replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?') .replace(/[^\u0020-\u007e\u00a1-\u00ff]/g, '?')
} }
function kuerzenFuerSchrift(text: string, maxZeichen: number): string { function kuerzenFuerSchrift(text: string, maxZeichen: number): string {
const sauber = fuerStandardschrift(text) const sauber = fuerStandardschrift(text)
return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber return sauber.length > maxZeichen ? `${sauber.slice(0, maxZeichen - 1)}...` : sauber
} }
+40 -7
View File
@@ -1,7 +1,7 @@
import 'server-only' import 'server-only'
import { db, nichtGeloescht } from '@/lib/db' import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import { import {
absenderBlock, absenderBlock,
anschriftBlock, anschriftBlock,
@@ -107,7 +107,8 @@ export function betreffAusText(text: string): string {
export type EntwurfEingabe = { export type EntwurfEingabe = {
betreuteId: string betreuteId: string
benutzerName: string /** Wer schreibt - mit Anschrift, wenn hinterlegt. */
benutzer: Absender
art: SchreibenArt art: SchreibenArt
vertragId?: string | null vertragId?: string | null
vorgangId?: string | null vorgangId?: string | null
@@ -127,7 +128,7 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
const kopf = briefkopfBauen({ const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute), betreute: oeffnen('Betreute', betreute),
benutzer: { name: eingabe.benutzerName }, benutzer: eingabe.benutzer,
}) })
const gemeinsam = { const gemeinsam = {
@@ -201,8 +202,19 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
const vorgang = await db.vorgang.findFirst({ const vorgang = await db.vorgang.findFirst({
where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht }, where: { id: eingabe.vorgangId, betreuteId: eingabe.betreuteId, ...nichtGeloescht },
// Der benannte Ansprechpartner, nicht irgendeiner der verknuepften
// Kontakte: Wer angeredet wird, ist eine Entscheidung und kein Zufall.
include: { include: {
kontakte: { select: { name: true, organisation: true }, take: 1 }, ansprechpartner: {
select: {
name: true,
anrede: true,
organisation: true,
strasse: true,
plz: true,
ort: true,
},
},
}, },
}) })
if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).') if (!vorgang) throw new Error('Diesen Vorgang gibt es nicht (mehr).')
@@ -218,19 +230,35 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
geburtsdatum: betreute.geburtsdatum, geburtsdatum: betreute.geburtsdatum,
}, },
betreuer: { betreuer: {
name: eingabe.benutzerName, name: eingabe.benutzer.name,
aktenzeichenGericht: betreute.aktenzeichenGericht, aktenzeichenGericht: betreute.aktenzeichenGericht,
betreuungsgericht: betreute.betreuungsgericht, betreuungsgericht: betreute.betreuungsgericht,
}, },
ansprechpartner: vorgang.ansprechpartner,
heute, heute,
}) })
// Ist ein Ansprechpartner benannt, kommt seine Anschrift gleich mit -
// sie steht im Kontaktverzeichnis und muesste sonst abgetippt werden.
const anschrift = vorgang.ansprechpartner
? [
vorgang.ansprechpartner.strasse,
[vorgang.ansprechpartner.plz, vorgang.ansprechpartner.ort]
.filter(Boolean)
.join(' ')
.trim() || null,
]
.filter(Boolean)
.join('\n')
: ''
return { return {
...gemeinsam, ...gemeinsam,
text: anfrage.text, text: anfrage.text,
betreff: anfrage.betreff, betreff: anfrage.betreff,
empfaengerName: vorgang.gegenstelle ?? '', empfaengerName:
empfaengerAnschrift: '', vorgang.ansprechpartner?.name ?? vorgang.gegenstelle ?? '',
empfaengerAnschrift: anschrift,
} }
} }
@@ -251,6 +279,11 @@ export async function entwurfBauen(eingabe: EntwurfEingabe): Promise<SchreibenEn
: null, : null,
betreute_anschrift: kopf.betreuteAnschrift, betreute_anschrift: kopf.betreuteAnschrift,
betreuer_name: kopf.betreuerName, betreuer_name: kopf.betreuerName,
betreuer_anschrift:
[kopf.betreuerStrasse, [kopf.betreuerPlz, kopf.betreuerOrt].filter(Boolean).join(' ')]
.filter((zeile) => zeile && zeile.trim() !== '')
.join('\n') || null,
betreuer_ort: kopf.betreuerOrt,
betreuungsgericht: kopf.betreuungsgericht, betreuungsgericht: kopf.betreuungsgericht,
aktenzeichen_gericht: kopf.aktenzeichenGericht, aktenzeichen_gericht: kopf.aktenzeichenGericht,
bestellung_datum: kopf.bestellungAm ? formatDatum(kopf.bestellungAm) : null, bestellung_datum: kopf.bestellungAm ? formatDatum(kopf.bestellungAm) : null,
+8
View File
@@ -21,6 +21,8 @@ export type PlatzhalterName =
| 'betreute_geburtsdatum' | 'betreute_geburtsdatum'
| 'betreute_anschrift' | 'betreute_anschrift'
| 'betreuer_name' | 'betreuer_name'
| 'betreuer_anschrift'
| 'betreuer_ort'
| 'betreuungsgericht' | 'betreuungsgericht'
| 'aktenzeichen_gericht' | 'aktenzeichen_gericht'
| 'bestellung_datum' | 'bestellung_datum'
@@ -49,6 +51,12 @@ export const PLATZHALTER: readonly PlatzhalterBeschreibung[] = [
{ name: 'betreute_geburtsdatum', beschriftung: 'Geburtsdatum', quelle: 'Stammdaten' }, { name: 'betreute_geburtsdatum', beschriftung: 'Geburtsdatum', quelle: 'Stammdaten' },
{ name: 'betreute_anschrift', beschriftung: 'Anschrift (Heim)', quelle: 'Stammdaten' }, { name: 'betreute_anschrift', beschriftung: 'Anschrift (Heim)', quelle: 'Stammdaten' },
{ name: 'betreuer_name', beschriftung: 'Dein Name', quelle: 'Dein Zugang' }, { name: 'betreuer_name', beschriftung: 'Dein Name', quelle: 'Dein Zugang' },
{
name: 'betreuer_anschrift',
beschriftung: 'Deine Anschrift',
quelle: 'Mein Konto',
},
{ name: 'betreuer_ort', beschriftung: 'Dein Ort', quelle: 'Mein Konto' },
{ name: 'betreuungsgericht', beschriftung: 'Betreuungsgericht', quelle: 'Stammdaten' }, { name: 'betreuungsgericht', beschriftung: 'Betreuungsgericht', quelle: 'Stammdaten' },
{ name: 'aktenzeichen_gericht', beschriftung: 'Aktenzeichen des Gerichts', quelle: 'Stammdaten' }, { name: 'aktenzeichen_gericht', beschriftung: 'Aktenzeichen des Gerichts', quelle: 'Stammdaten' },
{ name: 'bestellung_datum', beschriftung: 'Datum der Bestellung', quelle: 'Stammdaten' }, { name: 'bestellung_datum', beschriftung: 'Datum der Bestellung', quelle: 'Stammdaten' },
+237 -237
View File
@@ -1,237 +1,237 @@
/** /**
* Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens. * Beträge, Staffeln und Grenzwerte des Sozialhilfeverfahrens.
* *
* ACHTUNG - EINZIGE STELLE. * ACHTUNG - EINZIGE STELLE.
* Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert, * Alles, was sich jährlich (oder mit dem nächsten Gesetzespaket) ändert,
* steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und * steht hier und nirgendwo sonst. Jede Konstante trägt ihr Stand-Datum und
* die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler * die Fundstelle. Wer einen Wert im Fachcode findet, hat einen Fehler
* gefunden. * gefunden.
* *
* Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für * Und der wichtigste Satz vorweg: Diese Zahlen sind Arbeitsgrundlage für
* die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der * die eigene Vorbereitung, nicht die Rechtslage. Maßgeblich ist immer der
* Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel * Bescheid des Trägers. Vor jedem Antrag und zu jedem Jahreswechsel
* gehören die Werte hier nachgeprüft. * gehören die Werte hier nachgeprüft.
*/ */
/** Wann wurden die Werte in dieser Datei zuletzt geprüft? */ /** Wann wurden die Werte in dieser Datei zuletzt geprüft? */
export const KONSTANTEN_STAND = '2025-01-01' export const KONSTANTEN_STAND = '2025-01-01'
/** /**
* Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche * Nächster Termin, zu dem die Werte hier durchzusehen sind. Die Oberfläche
* blendet ab diesem Datum einen Prüfhinweis ein. * blendet ab diesem Datum einen Prüfhinweis ein.
*/ */
export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01' export const KONSTANTEN_NAECHSTE_PRUEFUNG = '2027-01-01'
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung // Regelbedarf - Bezugsgröße für Barbetrag und Zuzahlungsbefreiung
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung. * Regelbedarfsstufe 1 nach der Regelbedarfsstufen-Fortschreibungsverordnung.
* Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die * Stand 01.01.2025: 563,00 EUR monatlich (unverändert gegenüber 2024, die
* Fortschreibung ergab rechnerisch keine Erhöhung). * Fortschreibung ergab rechnerisch keine Erhöhung).
* *
* Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze * Aus diesem Wert leiten sich unten der Barbetrag und die Belastungsgrenze
* der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht * der Zuzahlungsbefreiung ab - deshalb steht er hier oben und wird nicht
* doppelt gepflegt. * doppelt gepflegt.
*/ */
export const REGELBEDARFSSTUFE_1_CENT = 56_300n export const REGELBEDARFSSTUFE_1_CENT = 56_300n
/** /**
* Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in * Barbetrag zur persönlichen Verfügung ("Taschengeld") für Menschen in
* Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der * Einrichtungen, § 27b Abs. 2 SGB XII: mindestens 27 % der
* Regelbedarfsstufe 1. * Regelbedarfsstufe 1.
* *
* 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025). * 27 % von 563,00 EUR = 152,01 EUR monatlich (Stand 01.01.2025).
* *
* "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun * "Mindestens": Der Träger darf mehr festsetzen, und einzelne Bezirke tun
* das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid. * das auch. Steht im Bescheid ein höherer Betrag, gilt der Bescheid.
*/ */
export const BARBETRAG_ANTEIL_PROZENT = 27 export const BARBETRAG_ANTEIL_PROZENT = 27
export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n export const BARBETRAG_CENT = (REGELBEDARFSSTUFE_1_CENT * BigInt(BARBETRAG_ANTEIL_PROZENT)) / 100n
/** /**
* Bekleidungspauschale. * Bekleidungspauschale.
* *
* Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die * Bewusst ohne Zahl. Es gibt keinen bundeseinheitlichen Betrag - die
* Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger * Bekleidungsbeihilfe nach § 27b Abs. 1 SGB XII setzt der jeweilige Träger
* fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je * fest, der Bezirk Oberfranken zahlt sie üblicherweise als Pauschale je
* Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen * Quartal oder Jahr. Der Betrag ist dem Bewilligungsbescheid zu entnehmen
* und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier * und im Heimkostenmonat einzutragen. Eine erfundene Zahl wäre hier
* schädlicher als gar keine. * schädlicher als gar keine.
*/ */
export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null export const BEKLEIDUNGSPAUSCHALE_CENT: bigint | null = null
export const BEKLEIDUNGSPAUSCHALE_HINWEIS = export const BEKLEIDUNGSPAUSCHALE_HINWEIS =
'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' + 'Die Höhe der Bekleidungspauschale legt der Träger fest (§ 27b Abs. 1 SGB XII). ' +
'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.' 'Übernimm den Betrag aus dem Bewilligungsbescheid, er ist regional unterschiedlich.'
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Leistungen der Pflegekasse, § 43 SGB XI // Leistungen der Pflegekasse, § 43 SGB XI
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export type Pflegegrad = 1 | 2 | 3 | 4 | 5 export type Pflegegrad = 1 | 2 | 3 | 4 | 5
/** /**
* Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege, * Monatliche Pauschalen der Pflegekasse bei vollstationärer Pflege,
* § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das * § 43 Abs. 2 SGB XI. Stand 01.01.2025 (Erhöhung um 4,5 % durch das
* Pflegeunterstützungs- und -entlastungsgesetz). * Pflegeunterstützungs- und -entlastungsgesetz).
* *
* Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht. * Pflegegrad 1 ist der Sonderfall: § 43 Abs. 2 SGB XI greift dort nicht.
* Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach * Wer mit Pflegegrad 1 vollstationär gepflegt wird, erhält nach
* § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht * § 43 Abs. 3 SGB XI einen Zuschuss von 125 EUR monatlich - deshalb steht
* er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage. * er hier mit ebendiesem Betrag, aber unter eigener Rechtsgrundlage.
* *
* Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für * Die nächste turnusmäßige Anpassung nach § 30 SGB XI ist gesetzlich für
* 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen. * 2028 vorgesehen. Trotzdem: zum Jahreswechsel nachsehen.
*/ */
export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = { export const PFLEGEKASSE_VOLLSTATIONAER_CENT: Record<Pflegegrad, bigint> = {
1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale 1: 12_500n, // § 43 Abs. 3 SGB XI - Zuschuss, keine Pauschale
2: 80_500n, 2: 80_500n,
3: 131_900n, 3: 131_900n,
4: 185_500n, 4: 185_500n,
5: 209_600n, 5: 209_600n,
} }
export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = { export const PFLEGEKASSE_RECHTSGRUNDLAGE: Record<Pflegegrad, string> = {
1: '§ 43 Abs. 3 SGB XI', 1: '§ 43 Abs. 3 SGB XI',
2: '§ 43 Abs. 2 SGB XI', 2: '§ 43 Abs. 2 SGB XI',
3: '§ 43 Abs. 2 SGB XI', 3: '§ 43 Abs. 2 SGB XI',
4: '§ 43 Abs. 2 SGB XI', 4: '§ 43 Abs. 2 SGB XI',
5: '§ 43 Abs. 2 SGB XI', 5: '§ 43 Abs. 2 SGB XI',
} }
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Leistungszuschlag, § 43c SGB XI // Leistungszuschlag, § 43c SGB XI
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// //
// STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach // STEHT NICHT HIER. Die Staffel (15 / 30 / 50 / 75 Prozent nach
// Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in // Verweildauer, Stand 01.01.2024) gehoert dem Pflegemodul und liegt in
// src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber // src/lib/pflege/konstanten.ts. Dieses Modul benutzt sie ueber
// src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien // src/lib/sozialhilfe/heimkosten.ts, kopiert sie aber nicht - zwei Kopien
// derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung // derselben Prozentsaetze werden bei der naechsten Gesetzesaenderung
// zuverlaessig nur an einer Stelle nachgezogen. // zuverlaessig nur an einer Stelle nachgezogen.
// //
// Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den // Merksatz zur Anwendung: Der Zuschlag mindert ausschliesslich den
// pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung, // pflegebedingten Eigenanteil (EEE) - nicht Unterkunft und Verpflegung,
// nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf // nicht die Investitionskosten, nicht die Ausbildungsumlage. Wer ihn auf
// die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein. // die ganze Heimrechnung anwendet, rechnet die Deckungsluecke zu klein.
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Abwesenheit (Krankenhaus, Reha, Urlaub) // Abwesenheit (Krankenhaus, Reha, Urlaub)
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag. * Abwesenheitstage mindern die Heimrechnung nicht ab dem ersten Tag.
* *
* Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten * Die Rahmenverträge nach § 75 SGB XI regeln das landesweit: Die ersten
* drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten * drei Abwesenheitstage sind voll zu zahlen, erst ab dem vierten
* zusammenhängenden Tag wird das Entgelt gemindert - in Bayern * zusammenhängenden Tag wird das Entgelt gemindert - in Bayern
* üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung. * üblicherweise um 25 % von Pflegesatz sowie Unterkunft und Verpflegung.
* Die Investitionskosten laufen weiter, der Platz wird ja freigehalten * Die Investitionskosten laufen weiter, der Platz wird ja freigehalten
* (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr). * (§ 87a Abs. 1 SGB XI, Platzfreihaltung bis 42 Tage im Kalenderjahr).
* *
* Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und * Weil die Prozentsätze im Rahmenvertrag und im Heimvertrag stehen und
* nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was * nicht im Gesetz, rechnet die Anwendung damit nur eine *Schätzung*. Was
* gilt, steht in der Heimrechnung. * gilt, steht in der Heimrechnung.
*/ */
export const ABWESENHEIT_KARENZTAGE = 3 export const ABWESENHEIT_KARENZTAGE = 3
export const ABWESENHEIT_MINDERUNG_PROZENT = 25 export const ABWESENHEIT_MINDERUNG_PROZENT = 25
export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42 export const ABWESENHEIT_PLATZFREIHALTUNG_TAGE = 42
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Vermögen, § 90 SGB XII // Vermögen, § 90 SGB XII
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII * Vermögensfreibetrag ("kleinere Barbeträge"), § 90 Abs. 2 Nr. 9 SGB XII
* i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des * i.V.m. § 1 Abs. 1 Nr. 1 lit. b der Verordnung zur Durchführung des
* § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR. * § 90 Abs. 2 Nr. 9 SGB XII: 10.000 EUR.
* *
* Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der * Stand 01.01.2023 (bis dahin 5.000 EUR). Für jede Person, die von der
* leistungsberechtigten Person überwiegend unterhalten wird, kommen * leistungsberechtigten Person überwiegend unterhalten wird, kommen
* weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung). * weitere 500 EUR hinzu (§ 1 Abs. 2 der Verordnung).
*/ */
export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n export const VERMOEGENSFREIBETRAG_CENT = 1_000_000n
export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n export const VERMOEGENSFREIBETRAG_JE_UNTERHALTENE_PERSON_CENT = 50_000n
export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01' export const VERMOEGENSFREIBETRAG_STAND = '2023-01-01'
/** /**
* Rückforderung einer Schenkung wegen Verarmung, § 528 BGB. * Rückforderung einer Schenkung wegen Verarmung, § 528 BGB.
* Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der * Der Anspruch ist ausgeschlossen, wenn zum Zeitpunkt des Eintritts der
* Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre * Bedürftigkeit seit der Leistung des geschenkten Gegenstands zehn Jahre
* verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach * verstrichen sind (§ 529 Abs. 1 BGB). Deshalb fragt der Träger nach
* notariellen Übertragungen der letzten zehn Jahre. * notariellen Übertragungen der letzten zehn Jahre.
*/ */
export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10 export const SCHENKUNG_RUECKFORDERUNG_JAHRE = 10
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Rechtsbehelf // Rechtsbehelf
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */ /** Regelfall: ein Monat ab Bekanntgabe, § 84 Abs. 1 Satz 1 SGG. */
export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1 export const WIDERSPRUCHSFRIST_MONATE_REGEL = 1
/** /**
* Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt * Fehlt die Rechtsbehelfsbelehrung oder ist sie unrichtig erteilt, beträgt
* die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG). * die Frist ein Jahr seit Bekanntgabe (§ 66 Abs. 2 Satz 1 SGG).
* Das wird ausdrücklich ausgewählt und nie automatisch angenommen. * Das wird ausdrücklich ausgewählt und nie automatisch angenommen.
*/ */
export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12 export const WIDERSPRUCHSFRIST_MONATE_OHNE_BELEHRUNG = 12
/** /**
* Zugangsvermutung bei Übermittlung durch die Post im Inland: * Zugangsvermutung bei Übermittlung durch die Post im Inland:
* bekanntgegeben am dritten Tag nach der Aufgabe zur Post * bekanntgegeben am dritten Tag nach der Aufgabe zur Post
* (§ 37 Abs. 2 Satz 1 SGB X). * (§ 37 Abs. 2 Satz 1 SGB X).
*/ */
export const ZUGANGSVERMUTUNG_TAGE = 3 export const ZUGANGSVERMUTUNG_TAGE = 3
/** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */ /** Vorwarnstufen für die Frist "Widerspruch möglich bis" (Tage vorher). */
export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1] export const WIDERSPRUCH_VORWARNUNG_TAGE = [21, 14, 7, 3, 1]
/** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */ /** Vorwarnstufen für eine vom Träger gesetzte Nachforderungsfrist. */
export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1] export const NACHFORDERUNG_VORWARNUNG_TAGE = [14, 7, 3, 1]
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Weitere Leistungen // Weitere Leistungen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** /**
* Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2, * Bayerisches Landespflegegeld: 1.000 EUR im Jahr, ab Pflegegrad 2,
* Antrag bis 31.12. des jeweiligen Jahres (Bayerisches * Antrag bis 31.12. des jeweiligen Jahres (Bayerisches
* Landespflegegeldgesetz - BayLPflGG). * Landespflegegeldgesetz - BayLPflGG).
* *
* Der Betrag steht im Haushalt des Freistaats und wurde politisch schon * Der Betrag steht im Haushalt des Freistaats und wurde politisch schon
* mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe * mehrfach in Frage gestellt. Vor der Antragstellung die aktuelle Höhe
* beim Landesamt für Pflege prüfen. * beim Landesamt für Pflege prüfen.
*/ */
export const LANDESPFLEGEGELD_CENT = 100_000n export const LANDESPFLEGEGELD_CENT = 100_000n
export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2 export const LANDESPFLEGEGELD_AB_PFLEGEGRAD = 2
export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.' export const LANDESPFLEGEGELD_ANTRAGSSCHLUSS = '31.12.'
/** /**
* Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der * Zuzahlungsbefreiung, § 62 SGB V: Die Belastungsgrenze liegt bei 2 % der
* jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend * jährlichen Bruttoeinnahmen zum Lebensunterhalt, bei schwerwiegend
* chronisch Kranken bei 1 %. * chronisch Kranken bei 1 %.
* *
* Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung * Für Menschen, die Leistungen nach dem SGB XII in einer Einrichtung
* erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1 * erhalten, ist als Bruttoeinnahme der Regelbedarf der Regelbedarfsstufe 1
* anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche * anzusetzen (§ 62 Abs. 2 Satz 5 und 6 SGB V) - nicht die tatsächliche
* Rente. Das macht die Befreiung meist schon im Januar erreichbar. * Rente. Das macht die Befreiung meist schon im Januar erreichbar.
* *
* 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025). * 2 % von 12 x 563,00 EUR = 135,12 EUR; 1 % = 67,56 EUR (Stand 2025).
*/ */
export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2 export const ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT = 2
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1 export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT = 1
export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT = export const ZUZAHLUNG_BELASTUNGSGRENZE_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n (REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_PROZENT)) / 100n
export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT = export const ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_CENT =
(REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n (REGELBEDARFSSTUFE_1_CENT * 12n * BigInt(ZUZAHLUNG_BELASTUNGSGRENZE_CHRONIKER_PROZENT)) / 100n
/** /**
* Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag: * Rundfunkbeitrag je Wohnung, § 8 Rundfunkfinanzierungsstaatsvertrag:
* 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein * 18,36 EUR monatlich, unverändert seit 01.08.2021. Die Ermäßigung auf ein
* Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR. * Drittel (§ 4 Abs. 2 RBStV, Merkzeichen RF) beträgt 6,12 EUR.
*/ */
export const RUNDFUNKBEITRAG_CENT = 1_836n export const RUNDFUNKBEITRAG_CENT = 1_836n
export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n export const RUNDFUNKBEITRAG_ERMAESSIGT_CENT = 612n
export const RUNDFUNKBEITRAG_STAND = '2021-08-01' export const RUNDFUNKBEITRAG_STAND = '2021-08-01'
+47 -54
View File
@@ -1,8 +1,8 @@
import 'server-only' import 'server-only'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises' import { mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises'
import { dirname, join, resolve, sep } from 'node:path' import { dirname, join, resolve, sep } from 'node:path'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
import { import {
dateiRoutePfad, dateiRoutePfad,
schluesselErfordern, schluesselErfordern,
@@ -23,15 +23,10 @@ import {
* gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis. * gibt sonst Bescheide, Arztbriefe und Kontoauszuege im Klartext preis.
* Der Schluessel liegt in der Umgebung, nicht neben den Daten. * Der Schluessel liegt in der Umgebung, nicht neben den Daten.
* *
* Umschlag (binaer): * Der Umschlag selbst steht in src/lib/speicher/umschlag.ts - der
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext * Objektspeicher benutzt denselben.
*/ */
const MAGIE = Buffer.from('BCD1', 'ascii')
const IV_LAENGE = 12
const TAG_LAENGE = 16
const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
function wurzel(): string { function wurzel(): string {
return resolve(process.cwd(), env.dateiSpeicherPfad) return resolve(process.cwd(), env.dateiSpeicherPfad)
} }
@@ -39,8 +34,18 @@ function wurzel(): string {
/** /**
* Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich * Baut den Dateipfad - und prueft danach noch einmal, dass er wirklich
* unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits * unterhalb des Ablageverzeichnisses liegt. Der Schluessel ist zwar bereits
* streng geprueft, aber diese zweite Sperre kostet nichts und faengt auch * streng geprueft, aber diese zweite Sperre kostet nichts.
* Symlink-Tricks und Windows-Eigenheiten ab. *
* Was sie NICHT abfaengt, obwohl hier lange das Gegenteil stand: Symlinks.
* resolve() rechnet nur mit Zeichenketten und folgt keinem Link - dafuer
* braeuchte es realpath(). Ein untergeschobener Symlink im Ablageverzeichnis
* wuerde also nicht auffallen.
*
* Nachgeruestet wird das trotzdem nicht, und zwar aus einem Grund und nicht
* aus Bequemlichkeit: Wer dort einen Symlink anlegen kann, hat bereits
* Schreibrechte im Datenverzeichnis des Servers - dann ist der Umweg ueber
* einen Link die Sorge, die am wenigsten draengt. Der Satz musste
* verschwinden, weil er eine Sicherheit versprach, die es nie gab.
*/ */
function pfadFuer(schluessel: string): string { function pfadFuer(schluessel: string): string {
const geprueft = schluesselErfordern(schluessel) const geprueft = schluesselErfordern(schluessel)
@@ -52,57 +57,15 @@ function pfadFuer(schluessel: string): string {
return ziel return ziel
} }
function verschluesselnBinaer(inhalt: Buffer): Buffer {
const version = env.aktiveSchluesselversion
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`)
const iv = randomBytes(IV_LAENGE)
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
cipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()])
const versionBytes = Buffer.alloc(4)
versionBytes.writeUInt32BE(version, 0)
return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim])
}
function entschluesselnBinaer(roh: Buffer): Buffer {
// Dateien ohne Umschlag (von Hand hineingelegt, aus einer aelteren
// Installation uebernommen) werden unveraendert durchgereicht.
if (roh.length < KOPF_LAENGE || !roh.subarray(0, MAGIE.length).equals(MAGIE)) return roh
const version = roh.readUInt32BE(MAGIE.length)
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) {
throw new Error(
`Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` +
`Rotieren nicht aus der Umgebung entfernt werden.`,
)
}
const ivVon = MAGIE.length + 4
const iv = roh.subarray(ivVon, ivVon + IV_LAENGE)
const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE)
const geheim = roh.subarray(KOPF_LAENGE)
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
decipher.setAAD(Buffer.from(`bcd:${version}`, 'utf8'))
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(geheim), decipher.final()])
}
export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> { export async function schreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = pfadFuer(schluessel) const pfad = pfadFuer(schluessel)
await mkdir(dirname(pfad), { recursive: true }) await mkdir(dirname(pfad), { recursive: true })
await writeFile(pfad, verschluesselnBinaer(inhalt), { mode: 0o600 }) await writeFile(pfad, verschluesselnBinaer(inhalt, schluessel), { mode: 0o600 })
} }
export async function lesen(schluessel: string): Promise<Buffer> { export async function lesen(schluessel: string): Promise<Buffer> {
const roh = await readFile(pfadFuer(schluessel)) const roh = await readFile(pfadFuer(schluessel))
return entschluesselnBinaer(roh) return entschluesselnBinaer(roh, schluessel)
} }
export async function entfernen(schluessel: string): Promise<void> { export async function entfernen(schluessel: string): Promise<void> {
@@ -130,6 +93,36 @@ export async function signierteUrl(
return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden)) return dateiRoutePfad(zugriffTokenErzeugen(schluessel, sekunden))
} }
// -----------------------------------------------------------------------------
// Bereitschaftspruefung
// -----------------------------------------------------------------------------
//
// Ohne Umschlag und ohne die strenge Schluesselpruefung: Der Probeschluessel
// gehoert zu keiner betreuten Person und faellt durch das Muster.
function probepfad(schluessel: string): string {
const basis = wurzel()
const ziel = resolve(basis, ...schluessel.split('/'))
if (ziel !== basis && !ziel.startsWith(basis + sep)) {
throw new Error('Der Ablagepfad läge außerhalb des Dokumentenverzeichnisses.')
}
return ziel
}
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
const pfad = probepfad(schluessel)
await mkdir(dirname(pfad), { recursive: true })
await writeFile(pfad, inhalt, { mode: 0o600 })
}
export async function rohLesen(schluessel: string): Promise<Buffer> {
return readFile(probepfad(schluessel))
}
export async function rohEntfernen(schluessel: string): Promise<void> {
await rm(probepfad(schluessel), { force: true })
}
/** Nur fuer Wartungsskripte: das Ablageverzeichnis. */ /** Nur fuer Wartungsskripte: das Ablageverzeichnis. */
export function ablageverzeichnis(): string { export function ablageverzeichnis(): string {
return wurzel() return wurzel()
+129
View File
@@ -0,0 +1,129 @@
import 'server-only'
/**
* Speicherfehler in Sätze übersetzen, mit denen jemand etwas anfangen kann.
*
* Anlass ist eine Fehlersuche vom 31.08.2026. Beim Ablegen eines Schreibens
* stand in der Oberfläche:
*
* Das Schreiben konnte nicht abgelegt werden.
*
* Dahinter lagen nacheinander DREI verschiedene Ursachen — ein fehlender
* Ablagebereich, ein falsch konfigurierter Endpunkt und eine
* Verschlüsselung, die der Speicher nicht anbietet. Jedes Mal derselbe
* Satz. Die Suche dauerte entsprechend, und ohne Zugang zum Serverlog wäre
* sie aussichtslos gewesen.
*
* ## Was hier NICHT passiert
*
* Es wird nichts durchgereicht, was der Speicher sagt. Fremde
* Fehlertexte können Hostnamen, Eimernamen und Anfragekennungen
* enthalten - das gehört ins Log, nicht auf den Bildschirm eines
* Betreuers. Übersetzt werden nur die Fälle, die wir kennen; alles andere
* bleibt bei der allgemeinen Meldung des Aufrufers.
*
* Die technische Ursache steht weiterhin vollständig im Log.
*/
/** Ein erkannter Speicherfehler samt Sprachfassung. */
export type Speicherfehler = {
/** Kurzform für Protokoll und Gesundheitsauskunft. */
art: 'kein-ablagebereich' | 'verschluesselung' | 'zugang' | 'nicht-erreichbar'
/** Was in der Oberfläche stehen soll. */
meldung: string
}
/**
* Holt den Fehlercode heraus.
*
* Die Reihenfolge ist wichtig und war zuerst falsch: "name" traegt bei
* einem gewoehnlichen Node-Fehler den Wert "Error" - eine nichtssagende
* Zeichenkette, die aber nicht leer ist. Stuende sie vorn, kaeme der
* eigentliche Code aus "code" (ECONNREFUSED und Verwandte) nie zum Zug.
*
* Deshalb: erst "Code" (AWS-SDK), dann "code" (Node-Systemaufruf), und
* "name" nur als letzte Zuflucht.
*/
function code(fehler: unknown): string {
if (typeof fehler !== 'object' || fehler === null) return ''
const mitCode = fehler as { Code?: unknown; code?: unknown; name?: unknown }
for (const wert of [mitCode.Code, mitCode.code, mitCode.name]) {
if (typeof wert === 'string' && wert !== '' && wert !== 'Error') return wert
}
return ''
}
function nachricht(fehler: unknown): string {
return fehler instanceof Error ? fehler.message : ''
}
/**
* Erkennt die häufigen Speicherfehler.
*
* @returns null, wenn es keiner der bekannten Fälle ist - dann bleibt es
* bei der allgemeinen Meldung des Aufrufers.
*/
export function speicherfehlerErkennen(fehler: unknown): Speicherfehler | null {
const kennung = code(fehler)
const text = nachricht(fehler)
if (kennung === 'NoSuchBucket') {
return {
art: 'kein-ablagebereich',
meldung:
'Im Objektspeicher fehlt der Ablagebereich für die Dokumente. Er wird bei der ' +
'Installation einmalig angelegt — siehe INSTALLATION.md, Schritt 6. Bis dahin lässt ' +
'sich nichts ablegen; deine Eingaben gehen aber nicht verloren.',
}
}
// MinIO ohne KMS antwortet so, sobald S3_SSE gesetzt ist.
if (kennung === 'NotImplemented' && /encryption/i.test(text)) {
return {
art: 'verschluesselung',
meldung:
'Der Objektspeicher unterstützt die eingestellte serverseitige Verschlüsselung nicht. ' +
'Der mitgelieferte Speicher braucht sie auch nicht — die Anwendung verschlüsselt selbst. ' +
'Nimm S3_SSE aus der .env heraus.',
}
}
if (
kennung === 'AccessDenied' ||
kennung === 'SignatureDoesNotMatch' ||
kennung === 'InvalidAccessKeyId'
) {
return {
art: 'zugang',
meldung:
'Die Zugangsdaten zum Objektspeicher werden abgelehnt. Prüfe S3_ACCESS_KEY_ID und ' +
'S3_SECRET_ACCESS_KEY in der .env — und ob sie zu dem Speicher gehören, auf den ' +
'S3_ENDPOINT zeigt.',
}
}
if (
['ECONNREFUSED', 'ENOTFOUND', 'ETIMEDOUT', 'EAI_AGAIN', 'TimeoutError'].includes(kennung) ||
/ECONNREFUSED|ENOTFOUND|ETIMEDOUT/.test(text)
) {
return {
art: 'nicht-erreichbar',
meldung:
'Der Objektspeicher antwortet nicht. Läuft er, und zeigt S3_ENDPOINT auf die richtige ' +
'Adresse? Deine Eingaben gehen nicht verloren.',
}
}
return null
}
/**
* Bequemer Aufruf für die Stellen, die eine Meldung brauchen.
*
* @param allgemein Was stehen soll, wenn der Fehler keiner der bekannten
* ist - z.B. "Das Schreiben konnte nicht abgelegt werden."
*/
export function speicherfehlerMeldung(fehler: unknown, allgemein: string): string {
const erkannt = speicherfehlerErkennen(fehler)
return erkannt ? erkannt.meldung : allgemein
}
+154
View File
@@ -0,0 +1,154 @@
import 'server-only'
import { randomUUID } from 'node:crypto'
import { env } from '@/lib/env'
import { speicherfehlerErkennen } from '@/lib/speicher/fehler'
/**
* Ist die Dateiablage benutzbar?
*
* Bis v1.12.0 fiel eine kaputte Speicherkonfiguration erst auf, wenn
* jemand ein Dokument ablegen wollte. Auf dem Livesystem waren das
* mehrere Wochen nach der Installation - und dann mitten in der Arbeit,
* mit einer Meldung, die nichts sagte.
*
* Diese Pruefung schreibt eine winzige Datei, liest sie zurueck und raeumt
* sie weg. Sie beantwortet damit alle drei Fragen auf einmal: erreichbar,
* Ablagebereich vorhanden, Schreibrecht da.
*
* ## Sie verhindert den Start nicht
*
* Eine Anwendung, die wegen des Objektspeichers nicht hochkommt, ist
* schlimmer als eine, die ihn meldet: Ohne Oberflaeche sieht niemand die
* Erklaerung, und der Rest der Akte - Fristen, Buchungen, Stammdaten -
* waere ohne Not mit abgeschaltet.
*/
export type Speicherzustand =
| { ok: true; treiber: string }
| { ok: false; treiber: string; art: string; meldung: string }
/** Der Ablageschluessel fuer die Probe. Liegt im eigenen Bereich. */
function probeschluessel(): string {
return `pruefung/${randomUUID()}.txt`
}
/**
* Schreibt, liest und loescht eine Probedatei.
*
* Bewusst ueber die Treiber und nicht ueber die Fassade in index.ts: Die
* prueft den Ablageschluessel gegen das Muster fuer Dokumente einer
* betreuten Person, und darum geht es hier nicht.
*/
export async function speicherPruefen(): Promise<Speicherzustand> {
const treiber = env.speicherTreiber
const schluessel = probeschluessel()
const inhalt = Buffer.from(`bereitschaftspruefung ${new Date().toISOString()}`, 'utf8')
try {
if (treiber === 's3') {
const s3 = await import('@/lib/speicher/s3')
await s3.rohSchreiben(schluessel, inhalt)
await s3.rohLesen(schluessel)
await s3.rohEntfernen(schluessel)
} else {
const datei = await import('@/lib/speicher/datei')
await datei.rohSchreiben(schluessel, inhalt)
await datei.rohLesen(schluessel)
await datei.rohEntfernen(schluessel)
}
return { ok: true, treiber }
} catch (fehler) {
const erkannt = speicherfehlerErkennen(fehler)
return {
ok: false,
treiber,
art: erkannt?.art ?? 'unbekannt',
meldung: erkannt?.meldung ?? 'Die Dateiablage ist nicht benutzbar.',
}
}
}
// -----------------------------------------------------------------------------
// Gemerkter Zustand
// -----------------------------------------------------------------------------
/**
* Warum die Pruefung an der Gesundheitsabfrage haengt und nicht an einem
* Startaufruf.
*
* Der naheliegende Ort waere instrumentation.ts - der Haken von Next.js
* fuer "einmal beim Hochfahren". Der wird aber auch fuer die
* Edge-Laufzeit gebuendelt, und ueber die Treiber kaeme node:path in ein
* Buendel, in dem es das nicht gibt: Der Build schlaegt fehl. Es gibt
* Kunstgriffe dagegen; sie sind fragil und muessten bei jedem
* Next-Wechsel nachgezogen werden.
*
* Der Healthcheck aus docker-compose ruft /api/gesundheit ohnehin ab 40
* Sekunden nach dem Start alle 30 Sekunden auf. Die erste Pruefung
* passiert damit von selbst kurz nach dem Hochfahren, und die Zeile steht
* im Log, wo man sie sucht. Ohne Behaelter geschieht dasselbe beim ersten
* Aufruf der Adresse.
*/
const NEU_PRUEFEN_NACH_MS = 5 * 60 * 1000
let gemerkt: { zustand: Speicherzustand; zeitpunkt: number } | null = null
let zuletztGemeldet: string | null = null
/**
* Der Zustand der Ablage, hoechstens alle fuenf Minuten frisch geholt.
*
* Ohne diese Schonfrist schriebe der Healthcheck alle 30 Sekunden eine
* Probedatei in den Objektspeicher - unnoetiger Verkehr fuer eine Frage,
* deren Antwort sich selten aendert.
*/
export async function speicherZustand(): Promise<Speicherzustand> {
const jetzt = Date.now()
if (gemerkt && jetzt - gemerkt.zeitpunkt < NEU_PRUEFEN_NACH_MS) {
return gemerkt.zustand
}
const zustand = await speicherPruefen()
gemerkt = { zustand, zeitpunkt: jetzt }
melden(zustand)
return zustand
}
/**
* Schreibt eine Zeile ins Protokoll - aber nur, wenn sich etwas geaendert
* hat. Sonst stuende alle fuenf Minuten dieselbe Meldung im Log und
* niemand liest sie mehr.
*/
function melden(zustand: Speicherzustand): void {
const kennung = zustand.ok ? 'ok' : `fehler:${zustand.art}`
if (kennung === zuletztGemeldet) return
zuletztGemeldet = kennung
if (zustand.ok) {
console.log(`[speicher] Ablage bereit (Treiber: ${zustand.treiber}).`)
return
}
console.error(
`[speicher] Die Dateiablage ist NICHT benutzbar (Treiber: ${zustand.treiber}, ` +
`${zustand.art}). ${zustand.meldung} ` +
`Die Anwendung laeuft trotzdem weiter - alles ausser dem Ablegen von ` +
`Dateien funktioniert.`,
)
}
/**
* Nur fuer Tests: die Schonfrist ueberspringen.
*
* Bewusst OHNE zuletztGemeldet zurueckzusetzen - das entspricht dem, was
* nach fuenf Minuten im Betrieb passiert: neu geprueft, aber nur bei einer
* Aenderung gemeldet.
*/
export function schonfristUeberspringen(): void {
gemerkt = null
}
/** Nur fuer Tests: auch das Meldegedaechtnis leeren. */
export function zustandVergessen(): void {
gemerkt = null
zuletztGemeldet = null
}
+121 -20
View File
@@ -3,12 +3,19 @@ import {
DeleteObjectCommand, DeleteObjectCommand,
GetObjectCommand, GetObjectCommand,
HeadObjectCommand, HeadObjectCommand,
ListObjectsV2Command,
PutObjectCommand, PutObjectCommand,
S3Client, S3Client,
type ServerSideEncryption,
} from '@aws-sdk/client-s3' } from '@aws-sdk/client-s3'
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
import { env } from '@/lib/env' import { env } from '@/lib/env'
import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/schluessel' import {
dateiRoutePfad,
schluesselErfordern,
zugriffTokenErzeugen,
TOKEN_STANDARD_SEKUNDEN,
} from '@/lib/speicher/schluessel'
import { entschluesselnBinaer, verschluesselnBinaer } from '@/lib/speicher/umschlag'
/** /**
* Treiber "s3": S3-kompatibler Objektspeicher, betrieben in der EU. * Treiber "s3": S3-kompatibler Objektspeicher, betrieben in der EU.
@@ -17,10 +24,33 @@ import { schluesselErfordern, TOKEN_STANDARD_SEKUNDEN } from '@/lib/speicher/sch
* Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und * Art. 28 DSGVO mit dem Anbieter - hier liegen Gesundheits- und
* Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber. * Finanzdaten. Ohne diesen Vertrag bleibt es beim Dateitreiber.
* *
* Ausgeliefert wird nur ueber kurzlebige presigned URLs. Sie tragen * ## Anwendungsseitige Verschluesselung
* Content-Disposition: attachment und einen festen Content-Type, damit der *
* Objektspeicher eine hochgeladene Datei niemals als HTML im Browser * Frueher stand hier nur ServerSideEncryption: 'AES256'. Das ist
* rendern kann. * Verschluesselung mit dem Schluessel des Anbieters: Er entschluesselt beim
* Ausliefern selbst. Gegen eine verlorene Festplatte im Rechenzentrum hilft
* das, gegen den Anbieter und gegen gestohlene Zugangsdaten zum Eimer
* nicht - und in diesem Eimer liegen Bescheide, Arztbriefe und
* Kontoauszuege.
*
* Deshalb liegt jetzt derselbe Umschlag darum wie beim Dateitreiber, mit
* einem Schluessel aus der Umgebung.
*
* Die serverseitige Verschluesselung ist damit ueberfluessig und wird nur
* noch gesendet, wenn S3_SSE gesetzt ist. Im Kommentar stand hier einmal,
* sie "kostet nichts" - das war falsch: Das mitgelieferte MinIO laeuft
* ohne KMS und lehnte jedes PutObject mit "NotImplemented" ab. Sie kostete
* die gesamte Funktion.
*
* ## Und deshalb keine presigned URLs mehr
*
* Eine presigned URL laesst den Browser direkt beim Objektspeicher laden.
* Was dort liegt, ist jetzt aber Geheimtext - der Browser bekaeme
* unlesbare Bytes. Die Auslieferung geht darum ueber dieselbe eigene Route
* wie beim Dateitreiber: Sie prueft zusaetzlich die Berechtigung des
* angemeldeten Benutzers und schreibt einen Audit-Eintrag. Dass jeder
* Abruf durch die Anwendung laeuft, ist bei einer Handvoll Dokumenten am
* Tag kein Thema.
*/ */
let klient: S3Client | null = null let klient: S3Client | null = null
@@ -42,22 +72,34 @@ function eimer(): string {
return env.s3.bucket return env.s3.bucket
} }
/**
* Legt ein Objekt ab - verschluesselt.
*
* Der gemeldete MIME-Typ wird nicht mehr gebraucht: Was hier landet, ist
* Geheimtext und wird als application/octet-stream ausgezeichnet. Der
* Parameter bleibt in der Signatur, damit die Fassade beide Treiber gleich
* aufrufen kann.
*/
export async function schreiben( export async function schreiben(
schluessel: string, schluessel: string,
inhalt: Buffer, inhalt: Buffer,
mimeTyp: string, _mimeTyp: string,
): Promise<void> { ): Promise<void> {
const geprueft = schluesselErfordern(schluessel) const geprueft = schluesselErfordern(schluessel)
const sse = env.s3.serverseitigeVerschluesselung
await s3().send( await s3().send(
new PutObjectCommand({ new PutObjectCommand({
Bucket: eimer(), Bucket: eimer(),
Key: geprueft, Key: geprueft,
Body: inhalt, Body: verschluesselnBinaer(inhalt, geprueft),
ContentType: mimeTyp, // Bewusst nicht der echte Typ: Was hier liegt, ist Geheimtext. Ein
// "application/pdf" darauf waere eine Falschauskunft, und der einzige
// Weg zum Inhalt fuehrt ohnehin durch die Anwendung.
ContentType: 'application/octet-stream',
// Auch beim direkten Abruf niemals im Browser darstellen. // Auch beim direkten Abruf niemals im Browser darstellen.
ContentDisposition: 'attachment', ContentDisposition: 'attachment',
// Serverseitige Verschluesselung, wo der Anbieter sie unterstuetzt. // Nur mitschicken, wenn konfiguriert - siehe env.s3.
ServerSideEncryption: 'AES256', ...(sse ? { ServerSideEncryption: sse as ServerSideEncryption } : {}),
}), }),
) )
} }
@@ -67,7 +109,10 @@ export async function lesen(schluessel: string): Promise<Buffer> {
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft })) const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: geprueft }))
if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`) if (!antwort.Body) throw new Error(`Objekt ${geprueft} ist leer.`)
const bytes = await antwort.Body.transformToByteArray() const bytes = await antwort.Body.transformToByteArray()
return Buffer.from(bytes) // Objekte, die vor dieser Aenderung abgelegt wurden, tragen keinen
// Umschlag und kommen unveraendert zurueck. Der Migrationslauf
// (npm run speicher:verschluesseln) schreibt sie nach und nach um.
return entschluesselnBinaer(Buffer.from(bytes), geprueft)
} }
export async function entfernen(schluessel: string): Promise<void> { export async function entfernen(schluessel: string): Promise<void> {
@@ -85,20 +130,76 @@ export async function existiert(schluessel: string): Promise<boolean> {
} }
} }
/**
* Zeigt auf die eigene, signierte Route - nicht mehr auf den
* Objektspeicher.
*
* Der direkte Weg zum Anbieter lieferte den Rohinhalt aus. Solange der
* Klartext war, ging das; jetzt ist es Geheimtext, und entschluesseln kann
* nur die Anwendung. Der Nebeneffekt ist erwuenscht: Die Route prueft die
* Berechtigung des angemeldeten Benutzers und schreibt einen Audit-Eintrag.
* Ein weitergeleiteter Link allein oeffnet also nichts mehr.
*/
export async function signierteUrl( export async function signierteUrl(
schluessel: string, schluessel: string,
sekunden: number = TOKEN_STANDARD_SEKUNDEN, sekunden: number = TOKEN_STANDARD_SEKUNDEN,
): Promise<string> { ): Promise<string> {
const geprueft = schluesselErfordern(schluessel) const geprueft = schluesselErfordern(schluessel)
return getSignedUrl( return dateiRoutePfad(zugriffTokenErzeugen(geprueft, sekunden))
s3(), }
new GetObjectCommand({
// -----------------------------------------------------------------------------
// Wartung
// -----------------------------------------------------------------------------
//
// Nur fuer den Migrationslauf, der bestehende Objekte nachtraeglich in den
// Umschlag steckt. Bewusst hier und nicht im Skript: Der Zugang zum Eimer
// wird an genau einer Stelle aufgebaut.
/** Alle Ablageschluessel im Eimer, seitenweise geholt. */
export async function objekteAuflisten(): Promise<string[]> {
const gefunden: string[] = []
let weiter: string | undefined
do {
const antwort = await s3().send(
new ListObjectsV2Command({ Bucket: eimer(), ContinuationToken: weiter }),
)
for (const eintrag of antwort.Contents ?? []) {
if (eintrag.Key) gefunden.push(eintrag.Key)
}
weiter = antwort.IsTruncated ? antwort.NextContinuationToken : undefined
} while (weiter)
return gefunden
}
/**
* Legt Bytes ohne Umschlag ab. Nur fuer die Bereitschaftspruefung.
*
* Bewusst ohne schluesselErfordern(): Der Probeschluessel gehoert zu
* keiner betreuten Person und faellt durch das Muster.
*/
export async function rohSchreiben(schluessel: string, inhalt: Buffer): Promise<void> {
await s3().send(
new PutObjectCommand({
Bucket: eimer(), Bucket: eimer(),
Key: geprueft, Key: schluessel,
// Die Antwort des Objektspeichers wird hier festgenagelt: Anhang, Body: inhalt,
// niemals HTML, egal was beim Hochladen gemeldet wurde. ContentType: 'application/octet-stream',
ResponseContentDisposition: 'attachment', ContentDisposition: 'attachment',
}), }),
{ expiresIn: Math.min(Math.max(Math.floor(sekunden), 1), 60 * 60) },
) )
} }
/** Raeumt die Probedatei wieder weg. */
export async function rohEntfernen(schluessel: string): Promise<void> {
await s3().send(new DeleteObjectCommand({ Bucket: eimer(), Key: schluessel }))
}
/** Der Inhalt, wie er im Eimer liegt - ohne den Umschlag abzunehmen. */
export async function rohLesen(schluessel: string): Promise<Buffer> {
const antwort = await s3().send(new GetObjectCommand({ Bucket: eimer(), Key: schluessel }))
if (!antwort.Body) throw new Error(`Objekt ${schluessel} ist leer.`)
return Buffer.from(await antwort.Body.transformToByteArray())
}
+119
View File
@@ -0,0 +1,119 @@
import 'server-only'
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'
import { env } from '@/lib/env'
/**
* Anwendungsseitiger Umschlag fuer abgelegte Dateien (AES-256-GCM).
*
* Der Dateitreiber hat das von Anfang an getan, der Objektspeicher nicht:
* Dort stand nur ServerSideEncryption: 'AES256'. Das ist Verschluesselung
* mit dem Schluessel des Anbieters - er entschluesselt beim Ausliefern
* selbst. Gegen ein kopiertes Backup hilft es, gegen den Anbieter und gegen
* gestohlene Zugangsdaten zum Eimer nicht. Bei Bescheiden, Arztbriefen und
* Kontoauszuegen ist das der falsche Zuschnitt.
*
* Jetzt liegt derselbe Umschlag um beides. Der Schluessel kommt aus der
* Umgebung und nie aus der Datenbank oder vom Anbieter.
*
* ## Format
*
* "BCD1" | Schluesselversion (uint32 BE) | IV (12) | AuthTag (16) | Geheimtext
*
* Bewusst dasselbe Format wie bisher im Dateitreiber - bestehende Dateien
* bleiben ohne Umschreiben lesbar.
*
* ## Zusatzdaten
*
* Mitauthentifiziert wird die Schluesselversion und, wo der Aufrufer ihn
* angibt, der Ablageschluessel des Objekts. Letzteres bindet den Geheimtext
* an seinen Platz: Wer Schreibrechte auf den Eimer hat, kann die Bytes von
* Dokument A nicht unter dem Namen von Dokument B ablegen - beim Lesen
* schlaegt die Pruefung fehl, statt den falschen Bescheid auszuliefern.
*
* Ohne Angabe bleibt es bei "bcd:<version>", damit die Dateien, die der
* Dateitreiber frueher geschrieben hat, weiter aufgehen.
*/
const MAGIE = Buffer.from('BCD1', 'ascii')
const IV_LAENGE = 12
const TAG_LAENGE = 16
export const KOPF_LAENGE = MAGIE.length + 4 + IV_LAENGE + TAG_LAENGE
function zusatzdaten(version: number, ablageschluessel?: string): Buffer {
return Buffer.from(
ablageschluessel ? `bcd:${version}:${ablageschluessel}` : `bcd:${version}`,
'utf8',
)
}
/** Traegt dieser Inhalt bereits einen Umschlag? */
export function istVerschluesselt(roh: Buffer): boolean {
return roh.length >= KOPF_LAENGE && roh.subarray(0, MAGIE.length).equals(MAGIE)
}
export function verschluesselnBinaer(inhalt: Buffer, ablageschluessel?: string): Buffer {
const version = env.aktiveSchluesselversion
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) throw new Error(`Verschlüsselungsschlüssel Version ${version} fehlt.`)
const iv = randomBytes(IV_LAENGE)
const cipher = createCipheriv('aes-256-gcm', schluessel, iv, { authTagLength: TAG_LAENGE })
cipher.setAAD(zusatzdaten(version, ablageschluessel))
const geheim = Buffer.concat([cipher.update(inhalt), cipher.final()])
const versionBytes = Buffer.alloc(4)
versionBytes.writeUInt32BE(version, 0)
return Buffer.concat([MAGIE, versionBytes, iv, cipher.getAuthTag(), geheim])
}
/**
* Nimmt den Umschlag ab.
*
* Inhalte ohne Umschlag werden unveraendert durchgereicht: Dateien aus
* einer aelteren Installation, von Hand hineingelegte, und - bis der
* Migrationslauf durch ist - die Objekte, die vor dieser Aenderung im
* Objektspeicher lagen.
*/
export function entschluesselnBinaer(roh: Buffer, ablageschluessel?: string): Buffer {
if (!istVerschluesselt(roh)) return roh
const version = roh.readUInt32BE(MAGIE.length)
const schluessel = env.verschluesselungsschluessel.get(version)
if (!schluessel) {
throw new Error(
`Zum Entschlüsseln dieser Datei fehlt ENC_KEY_V${version}. Alte Schlüssel dürfen beim ` +
`Rotieren nicht aus der Umgebung entfernt werden.`,
)
}
const ivVon = MAGIE.length + 4
const iv = roh.subarray(ivVon, ivVon + IV_LAENGE)
const tag = roh.subarray(ivVon + IV_LAENGE, ivVon + IV_LAENGE + TAG_LAENGE)
const geheim = roh.subarray(KOPF_LAENGE)
// Zwei Anlaeufe: erst mit dem Ablageschluessel als Zusatzdaten, dann ohne.
// Der zweite Anlauf ist fuer alles, was vor der Bindung an den Platz
// geschrieben wurde - ohne ihn waeren die alten Dateien nicht mehr
// lesbar, und das waere ein Datenverlust fuer eine Verbesserung.
const varianten = ablageschluessel ? [ablageschluessel, undefined] : [undefined]
let letzterFehler: unknown = null
for (const variante of varianten) {
try {
const decipher = createDecipheriv('aes-256-gcm', schluessel, iv, {
authTagLength: TAG_LAENGE,
})
decipher.setAAD(zusatzdaten(version, variante))
decipher.setAuthTag(tag)
return Buffer.concat([decipher.update(geheim), decipher.final()])
} catch (fehler) {
letzterFehler = fehler
}
}
throw letzterFehler instanceof Error
? letzterFehler
: new Error('Die Datei ließ sich nicht entschlüsseln.')
}
+10 -1
View File
@@ -1,5 +1,5 @@
import { z } from 'zod' import { z } from 'zod'
import type { KontaktArt, KontaktRolle } from '@prisma/client' import type { KontaktAnrede, KontaktArt, KontaktRolle } from '@prisma/client'
/** /**
* Eingabepruefung fuer Kontakte und Kontaktprotokolle. * Eingabepruefung fuer Kontakte und Kontaktprotokolle.
@@ -101,6 +101,13 @@ export const KONTAKT_ROLLEN = [
'SONSTIGES', 'SONSTIGES',
] as const satisfies readonly KontaktRolle[] ] as const satisfies readonly KontaktRolle[]
/** Anreden fuer die Briefanrede - Reihenfolge der Auswahlliste. */
export const KONTAKT_ANREDEN = [
'OHNE',
'FRAU',
'HERR',
] as const satisfies readonly KontaktAnrede[]
export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = { export const KONTAKT_ROLLE_BESCHRIFTUNG: Record<KontaktRolle, string> = {
RECHTSPFLEGER: 'Rechtspfleger', RECHTSPFLEGER: 'Rechtspfleger',
RICHTER: 'Richter', RICHTER: 'Richter',
@@ -177,6 +184,7 @@ export const KontaktSchema = z.object({
rolle: z.enum(KONTAKT_ROLLEN, { rolle: z.enum(KONTAKT_ROLLEN, {
errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }), errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }),
}), }),
anrede: z.enum(KONTAKT_ANREDEN).default('OHNE'),
telefon: optionalerText(50), telefon: optionalerText(50),
mobil: optionalerText(50), mobil: optionalerText(50),
fax: optionalerText(50), fax: optionalerText(50),
@@ -198,6 +206,7 @@ export function kontaktFormularLesen(formular: FormData): Record<string, unknown
for (const feld of [ for (const feld of [
'name', 'name',
'organisation', 'organisation',
'anrede',
'rolle', 'rolle',
'telefon', 'telefon',
'mobil', 'mobil',
+437 -437
View File
@@ -1,437 +1,437 @@
import { z } from 'zod' import { z } from 'zod'
import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag' import { betragParsenWennMoeglich } from '@/lib/finanzen/betrag'
/** /**
* Eingabeprüfung für das Sozialhilfemodul. * Eingabeprüfung für das Sozialhilfemodul.
* *
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist - * Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine * nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
* Nachforderung erfasst, hat keine Lust auf "invalid input". * Nachforderung erfasst, hat keine Lust auf "invalid input".
*/ */
const optionalerText = (max = 200) => const optionalerText = (max = 200) =>
z z
.string() .string()
.trim() .trim()
.max(max, `Bitte höchstens ${max} Zeichen.`) .max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
.default(null) .default(null)
const optionalesDatum = z const optionalesDatum = z
.string() .string()
.trim() .trim()
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
.default(null) .default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), { .refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
}) })
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`))) .transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z const pflichtDatum = z
.string() .string()
.trim() .trim()
.min(1, 'Bitte ein Datum angeben.') .min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), { .refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
}) })
.transform((wert) => new Date(`${wert}T00:00:00.000Z`)) .transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/** /**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine * Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei. * Angabe" und "null Euro" sind fachlich zweierlei.
*/ */
const optionalerBetrag = z const optionalerBetrag = z
.string() .string()
.trim() .trim()
.default('') .default('')
.transform((wert, ctx) => { .transform((wert, ctx) => {
if (wert === '') return null if (wert === '') return null
const cent = betragParsenWennMoeglich(wert) const cent = betragParsenWennMoeglich(wert)
if (cent === null) { if (cent === null) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.', message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
}) })
return z.NEVER return z.NEVER
} }
return cent return cent
}) })
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */ /** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z const betragOderNull = z
.string() .string()
.trim() .trim()
.default('') .default('')
.transform((wert, ctx) => { .transform((wert, ctx) => {
if (wert === '') return 0n if (wert === '') return 0n
const cent = betragParsenWennMoeglich(wert) const cent = betragParsenWennMoeglich(wert)
if (cent === null) { if (cent === null) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.', message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
}) })
return z.NEVER return z.NEVER
} }
if (cent < 0n) { if (cent < 0n) {
ctx.addIssue({ ctx.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.', message: 'In dieser Aufstellung stehen nur positive Beträge.',
}) })
return z.NEVER return z.NEVER
} }
return cent return cent
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Antragsakte // Antragsakte
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const AntragSchema = z export const AntragSchema = z
.object({ .object({
traeger: z traeger: z
.string() .string()
.trim() .trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.') .min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150), .max(150),
leistungsart: z leistungsart: z
.string() .string()
.trim() .trim()
.min(2, 'Bitte die Leistungsart angeben.') .min(2, 'Bitte die Leistungsart angeben.')
.max(200), .max(200),
aktenzeichen: optionalerText(80), aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150), sachbearbeiter: optionalerText(150),
telefon: optionalerText(50), telefon: optionalerText(50),
email: z email: z
.string() .string()
.trim() .trim()
.max(200) .max(200)
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
.default(null) .default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), { .refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.', message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}), }),
antragAm: optionalesDatum, antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum, eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum, bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum, bewilligtBis: optionalesDatum,
notizen: optionalerText(4000), notizen: optionalerText(4000),
}) })
.refine( .refine(
(daten) => (daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis, !daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{ {
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.', message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'], path: ['bewilligtBis'],
}, },
) )
.refine( .refine(
(daten) => (daten) =>
!daten.antragAm || !daten.antragAm ||
!daten.eingangBeimTraegerAm || !daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm, daten.antragAm <= daten.eingangBeimTraegerAm,
{ {
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.', message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'], path: ['eingangBeimTraegerAm'],
}, },
) )
export type AntragEingabe = z.infer<typeof AntragSchema> export type AntragEingabe = z.infer<typeof AntragSchema>
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Nachforderung // Nachforderung
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [ export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN', 'OFFEN',
'IN_BESCHAFFUNG', 'IN_BESCHAFFUNG',
'UEBERSANDT', 'UEBERSANDT',
'ANERKANNT', 'ANERKANNT',
'NICHT_VORHANDEN', 'NICHT_VORHANDEN',
] as const ] as const
export const NachforderungSchema = z export const NachforderungSchema = z
.object({ .object({
bezeichnung: z bezeichnung: z
.string() .string()
.trim() .trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.') .min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200), .max(200),
erlaeuterung: optionalerText(2000), erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'), status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum, angefordertAm: optionalesDatum,
fristBis: optionalesDatum, fristBis: optionalesDatum,
uebersandtAm: optionalesDatum, uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000), begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0), reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40), dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */ /** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false), fristAnlegen: z.coerce.boolean().default(false),
}) })
.refine( .refine(
(daten) => (daten) =>
daten.status !== 'NICHT_VORHANDEN' || daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null && (daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5), daten.begruendungNichtVorhanden.trim().length >= 5),
{ {
message: message:
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' + 'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.', 'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
path: ['begruendungNichtVorhanden'], path: ['begruendungNichtVorhanden'],
}, },
) )
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, { .refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message: message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' + 'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.', 'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'], path: ['uebersandtAm'],
}) })
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, { .refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.', message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'], path: ['fristBis'],
}) })
export type NachforderungEingabe = z.infer<typeof NachforderungSchema> export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z export const NachforderungStatusSchema = z
.object({ .object({
nachforderungId: z.string().trim().min(1), nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE), status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000), begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum, uebersandtAm: optionalesDatum,
}) })
.refine( .refine(
(daten) => (daten) =>
daten.status !== 'NICHT_VORHANDEN' || daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null && (daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5), daten.begruendungNichtVorhanden.trim().length >= 5),
{ {
message: 'Bitte begründen, warum es die Unterlage nicht gibt.', message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'], path: ['begruendungNichtVorhanden'],
}, },
) )
export const ChecklisteSchema = z.object({ export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1), antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'), schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum, angefordertAm: optionalesDatum,
fristBis: optionalesDatum, fristBis: optionalesDatum,
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Bescheid // Bescheid
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [ export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG', 'BEWILLIGUNG',
'ABLEHNUNG', 'ABLEHNUNG',
'AENDERUNG', 'AENDERUNG',
'AUFHEBUNG', 'AUFHEBUNG',
'ERSTATTUNG', 'ERSTATTUNG',
'VORSCHUSS', 'VORSCHUSS',
'WIDERSPRUCHSBESCHEID', 'WIDERSPRUCHSBESCHEID',
] as const ] as const
export const PRUEFSTATUS_WERTE = [ export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT', 'UNGEPRUEFT',
'GEPRUEFT_OK', 'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT', 'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT', 'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN', 'FRIST_VERSTRICHEN',
] as const ] as const
export const BescheidSchema = z export const BescheidSchema = z
.object({ .object({
art: z.enum(BESCHEID_ART_WERTE), art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum, bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum, zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum, zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum, zeitraumBis: optionalesDatum,
betrag: optionalerBetrag, betrag: optionalerBetrag,
/** /**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte * 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten. * Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/ */
rechtsbehelfsfristMonate: z.coerce rechtsbehelfsfristMonate: z.coerce
.number() .number()
.int() .int()
.refine((wert) => wert === 1 || wert === 12, { .refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.', message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
}) })
.default(1), .default(1),
widerspruchEingelegtAm: optionalesDatum, widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000), notizen: optionalerText(4000),
dokumentId: optionalerText(40), dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */ /** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false), fristAnlegen: z.coerce.boolean().default(false),
}) })
.refine( .refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom, (daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{ {
message: message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' + 'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.', 'Poststempel.',
path: ['zugegangenAm'], path: ['zugegangenAm'],
}, },
) )
.refine( .refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis, (daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] }, { message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
) )
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, { .refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message: message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' + 'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.', 'keine Frist anlegen.',
path: ['zugegangenAm'], path: ['zugegangenAm'],
}) })
export type BescheidEingabe = z.infer<typeof BescheidSchema> export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({ export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1), bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE), pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum, widerspruchEingelegtAm: optionalesDatum,
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Vermögensbewertung // Vermögensbewertung
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z export const BewertungSchema = z
.object({ .object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']), herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1), id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE), bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000), begruendung: optionalerText(6000),
}) })
.refine( .refine(
(daten) => (daten) =>
daten.bewertung === 'UNGEPRUEFT' || daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10), (daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{ {
message: message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' + 'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.', 'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'], path: ['begruendung'],
}, },
) )
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Heimkosten // Heimkosten
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({ export const HeimkostenSchema = z.object({
jahr: z.coerce jahr: z.coerce
.number() .number()
.int() .int()
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.') .min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'), .max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12), monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
pflegeEigenanteil: betragOderNull, pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull, unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull, investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull, ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull, zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull, pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce leistungszuschlagProzent: z.coerce
.number() .number()
.int() .int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.') .min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.') .max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0), .default(0),
leistungszuschlag: betragOderNull, leistungszuschlag: betragOderNull,
rente: betragOderNull, rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull, sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull, sozialhilfe: betragOderNull,
eigenmittel: betragOderNull, eigenmittel: betragOderNull,
barbetrag: betragOderNull, barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull, bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce abwesenheitstage: z.coerce
.number() .number()
.int() .int()
.min(0, 'Abwesenheitstage können nicht negativ sein.') .min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.') .max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0), .default(0),
bemerkung: optionalerText(2000), bemerkung: optionalerText(2000),
}) })
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema> export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Weitere Leistungen // Weitere Leistungen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [ export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT', 'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT', 'PRUEFUNG_LAEUFT',
'BEANTRAGT', 'BEANTRAGT',
'BEWILLIGT', 'BEWILLIGT',
'ABGELEHNT', 'ABGELEHNT',
'LAEUFT', 'LAEUFT',
'NICHT_ZUTREFFEND', 'NICHT_ZUTREFFEND',
] as const ] as const
export const LeistungSchema = z export const LeistungSchema = z
.object({ .object({
leistungId: optionalerText(40), leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80), katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200), bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200), traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200), rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'), status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum, beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum, bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum, gueltigBis: optionalesDatum,
betrag: optionalerBetrag, betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false), jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000), notizen: optionalerText(2000),
}) })
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, { .refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.', message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'], path: ['bewilligtAm'],
}) })
export const KatalogUebernehmenSchema = z.object({ export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'), schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
}) })
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
// Formularhilfen // Formularhilfen
// ----------------------------------------------------------------------------- // -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */ /** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean { export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name) const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1' return wert === 'on' || wert === 'true' || wert === '1'
} }
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */ /** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> { export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {} const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) { for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert if (typeof wert === 'string') roh[name] = wert
} }
return roh return roh
} }
/** /**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten. * Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/ */
export function feldfehler(fehler: z.ZodError): Record<string, string> { export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {} const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) { for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0] const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste if (erste) ergebnis[feld] = erste
} }
return ergebnis return ergebnis
} }
+171 -171
View File
@@ -1,171 +1,171 @@
import { z } from 'zod' import { z } from 'zod'
import { import {
optionalerBetrag, optionalerBetrag,
optionalerText, optionalerText,
optionaleZahl, optionaleZahl,
optionalesDatum, optionalesDatum,
pflichtBetrag, pflichtBetrag,
pflichtDatum, pflichtDatum,
pflichtText, pflichtText,
} from '@/lib/validierung/substanz' } from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten' import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/** /**
* Eingabepruefung fuer Versicherungen und Vertraege. * Eingabepruefung fuer Versicherungen und Vertraege.
*/ */
export const VERTRAG_ARTEN = [ export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG', 'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG', 'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE', 'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT', 'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG', 'UNFALLVERSICHERUNG',
'HAFTPFLICHT', 'HAFTPFLICHT',
'HAUSRAT', 'HAUSRAT',
'WOHNGEBAEUDE', 'WOHNGEBAEUDE',
'KRANKENZUSATZ', 'KRANKENZUSATZ',
'PFLEGEZUSATZ', 'PFLEGEZUSATZ',
'RECHTSSCHUTZ', 'RECHTSSCHUTZ',
'STERBEKASSE', 'STERBEKASSE',
'STROM', 'STROM',
'GAS', 'GAS',
'WASSER', 'WASSER',
'TELEFON_INTERNET', 'TELEFON_INTERNET',
'RUNDFUNKBEITRAG', 'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT', 'MITGLIEDSCHAFT',
'ABONNEMENT', 'ABONNEMENT',
'SONSTIGES', 'SONSTIGES',
] as const ] as const
export const VERTRAG_STATUS = [ export const VERTRAG_STATUS = [
'AKTIV', 'AKTIV',
'BEITRAGSFREI', 'BEITRAGSFREI',
'RUHEND', 'RUHEND',
'GEKUENDIGT', 'GEKUENDIGT',
'ABGETRETEN', 'ABGETRETEN',
'VERWERTET', 'VERWERTET',
'ABGELAUFEN', 'ABGELAUFEN',
] as const ] as const
export const ZAHL_RHYTHMEN = [ export const ZAHL_RHYTHMEN = [
'MONATLICH', 'MONATLICH',
'VIERTELJAEHRLICH', 'VIERTELJAEHRLICH',
'HALBJAEHRLICH', 'HALBJAEHRLICH',
'JAEHRLICH', 'JAEHRLICH',
'EINMALIG', 'EINMALIG',
] as const ] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z export const VertragSchema = z
.object({ .object({
bezeichnung: pflichtText( bezeichnung: pflichtText(
150, 150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.', 'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
), ),
gesellschaft: optionalerText(150), gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN), art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'), status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */ /** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80), vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum, beginnAm: optionalesDatum,
endeAm: optionalesDatum, endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl( kuendigungsfristMonate: optionaleZahl(
0, 0,
120, 120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.', 'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
), ),
kuendigungZum: optionalesDatum, kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum, gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag, beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null), rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'), bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000), bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000), notizen: optionalerText(4000),
}) })
.superRefine((daten, kontext) => { .superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) { if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['endeAm'], path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.', message: 'Das Vertragsende liegt vor dem Beginn.',
}) })
} }
if (daten.beitrag !== null && !daten.rhythmus) { if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['rhythmus'], path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.', message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
}) })
} }
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) { if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'], path: ['bewertungBegruendung'],
message: message:
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' + 'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.', 'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
}) })
} }
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) { if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'], path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.', message: 'Bitte das Datum der Kündigung angeben.',
}) })
} }
}) })
export type VertragEingabe = z.infer<typeof VertragSchema> export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */ /** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({ export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum, stichtag: pflichtDatum,
wert: pflichtBetrag, wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, { wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }), errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}), }),
bemerkung: optionalerText(1000), bemerkung: optionalerText(1000),
}) })
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema> export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({ export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum, stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500), empfaengerAnschrift: optionalerText(500),
}) })
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema> export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/** /**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt * Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der * sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat. * gekündigt hat.
*/ */
export const KuendigungVermerkenSchema = z export const KuendigungVermerkenSchema = z
.object({ .object({
gekuendigtAm: pflichtDatum, gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum, kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'), versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false), nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false), genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000), bemerkung: optionalerText(2000),
}) })
.superRefine((daten, kontext) => { .superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) { if (!daten.nachweisVorhanden) {
kontext.addIssue({ kontext.addIssue({
code: z.ZodIssueCode.custom, code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'], path: ['nachweisVorhanden'],
message: message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' + 'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.', 'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
}) })
} }
}) })
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema> export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>
+9
View File
@@ -19,6 +19,14 @@ const optionalerText = (max = 200) =>
.transform((wert) => (wert === '' ? null : wert)) .transform((wert) => (wert === '' ? null : wert))
.nullable() .nullable()
/** Ein Verweis auf einen anderen Datensatz - leer heisst "keiner". */
const optionaleId = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z const optionalesDatum = z
.string() .string()
.trim() .trim()
@@ -90,6 +98,7 @@ export const VorgangSchema = z.object({
beschreibung: optionalerText(4000), beschreibung: optionalerText(4000),
gegenstelle: optionalerText(150), gegenstelle: optionalerText(150),
aktenzeichen: optionalerText(80), aktenzeichen: optionalerText(80),
ansprechpartnerId: optionaleId,
verantwortlich: optionalerText(120), verantwortlich: optionalerText(120),
naechsterSchritt: optionalerText(300), naechsterSchritt: optionalerText(300),
faelligAm: optionalesDatum, faelligAm: optionalesDatum,
+1 -1
View File
@@ -17,7 +17,7 @@
*/ */
/** Muss zur "version" in der package.json passen. */ /** Muss zur "version" in der package.json passen. */
export const VERSION = '1.7.0' export const VERSION = '1.12.0'
/** /**
* Kurzer Git-Stand, beim Bauen hineingereicht: * Kurzer Git-Stand, beim Bauen hineingereicht:
+19 -10
View File
@@ -3,11 +3,20 @@ import type { Briefkopf } from '@/lib/vertraege/anschreiben'
/** /**
* Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer. * Baut den Briefkopf aus Stammdaten und angemeldetem Benutzer.
* *
* Die Anschrift des Betreuers steht im Datenmodell nicht - der Benutzer * Die Anschrift des Betreuers haengt am Benutzer, nicht an der betreuten
* hat nur Name und E-Mail. Sie bleibt deshalb leer; im erzeugten Text * Person: Sie gehoert zu mir und gilt fuer alle Akten. Ist sie nicht
* fehlen dann schlicht die beiden Zeilen, und wer den Entwurf kopiert, * hinterlegt, bleiben die Zeilen weg - ein Absenderblock mit leeren
* ergaenzt sie im Briefkopf seines Textverarbeitungsprogramms ohnehin. * Zeilen sieht schlimmer aus als einer, der nur aus dem Namen besteht.
*/ */
/** Wer schreibt - Name und, wenn hinterlegt, die eigene Anschrift. */
export type Absender = {
name: string
strasse?: string | null
plz?: string | null
ort?: string | null
}
export type BriefkopfQuelle = { export type BriefkopfQuelle = {
betreute: { betreute: {
vorname: string vorname: string
@@ -22,7 +31,7 @@ export type BriefkopfQuelle = {
aktenzeichenGericht: string | null aktenzeichenGericht: string | null
bestellungAm: Date | null bestellungAm: Date | null
} }
benutzer: { name: string } benutzer: Absender
} }
export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf { export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
@@ -37,11 +46,11 @@ export function briefkopfBauen(quelle: BriefkopfQuelle): Briefkopf {
return { return {
betreuerName: quelle.benutzer.name, betreuerName: quelle.benutzer.name,
betreuerStrasse: null, betreuerStrasse: quelle.benutzer.strasse ?? null,
betreuerPlz: null, betreuerPlz: quelle.benutzer.plz ?? null,
// Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der des // Der Ort in der Datumszeile ist der Ort des Betreuers, nicht der
// Heims - er steht nirgends im Datenmodell und bleibt deshalb leer. // des Heims.
betreuerOrt: null, betreuerOrt: quelle.benutzer.ort ?? null,
betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(), betreuteName: `${b.anrede ? `${b.anrede} ` : ''}${b.vorname} ${b.nachname}`.trim(),
betreuteGeburtsdatum: b.geburtsdatum, betreuteGeburtsdatum: b.geburtsdatum,
betreuteAnschrift: anschrift || null, betreuteAnschrift: anschrift || null,
+227 -227
View File
@@ -1,227 +1,227 @@
import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum' import { alsIsoTag, heuteAlsTag, plusJahre, plusMonate, plusTage, tageZwischen, tagesdatum } from '@/lib/fristen/datum'
/** /**
* Kuendigungsfrist-Rechner. * Kuendigungsfrist-Rechner.
* *
* Die eigentliche Falle bei Versicherungsvertraegen ist nicht der * Die eigentliche Falle bei Versicherungsvertraegen ist nicht der
* Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus * Kuendigungstermin, sondern der Tag, an dem der Brief spaetestens raus
* muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht * muss. Eine Kuendigung wird mit dem ZUGANG beim Versicherer wirksam, nicht
* mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist * mit dem Absenden - wer am letzten Tag der Frist einwirft, hat die Frist
* verpasst und zahlt ein weiteres Jahr. * verpasst und zahlt ein weiteres Jahr.
* *
* Deshalb rechnet diese Datei drei Daten aus: * Deshalb rechnet diese Datei drei Daten aus:
* *
* 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden), * 1. den naechstmoeglichen Kuendigungstermin (zu wann kann gekuendigt werden),
* 2. den Tag, an dem die Kuendigung spaetestens zugehen muss, * 2. den Tag, an dem die Kuendigung spaetestens zugehen muss,
* 3. den spaetesten Absendetag mit Postlaufpuffer. * 3. den spaetesten Absendetag mit Postlaufpuffer.
* *
* Reine Logik, keine Datenbank. * Reine Logik, keine Datenbank.
*/ */
/** /**
* Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber * Puffer zwischen Absenden und Zugang. Drei Kalendertage sind knapp, aber
* bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht, * bei einem Einwurf-Einschreiben realistisch. Wer es genauer braucht,
* schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den * schickt zusaetzlich ein Fax oder eine E-Mail - beides zaehlt fuer den
* Zugang und ist am selben Tag da. * Zugang und ist am selben Tag da.
*/ */
export const POSTLAUF_PUFFER_TAGE = 3 export const POSTLAUF_PUFFER_TAGE = 3
/** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */ /** Ab wann wird gewarnt, dass der Absendetag naeher rueckt. */
export const KUENDIGUNG_VORWARNUNG_TAGE = 30 export const KUENDIGUNG_VORWARNUNG_TAGE = 30
/** /**
* Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr * Verlaengerung, wenn im Vertrag keine Laufzeit hinterlegt ist. Ein Jahr
* ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG). * ist bei Versicherungen der Regelfall (§ 11 Abs. 1 VVG).
*/ */
export const STANDARD_LAUFZEIT_JAHRE = 1 export const STANDARD_LAUFZEIT_JAHRE = 1
export type KuendigungEingabe = { export type KuendigungEingabe = {
/** Laufzeitbeginn laut Vertrag. */ /** Laufzeitbeginn laut Vertrag. */
beginnAm: Date | null beginnAm: Date | null
/** Fest vereinbartes Vertragsende, falls bekannt. */ /** Fest vereinbartes Vertragsende, falls bekannt. */
endeAm?: Date | null endeAm?: Date | null
/** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */ /** Vertragsdauer in Jahren; fehlt sie, wird mit einem Jahr gerechnet. */
laufzeitJahre?: number | null laufzeitJahre?: number | null
/** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */ /** Kuendigungsfrist in Monaten zum Ende der Laufzeit. */
kuendigungsfristMonate: number | null kuendigungsfristMonate: number | null
/** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */ /** Verlaengert sich der Vertrag stillschweigend? Regelfall: ja. */
verlaengertSichAutomatisch?: boolean verlaengertSichAutomatisch?: boolean
/** Bezugstag; im Test gesetzt, sonst heute. */ /** Bezugstag; im Test gesetzt, sonst heute. */
heute?: Date heute?: Date
} }
export type Kuendigungsplan = { export type Kuendigungsplan = {
/** Laesst sich ueberhaupt etwas berechnen? */ /** Laesst sich ueberhaupt etwas berechnen? */
berechenbar: boolean berechenbar: boolean
/** Zu diesem Tag kann als Naechstes gekuendigt werden. */ /** Zu diesem Tag kann als Naechstes gekuendigt werden. */
naechsterTermin: Date | null naechsterTermin: Date | null
/** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */ /** Bis zu diesem Tag muss die Kuendigung beim Vertragspartner sein. */
zugangSpaetestensAm: Date | null zugangSpaetestensAm: Date | null
/** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */ /** Bis zu diesem Tag muss der Brief raus (Zugang minus Postlauf). */
spaetesterAbsendetag: Date | null spaetesterAbsendetag: Date | null
/** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */ /** Tage von heute bis zum Absendetag. Negativ heisst: schon vorbei. */
tageBisAbsendetag: number | null tageBisAbsendetag: number | null
/** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */ /** Weniger als 30 Tage bis zum Absendetag - jetzt handeln. */
vorwarnung: boolean vorwarnung: boolean
/** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */ /** Der Absendetag ist verstrichen und der Vertrag laeuft aus. */
verpasst: boolean verpasst: boolean
/** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */ /** Erklaerungen und Annahmen, die in die Rechnung eingegangen sind. */
hinweise: string[] hinweise: string[]
} }
const LEERER_PLAN: Kuendigungsplan = { const LEERER_PLAN: Kuendigungsplan = {
berechenbar: false, berechenbar: false,
naechsterTermin: null, naechsterTermin: null,
zugangSpaetestensAm: null, zugangSpaetestensAm: null,
spaetesterAbsendetag: null, spaetesterAbsendetag: null,
tageBisAbsendetag: null, tageBisAbsendetag: null,
vorwarnung: false, vorwarnung: false,
verpasst: false, verpasst: false,
hinweise: [], hinweise: [],
} }
/** /**
* Berechnet den naechstmoeglichen Kuendigungstermin. * Berechnet den naechstmoeglichen Kuendigungstermin.
* *
* Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige * Vorgehen: Vom ersten Ablauftermin an wird geprueft, ob der zugehoerige
* Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine * Absendetag noch in der Zukunft liegt. Ist er verstrichen, wird um eine
* Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig * Laufzeit weitergerueckt. Der erste Termin, dessen Brief noch rechtzeitig
* rausgeht, ist der naechstmoegliche. * rausgeht, ist der naechstmoegliche.
*/ */
export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan { export function kuendigungsplan(eingabe: KuendigungEingabe): Kuendigungsplan {
const heute = tagesdatum(eingabe.heute ?? heuteAlsTag()) const heute = tagesdatum(eingabe.heute ?? heuteAlsTag())
const hinweise: string[] = [] const hinweise: string[] = []
const frist = eingabe.kuendigungsfristMonate const frist = eingabe.kuendigungsfristMonate
if (frist === null || frist === undefined) { if (frist === null || frist === undefined) {
hinweise.push( hinweise.push(
'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' + 'Es ist keine Kündigungsfrist hinterlegt. Es wird ohne Frist gerechnet — ' +
'bitte im Vertrag nachsehen und nachtragen.', 'bitte im Vertrag nachsehen und nachtragen.',
) )
} }
const fristMonate = frist ?? 0 const fristMonate = frist ?? 0
const laufzeit = const laufzeit =
eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0 eingabe.laufzeitJahre && eingabe.laufzeitJahre > 0
? eingabe.laufzeitJahre ? eingabe.laufzeitJahre
: STANDARD_LAUFZEIT_JAHRE : STANDARD_LAUFZEIT_JAHRE
if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) { if (!eingabe.laufzeitJahre || eingabe.laufzeitJahre <= 0) {
hinweise.push( hinweise.push(
`Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` + `Im Vertrag steht keine Laufzeit. Gerechnet wird mit einer Verlängerung um ` +
`${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`, `${STANDARD_LAUFZEIT_JAHRE} Jahr — bei Versicherungen der Regelfall.`,
) )
} }
const verlaengert = eingabe.verlaengertSichAutomatisch ?? true const verlaengert = eingabe.verlaengertSichAutomatisch ?? true
// Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit. // Erster Ablauftermin: das vereinbarte Ende, sonst Beginn plus Laufzeit.
let termin: Date | null = null let termin: Date | null = null
if (eingabe.endeAm) { if (eingabe.endeAm) {
termin = tagesdatum(eingabe.endeAm) termin = tagesdatum(eingabe.endeAm)
} else if (eingabe.beginnAm) { } else if (eingabe.beginnAm) {
termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit) termin = plusJahre(tagesdatum(eingabe.beginnAm), laufzeit)
} }
if (!termin) { if (!termin) {
return { return {
...LEERER_PLAN, ...LEERER_PLAN,
hinweise: [ hinweise: [
'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' + 'Ohne Laufzeitbeginn oder Vertragsende lässt sich kein Kündigungstermin ' +
'berechnen. Bitte mindestens den Vertragsbeginn eintragen.', 'berechnen. Bitte mindestens den Vertragsbeginn eintragen.',
], ],
} }
} }
// Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von // Bis zu 200 Verlaengerungen durchgehen - deckt auch einen Vertrag von
// 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen. // 1962 ab, ohne bei fehlerhaften Daten endlos zu laufen.
let absendetag = absendetagZu(termin, fristMonate) let absendetag = absendetagZu(termin, fristMonate)
let durchlaeufe = 0 let durchlaeufe = 0
while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) { while (absendetag.getTime() < heute.getTime() && verlaengert && durchlaeufe < 200) {
termin = plusJahre(termin, laufzeit) termin = plusJahre(termin, laufzeit)
absendetag = absendetagZu(termin, fristMonate) absendetag = absendetagZu(termin, fristMonate)
durchlaeufe += 1 durchlaeufe += 1
} }
const zugang = plusMonate(termin, -fristMonate) const zugang = plusMonate(termin, -fristMonate)
const tageBis = tageZwischen(heute, absendetag) const tageBis = tageZwischen(heute, absendetag)
const verpasst = absendetag.getTime() < heute.getTime() const verpasst = absendetag.getTime() < heute.getTime()
if (verpasst) { if (verpasst) {
hinweise.push( hinweise.push(
`Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` + `Der Absendetag für den Termin ${alsIsoTag(termin)} ist verstrichen. ` +
`Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` + `Dieser Vertrag verlängert sich nicht automatisch — eine Kündigung zu diesem ` +
`Termin ist nicht mehr möglich.`, `Termin ist nicht mehr möglich.`,
) )
} else if (durchlaeufe > 0) { } else if (durchlaeufe > 0) {
hinweise.push( hinweise.push(
`Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` + `Der Vertrag hat sich bereits ${durchlaeufe}-mal verlängert. Angezeigt ist der ` +
`nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`, `nächste Termin, zu dem noch rechtzeitig gekündigt werden kann.`,
) )
} }
hinweise.push( hinweise.push(
`Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` + `Eine Kündigung wird mit dem Zugang wirksam, nicht mit dem Absenden. Deshalb ` +
`sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` + `sind zwischen Absendetag und Zugang ${POSTLAUF_PUFFER_TAGE} Tage Postlauf ` +
`eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` + `eingerechnet. Am sichersten: Einschreiben mit Rückschein oder zusätzlich ` +
`ein Fax mit Sendeprotokoll.`, `ein Fax mit Sendeprotokoll.`,
) )
return { return {
berechenbar: true, berechenbar: true,
naechsterTermin: termin, naechsterTermin: termin,
zugangSpaetestensAm: zugang, zugangSpaetestensAm: zugang,
spaetesterAbsendetag: absendetag, spaetesterAbsendetag: absendetag,
tageBisAbsendetag: tageBis, tageBisAbsendetag: tageBis,
vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE, vorwarnung: !verpasst && tageBis <= KUENDIGUNG_VORWARNUNG_TAGE,
verpasst, verpasst,
hinweise, hinweise,
} }
} }
/** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */ /** Der Absendetag zu einem Termin: Termin minus Frist minus Postlauf. */
export function absendetagZu(termin: Date, fristMonate: number): Date { export function absendetagZu(termin: Date, fristMonate: number): Date {
return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE) return plusTage(plusMonate(tagesdatum(termin), -fristMonate), -POSTLAUF_PUFFER_TAGE)
} }
/** /**
* Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag. * Kurzfassung fuer die Uebersichtstabelle: eine Ampelstufe je Vertrag.
* Farbe ist nie die einzige Information - der Text steht daneben. * Farbe ist nie die einzige Information - der Text steht daneben.
*/ */
export type Kuendigungsampel = { export type Kuendigungsampel = {
variante: 'rot' | 'orange' | 'gelb' | 'gruen' variante: 'rot' | 'orange' | 'gelb' | 'gruen'
text: string text: string
} }
export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null { export function kuendigungsampel(plan: Kuendigungsplan): Kuendigungsampel | null {
if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null if (!plan.berechenbar || plan.tageBisAbsendetag === null) return null
if (plan.verpasst) { if (plan.verpasst) {
return { variante: 'rot', text: 'Absendetag verstrichen' } return { variante: 'rot', text: 'Absendetag verstrichen' }
} }
const tage = plan.tageBisAbsendetag const tage = plan.tageBisAbsendetag
if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` } if (tage <= 7) return { variante: 'rot', text: `Brief muss in ${tage} Tagen raus` }
if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) { if (tage <= KUENDIGUNG_VORWARNUNG_TAGE) {
return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` } return { variante: 'orange', text: `Brief muss in ${tage} Tagen raus` }
} }
if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` } if (tage <= 60) return { variante: 'gelb', text: `Absendetag in ${tage} Tagen` }
return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` } return { variante: 'gruen', text: `Absendetag in ${tage} Tagen` }
} }
/** /**
* Die Vertragsdauer aus Beginn und vereinbartem Ende. * Die Vertragsdauer aus Beginn und vereinbartem Ende.
* *
* Das Datenmodell kennt kein Feld "Laufzeit" (siehe * Das Datenmodell kennt kein Feld "Laufzeit" (siehe
* docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im * docs/schema-wuensche/substanz.md, Punkt 8). Stehen aber beide Daten im
* Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis * Vertrag, ergibt sich die Dauer daraus: Ein Vertrag vom 01.01.2018 bis
* 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um * 01.01.2021 hat eine dreijaehrige Laufzeit und verlaengert sich um
* dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage, * dieselbe Spanne. Gerundet wird auf volle Jahre - ein Jahr sind 365 Tage,
* ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die * ein Schaltjahr 366, und beides soll dieselbe Laufzeit ergeben. Liegt die
* Spanne unter einem halben Jahr, bleibt es beim Standard. * Spanne unter einem halben Jahr, bleibt es beim Standard.
*/ */
export function laufzeitAusVertrag( export function laufzeitAusVertrag(
beginnAm: Date | null, beginnAm: Date | null,
endeAm: Date | null, endeAm: Date | null,
): number | null { ): number | null {
if (!beginnAm || !endeAm) return null if (!beginnAm || !endeAm) return null
const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25) const jahre = Math.round(tageZwischen(beginnAm, endeAm) / 365.25)
return jahre >= 1 ? jahre : null return jahre >= 1 ? jahre : null
} }
+28
View File
@@ -114,3 +114,31 @@ export function anfrageStatus(
tageSeitAnfrage: tage, tageSeitAnfrage: tage,
} }
} }
/**
* Die Rueckkaufswert-Historie eines Vertrags, juengster Wert zuerst.
*
* Der Wert lebt nicht am Vertrag, sondern als Wertstand am
* Vermoegensposten - genau derselbe, aus dem das Vermoegensverzeichnis
* rechnet. Es gibt also keine zweite Zahl, die abweichen koennte, und
* der Vertrag zeigt nur an, was ohnehin schon in der Akte steht.
*
* Verknuepft wird ueber die Vertrags-Id, nicht ueber die Art des Postens.
* Wer einen Posten von Hand als "Sonstiges" anlegt und mit dem Vertrag
* verknuepft, meint denselben Vertrag - er darf dadurch nicht eine
* zweite, unsichtbare Wahrheit bekommen.
*/
export function wertHistorie<T extends { stichtag: Date }>(
posten: { wertstaende: T[] }[],
): T[] {
return posten
.flatMap((p) => p.wertstaende)
.sort((a, b) => b.stichtag.getTime() - a.stichtag.getTime())
}
/** Der zuletzt mitgeteilte Wert, oder null, wenn noch keiner vorliegt. */
export function juengsterWert<T extends { stichtag: Date }>(
posten: { wertstaende: T[] }[],
): T | null {
return wertHistorie(posten)[0] ?? null
}
+3 -3
View File
@@ -4,7 +4,7 @@ import { db, nichtGeloescht } from '@/lib/db'
import { oeffnen } from '@/lib/crypto/feld' import { oeffnen } from '@/lib/crypto/feld'
import { protokollieren } from '@/lib/audit' import { protokollieren } from '@/lib/audit'
import { ablegenMitTyp } from '@/lib/speicher' import { ablegenMitTyp } from '@/lib/speicher'
import { briefkopfBauen } from '@/lib/vertraege/briefkopf' import { briefkopfBauen, type Absender } from '@/lib/vertraege/briefkopf'
import { import {
RUECKKAUFSWERT_WIEDERVORLAGE_TAGE, RUECKKAUFSWERT_WIEDERVORLAGE_TAGE,
rueckkaufswertAnschreiben, rueckkaufswertAnschreiben,
@@ -53,7 +53,7 @@ export async function serienlaufDurchfuehren(eingabe: {
betreuteId: string betreuteId: string
vertragIds: string[] vertragIds: string[]
stichtag: Date stichtag: Date
benutzer: { id: string; name: string; email: string } benutzer: { id: string; name: string; email: string } & Absender
}): Promise<SerienErgebnis> { }): Promise<SerienErgebnis> {
const heute = heuteAlsTag() const heute = heuteAlsTag()
const { betreuteId, benutzer } = eingabe const { betreuteId, benutzer } = eingabe
@@ -65,7 +65,7 @@ export async function serienlaufDurchfuehren(eingabe: {
const kopf = briefkopfBauen({ const kopf = briefkopfBauen({
betreute: oeffnen('Betreute', betreute), betreute: oeffnen('Betreute', betreute),
benutzer: { name: benutzer.name }, benutzer,
}) })
// Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere. // Nur Vertraege dieser betreuten Person - fremde IDs laufen ins Leere.
+13 -6
View File
@@ -1,4 +1,5 @@
import { differenceInCalendarDays } from 'date-fns' import { differenceInCalendarDays } from 'date-fns'
import { anredeZeile, type Ansprechpartner } from '@/lib/kontakte/anrede'
import { formatDatum } from '@/lib/utils' import { formatDatum } from '@/lib/utils'
/** /**
@@ -36,8 +37,12 @@ export type SachstandsanfrageEingabe = {
aktenzeichenGericht?: string | null aktenzeichenGericht?: string | null
betreuungsgericht?: string | null betreuungsgericht?: string | null
} }
/** Ansprechpartner bei der Gegenstelle, wenn bekannt. */ /**
ansprechpartner?: string | null * Ansprechpartner bei der Gegenstelle, wenn am Vorgang benannt.
* Bestimmt die Anrede - ohne ihn bleibt es bei "Sehr geehrte Damen
* und Herren".
*/
ansprechpartner?: Ansprechpartner | null
/** Für den Test überschreibbar. */ /** Für den Test überschreibbar. */
heute?: Date heute?: Date
} }
@@ -69,13 +74,15 @@ export function sachstandsanfrageErzeugen(
.filter(Boolean) .filter(Boolean)
.join(' · ') .join(' · ')
const anrede = eingabe.ansprechpartner const anrede = anredeZeile(eingabe.ansprechpartner ?? null)
? `Sehr geehrte(r) ${eingabe.ansprechpartner},`
: 'Sehr geehrte Damen und Herren,'
const zeilen: string[] = [] const zeilen: string[] = []
if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle, '') // Der Name steht ueber der Stelle, nicht darunter: So sortiert die
// Poststelle den Brief zur Person und nicht ins allgemeine Fach.
if (eingabe.ansprechpartner?.name.trim()) zeilen.push(eingabe.ansprechpartner.name.trim())
if (eingabe.gegenstelle) zeilen.push(eingabe.gegenstelle)
if (eingabe.ansprechpartner?.name.trim() || eingabe.gegenstelle) zeilen.push('')
zeilen.push(formatDatum(heute), '') zeilen.push(formatDatum(heute), '')
zeilen.push(`Betreff: ${betreff}`, '') zeilen.push(`Betreff: ${betreff}`, '')
zeilen.push(anrede, '') zeilen.push(anrede, '')
+16 -3
View File
@@ -30,8 +30,21 @@ import { NextResponse, type NextRequest } from 'next/server'
* Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen. * Data-URI eingebettet, statt sie ueber einen Fremddienst zu holen.
*/ */
/** Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen. */ /**
const OEFFENTLICHE_PFADE = [ * Seiten, die auch ohne Anmeldung sinnvoll im Verlauf stehen duerfen.
*
* ACHTUNG - was diese Liste NICHT ist: eine Zugriffskontrolle. Frueher
* stand hier "Ohne Authentifizierungs-Cookie gar nicht erst weiterreichen",
* und das war schlicht falsch: Die Middleware prueft keine Anmeldung, sie
* setzt Kopfzeilen. Ausgewertet wird die Liste einzig fuer die Frage, ob
* "Cache-Control: no-store" gesetzt wird.
*
* Der Irrtum waere teuer geworden: Wer den Satz glaubt, verzichtet an einer
* neuen Seite auf erfordereBenutzer() - "die Middleware faengt das ja ab".
* Sie faengt nichts ab. Der Zugriffsschutz sitzt in
* src/lib/auth/sitzung.ts, und nur dort.
*/
const OHNE_CACHE_SPERRE = [
'/anmelden', '/anmelden',
'/ersteinrichtung', '/ersteinrichtung',
'/datenschutz', '/datenschutz',
@@ -79,7 +92,7 @@ export function middleware(anfrage: NextRequest) {
// Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers // Verhindert, dass ein Bericht oder Beleg im Verlauf des Browsers
// zwischengespeichert wird. // zwischengespeichert wird.
if (!OEFFENTLICHE_PFADE.some((pfad) => anfrage.nextUrl.pathname.startsWith(pfad))) { if (!OHNE_CACHE_SPERRE.some((pfad) => anfrage.nextUrl.pathname.startsWith(pfad))) {
antwort.headers.set('Cache-Control', 'no-store, must-revalidate') antwort.headers.set('Cache-Control', 'no-store, must-revalidate')
} }
+3 -3
View File
@@ -5,6 +5,7 @@ import { headers } from 'next/headers'
import { z } from 'zod' import { z } from 'zod'
import { signIn } from '@/auth' import { signIn } from '@/auth'
import { anmeldungErlaubt } from '@/lib/auth/ratelimit' import { anmeldungErlaubt } from '@/lib/auth/ratelimit'
import { anschlussAus } from '@/lib/auth/anschluss'
/** /**
* Server Action fuer das Anmeldeformular. * Server Action fuer das Anmeldeformular.
@@ -43,11 +44,10 @@ function istWeiterleitung(fehler: unknown): boolean {
) )
} }
/** Hinter dem Reverse Proxy steht die echte Adresse im weitergereichten Kopf. */ /** Siehe src/lib/auth/anschluss.ts - der Kopf wird nur mit Proxy geglaubt. */
async function ipDerAnfrage(): Promise<string | null> { async function ipDerAnfrage(): Promise<string | null> {
try { try {
const kopf = await headers() return anschlussAus(await headers())
return kopf.get('x-forwarded-for')?.split(',')[0]?.trim() || kopf.get('x-real-ip') || null
} catch { } catch {
return null return null
} }
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -5,7 +5,7 @@ import { z } from 'zod'
import { db } from '@/lib/db' import { db } from '@/lib/db'
import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung' import { erfordereBenutzer, reauthBestaetigen } from '@/lib/auth/sitzung'
import { passwortPruefen } from '@/lib/crypto/hash' import { passwortPruefen } from '@/lib/crypto/hash'
import { totpPruefen, totpSecretEntschluesseln } from '@/lib/crypto/totp' import { zweitenFaktorEinloesen } from '@/lib/auth/zweiter-faktor'
import { aufMindestdauerWarten } from '@/lib/auth/ratelimit' import { aufMindestdauerWarten } from '@/lib/auth/ratelimit'
/** /**
@@ -56,10 +56,14 @@ export async function anmeldungBestaetigen(
const passwortStimmt = await passwortPruefen(frisch.passwortHash, gelesen.data.passwort) const passwortStimmt = await passwortPruefen(frisch.passwortHash, gelesen.data.passwort)
// Der Code wird nur eingeloest, wenn das Passwort stimmt. Sonst
// verbrauchte ein falsches Passwort mit richtigem Code das Zeitfenster,
// und der Benutzer muesste dreissig Sekunden auf den naechsten warten -
// ohne zu verstehen, warum.
let codeStimmt = true let codeStimmt = true
if (frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) { if (passwortStimmt && frisch.totpSecretVerschluesselt && frisch.totpAktiviertAm) {
const secret = totpSecretEntschluesseln(frisch.totpSecretVerschluesselt) const eingeloest = await zweitenFaktorEinloesen(frisch, gelesen.data.code)
codeStimmt = totpPruefen(secret, frisch.email, gelesen.data.code).gueltig codeStimmt = eingeloest.ok
} }
if (!passwortStimmt || !codeStimmt) { if (!passwortStimmt || !codeStimmt) {
+5 -1
View File
@@ -11,6 +11,7 @@ import { bekannteAbsender } from '@/lib/dokumente/suche'
import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung' import { erkennungsvorschlaege, type Erkennungsergebnis } from '@/lib/dokumente/erkennung'
import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf' import { ocrAnstossen } from '@/lib/dokumente/ocr-nachlauf'
import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher' import { ablegenMitTyp, DateiAbgelehnt, entfernen, signierteUrl } from '@/lib/speicher'
import { speicherfehlerMeldung } from '@/lib/speicher/fehler'
import { import {
DokumentMetadatenSchema, DokumentMetadatenSchema,
FristUebernehmenSchema, FristUebernehmenSchema,
@@ -217,7 +218,10 @@ export async function dokumenteHochladen(
const meldung = const meldung =
fehler instanceof DateiAbgelehnt fehler instanceof DateiAbgelehnt
? fehler.message ? fehler.message
: `„${anzeigename}“ konnte nicht gespeichert werden. Bitte versuche es erneut.` : speicherfehlerMeldung(
fehler,
`„${anzeigename}“ konnte nicht gespeichert werden. Bitte versuche es erneut.`,
)
if (!(fehler instanceof DateiAbgelehnt)) { if (!(fehler instanceof DateiAbgelehnt)) {
console.error('[dokumente] Upload gescheitert:', fehler) console.error('[dokumente] Upload gescheitert:', fehler)
} }

Some files were not shown because too many files have changed in this diff Show More