Compare commits

..
9 Commits
Author SHA1 Message Date
Florian Wilde 7780739a1a fix: Ersteinrichtung liess sich nicht abschliessen, Betreute nicht loeschen
Zwei Fehler, beide erst im Betrieb sichtbar.

1. Schritt 3 der Ersteinrichtung meldete "Bitte pruefe die markierten
   Felder" - und markierte keines. Man kam dort nicht weiter.

   Das kurze Formular schickt neun Felder, das vollstaendige unter
   Stammdaten alle. Beide gehen durch dasselbe Schema, und die optionalen
   Felder waren zwar nullable, aber nicht optional: Ein Feld, das gar
   nicht abgeschickt wurde, ist undefined, nicht null. Beanstandet wurden
   also zwei Dutzend Felder, die auf dem Bildschirm nie standen.

   Damit das nicht wiederkommt, unterscheidet die Aktion jetzt zwischen
   Eingabefehler und Formular-Schema-Bruch: Beanstandet die Pruefung ein
   Feld, das nicht abgeschickt wurde, sagt sie das ausdruecklich und
   schreibt es ins Serverlog, statt es dem Nutzer als Eingabefehler
   unterzuschieben, den er nirgends finden kann.

2. Eine betreute Person liess sich nicht endgueltig loeschen. Postgres
   setzt beim Loeschen den Verweis im Audit-Log auf NULL, und genau
   dieses UPDATE hat der Unveraenderlichkeits-Trigger abgewiesen. Damit
   scheiterte auch das Leeren des Papierkorbs nach 30 Tagen.

   Der Trigger erlaubt jetzt genau eine Ausnahme: das Nullen der beiden
   Fremdschluessel. Jede andere Aenderung bleibt verboten, auch das
   Setzen eines Fremdschluessels auf einen anderen Wert. Der Nachweis
   bleibt vollstaendig - es verschwindet nur der Verweis auf einen
   Datensatz, den es nicht mehr gibt.

Geprueft: sieben neue Tests gegen die tatsaechlichen Formularinhalte, der
Ablauf einmal komplett im Browser bis zum Dashboard, und der Trigger
gegen eine laufende Datenbank - Loeschen gelingt, Faelschen scheitert.

Etappe: 8
Phase: Haertung
2026-08-22 14:36:18 +02:00
Florian Wilde 9365645f72 fix: Bindeadresse war in der Vorlage stillschweigend vorbelegt
.env.example setzte APP_BIND=127.0.0.1 als fertigen Wert, mitten zwischen
lauter anderen fertigen Werten. Wer die Datei kopiert und seinen Reverse
Proxy auf einem anderen Server betreibt, kommt damit nicht an die
Anwendung heran - und der Fehler sieht aus, als laege es am Proxy.

Der sichere Standard bleibt 127.0.0.1. Er wird nur nicht mehr als
"schon erledigt" praesentiert, sondern als Entscheidung mit drei
benannten Faellen: Proxy daneben, Proxy woanders mit privatem Netz,
Proxy woanders ohne. Beim dritten Fall steht die Firewallregel dabei.

Die Installationsanleitung nennt APP_BIND jetzt schon in Schritt 3, wo
die .env ausgefuellt wird - nicht erst in Schritt 7, wo der Proxy
drankommt. Wer der Anleitung von oben nach unten folgt, stolpert sonst
erst vier Schritte spaeter darueber.

Dazu ein eigener Abschnitt in der Fehlersuche mit den zwei curl-Proben,
die den Unterschied zwischen falscher Bindeadresse und Firewall zeigen -
und dem Hinweis, dass dafuer kein Neubau noetig ist.

Am Programm aendert sich nichts.

Etappe: 8
Phase: Haertung
2026-08-22 14:22:38 +02:00
Florian Wilde 232543e2aa fix: Leere Schluesselvariable legte die Verschluesselung lahm
docker-compose.yml reicht ENC_KEY_V2 immer an den Behaelter durch - mit
leerem Wert, wenn in der .env keiner steht. src/lib/env.ts erkannte
Schluessel aber am Variablennamen statt am Inhalt, fand also ein leeres
ENC_KEY_V2 und brach ab mit "Umgebungsvariable ENC_KEY_V2 fehlt".

Die Meldung war doppelt irrefuehrend: Sie nannte einen Schluessel, den
niemand wollte, und sie erschien an einer Stelle, die damit nichts zu tun
hat - beim Einrichten des zweiten Faktors, als Serverfehler ohne Text.

Betroffen war jede Stelle, die verschluesselte Felder schreibt oder
liest, also praktisch alles ausser der Anmeldung selbst. Die kam durch,
weil sie ohne Verschluesselung auskommt - der Fehler trat deshalb erst
auf der naechsten Seite auf, und der Zusammenhang war nicht zu sehen.

Leere Schluesselvariablen werden jetzt uebersprungen. Sieben Tests decken
die Auswertung ab, darunter genau diese Konstellation.

Die Meldung beim Schluesselwechsel nennt jetzt ausserdem, welche
Versionen vorhanden sind, und die richtige Reihenfolge: erst den neuen
Schluessel eintragen, dann ENC_KEY_AKTIV umstellen.

Dazu scripts/anmeldung-pruefen.mjs. Das Anmeldeformular sagt bewusst
nicht, woran eine Anmeldung scheiterte - sonst liesse sich daran ablesen,
welche Adressen es gibt. Auf dem Server darf man es wissen: Jeder Versuch
wird mit Grund protokolliert, und das Skript liest ihn vor, zusammen mit
dem Zustand des Zugangs, dem Stand der Sperre und der AUTH_URL.

Etappe: 8
Phase: Haertung
2026-08-22 14:00:25 +02:00
Florian Wilde 5ee3275112 fix: Betriebsskripte fehlten im Docker-Abbild
Das Verzeichnis scripts/ wurde beim Bauen nie in die Laufzeitstufe
uebernommen. Der in der Anleitung dokumentierte Befehl

    docker compose exec app node scripts/totp-geheimnis.mjs ...

endete deshalb mit "Cannot find module". Betroffen war nicht nur dieses
eine Skript, sondern alle, die im laufenden Behaelter gedacht sind - auch
wartungsschluessel.mjs und schluessel-erzeugen.mjs.

Kopiert wird jetzt das ganze Verzeichnis, nicht einzelne Dateien. Sonst
faellt beim naechsten Betriebsskript wieder auf, dass es fehlt - und zwar
erst dann, wenn man es braucht. Die Entwicklungsskripte darin laufen dort
mangels Werkzeugen nicht, kosten aber nur ein paar Kilobyte.

Geprueft im fertigen Abbild und gegen eine echte Datenbank: Das Skript
liegt unter /app/scripts, laeuft als Benutzer cockpit und gibt dasselbe
Geheimnis aus wie auf dem Entwicklungsrechner.

Die Anleitung nennt jetzt zusaetzlich den Weg fuer alle, die noch eine
aeltere Fassung laufen haben und nicht sofort aktualisieren wollen:
docker compose cp legt die Datei einmalig in den Behaelter.

Etappe: 8
Phase: Haertung
2026-08-22 12:02:16 +02:00
Florian Wilde 9df6419a68 feat: TOTP-Geheimnis auslesen, um ein zweites Geraet einzurichten
Die Anwendung zeigt das Geheimnis genau einmal an, beim Einrichten - das
ist richtig so. Wer den Zugang aber auf ein zweites Geraet legen will,
Handy und Tablet oder ein Ersatzgeraet im Schrank, braucht es noch
einmal. Bisher blieb dafuer nur, den zweiten Faktor zurueckzusetzen und
alle Wiederherstellungscodes neu zu erzeugen.

Das Skript laeuft nur auf dem Server und nur mit Zugriff auf die
Datenbank UND auf ENC_KEY_V1. Wer beides hat, kann ohnehin alles lesen -
genau deshalb liegt der Schluessel nicht in der Datenbank. Es ist also
keine Hintertuer, sondern erspart eine unnoetige Neueinrichtung.

Es gibt den otpauth-URI mit aus, aber mit dem ausdruecklichen Hinweis,
ihn in einen QR-Erzeuger auf demselben Rechner zu geben und nicht in
einen Dienst im Netz - sonst haette man das Geheimnis gerade dorthin
gegeben, wovor der zweite Faktor schuetzen soll.

Geprueft gegen den Entwicklungszugang: der ausgelesene Wert stimmt mit
SEED_DEV_TOTP_SECRET ueberein.

Etappe: 8
Phase: Haertung
2026-08-22 11:44:29 +02:00
Florian Wilde 7633603a2a docs: Nginx Proxy Manager als erste Proxy-Variante
Die Werte, die in der Oberflaeche einzutragen sind, Feld fuer Feld -
Details, SSL und Advanced. Steht vorn, weil es der Fall ist, der hier
tatsaechlich laeuft.

Drei Punkte, die man in der Oberflaeche nicht sieht und die sonst Zeit
kosten:

- Scheme ist http, nicht https. Die Anwendung beendet kein TLS.
- HSTS bleibt in NPM aus - die Anwendung setzt den Kopf bereits selbst,
  sonst geht er zweimal raus.
- Die vier proxy_set_header gehoeren NICHT ins Advanced-Feld. NPM setzt
  sie schon, und zwar im location-Block; ein zweiter Satz laege auf
  server-Ebene und wuerde ueberschrieben. Wer sie trotzdem eintraegt,
  sucht spaeter an der falschen Stelle.

Ins Advanced-Feld gehoeren nur die Werte, die die Oberflaeche nicht
abbildet: Uploadgroesse, Zeitgrenzen und das Abschalten der Pufferung
fuer den Vollexport.

Etappe: 8
Phase: Haertung
2026-08-22 11:27:43 +02:00
Florian Wilde c35fc16133 docs: Installationsanleitung fuer vorhandenes Docker und externen Proxy
Die Anleitung setzt jetzt voraus, was ohnehin da ist: ein Server mit
laufendem Docker und ein Reverse Proxy, der schon steht - auch auf einem
anderen Rechner. Docker-Installation und Betriebssystemhaertung sind
raus, dafuer ist der Proxy-Abschnitt der ausfuehrlichste geworden.

Damit ein externer Proxy die Anwendung ueberhaupt erreicht, war eine
Aenderung noetig: Die Bindeadresse stand fest auf 127.0.0.1. Jetzt gibt
es APP_BIND, Standard bleibt die Loopback-Adresse.

Der Weg dorthin ist nach Vorzug sortiert - privates Netz, dann
WireGuard, dann als Notloesung oeffentliche Adresse mit Firewallregel.
Zwischen Proxy und Anwendung laeuft unverschluesseltes HTTP; das gehoert
nicht ins offene Netz. Deshalb steht in der Kontrolle eine Gegenprobe,
die fehlschlagen MUSS: Antwortet der Anwendungsport von aussen, ist die
Firewallregel falsch.

Fertige Konfigurationen fuer Caddy, nginx und Traefik, jeweils mit den
Werten, die sonst uebersehen werden:

- Zeitgrenze 10 Minuten - sonst brechen Belegmappe, Rechnungslegung und
  Vollexport mitten im Erzeugen ab. Die Standardwerte von nginx liegen
  bei 60 Sekunden.
- Antwort nicht puffern - der Vollexport kommt als Strom. Puffert der
  Proxy, wartet der Browser, bis alles im Speicher des Proxys liegt, und
  laeuft dabei in die Zeitgrenze.
- Und der Hinweis, dass der Proxy KEINE eigene CSP setzen darf: Sie
  enthaelt eine Nonce, die pro Anfrage neu ist. Eine feste CSP blockiert
  die eigenen Skripte - die Seite laedt dann, laesst sich aber nicht
  bedienen.

Der A-Record zeigt auf den Proxy-Server, nicht auf den Anwendungsserver.
Stand vorher falsch da.

Etappe: 8
Phase: Haertung
2026-08-22 11:00:29 +02:00
Florian Wilde 89bc5fe5a8 docs: Installationsanleitung Schritt fuer Schritt
Die README hatte Installationsabschnitte, aber keine durchgehende
Anleitung fuer jemanden, der vor einem frischen Server sitzt. Genau da
faengt diese hier an.

Dreizehn Schritte vom leeren Server bis zur laufenden Anwendung, jeder
mit einer Kontrolle am Ende - man geht nicht weiter, ohne geprueft zu
haben, ob der letzte Schritt geklappt hat. Am Ende eine Abnahmeliste
zum Abhaken, bevor echte Daten hineinkommen.

Aufgenommen sind die Fallstricke, die tatsaechlich Zeit kosten:

- AV-Vertrag klaeren, BEVOR der Server bestellt wird
- Zeitzone setzen, sonst rechnet der Server in UTC und verschiebt Fristen
- AUTH_URL muss exakt der aufgerufenen Adresse entsprechen - sonst landet
  die Anmeldung stumm wieder auf dem Formular, ohne Fehlermeldung. Das
  ist der Fehler, den man am laengsten sucht.
- Der Proxy muss X-Forwarded-For weiterreichen, sonst zaehlt die
  Login-Sperre alle Zugriffe als denselben Anschluss.
- Die beiden SQL-Skripte laufen nicht mit den Migrationen mit.

Zwei Kontrollen sind keine Formalie, sondern Nachweise: Der Schutz des
Audit-Logs wird geprueft, indem ein UPDATE ausdruecklich fehlschlagen
MUSS. Und die Sicherung wird nicht nur angestossen, sondern einmal
zurueckgespielt - solange noch nichts drinsteht, was weh tut.

Der letzte Schritt bringt ENC_KEY_V1 in Sicherheit, an zwei Orte, von
denen keiner der Server ist. Ein gestohlenes Backup ist ohne ihn
wertlos - und ein verlorener Schluessel macht auch das eigene Backup
wertlos. Das ist dieselbe Eigenschaft von zwei Seiten.

Etappe: 8
Phase: Haertung
2026-08-22 10:54:21 +02:00
Florian Wilde 0e7da42f27 docs: Bedienungsanleitung und Roadmap
Bis jetzt gab es Code und Betriebsdokumentation, aber nichts, was einem
beim Arbeiten hilft.

Die Anleitung ist kein Handbuch zum Durchlesen, sondern die zwoelf
Handgriffe, die im Alltag vorkommen - vom Behoerdenschreiben aufnehmen
bis zum Vertrag kuendigen. Dazu der Jahresrhythmus, die Berichte, was am
Handy anders ist, und ein Abschnitt "wenn etwas schiefgeht" mit den
Faellen, die wirklich passieren: Handy verloren, Code wird nicht
angenommen, Frist stimmt nicht, Bericht zeigt eine Differenz.

Ein eigener Abschnitt sagt, was die Anwendung NICHT tut. Wer sich auf
etwas verlaesst, das sie nicht leistet, faellt sonst genau dann darauf
herein, wenn es darauf ankommt.

Sie ist unter /anleitung auch in der Anwendung lesbar - dieselbe Datei,
kein zweiter Text, der auseinanderlaeuft. Wer im Heim steht und nicht
weiterweiss, hat sie auf dem Telefon dabei, und der Druckknopf legt sie
zu den Betreuungsunterlagen.

Dafuer ein schlichter Markdown-Baustein statt einer Bibliothek: die
Anwendung zeigt genau zwei solche Dokumente, beide schreibe ich selbst.
Rohes HTML wird nicht durchgereicht. Die Datenschutzseite benutzt ihn
jetzt ebenfalls, statt eine zweite Umsetzung zu pflegen.

Beide Dateien werden ueber outputFileTracingIncludes in den
Standalone-Build gezogen - der Pfad entsteht erst zur Laufzeit, die
Dateiverfolgung von Next.js kann ihn nicht erkennen. Ohne den Eintrag
waeren beide Seiten im Behaelter leer.

Die Roadmap sortiert die 44 Schema-Wuensche der Etappen nach einer
einzigen Frage zuerst: Kann daraus ein falscher Wert in einem Bericht
ans Gericht werden? Was das kann, steht in 1.1. Dazu ein Abschnitt
"bewusst nicht geplant" mit Begruendung - damit klar ist, worauf
niemand wartet.

Etappe: 8
Phase: Haertung
2026-08-22 10:51:28 +02:00
24 changed files with 3116 additions and 161 deletions
+29 -1
View File
@@ -16,9 +16,37 @@ DATABASE_URL="postgresql://cockpit:bitte-aendern@localhost:5432/betreuungs_cockp
AUTH_SECRET= AUTH_SECRET=
# Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash). # Oeffentliche Basis-URL der Anwendung (ohne abschliessenden Slash).
AUTH_URL=http://localhost:3000 AUTH_URL=http://localhost:3000
# Hinter einem Reverse Proxy (Caddy) auf true setzen. # Hinter einem Reverse Proxy auf true setzen.
AUTH_TRUST_HOST=true AUTH_TRUST_HOST=true
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
#
# Auf welcher Adresse soll die Anwendung erreichbar sein? Das haengt davon
# ab, WO dein Reverse Proxy laeuft. Waehl genau eine Zeile aus:
#
# Proxy auf DEMSELBEN Server
# APP_BIND=127.0.0.1
# Von aussen kommt niemand heran, nur der Proxy nebenan. Sicherste
# Variante - aber ein Proxy auf einem anderen Rechner erreicht die
# Anwendung damit NICHT.
#
# Proxy auf einem ANDEREN Server, privates Netz oder WireGuard vorhanden
# APP_BIND=10.0.0.5 <- die Adresse DIESES Servers in dem Netz
# Erste Wahl, wenn der Proxy woanders steht.
#
# Proxy auf einem ANDEREN Server, kein privates Netz
# APP_BIND=0.0.0.0
# Nur zusammen mit einer Firewallregel, die den Port ausschliesslich
# fuer die IP des Proxy-Servers oeffnet:
# ufw allow from <IP-des-Proxys> to any port 3000 proto tcp
# ufw deny 3000/tcp
#
# Zwischen Proxy und Anwendung laeuft unverschluesseltes HTTP - die
# Anwendung beendet kein TLS. Dieser Weg darf nie durchs offene Netz gehen.
# Einzelheiten: docs/INSTALLATION.md, Schritt 7.
APP_BIND=127.0.0.1
APP_PORT=3000
# --- Anwendungsseitige Feldverschluesselung --------------------------------- # --- Anwendungsseitige Feldverschluesselung ---------------------------------
# AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN # AES-256-GCM. 32 Bytes, base64. OHNE DIESEN SCHLUESSEL SIND DIE VERSCHLUESSELTEN
# FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern! # FELDER UNWIEDERBRINGLICH VERLOREN - unbedingt getrennt vom Backup sichern!
+168 -1
View File
@@ -26,6 +26,169 @@ Wiederherstellung*.
_Noch nichts._ _Noch nichts._
## [1.0.4] – 2026-08-22
### Behoben
- **Die Ersteinrichtung ließ sich nicht abschließen.** Schritt 3 meldete
„Bitte prüfe die markierten Felder" — markierte aber keines. Man kam an
dieser Stelle nicht weiter.
Ursache: Das kurze Formular der Ersteinrichtung schickt neun Felder, das
vollständige unter *Stammdaten* alle. Beide gehen durch dasselbe Schema,
und die optionalen Felder waren zwar `nullable`, aber nicht `optional` —
ein Feld, das gar nicht abgeschickt wurde, ist `undefined`, nicht
`null`. Beanstandet wurden also zwei Dutzend Felder, die auf dem
Bildschirm nie standen.
- **Eine Meldung ohne zugehöriges Feld kann nicht mehr unsichtbar
bleiben.** Beanstandet die Prüfung ein Feld, das das Formular gar nicht
gesendet hat, sagt die Anwendung das jetzt ausdrücklich und schreibt es
ins Serverlog — statt es als Eingabefehler auszugeben, den niemand
finden kann. Das ist ein Fehler im Programm, und er soll auch so
aussehen.
- **Eine betreute Person ließ sich nicht endgültig löschen.** Beim Löschen
setzt Postgres den Verweis im Audit-Log auf NULL (`ON DELETE SET NULL`),
und genau dieses UPDATE hat der Unveränderlichkeits-Trigger abgewiesen.
Betroffen war damit auch das Leeren des Papierkorbs nach 30 Tagen und
das Entfernen des Demofalls.
Der Trigger erlaubt jetzt genau eine Ausnahme: das Nullen der beiden
Fremdschlüssel `betreuteId` und `userId`. Jede andere Änderung bleibt
verboten, auch das Setzen eines Fremdschlüssels auf einen **anderen**
Wert. Der Nachweis bleibt vollständig — Zeitpunkt, Aktion, Bezeichnung,
die verschlüsselte Änderung und die Momentaufnahmen von Name und
E-Mail sind unberührt. Es verschwindet nur der Verweis auf einen
Datensatz, den es nicht mehr gibt.
**Beim Update nötig:** das SQL-Skript erneut ausführen, sonst bleibt der
alte Trigger stehen.
```bash
docker compose exec app node node_modules/prisma/build/index.js db execute --schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql
```
### Geprüft
- Sieben neue Tests (`tests/stammdaten.test.ts`) prüfen genau die
Formularinhalte der Ersteinrichtung, dazu das vollständige Formular mit
leeren Feldern und die weiterhin verlangten Pflichtangaben.
- Der Ablauf ist zusätzlich im Browser durchgespielt worden: Anmeldung,
zweiter Faktor, Schritt 3, Landung auf dem Dashboard — und die daraus
abgeleiteten Pflichtfristen samt Ausschlussfrist für die
Aufwandspauschale.
- Der geänderte Trigger ist gegen eine laufende Datenbank geprüft:
Löschen gelingt, Fälschen und Löschen von Einträgen scheitert weiterhin.
## [1.0.3] – 2026-08-22
Keine Änderung am Programm — nur an der Konfigurationsvorlage und der
Anleitung. Ein Neubau des Abbilds ist nicht nötig.
### Behoben
- **`.env.example` band die Anwendung stillschweigend an `127.0.0.1`.**
Der Wert stand dort als fertig ausgefüllte Zeile zwischen lauter anderen
fertigen Zeilen. Wer die Datei kopiert hat und seinen Reverse Proxy auf
einem **anderen** Server betreibt, kam nicht an die Anwendung heran —
und der Fehler sah aus, als läge es am Proxy.
Jetzt steht dort eine Entscheidung mit drei benannten Fällen statt eines
Vorgabewerts. Der sichere Standard bleibt `127.0.0.1`; er wird nur nicht
mehr als „schon erledigt" präsentiert.
Für bestehende Installationen genügt eine Zeile in der `.env`:
```bash
APP_BIND=10.0.0.5 # Adresse, über die der Proxy diesen Server erreicht
docker compose up -d
```
### Geändert
- **`docs/INSTALLATION.md`** nennt `APP_BIND` jetzt schon in Schritt 3, wo
die `.env` ausgefüllt wird — nicht erst in Schritt 7, wo der Proxy
drankommt. Dazu ein eigener Abschnitt in der Fehlersuche: „Der Proxy
erreicht die Anwendung nicht", mit den zwei `curl`-Proben, die den
Unterschied zwischen falscher Bindeadresse und Firewall zeigen.
## [1.0.2] – 2026-08-22
### Behoben
- **Die Einrichtung des zweiten Faktors brach mit einem Serverfehler ab**
(„Application error: a server-side exception has occurred"). Im
Serverlog stand `Umgebungsvariable ENC_KEY_V2 fehlt` — obwohl niemand
einen zweiten Schlüssel wollte.
Ursache: `docker-compose.yml` reicht `ENC_KEY_V2` immer an den Behälter
durch, mit **leerem Wert**, wenn in der `.env` keiner steht. Die
Anwendung erkannte Schlüssel aber am Variablennamen statt am Inhalt,
fand also ein leeres `ENC_KEY_V2` und brach ab. Leere
Schlüsselvariablen werden jetzt übersprungen.
Betroffen war jede Stelle, die verschlüsselte Felder schreibt oder
liest — also praktisch alles außer der reinen Anmeldung. Die Anmeldung
ging durch, weil sie ohne Verschlüsselung auskommt; der Fehler trat
deshalb erst auf der nächsten Seite auf.
- **Die Meldung beim Schlüsselwechsel** nennt jetzt, welche Versionen
tatsächlich vorhanden sind, und die richtige Reihenfolge: erst den
neuen Schlüssel eintragen, dann `ENC_KEY_AKTIV` umstellen.
### Hinzugefügt
- **`scripts/anmeldung-pruefen.mjs`** — beantwortet die Frage, warum eine
Anmeldung nicht klappt. Das Formular sagt bewusst nicht, woran es lag,
sonst ließe sich daran ablesen, welche Adressen es gibt. Auf dem Server
darf man es wissen: Jeder Versuch wird mit Grund protokolliert, und das
Skript liest ihn vor — dazu den Zustand des Zugangs, den Stand der
Login-Sperre und die eingestellte `AUTH_URL`.
```bash
docker compose exec app node scripts/anmeldung-pruefen.mjs deine@adresse.de
```
Es ändert nichts, es liest nur.
### Geprüft
- Sieben neue Tests (`tests/umgebung.test.ts`) decken die
Schlüsselauswertung ab, darunter genau die Konstellation aus dem
Fehlerbericht: gesetztes `ENC_KEY_V1`, leeres `ENC_KEY_V2`.
## [1.0.1] – 2026-08-22
### Behoben
- **Betriebsskripte fehlten im Docker-Abbild.** Das Verzeichnis `scripts/`
wurde beim Bauen nie in die Laufzeitstufe übernommen. `docker compose
exec app node scripts/totp-geheimnis.mjs …` endete deshalb mit
`Cannot find module`. Betroffen waren alle Skripte, die im laufenden
Behälter gedacht sind — auch `wartungsschluessel.mjs` und
`schluessel-erzeugen.mjs`.
Nach dem Update funktioniert der Befehl wie dokumentiert. Wer nicht
gleich aktualisieren will, kommt einmalig auch so an das Skript:
```bash
docker compose cp scripts/totp-geheimnis.mjs app:/app/totp-geheimnis.mjs
docker compose exec app node totp-geheimnis.mjs deine@adresse.de
```
### Hinzugefügt
- **`scripts/totp-geheimnis.mjs`** — liest das TOTP-Geheimnis eines Zugangs
aus, um denselben Zugang auf einem zweiten Gerät einzurichten (Handy und
Tablet, oder ein Ersatzgerät). Die Anwendung zeigt das Geheimnis sonst
nur einmal an, beim Einrichten; bisher blieb nur, den zweiten Faktor
zurückzusetzen und alle Wiederherstellungscodes neu zu erzeugen.
Das Skript läuft nur auf dem Server und nur mit Zugriff auf die Datenbank
**und** auf `ENC_KEY_V1`. Wer beides hat, kann ohnehin alles lesen — es
ist also keine Hintertür, sondern erspart eine unnötige Neueinrichtung.
## [1.0.0] – 2026-08-22 ## [1.0.0] – 2026-08-22
Erste vollständige Fassung. Alle acht Etappen umgesetzt, alle Erste vollständige Fassung. Alle acht Etappen umgesetzt, alle
@@ -188,5 +351,9 @@ 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.0.0...HEAD [Unveröffentlicht]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.4...HEAD
[1.0.4]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.3...v1.0.4
[1.0.3]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.2...v1.0.3
[1.0.2]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.1...v1.0.2
[1.0.1]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/compare/v1.0.0...v1.0.1
[1.0.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/releases/tag/v1.0.0 [1.0.0]: https://git.wilde-server.de/florian.wilde/betreuungs-cockpit/releases/tag/v1.0.0
+11
View File
@@ -95,6 +95,17 @@ COPY --from=bau --chown=cockpit:cockpit /app/node_modules/.prisma ./node_modules
COPY --from=bau --chown=cockpit:cockpit /app/node_modules/@prisma ./node_modules/@prisma COPY --from=bau --chown=cockpit:cockpit /app/node_modules/@prisma ./node_modules/@prisma
COPY --from=bau --chown=cockpit:cockpit /app/node_modules/prisma ./node_modules/prisma COPY --from=bau --chown=cockpit:cockpit /app/node_modules/prisma ./node_modules/prisma
# Betriebsskripte, die im laufenden Behaelter gebraucht werden:
# totp-geheimnis.mjs zweites Geraet fuer den Zweitfaktor einrichten
# wartungsschluessel.mjs Schluessel fuer /api/wartung ausrechnen
# schluessel-erzeugen.mjs neue Schluessel fuer die Rotation
#
# Das ganze Verzeichnis, nicht einzelne Dateien: sonst faellt beim
# naechsten Betriebsskript wieder auf, dass es fehlt - und zwar erst dann,
# wenn man es braucht. Die Entwicklungsskripte darin laufen hier mangels
# Werkzeugen nicht, kosten aber auch nur ein paar Kilobyte.
COPY --from=bau --chown=cockpit:cockpit /app/scripts ./scripts
# Ablage fuer den Dateitreiber, falls kein Objektspeicher benutzt wird. # Ablage fuer den Dateitreiber, falls kein Objektspeicher benutzt wird.
RUN mkdir -p /app/daten/dokumente && chown -R cockpit:cockpit /app/daten RUN mkdir -p /app/daten/dokumente && chown -R cockpit:cockpit /app/daten
+53 -46
View File
@@ -15,6 +15,7 @@ eines Angehörigen: Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe, Berichte
## Inhalt ## Inhalt
- [Was die Anwendung kann](#was-die-anwendung-kann) - [Was die Anwendung kann](#was-die-anwendung-kann)
- [Die drei Dokumente, die du wirklich brauchst](#die-drei-dokumente-die-du-wirklich-brauchst)
- [Schnellstart zum Ausprobieren](#schnellstart-zum-ausprobieren) - [Schnellstart zum Ausprobieren](#schnellstart-zum-ausprobieren)
- [Produktivbetrieb mit Docker Compose](#produktivbetrieb-mit-docker-compose) - [Produktivbetrieb mit Docker Compose](#produktivbetrieb-mit-docker-compose)
- [Reverse Proxy](#reverse-proxy) - [Reverse Proxy](#reverse-proxy)
@@ -46,6 +47,23 @@ eines Angehörigen: Fristen, Vorgänge, Belege, Vermögen, Sozialhilfe, Berichte
--- ---
## Die drei Dokumente, die du wirklich brauchst
| Wofür | Wo |
|---|---|
| **Die Anwendung aufsetzen** — auf einem Server, der Docker und einen Proxy schon hat, mit Kontrolle nach jedem Schritt | [`docs/INSTALLATION.md`](docs/INSTALLATION.md) |
| **Täglich damit arbeiten** — die zwölf Handgriffe, der Jahresrhythmus, was tun wenn etwas schiefgeht | [`docs/ANLEITUNG.md`](docs/ANLEITUNG.md), in der Anwendung unter `/anleitung` |
| **Wissen, was sich ändert** — was neu ist, was du vor einem Update beachten musst | [`CHANGELOG.md`](CHANGELOG.md) |
Dazu: [`docs/ROADMAP.md`](docs/ROADMAP.md) für das, was als Nächstes
kommt, und [`docs/BAUPROTOKOLL.md`](docs/BAUPROTOKOLL.md) für die Frage,
wie das hier entstanden ist.
Der Rest dieser Datei ist die Kurzfassung für alle, die schon wissen, was
sie tun.
---
## Schnellstart zum Ausprobieren ## Schnellstart zum Ausprobieren
Voraussetzungen: **Node.js ≥ 20.11**, **Docker** und **Git**. Voraussetzungen: **Node.js ≥ 20.11**, **Docker** und **Git**.
@@ -113,6 +131,10 @@ laufender Antrag beim Bezirk Oberfranken mit acht Nachforderungen.
## Produktivbetrieb mit Docker Compose ## Produktivbetrieb mit Docker Compose
> **Zum ersten Mal?** Nimm die [Schritt-für-Schritt-Anleitung](docs/INSTALLATION.md).
> Sie fängt beim leeren Server an, hat nach jedem Abschnitt eine Kontrolle
> und endet mit einer Abnahmeliste. Was hier steht, ist die Kurzfassung.
Das Compose-Setup bringt drei Dienste mit: die Anwendung, PostgreSQL und Das Compose-Setup bringt drei Dienste mit: die Anwendung, PostgreSQL und
MinIO als S3-kompatiblen Objektspeicher, dazu einen Sicherungsdienst. MinIO als S3-kompatiblen Objektspeicher, dazu einen Sicherungsdienst.
@@ -166,61 +188,45 @@ Backup anstoßen** (siehe unten) — eine Migration lässt sich nicht zurückrol
## Reverse Proxy ## Reverse Proxy
Die Anwendung erwartet, dass der Proxy TLS beendet und die echte Client-IP Die Anwendung **beendet selbst kein TLS** und bringt keinen Proxy mit. Sie
weiterreicht. Sie liest `X-Forwarded-For` bzw. `X-Real-IP` für das horcht standardmäßig auf `127.0.0.1:3000`.
Rate-Limiting am Login und für das Audit-Log (dort nur als Hash).
**Caddy** **Läuft der Proxy auf demselben Server**, ist damit alles gesagt: Er zeigt
auf `127.0.0.1:3000`, fertig.
```caddyfile **Läuft er auf einem anderen Server**, setz in der `.env` die Adresse, über
cockpit.example.de { die er herankommt — am besten die eines privaten Netzes oder eines
encode zstd gzip WireGuard-Tunnels:
reverse_proxy 127.0.0.1:3000
# Uploads: Fotos von Behördenschreiben können groß sein. ```bash
request_body { APP_BIND=10.0.0.5
max_size 30MB APP_PORT=3000
}
}
``` ```
**nginx** `0.0.0.0` nur, wenn eine Firewall den Port ausschließlich für die IP des
Proxy-Servers öffnet. Zwischen Proxy und Anwendung läuft unverschlüsseltes
HTTP — dieser Weg darf nie durchs offene Netz gehen.
```nginx ### Was der Proxy leisten muss
server {
listen 443 ssl http2;
server_name cockpit.example.de;
ssl_certificate /etc/letsencrypt/live/cockpit.example.de/fullchain.pem; | Anforderung | Was ohne sie passiert |
ssl_certificate_key /etc/letsencrypt/live/cockpit.example.de/privkey.pem; |---|---|
| TLS beenden | Der Browser lehnt Cookies mit `Secure`-Kennzeichen ab, die Anmeldung schlägt stumm fehl |
| `Host` durchreichen | Auth.js vergleicht ihn mit `AUTH_URL` und weist die Anmeldung ab |
| `X-Forwarded-Proto: https` | Die Anwendung baut Adressen mit `http` |
| `X-Forwarded-For` | Die Login-Sperre zählt alle Zugriffe als denselben Anschluss |
| Uploads ≥ 30 MB | Fotos mehrseitiger Bescheide werden abgeschnitten |
| Zeitgrenze ≥ 10 Minuten | Belegmappe, Rechnungslegung und Vollexport brechen ab |
| Antwort nicht puffern | Der Vollexport kommt als Strom |
client_max_body_size 30M; **Keine eigenen Sicherheitskopfzeilen setzen.** CSP, `X-Frame-Options`,
`nosniff` und HSTS kommen aus der Anwendung. Die CSP enthält eine Nonce,
die pro Anfrage neu ist — eine feste CSP aus dem Proxy blockiert die
eigenen Skripte.
location / { **Fertige Konfigurationen** für Caddy, nginx und Traefik, mit Zeitgrenzen
proxy_pass http://127.0.0.1:3000; und den Gegenproben, stehen in
proxy_http_version 1.1; [`docs/INSTALLATION.md`](docs/INSTALLATION.md), Schritt 7.
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
```
**Traefik** (Labels am `app`-Dienst)
```yaml
labels:
- traefik.enable=true
- traefik.http.routers.cockpit.rule=Host(`cockpit.example.de`)
- traefik.http.routers.cockpit.tls.certresolver=letsencrypt
- traefik.http.services.cockpit.loadbalancer.server.port=3000
```
In allen Fällen muss `AUTH_URL` in der `.env` auf die öffentliche Adresse
zeigen (`https://cockpit.example.de`), sonst schlägt die Anmeldung fehl.
--- ---
@@ -479,6 +485,7 @@ npm test # Vitest: Fachlogik
npm run test:e2e # Playwright: kritische Abläufe npm run test:e2e # Playwright: kritische Abläufe
npm run db:studio # Prisma Studio npm run db:studio # Prisma Studio
npm run protokoll # Bauprotokoll aus der Git-Historie npm run protokoll # Bauprotokoll aus der Git-Historie
npm run version -- patch # Version anheben, Changelog vorbereiten
``` ```
Konventionen — verbindlich für alle Beiträge: Konventionen — verbindlich für alle Beiträge:
+22 -7
View File
@@ -4,10 +4,13 @@
# #
# docker compose up -d --build # docker compose up -d --build
# #
# Kein Reverse Proxy in dieser Datei: der ist auf dem Server bereits # Kein Reverse Proxy in dieser Datei: der ist bereits vorhanden. Die
# vorhanden. Die Anwendung horcht nur auf 127.0.0.1:3000 - von aussen # Anwendung beendet selbst kein TLS und horcht standardmaessig nur auf
# erreichbar wird sie ausschliesslich ueber den vorhandenen Proxy, der # 127.0.0.1:3000.
# auch TLS beendet. Siehe README, Abschnitt "Reverse Proxy". #
# Liegt der Proxy auf einem anderen Server, setze APP_BIND in der .env auf
# die Adresse, ueber die er herankommt - siehe docs/INSTALLATION.md,
# Schritt 7.
# #
# Die Datenbank hat bewusst KEINE Portfreigabe. Sie ist nur ueber das # Die Datenbank hat bewusst KEINE Portfreigabe. Sie ist nur ueber das
# interne Docker-Netz erreichbar. Wer mit psql heran muss, geht ueber # interne Docker-Netz erreichbar. Wer mit psql heran muss, geht ueber
@@ -88,6 +91,8 @@ services:
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'
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
# leere Schluesselvariablen - siehe src/lib/env.ts.
ENC_KEY_V2: ${ENC_KEY_V2:-} ENC_KEY_V2: ${ENC_KEY_V2:-}
ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1} ENC_KEY_AKTIV: ${ENC_KEY_AKTIV:-1}
INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env} INDEX_KEY: ${INDEX_KEY:?INDEX_KEY fehlt in der .env}
@@ -111,9 +116,19 @@ services:
SMTP_FROM: ${SMTP_FROM:-} SMTP_FROM: ${SMTP_FROM:-}
TZ: Europe/Berlin TZ: Europe/Berlin
ports: ports:
# Nur Loopback. Der vorhandene Reverse Proxy auf dem Host reicht # Standard ist die Loopback-Adresse: dann erreicht die Anwendung nur
# von aussen hierher durch und beendet TLS. # ein Proxy, der auf demselben Rechner laeuft.
- '127.0.0.1:${APP_PORT:-3000}:3000' #
# Liegt der Reverse Proxy auf einem ANDEREN Server, muss hier die
# Adresse stehen, ueber die er herankommt - am besten die eines
# privaten Netzes oder eines WireGuard-Tunnels:
#
# APP_BIND=10.0.0.5
#
# 0.0.0.0 nur setzen, wenn eine Firewall den Port ausschliesslich
# fuer die IP des Proxy-Servers oeffnet. Die Anwendung beendet kein
# TLS und gehoert nie unverschluesselt ins offene Netz.
- '${APP_BIND:-127.0.0.1}:${APP_PORT:-3000}:3000'
networks: [intern] networks: [intern]
healthcheck: healthcheck:
test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/api/gesundheit').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
+451
View File
@@ -0,0 +1,451 @@
# Bedienungsanleitung
Für dich als ehrenamtlichen Betreuer. Kein Handbuch zum Durchlesen —
sondern eine Sammlung der Handgriffe, die du tatsächlich brauchst, in der
Reihenfolge, in der sie im Alltag vorkommen.
> Diese Anwendung ist ein **Arbeits- und Dokumentationswerkzeug**. Sie
> rechnet Fristen aus und sortiert Unterlagen. Sie erteilt **keine
> Rechtsberatung** und kennt deinen Einzelfall nicht. Wo ein Paragraph
> steht, steht daneben der Hinweis, dass er die Rückfrage bei Gericht,
> Bezirk oder Anwalt nicht ersetzt. Nimm den ernst.
---
## Der Grundgedanke in drei Sätzen
Du trägst einmal ein, was passiert ist — ein Schreiben, ein Besuch, eine
Überweisung. Die Anwendung merkt sich, wann daraus etwas folgt, und
erinnert dich rechtzeitig. Am Ende des Betreuungsjahres erzeugt sie aus
demselben Bestand die Berichte, die Gericht und Bezirk von dir wollen.
Das heißt umgekehrt: **Was du nicht einträgst, steht später nicht im
Bericht.** Der Aufwand liegt vorn, der Gewinn hinten.
---
## Der erste Tag
Beim allerersten Aufruf führt dich ein Assistent durch drei Schritte.
**Schritt 1 — Dein Zugang.** Name, E-Mail, Passwort. Nimm vier zufällige
Wörter statt eines kurzen Kunstworts: *Kastanie-Waschbär-Turm-Grün* ist
leichter zu merken und deutlich schwerer zu knacken. Noch besser ist ein
Passwortmanager.
**Schritt 2 — Der zweite Faktor.** Installiere eine Authenticator-App —
**Aegis** (Android) oder **2FAS** (iOS) sind quelloffen und brauchen kein
Konto. Scanne den Code, tippe die sechs Ziffern ein.
Danach bekommst du **acht Wiederherstellungscodes**. Die sind der einzige
Weg zurück, wenn dein Handy verloren geht. Druck sie aus und leg sie zu
deinen Betreuungsunterlagen — **nicht** auf dasselbe Gerät wie die App.
Sie werden dir nie wieder angezeigt.
**Schritt 3 — Die betreute Person.** Nur das Nötigste. Ein Feld ist dabei
wichtiger als alle anderen:
> **Das Bestellungsdatum.** Es steht in deiner Bestellungsurkunde. Daraus
> berechnet die Anwendung dein Betreuungsjahr — und damit, wann
> Anfangsbericht, Vermögensverzeichnis, Jahresbericht und die
> Aufwandspauschale fällig sind. Ist es falsch, ist alles falsch.
Alles Weitere trägst du später unter **Stammdaten → Bearbeiten** in Ruhe
nach. Das lohnt sich: Was dort steht, taucht in jedem Bericht und in jedem
Anschreiben wieder auf.
**Zum Schluss:** Geh auf **Fristen** und klicke einmal auf
*Pflichtfristen ableiten*. Danach stehen deine gesetzlichen Termine drin.
---
## Der Morgen: die Seite „Heute"
Diese eine Seite beantwortet die Frage, was ansteht. Von oben nach unten:
| Kachel | Was sie dir sagt |
|---|---|
| **Fristen** | Was überfällig ist (rot), was diese Woche fällig wird (orange), was diesen Monat (gelb) |
| **Wartet auf andere** | Vorgänge, bei denen die Gegenstelle am Zug ist — mit Tagen seit dem letzten Kontakt |
| **Nachforderungen des Trägers** | Was der Bezirk noch von dir will, mit Fortschrittsbalken |
| **Ungeprüfter Posteingang** | Neu hochgeladene Schreiben, die noch niemand zugeordnet hat |
| **Unverbuchte Belege** | Rechnungen ohne Buchung und Buchungen ohne Beleg |
| **Wo stehen wir?** | Vermögen zum letzten Stichtag, Heimkosten gegen Deckung, Tage bis zum Jahresbericht |
Jede Kachel endet in einer Handlung. Wenn irgendwo nichts steht, ist dort
auch nichts zu tun.
**Ist eine Kachel leer, obwohl du Daten erwartest?** Dann fehlt meist ein
Eintrag: keine Fristen ohne Bestellungsdatum, keine Deckungslücke ohne
erfassten Heimkostenmonat.
---
## Die zwölf häufigsten Handgriffe
### 1. Ein Behördenschreiben aufnehmen
**Dokumente → Aufnehmen**, oder unterwegs über den runden Knopf unten
rechts → *Beleg fotografieren*.
1. Foto machen oder PDF wählen — mehrere auf einmal sind ausdrücklich
erwünscht.
2. Warten, bis die Datei angenommen ist. Geprüft wird der **Inhalt**, nicht
die Endung: eine Datei, die sich nur als PDF ausgibt, wird abgelehnt.
3. Titel eintragen (oder leer lassen — die Erkennung schlägt einen vor).
4. **Eingangsdatum** setzen. Das ist wichtiger, als es aussieht: Bei
Bescheiden läuft die Widerspruchsfrist ab dem Zugang, nicht ab dem
Datum auf dem Brief.
5. Vorgang auswählen.
6. *In die Akte übernehmen*.
Danach liest der Server den Text (Texterkennung auf Deutsch) und schlägt
dir **Absender, Datum, Aktenzeichen, Dokumentart und eine im Text genannte
Frist** vor. Die Textstelle steht daneben — prüf sie, bevor du übernimmst.
Nichts davon wird automatisch angelegt.
**Trag ein, wo das Papier liegt.** Es gibt ein Feld für Ordner und
Register. Wenn der Rechtspfleger das Original sehen will, findest du es
dann in zehn Sekunden.
### 2. Aus einem Schreiben eine Frist machen
Öffne das Dokument. Steht unter den Vorschlägen eine erkannte Frist,
genügt ein Klick auf *Als Frist übernehmen*. Sonst: **Fristen → Neu**,
Datum eintragen, Vorgang zuordnen.
Setz bei behördlichen Fristen die Vorwarnung großzügig. Der Standard ist
60/30/7/1 Tage vorher.
### 3. Einen Besuch festhalten — in zwanzig Sekunden
Runder Knopf unten rechts → *Notiz / Kontakt festhalten*.
Datum ist auf heute vorbelegt, die Art wählst du aus einer Knopfreihe, die
Dauer per Schnellwahl. Ein Freitextfeld, fertig.
Zwei Felder lohnen die zusätzlichen Sekunden:
- **„Persönlicher Kontakt zur betreuten Person"** — dieses Häkchen zählt
direkt in den Jahresbericht. Es ist bewusst **nicht** vorbelegt: ein
versehentlich gesetztes Häkchen wäre eine falsche Angabe gegenüber dem
Gericht.
- **Geäußerte Wünsche** — seit der Reform 2023 ist der Wille der betreuten
Person die Leitschnur deines Handelns (§ 1821 BGB). Was du hier
festhältst, landet im Jahresbericht und trägt deine Entscheidungen.
Das ist der Handgriff, der sich am meisten auszahlt. Ein Jahresbericht aus
vierzig kurzen Notizen schreibt sich fast von selbst; einer aus der
Erinnerung ist eine Qual.
### 4. Eine Buchung erfassen
**Vermögen & Kasse → Buchungen → Neu**, oder über den runden Knopf.
Wichtig ist die Trennung: **Betrifft die Buchung die betreute Person oder
ist es deine eigene Auslage?** Läuft eine Buchung auf ein als privat
markiertes Konto und ist trotzdem als „betrifft die Betreute"
gekennzeichnet, warnt die Anwendung. Das ist der Fehler, der einem in der
Rechnungslegung um die Ohren fliegt.
**Belege.** Eine Buchung ohne Beleg wird in der Liste markiert. Das ist
kein Verbot — manchmal gibt es keinen. Dann trag eine Begründung ein, dann
ist die Zeile sauber und du weißt später noch, warum.
### 5. Einen Vorgang anlegen
**Vorgänge → Neuer Vorgang.** Nimm eine Vorlage, wenn eine passt:
Heimeinzug · Antrag Hilfe zur Pflege · Wohnungsauflösung · Hausverkauf ·
Darlehen § 91 SGB XII · Versicherungsverwertung · Pflegegrad-Höherstufung ·
Steuererklärung · Widerspruch gegen Bescheid
Jede bringt eine durchdachte Aufgabenliste in der richtigen Reihenfolge
mit. Bei den genehmigungspflichtigen erzwingt sie die Reihenfolge
**Mitteilung → Genehmigung → Rechtsgeschäft**.
Ein Feld solltest du immer pflegen: **Nächster Schritt.** Es steht auf dem
Dashboard und beantwortet dir in vier Wochen die Frage, was du dir dabei
gedacht hast.
### 6. Ein genehmigungspflichtiges Geschäft
Hausverkauf, Wohnungskündigung, Kreditaufnahme, Verwertung einer
Lebensversicherung — dafür brauchst du das Betreuungsgericht.
Markiere den Vorgang als genehmigungspflichtig und wähle die
Rechtsgrundlage. Die Anwendung zeigt dann eine feste Stufenfolge:
**Mitteilung ans Gericht → Antrag gestellt → Anhörung → Beschluss →
Rechtskraft → Vollzug**
> Die Stufe **Vollzug lässt sich erst setzen, wenn ein Beschlussdokument
> hinterlegt ist.** Das ist Absicht: Nach § 1858 BGB muss die Genehmigung
> **vor** dem Rechtsgeschäft vorliegen. Ein Vertrag, den du vorher
> unterschreibst, ist schwebend unwirksam.
Sprich vorher mit dem Rechtspfleger. Die Anwendung ersetzt das Gespräch
nicht, sie erinnert dich nur daran.
### 7. Eine Nachforderung des Bezirks abarbeiten
**Sozialhilfe → Akte öffnen.** Jede angeforderte Unterlage steht als
eigene Zeile mit Status und Frist. Der Fortschrittsbalken zeigt „x von y
Unterlagen geliefert".
Beim Anlegen des Antrags kannst du die **Checkliste der Standardunterlagen**
übernehmen — Rentenbescheide, Kontoauszüge, Versicherungsunterlagen,
notarielle Übertragungsverträge, Pflegegradbescheid, Heimvertrag,
Grundbuchauszug. Zu jeder steht dabei, **warum** der Träger sie will. Das
erspart Rückfragen.
Gibt es eine Unterlage nicht, setz den Status auf *nicht vorhanden* und
schreib eine Begründung dazu. Die geht später mit ins Unterlagenpaket —
besser als kommentarloses Schweigen.
Wenn alles beisammen ist: **Berichte → Unterlagenpaket**. Das macht aus
den markierten Dokumenten **eine** PDF mit Anschreiben, laufender Nummer
und Inhaltsverzeichnis.
### 8. Einen Bescheid prüfen
**Sozialhilfe → Akte → Bescheide → Neu.**
Trag das **Zugangsdatum** ein, nicht das Datum auf dem Bescheid. Die
Widerspruchsfrist läuft ab Bekanntgabe.
Die Anwendung rechnet die Frist aus — mit Wochenende, bayerischen
Feiertagen und dem Sonderfall, dass bei fehlender oder falscher
Rechtsbehelfsbelehrung ein Jahr statt eines Monats gilt (§ 66 SGG). Der
Rechenweg wird ausgegeben, damit du ihn nachvollziehen kannst. Aus dem
Ergebnis entsteht automatisch die Frist *Widerspruch möglich bis*.
**Rechne im Zweifel nach oder frag beim Bezirk.** Eine verstrichene
Widerspruchsfrist bekommst du nicht zurück.
### 9. Einen Rückkaufswert anfordern
**Verträge → Vertrag öffnen → Rückkaufswert anfordern.** Es entsteht ein
Anschreiben zum Kopieren, und die Anwendung legt eine **Wiedervorlage in
vier Wochen** an. Versicherer antworten selten von selbst.
### 10. Einen Vertrag kündigen
**Verträge → Vertrag öffnen → Kündigen.**
Der Rechner nennt dir den **spätesten Absendetag**, nicht den Fristablauf —
eine Kündigung wirkt mit dem Zugang, nicht mit dem Abschicken. Drei Tage
Postlauf sind eingerechnet.
Der Assistent fragt dich nach der Genehmigung. Er beantwortet die Frage
nicht, und das soll er auch nicht: Ob die Verwertung einer
Lebensversicherung genehmigungspflichtig ist, entscheidet der
Rechtspfleger. Der **Versandnachweis** (Einschreiben, Faxprotokoll) ist
Pflichtfeld — ohne ihn geht die Kündigung nicht durch.
### 11. Einen Wertstand nachtragen
**Vermögen & Kasse → Vermögen → Posten öffnen → Wert eintragen.**
Stichtag, Betrag und **Wertquelle** — letztere ist Pflicht. „Eigene
Schätzung" ist eine zulässige Antwort, „keine Angabe" nicht: Im
Vermögensverzeichnis steht die Quelle mit dabei, und das Gericht darf
wissen, woher die Zahl kommt.
Alte Werte werden **nie überschrieben**. Jede Vermögensübersicht bleibt
später reproduzierbar.
### 12. Etwas versehentlich Gelöschtes zurückholen
**Einstellungen → Papierkorb.** Gelöschtes bleibt 30 Tage liegen und lässt
sich mit einem Klick zurückholen. Erst danach ist es wirklich weg.
Endgültiges Löschen verlangt eine erneute Anmeldung.
---
## Das Betreuungsjahr
Dein Jahr läuft **ab dem Tag der Bestellung**, nicht ab dem 1. Januar. Wer
am 14. Februar bestellt wurde, hat sein erstes Betreuungsjahr bis zum
13. Februar des Folgejahres.
| Wann | Was | Grundlage |
|---|---|---|
| 3 Monate nach Bestellung | Vermögensverzeichnis und Anfangsbericht | §§ 1835, 1863 BGB |
| Zum Ende jedes Betreuungsjahres | Jahresbericht und Vermögensübersicht | §§ 1859, 1863 BGB |
| Bis 6 Monate danach | **Aufwandspauschale beantragen** | § 1878 BGB |
| Jährlich bis 31.12. | Bayerisches Landespflegegeld | BayLPflGG |
| Jährlich | Zuzahlungsbefreiung der Krankenkasse | § 62 SGB V |
| Jährlich | Rückkaufswerte aller Versicherungen aktualisieren | — |
> **Die Aufwandspauschale ist eine Ausschlussfrist.** Läuft sie ab, ist
> der Anspruch ersatzlos weg — keine Nachsicht, keine Wiedereinsetzung.
> Die Anwendung warnt deshalb schon 90 Tage vorher und markiert sie rot.
---
## Die Berichte
Alle unter **Berichte**, die beiden Finanzberichte unter **Vermögen &
Kasse → Berichte**. Jeder trägt Kopf mit Aktenzeichen, Fußzeile mit
Seitenzahl und ein Unterschriftenfeld.
**Vermögensverzeichnis (§ 1835 BGB).** Stichtag wählen, Knopf drücken.
Aktiva nach Gruppen, Passiva, laufende Einnahmen und Ausgaben,
Belegverweise. Hat ein Posten zum Stichtag keinen Wert, erscheint er als
**Lücke** — nicht als 0,00 €. Das ist ehrlicher und fällt dir auf.
**Vermögensübersicht / Rechnungslegung (§§ 1859, 1865 BGB).**
Betreuungsjahr wählen. Anfangsbestand, geordnete Einnahmen und Ausgaben,
Endbestand, Belegliste.
Zwei Varianten: die knappe Übersicht für **befreite Betreuer** (§ 1859 BGB
— als Angehöriger bist du das in der Regel) und die vollständige
Rechnungslegung.
> Geht die Rechenprobe nicht auf, wird die **Differenz im Bericht
> ausgewiesen** und jeder fehlende Saldostand benannt. Das ist Absicht.
> Eine still geglättete Rechnungslegung ist gegenüber dem Gericht wertlos —
> und meistens fehlt einfach ein Kontoauszug.
**Jahresbericht (§ 1863 BGB).** Die Anwendung erzeugt einen **Entwurf** aus
deinen Kontaktprotokollen, Gesundheitsereignissen und Vorgängen, gegliedert
nach dem, was das Gericht sehen will. Du überarbeitest ihn im Browser
(wird automatisch zwischengespeichert) und gibst ihn dann als PDF oder DOCX
aus.
> Es ist ein Entwurf, kein fertiger Bericht. Lies ihn durch, bevor du ihn
> abgibst. Was dort steht, verantwortest du.
**Belegmappe.** Alle Dokumente eines Vorgangs oder Zeitraums als eine
chronologisch sortierte, paginierte PDF mit Deckblatt und
Inhaltsverzeichnis. Genau das, was Gericht und Bezirk haben wollen.
**Vollexport.** Ein ZIP mit allen Originaldateien, allen Daten als
lesbarem JSON, den Berichten und Prüfsummen. Damit kannst du diese
Anwendung jederzeit verlassen. Verlangt eine erneute Anmeldung.
---
## Am Handy
Die Anwendung ist am Telefon vollwertig bedienbar — sie ist dafür gebaut,
nicht dafür angepasst.
- Menü über das Symbol links oben, schließt mit Wischen oder Escape.
- Der **runde Knopf unten rechts** ist immer erreichbar: Beleg
fotografieren, Notiz festhalten, Frist anlegen, Buchung erfassen.
- Tabellen scrollen in ihrem eigenen Rahmen, nie die ganze Seite.
- Zoomen ist erlaubt und abgeschaltet wird es nie.
Der typische Fall: Du sitzt im Heim, fotografierst die Heimrechnung,
ordnest sie dem Vorgang zu, hältst den Besuch fest — drei Minuten, und
abends musst du nichts nacharbeiten.
---
## Wenn etwas schiefgeht
**Ich will den Zugang auf ein zweites Gerät legen.**
Das Geheimnis wird nur einmal angezeigt, beim Einrichten. Wer es später
noch einmal braucht — Handy und Tablet, oder ein Ersatzgerät im Schrank —
liest es auf dem Server aus:
```bash
docker compose exec app node scripts/totp-geheimnis.mjs deine@adresse.de
```
Läuft der Behälter noch mit einer Fassung vor 1.0.1, fehlt das Skript im
Abbild (`Cannot find module`). Dann entweder aktualisieren, oder einmalig:
```bash
docker compose cp scripts/totp-geheimnis.mjs app:/app/totp-geheimnis.mjs
docker compose exec app node totp-geheimnis.mjs deine@adresse.de
```
Das ist keine Hintertür: Es läuft nur mit Zugriff auf die Datenbank **und**
auf `ENC_KEY_V1`. Wer beides hat, kommt ohnehin an alles. Die Ausgabe
gehört trotzdem in kein Protokoll und in keine E-Mail.
**Ich habe mein Handy mit der Authenticator-App verloren.**
Melde dich mit einem deiner Wiederherstellungscodes an — statt der sechs
Ziffern gibst du den Code im Format `XXXX-XXXX-XXXX` ein. Jeder
funktioniert einmal. Danach unter **Konto → Zweiter Faktor** neu
einrichten und **neue Codes erzeugen**.
**Ich habe auch die Wiederherstellungscodes verloren.**
Dann kommt nur noch jemand mit Zugriff auf den Server weiter, indem er den
zweiten Faktor in der Datenbank zurücksetzt. Genau deshalb: Codes
ausdrucken.
**Der Code wird nicht angenommen.**
Fast immer geht die Uhr des Telefons falsch. TOTP-Codes hängen an der
Uhrzeit. Stell die automatische Zeitsynchronisierung ein.
**„Zu viele Fehlversuche."**
Nach fünf Fehlversuchen wird für eine Viertelstunde gesperrt. Warte ab —
das gilt auch dann, wenn du selbst es warst.
**Ich habe mein Passwort geändert und bin überall abgemeldet.**
So gewollt. Ein Passwortwechsel beendet alle Sitzungen, auch auf Geräten,
an denen du gerade nicht sitzt.
**Eine Frist stimmt nicht.**
Prüf zuerst das Bestellungsdatum unter **Stammdaten**. Änderst du es,
werden die Pflichtfristen neu berechnet. Erledigte Fristen bleiben dabei
unangetastet.
**Ein Bericht zeigt eine Differenz.**
Meistens fehlt ein Saldostand. Der Bericht sagt dir, welcher. Trag ihn
unter **Vermögen & Kasse → Konten** nach und erzeuge neu.
**Die Texterkennung findet nichts.**
Sie braucht Tesseract auf dem Server. Fehlt es, funktioniert der Upload
trotzdem — nur ohne Volltextsuche. Titel und Absender kannst du von Hand
eintragen.
---
## Was diese Anwendung nicht tut
Damit du dich nicht darauf verlässt:
- **Sie berät dich nicht.** Sie rechnet Fristen und schlägt Einordnungen
vor. Ob dein Fall eine Ausnahme ist, weiß sie nicht.
- **Sie entscheidet nicht, was Schonvermögen ist.** Sie gibt dir
Argumentationshilfen zu § 90 SGB XII zum Übernehmen. Entscheiden tun der
Träger und notfalls das Sozialgericht.
- **Sie schätzt keine Bewilligungschancen.**
- **Sie holt keine Kontoauszüge ab.** Kontobewegungen kommen per
CSV-Import oder von Hand.
- **Sie verschickt nichts.** Anschreiben entstehen als Text zum Kopieren,
Berichte als PDF. Absenden musst du selbst — und den Versandnachweis
hochladen.
- **Sie ersetzt deine Papierablage nicht.** Originale bleiben Originale.
Deshalb gibt es das Feld für den Ablageort.
---
## Zwei Gewohnheiten, die sich lohnen
**Jeden Kontakt festhalten, sofort.** Auch das kurze Telefonat. Zwanzig
Sekunden jetzt sparen dir im Januar einen halben Tag.
**Einmal im Jahr die Wiederherstellung üben.** Ein Backup, das nie
zurückgespielt wurde, ist kein Backup. Die Anleitung steht in der README
unter *Sicherung und Wiederherstellung*. Zwanzig Minuten, einmal im Jahr.
---
## Wo du weiterkommst
- **Hinweise** in der Anwendung — 37 Karten zu den häufigen Fallstricken,
jede mit Rechtsgrundlage und Link zum Gesetzestext.
- **Protokoll** — jede Änderung, lückenlos und unveränderlich. Dein
Nachweis gegenüber Gericht und möglichen Miterben.
- Dein **Rechtspfleger** beim Betreuungsgericht. Er ist zuständig und
antwortet. Bei allem, was genehmigungspflichtig sein könnte: vorher
fragen, nicht nachher.
- Die örtliche **Betreuungsbehörde** und die Betreuungsvereine beraten
ehrenamtliche Betreuer kostenlos.
+1031
View File
File diff suppressed because it is too large Load Diff
+261
View File
@@ -0,0 +1,261 @@
# Roadmap
Was als Nächstes drankommt, und warum in dieser Reihenfolge.
**Maßstab ist der Alltag, nicht die Vollständigkeit.** Ein Punkt steht
weit oben, wenn er entweder etwas Falsches richtigstellt oder jeden Monat
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
als wichtiger herausstellt, rutscht nach oben.
Stand: 22.08.2026 · Aktuelle Fassung: **1.0.0** ([CHANGELOG](../CHANGELOG.md))
---
## Woher die Punkte kommen
Beim Bauen sind **44 Schema-Wünsche** aufgelaufen — Stellen, an denen das
Datenmodell etwas nicht abbildet, was die Fachlogik gebraucht hätte. Sie
liegen einzeln und begründet in [`schema-wuensche/`](schema-wuensche/),
nach Modul getrennt. Diese Roadmap sortiert sie nach Dringlichkeit und
bündelt sie zu Releases, die sich in einem Rutsch migrieren lassen.
Dazu kommen die bekannten Einschränkungen aus dem Changelog und das, was
sich im Betrieb noch zeigen wird.
---
## 1.1 — Was heute noch falsch sein kann
**Ziel:** Kein Wert in der Anwendung soll unter ungünstigen Umständen
falsch sein, ohne dass man es merkt. Das ist die einzige Art von Fehler,
die man einem Betreuungsgericht nicht erklären kann.
### Kostenkonto der Immobilie richtigstellen
`Buchung` hat kein `immobilieId`. Das Kostenkonto wertet deshalb über
Buchungskategorien aus — bei **einem** Grundstück richtig, bei einem
zweiten still falsch. Die Oberfläche sagt das derzeit an der Auswertung
dazu; das ist ein Pflaster, keine Lösung.
*Aufwand: klein. Migration: ein Feld, ein Index.*
### Umbuchungen kennen ihr Gegenkonto
Eine Umbuchung zwischen zwei Konten erscheint heute als zwei unverbundene
Buchungen. In der Rechnungslegung sieht das aus wie eine Ausgabe und eine
Einnahme — und wer nachrechnet, fragt zu Recht nach.
*Aufwand: klein. Migration: ein selbstbezüglicher Fremdschlüssel.*
### Dublettenschutz beim Import ohne Konto
Der Schutz vor Doppelbuchungen greift über `(kontoId, importReferenz)`.
Ist kein Konto zugeordnet, greift er nicht — ein zweimal eingelesener
Auszug erzeugt dann doppelte Buchungen.
*Aufwand: klein. Migration: partieller eindeutiger Index.*
### Rückkaufswert am Vertrag
Der Wert hängt heute am Vermögensposten. In der Vertragsübersicht steht
deshalb kein Betrag — ausgerechnet dort, wo man ihn beim Telefonat mit dem
Versicherer braucht.
*Aufwand: klein.*
### Genau eine Zielspalte je Dokumentverknüpfung
`DokumentVerknuepfung` hat zwölf mögliche Zielspalten; dass genau eine
gesetzt ist, erzwingt bisher nur die Anwendung. Ein CHECK-Constraint macht
daraus eine Zusage, die auch bei direktem Datenbankzugriff hält.
*Aufwand: klein. Migration: ein Constraint.*
### Papierkorb für Saldo- und Wertstände
Beide Modelle haben kein `geloeschtAm`. Ein versehentlich eingetragener
Kontostand lässt sich nur endgültig löschen — was der Zusage widerspricht,
dass Löschen 30 Tage rückgängig zu machen ist.
*Aufwand: klein.*
### Aufgaben aus Vorlagen entfernen können
Passt eine Aufgabe aus einer Vorgangs-Vorlage im Einzelfall nicht, bleibt
sie dauerhaft offen stehen und verfälscht jede Fortschrittsanzeige.
*Aufwand: klein.*
---
## 1.2 — Weniger tippen
**Ziel:** Die Handgriffe, die sich jeden Monat wiederholen, kürzer machen.
### Nachforderungen mit stabilem Schlüssel
Der Dublettenschutz beim Übernehmen der Standardcheckliste läuft über die
Bezeichnung. Wer eine Zeile umbenennt, bekommt sie beim nächsten Mal
doppelt.
### Bescheid und Frist verbinden
Aus einem Bescheid entsteht die Widerspruchsfrist heute über einen
Textschlüssel statt über einen Fremdschlüssel. Ändert sich das
Zugangsdatum, wandert die Frist nicht mit.
### Statuswechsel eines Vorgangs als eigene Tabelle
Der Zeitstrahl liest Statuswechsel aus dem entschlüsselten Audit-Log. Das
funktioniert, ist aber langsam und hängt an einem Format, das eigentlich
nur Nachweis sein soll.
### Wer hat was erfasst
Fristen, Saldo- und Wertstände tragen kein Feld für den erfassenden
Benutzer. Bei einer Betreuung zu zweit ist das die erste Frage.
### Ansprechpartner am Vorgang
Die Sachstandsanfrage kann namentlich anreden, bekommt den Ansprechpartner
mangels Feld aber nicht übergeben.
### Inventarliste als eigenes Modell
Wertgegenstände hängen heute an Freitext und Fotos. Für die
Wohnungsauflösung wäre eine Liste mit Bezeichnung, Raum, geschätztem Wert
und Verbleib das richtige Werkzeug.
### Pflegegrad-Historie
Der Pflegegrad steht als einzelnes Feld in den Stammdaten. Frühere Grade
mit ihren Zeiträumen gehören in eine Historie — sie werden im
Jahresbericht und bei einer Höherstufung gebraucht.
---
## 1.3 — Belastbarkeit
**Ziel:** Die Stellen absichern, die bisher nur unter Laborbedingungen
geprüft sind.
### Texterkennung gegen echtes Tesseract
Bisher ist nur der Weg „nicht verfügbar, Upload funktioniert trotzdem"
getestet. Ein Test im Container gegen echte Schreiben — auch schlechte
Handyfotos, auch mehrseitige Bescheide — fehlt.
### CAMT.053 gegen einen echten Bankauszug
Der Leser ist gegen selbst gebaute Beispiele geprüft. Ein echter Auszug
der Sparkasse ist der eigentliche Test.
### Suche und Belegmappe gegen Postgres
Beide sind nur in Einzelteilen geprüft, nicht end-to-end gegen die
Datenbank mit einigen hundert Dokumenten.
### Wiederherstellung als Test
Die Anleitung dafür steht in der README. Ein Skript, das sie einmal im
Monat automatisch durchspielt — Sicherung zurückspielen, Diagnosen im
Klartext prüfen, Testdatenbank verwerfen — wäre der Beweis statt der
Absichtserklärung.
### Barrierefreiheits-Durchgang mit einem Screenreader
Semantik, Fokusreihenfolge und Kontraste sind beim Bauen beachtet und in
Teilen getestet. Ein Durchgang mit NVDA oder VoiceOver ist etwas anderes
als eine Testzusicherung.
### `@react-pdf/renderer` ohne Krücke
`src/lib/pdf/standardschrift.ts` gleicht aus, dass die Unterabhängigkeiten
für Standardschriften kein Schriftobjekt mehr liefern. Ein `overrides`-
Eintrag in der `package.json` oder eine neuere Fassung macht die Datei
überflüssig — sie baut sich dann selbst ab.
---
## 2.0 — Mehr als eine Betreuung
**Ziel:** Was sich nur mit einem Bruch lösen lässt. Deshalb eine eigene
Hauptversion — und deshalb nicht vorher.
### Zweite betreute Person im Alltag erprobt
Das Datenmodell trägt es von Anfang an: Alles hängt an einer `betreuteId`,
der Zugriff wird gegen sie geprüft, die Auswahl steht in der Seitenleiste.
**Gelaufen ist es bisher nur mit einer.** Was fehlt, ist Erfahrung — und
vermutlich ein Dashboard, das über beide hinwegsieht.
### Heimkostenmodell entwirren
Der einrichtungseinheitliche Eigenanteil auf der Heimrechnung ist bereits
um die Leistung der Pflegekasse gekürzt. Das Modul rechnet deshalb
ausdrücklich nach der Lesart `EIGENANTEIL_NETTO` und warnt, wenn die
Pflegekassenleistung zusätzlich erfasst wird.
Sauberer wäre, die beiden Lesarten im Modell zu trennen, statt sie im Kopf
auseinanderzuhalten. Das ändert bestehende Datensätze — also 2.0.
### Mehrere Fassungen je Bericht
Heute verdrängt die DOCX-Ausgabe die abgegebene PDF-Fassung. Was einmal
beim Gericht lag, sollte unverändert nachweisbar bleiben.
### Verschlüsselte Ablage auch bei S3
Beim Dateitreiber liegen Dokumente zusätzlich AES-256-GCM-verschlüsselt.
Bei S3 geht das nicht, weil signierte URLs am Server vorbei ausgeliefert
werden. Der Ausweg wäre, Dateien durch die Anwendung zu reichen — das
kostet Durchsatz und ist eine bewusste Abwägung, keine Selbstverständlichkeit.
---
## Später, ohne Datum
Sinnvoll, aber nicht dringend:
- **Kalendarische Wiedervorlage per E-Mail als Wochenübersicht** statt
einzelner Erinnerungen.
- **Vorlagen für eigene Anschreiben**, über die mitgelieferten hinaus.
- **Import eines Grundbuchauszugs** als strukturierte Daten statt als PDF.
- **Mehrere Betreuungsgerichte** je betreuter Person (bei Umzug).
- **Zwei-Personen-Prinzip** für besonders sensible Aktionen — technisch
vorbereitet durch die erneute Anmeldung, fachlich noch nicht gebraucht.
- **Statistik über mehrere Betreuungsjahre** für den eigenen Überblick.
---
## Bewusst nicht geplant
Damit klar ist, worauf niemand warten muss:
| Was | Warum nicht |
|---|---|
| **Automatische Rechtsberatung** | Die Anwendung kennt den Einzelfall nicht. Sie rechnet und dokumentiert; entscheiden tun Gericht, Träger und im Zweifel das Sozialgericht. |
| **Prognosen zu Bewilligungschancen** | Eine Zahl, die niemand belegen kann, wird trotzdem geglaubt. Das ist schlimmer als keine Zahl. |
| **Direkte Bankanbindung** | Ein dauerhafter Kontozugriff ist ein Risiko, das der Bequemlichkeit nicht wert ist. CSV und CAMT reichen. |
| **Mehrmandantenfähigkeit für fremde Betreuungen** | Sobald mehrere Betreuer mit fremden Fällen auf derselben Installation arbeiten, ändert sich die datenschutzrechtliche Lage grundlegend. Mehrere Betreute **einer** Person bleiben möglich. |
| **Native App für Handy und Tablet** | Die Web-Anwendung ist am Telefon vollwertig bedienbar. Eine zweite Oberfläche wäre eine zweite Oberfläche, die veraltet. |
| **Cloud-Angebot für andere Betreuer** | Dann wäre ich Auftragsverarbeiter für fremde Gesundheitsdaten. Eine andere Aufgabe als die, für die das hier gebaut wurde. |
| **KI-Auswertung der Akte** | Gesundheitsdaten einer nicht entscheidungsfähigen Person verlassen diesen Server nicht. Die Texterkennung läuft lokal, und dabei bleibt es. |
---
## Wie hier priorisiert wird
Drei Fragen, in dieser Reihenfolge:
1. **Kann daraus ein falscher Wert in einem Bericht ans Gericht werden?**
Dann sofort — auch wenn es unbequem ist.
2. **Kostet es jeden Monat Zeit?** Dann bald.
3. **Wäre es schön?** Dann irgendwann, und ehrlich gesagt vielleicht nie.
Fällt ein Punkt in Kategorie 1 und wird trotzdem nicht sofort behoben,
steht der Grund dabei — und die Oberfläche sagt an der betroffenen Stelle,
worauf man achten muss. Ein stillschweigend falscher Wert ist das
Schlimmste, was diese Anwendung produzieren kann.
+10
View File
@@ -6,6 +6,16 @@ const nextConfig = {
poweredByHeader: false, poweredByHeader: false,
// Argon2 ist eine native Bibliothek und darf nicht gebundelt werden. // Argon2 ist eine native Bibliothek und darf nicht gebundelt werden.
serverExternalPackages: ['@node-rs/argon2'], serverExternalPackages: ['@node-rs/argon2'],
// Zwei Seiten lesen zur Laufzeit eine Markdown-Datei aus docs/:
// die Datenschutzhinweise und die Bedienungsanleitung. Der Pfad wird
// erst zur Laufzeit gebaut, die Dateiverfolgung von Next.js kann ihn
// also nicht erkennen - ohne diesen Eintrag fehlten beide Dateien im
// Standalone-Build, und im Behaelter waeren die Seiten leer.
outputFileTracingIncludes: {
'/datenschutz': ['./docs/datenschutzhinweise.md'],
'/anleitung': ['./docs/ANLEITUNG.md'],
},
eslint: { eslint: {
ignoreDuringBuilds: false, ignoreDuringBuilds: false,
}, },
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "betreuungs-cockpit", "name": "betreuungs-cockpit",
"version": "1.0.0", "version": "1.0.4",
"private": true, "private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": { "engines": {
+36
View File
@@ -25,6 +25,42 @@ BEGIN
RETURN OLD; RETURN OLD;
END IF; END IF;
/*
* Eine einzige Ausnahme beim UPDATE: das Nullen der beiden
* Fremdschluessel.
*
* Wird eine betreute Person oder ein Benutzer endgueltig geloescht
* (Papierkorb nach 30 Tagen), setzt Postgres wegen ON DELETE SET NULL
* die Spalten betreuteId bzw. userId auf NULL. Ohne diese Ausnahme
* scheitert das Loeschen - und der Papierkorb liesse sich nie leeren.
*
* Der Nachweis bleibt dabei vollstaendig: Zeitpunkt, Aktion, Entitaet,
* Bezeichnung und die verschluesselte Aenderung sind unberuehrt, ebenso
* die Momentaufnahmen userEmail und userName. Es verschwindet nur der
* Verweis auf einen Datensatz, den es nicht mehr gibt.
*
* Jede andere Aenderung wird abgewiesen - auch das Zurueckschreiben
* eines Fremdschluessels auf einen anderen Wert.
*/
IF TG_OP = 'UPDATE'
AND NEW.id = OLD.id
AND NEW.zeitpunkt = OLD.zeitpunkt
AND NEW.aktion = OLD.aktion
AND NEW.entitaet = OLD.entitaet
AND NEW."entitaetId" IS NOT DISTINCT FROM OLD."entitaetId"
AND NEW.bezeichnung IS NOT DISTINCT FROM OLD.bezeichnung
AND NEW."userEmail" IS NOT DISTINCT FROM OLD."userEmail"
AND NEW."userName" IS NOT DISTINCT FROM OLD."userName"
AND NEW."aenderungVerschluesselt" IS NOT DISTINCT FROM OLD."aenderungVerschluesselt"
AND NEW."ipHash" IS NOT DISTINCT FROM OLD."ipHash"
AND NEW."userAgent" IS NOT DISTINCT FROM OLD."userAgent"
-- Die beiden Fremdschluessel duerfen sich aendern, aber NUR nach NULL.
AND (NEW."betreuteId" IS NULL OR NEW."betreuteId" IS NOT DISTINCT FROM OLD."betreuteId")
AND (NEW."userId" IS NULL OR NEW."userId" IS NOT DISTINCT FROM OLD."userId")
THEN
RETURN NEW;
END IF;
RAISE EXCEPTION RAISE EXCEPTION
'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.', 'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.',
TG_OP TG_OP
+165
View File
@@ -0,0 +1,165 @@
#!/usr/bin/env node
/**
* Warum klappt die Anmeldung nicht?
*
* node --env-file=.env scripts/anmeldung-pruefen.mjs <e-mail>
* docker compose exec app node scripts/anmeldung-pruefen.mjs <e-mail>
*
* Die Anwendung sagt am Anmeldeformular bewusst nicht, woran es lag - sonst
* liesse sich daran ablesen, welche Adressen es gibt. Auf dem Server darf
* man es wissen: Jeder Versuch wird mit Grund protokolliert, und dieses
* Skript liest ihn vor.
*
* Es aendert nichts. Es liest nur.
*/
import { PrismaClient } from '@prisma/client'
const email = (process.argv[2] ?? '').trim().toLowerCase()
if (!email) {
console.error('Aufruf: node scripts/anmeldung-pruefen.mjs <e-mail>')
process.exit(1)
}
const db = new PrismaClient()
function zeit(d) {
return d ? new Date(d).toLocaleString('de-DE') : '—'
}
/** Klartext zu den Gruenden, die src/auth.ts protokolliert. */
const GRUND = {
unbekannt: 'Diese E-Mail-Adresse gibt es nicht (Tippfehler? andere Adresse?)',
'passwort-falsch': 'Das Passwort stimmt nicht',
'code-falsch': 'Der Code aus der Authenticator-App stimmt nicht',
gesperrt: 'Abgewiesen, weil zu diesem Zeitpunkt bereits gesperrt war',
'konto-inaktiv': 'Der Zugang ist deaktiviert oder im Papierkorb',
}
try {
const benutzer = await db.user.findUnique({ where: { email } })
console.log('\n══ Zugang ' + '═'.repeat(50))
if (!benutzer) {
console.log(` Es gibt KEINEN Zugang mit der Adresse ${email}.`)
const alle = await db.user.findMany({
select: { email: true, aktiv: true, geloeschtAm: true },
orderBy: { erstelltAm: 'asc' },
})
console.log(
alle.length
? '\n Vorhandene Adressen:\n' +
alle.map((u) => ` ${u.email}${u.geloeschtAm ? ' (im Papierkorb)' : u.aktiv ? '' : ' (deaktiviert)'}`).join('\n')
: '\n Es gibt ueberhaupt keinen Zugang. Leg ihn im Browser unter /ersteinrichtung an.',
)
} else {
const zweiterFaktor = benutzer.totpAktiviertAm
? `eingerichtet am ${zeit(benutzer.totpAktiviertAm)}`
: benutzer.totpSecretVerschluesselt
? 'vorbereitet, aber NICHT bestaetigt - beim Anmelden wird noch kein Code verlangt'
: 'noch nicht eingerichtet - beim Anmelden wird noch kein Code verlangt'
console.log(` Name: ${benutzer.name}`)
console.log(` Rolle: ${benutzer.rolle}`)
console.log(` Aktiv: ${benutzer.aktiv ? 'ja' : 'NEIN - Anmeldung nicht moeglich'}`)
console.log(` Papierkorb: ${benutzer.geloeschtAm ? 'JA seit ' + zeit(benutzer.geloeschtAm) : 'nein'}`)
console.log(` Zweiter Faktor: ${zweiterFaktor}`)
console.log(` Wiederherstellung: ${benutzer.wiederherstellungscodes.length} Codes uebrig`)
console.log(` Passwort gesetzt: ${zeit(benutzer.passwortGeaendertAm)}`)
console.log(` Letzte Anmeldung: ${zeit(benutzer.letzteAnmeldung)}`)
const zugaenge = await db.betreuungsZugang.count({ where: { userId: benutzer.id } })
const betreute = await db.betreute.count({ where: { geloeschtAm: null } })
console.log(` Betreute Personen: ${zugaenge} zugeordnet, ${betreute} insgesamt angelegt`)
if (zugaenge === 0 && betreute === 0) {
console.log(
'\n Hinweis: Nach dem Anmelden landest du zuerst bei der\n' +
' Ersteinrichtung - erst zweiter Faktor, dann betreute Person.',
)
}
}
// --- Versuche der letzten Tage -------------------------------------------
const versuche = await db.loginVersuch.findMany({
where: { email },
orderBy: { zeitpunkt: 'desc' },
take: 15,
})
console.log('\n══ Letzte Anmeldeversuche ' + '═'.repeat(35))
if (versuche.length === 0) {
console.log(
' Keine. Es ist also NIE ein Versuch beim Server angekommen.\n\n' +
' Das heisst: Das Formular hat gar nicht abgeschickt, oder es lief\n' +
' auf eine andere Adresse. Pruef AUTH_URL - siehe unten.',
)
} else {
for (const v of versuche) {
const kennzeichen = v.erfolg ? 'OK ' : 'FEHLER'
const grund = v.erfolg ? 'angemeldet' : (GRUND[v.grund] ?? v.grund ?? 'unbekannter Grund')
console.log(` ${zeit(v.zeitpunkt).padEnd(20)} ${kennzeichen} ${grund}`)
}
}
// --- Sperre ---------------------------------------------------------------
const fenster = Number.parseInt(process.env.LOGIN_FENSTER_SEKUNDEN ?? '900', 10)
const grenze = Number.parseInt(process.env.LOGIN_MAX_VERSUCHE ?? '5', 10)
const seit = new Date(Date.now() - fenster * 1000)
const offen = await db.loginVersuch.count({
where: { email, erfolg: false, zeitpunkt: { gte: seit } },
})
console.log('\n══ Sperre ' + '═'.repeat(51))
console.log(` ${offen} von ${grenze} Fehlversuchen im laufenden Fenster (${fenster / 60} Minuten)`)
if (offen >= grenze) {
console.log(
'\n GESPERRT. Warte das Fenster ab, oder setz die Zaehler zurueck:\n' +
' docker compose exec db psql -U cockpit betreuungs_cockpit \\\n' +
' -c "DELETE FROM login_versuch WHERE erfolg = false;"',
)
}
// --- Umgebung -------------------------------------------------------------
console.log('\n══ Umgebung ' + '═'.repeat(49))
console.log(` AUTH_URL: ${process.env.AUTH_URL ?? 'NICHT GESETZT'}`)
console.log(
'\n Diese Adresse muss EXAKT die sein, die du im Browser aufrufst -\n' +
' gleiches Schema (https), gleicher Hostname, kein Schraegstrich am\n' +
' Ende. Weicht sie ab, wird das Sitzungs-Cookie fuer die falsche\n' +
' Herkunft gesetzt: Die Anmeldung landet ohne Fehlermeldung wieder\n' +
' auf dem Formular, und oben steht kein einziger Fehlversuch.\n',
)
} catch (fehler) {
// Ein Prisma-Stacktrace hilft hier niemandem weiter.
const meldung = fehler instanceof Error ? fehler.message : String(fehler)
if (/Can't reach database server|ECONNREFUSED/i.test(meldung)) {
// Das Passwort in der Verbindungszeichenkette gehoert nicht auf den Schirm.
const url = (process.env.DATABASE_URL ?? 'NICHT GESETZT').replace(/:[^:@/]+@/, ':***@')
console.error(
[
'',
'Die Datenbank ist nicht erreichbar.',
'',
` DATABASE_URL: ${url}`,
'',
' Im Docker-Betrieb gehoert dieses Skript IN den Behaelter:',
' docker compose exec app node scripts/anmeldung-pruefen.mjs <e-mail>',
'',
' Laeuft die Datenbank ueberhaupt?',
' docker compose ps',
'',
].join('\n'),
)
} else {
console.error(`\nFehler: ${meldung}\n`)
}
process.exitCode = 1
} finally {
await db.$disconnect()
}
+122
View File
@@ -0,0 +1,122 @@
#!/usr/bin/env node
/**
* Das TOTP-Geheimnis eines Zugangs auslesen.
*
* node --env-file=.env scripts/totp-geheimnis.mjs <e-mail>
*
* Wofuer das gut ist: Die Anwendung zeigt das Geheimnis genau einmal an,
* beim Einrichten. Wer den Zugang auf ein zweites Geraet legen will -
* Handy und Tablet, oder ein Ersatzgeraet im Schrank - braucht es noch
* einmal. Ohne dieses Skript bliebe nur, den zweiten Faktor zurueckzusetzen
* und alle Wiederherstellungscodes neu zu erzeugen.
*
* Was es NICHT ist: eine Hintertuer. Es laeuft nur auf dem Server, nur mit
* Zugriff auf die Datenbank UND auf ENC_KEY_V1 aus der Umgebung. Wer beides
* hat, kann ohnehin alles lesen. Genau deshalb liegt der Schluessel nicht
* in der Datenbank.
*
* Im Docker-Betrieb:
*
* docker compose exec app node scripts/totp-geheimnis.mjs fw@example.de
*
* Die Ausgabe gehoert nicht in ein Protokoll und nicht in eine E-Mail.
*/
import { PrismaClient } from '@prisma/client'
import { createDecipheriv } from 'node:crypto'
const email = (process.argv[2] ?? '').trim().toLowerCase()
if (!email) {
console.error('Aufruf: node --env-file=.env scripts/totp-geheimnis.mjs <e-mail>')
process.exit(1)
}
/**
* Entschluesselt einen Umschlag aus src/lib/crypto/feld.ts.
* Format: bc1.<version>.<iv>.<tag>.<geheimtext>, alles base64url.
*/
function entschluesseln(umschlag, kontext) {
const teile = umschlag.split('.')
if (teile.length !== 5 || teile[0] !== 'bc1') {
throw new Error('Der gespeicherte Wert hat nicht das erwartete Format.')
}
const version = Number.parseInt(teile[1], 10)
const roh = process.env[`ENC_KEY_V${version}`]
if (!roh) {
throw new Error(
`ENC_KEY_V${version} ist nicht gesetzt. Ohne diesen Schluessel laesst ` +
`sich der Wert nicht lesen - auch nicht aus einem Backup.`,
)
}
const decipher = createDecipheriv(
'aes-256-gcm',
Buffer.from(roh, 'base64'),
Buffer.from(teile[2], 'base64url'),
{ authTagLength: 16 },
)
// Der Feldname wird mitauthentifiziert - er muss exakt stimmen.
decipher.setAAD(Buffer.from(`bc1:${version}:${kontext}`, 'utf8'))
decipher.setAuthTag(Buffer.from(teile[3], 'base64url'))
return Buffer.concat([
decipher.update(Buffer.from(teile[4], 'base64url')),
decipher.final(),
]).toString('utf8')
}
/** Base32 in Vierergruppen - so tippt es sich von Hand deutlich sicherer. */
function gruppiert(text) {
return text.replace(/(.{4})/g, '$1 ').trim()
}
const db = new PrismaClient()
try {
const benutzer = await db.user.findUnique({
where: { email },
select: {
email: true,
name: true,
totpSecretVerschluesselt: true,
totpAktiviertAm: true,
wiederherstellungscodes: true,
},
})
if (!benutzer) {
console.error(`Kein Zugang mit der Adresse ${email}.`)
process.exit(1)
}
if (!benutzer.totpSecretVerschluesselt) {
console.error(
`Fuer ${email} ist noch kein zweiter Faktor eingerichtet. ` +
`Das passiert beim ersten Anmelden.`,
)
process.exit(1)
}
const secret = entschluesseln(benutzer.totpSecretVerschluesselt, 'totpSecretVerschluesselt')
const uri =
`otpauth://totp/Betreuungs-Cockpit:${encodeURIComponent(benutzer.email)}` +
`?secret=${secret}&issuer=Betreuungs-Cockpit&algorithm=SHA1&digits=6&period=30`
console.log(`
Zugang: ${benutzer.name} <${benutzer.email}>
Aktiv seit: ${benutzer.totpAktiviertAm?.toLocaleString('de-DE') ?? 'noch nicht bestaetigt'}
Codes uebrig: ${benutzer.wiederherstellungscodes.length}
Geheimnis zum Abtippen:
${gruppiert(secret)}
Zum Einscannen (in einen QR-Code-Erzeuger auf DIESEM Rechner geben,
niemals in einen Dienst im Netz):
${uri}
Diese Ausgabe gehoert nicht in ein Protokoll und nicht in eine E-Mail.
`)
} finally {
await db.$disconnect()
}
+55
View File
@@ -0,0 +1,55 @@
import type { Metadata } from 'next'
import { readFile } from 'node:fs/promises'
import path from 'node:path'
import { BookOpen } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { SeitenKopf } from '@/components/seiten-kopf'
import { MarkdownSeite } from '@/components/markdown-seite'
import { DruckKnopf } from '@/components/druck-knopf'
import { erfordereBenutzer } from '@/lib/auth/sitzung'
export const metadata: Metadata = { title: 'Anleitung' }
export const dynamic = 'force-dynamic'
/**
* Die Bedienungsanleitung, gelesen aus docs/ANLEITUNG.md.
*
* Dieselbe Datei, die auch im Repository liegt und sich ausdrucken laesst -
* zwei Fassungen desselben Textes waeren zwei Fassungen, die auseinander-
* laufen. Wer im Heim steht und nicht weiterweiss, hat sie damit auf dem
* Telefon dabei.
*/
export default async function AnleitungSeite() {
await erfordereBenutzer()
const pfad = path.join(process.cwd(), 'docs', 'ANLEITUNG.md')
let inhalt: string
try {
inhalt = await readFile(pfad, 'utf8')
} catch {
inhalt = ''
}
return (
<div className="max-w-3xl space-y-6">
<SeitenKopf
titel="Bedienungsanleitung"
beschreibung="Die Handgriffe, die du tatsächlich brauchst — in der Reihenfolge, in der sie im Alltag vorkommen."
aktionen={<DruckKnopf beschriftung="Anleitung drucken" />}
/>
{inhalt === '' ? (
<Alert variant="warnung">
<BookOpen aria-hidden="true" />
<AlertDescription>
Die Anleitung konnte nicht geladen werden. Sie liegt im Projekt unter{' '}
<code>docs/ANLEITUNG.md</code>.
</AlertDescription>
</Alert>
) : (
<MarkdownSeite inhalt={inhalt} />
)}
</div>
)
}
+15 -80
View File
@@ -2,6 +2,7 @@ import type { Metadata } from 'next'
import Link from 'next/link' import Link from 'next/link'
import { readFile } from 'node:fs/promises' import { readFile } from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { MarkdownSeite } from '@/components/markdown-seite'
export const metadata: Metadata = { title: 'Datenschutz' } export const metadata: Metadata = { title: 'Datenschutz' }
@@ -11,83 +12,7 @@ export const metadata: Metadata = { title: 'Datenschutz' }
* Der Text kommt aus docs/datenschutzhinweise.md - dieselbe Datei, die man * Der Text kommt aus docs/datenschutzhinweise.md - dieselbe Datei, die man
* auch ausdrucken und der betreuten Person aushaendigen kann. Zwei Quellen * auch ausdrucken und der betreuten Person aushaendigen kann. Zwei Quellen
* fuer denselben Text waeren zwei Quellen fuer Fehler. * fuer denselben Text waeren zwei Quellen fuer Fehler.
*
* Die Umsetzung ist bewusst schlicht: keine Markdown-Bibliothek, nur die
* wenigen Auszeichnungen, die im Dokument wirklich vorkommen.
*/ */
function alsAbsaetze(markdown: string) {
const zeilen = markdown.split(/\r?\n/)
const bloecke: React.ReactNode[] = []
let absatz: string[] = []
const absatzSchliessen = (schluessel: number) => {
if (absatz.length === 0) return
bloecke.push(
<p key={`p-${schluessel}`} className="leading-relaxed">
{absatz.join(' ')}
</p>,
)
absatz = []
}
zeilen.forEach((zeile, i) => {
const roh = zeile.trim()
// Der Hinweisblock fuer den Ausfuellenden gehoert nicht auf die Seite.
if (roh.startsWith('>')) return
if (roh === '' || roh === '---') {
absatzSchliessen(i)
return
}
if (roh.startsWith('## ')) {
absatzSchliessen(i)
bloecke.push(
<h2 key={i} className="mt-8 text-xl font-semibold">
{roh.slice(3)}
</h2>,
)
return
}
if (roh.startsWith('# ')) {
absatzSchliessen(i)
bloecke.push(
<h1 key={i} className="text-2xl font-semibold">
{roh.slice(2)}
</h1>,
)
return
}
if (roh.startsWith('- ')) {
absatzSchliessen(i)
bloecke.push(
<li key={i} className="ml-5 list-disc leading-relaxed">
{roh.slice(2)}
</li>,
)
return
}
// Tabellenzeilen werden als einfache Aufzaehlung dargestellt.
if (roh.startsWith('|')) {
absatzSchliessen(i)
const spalten = roh.split('|').map((s) => s.trim()).filter(Boolean)
if (spalten.every((s) => /^-+$/.test(s))) return
bloecke.push(
<div key={i} className="flex flex-col gap-0.5 border-l-2 py-1 pl-3 sm:flex-row sm:gap-3">
<span className="min-w-56 font-medium">{spalten[0]}</span>
<span className="text-muted-foreground">{spalten.slice(1).join(' · ')}</span>
</div>,
)
return
}
absatz.push(roh)
})
absatzSchliessen(zeilen.length)
return bloecke
}
export default async function DatenschutzSeite() { export default async function DatenschutzSeite() {
const pfad = path.join(process.cwd(), 'docs', 'datenschutzhinweise.md') const pfad = path.join(process.cwd(), 'docs', 'datenschutzhinweise.md')
let inhalt: string let inhalt: string
@@ -95,14 +20,24 @@ export default async function DatenschutzSeite() {
try { try {
inhalt = await readFile(pfad, 'utf8') inhalt = await readFile(pfad, 'utf8')
} catch { } catch {
inhalt = inhalt = ''
'# Datenschutzhinweise\n\nDer Text konnte nicht geladen werden. ' +
'Er liegt in docs/datenschutzhinweise.md.'
} }
return ( return (
<div className="mx-auto max-w-3xl px-4 py-10 sm:px-6"> <div className="mx-auto max-w-3xl px-4 py-10 sm:px-6">
<article className="space-y-3 [&_strong]:font-semibold">{alsAbsaetze(inhalt)}</article> <h1 className="text-2xl font-semibold">Datenschutzhinweise</h1>
<p className="mt-1 text-sm text-muted-foreground">nach Art. 13 und 14 DSGVO</p>
<div className="mt-8">
{inhalt === '' ? (
<p className="text-muted-foreground">
Der Text konnte nicht geladen werden. Er liegt im Projekt unter{' '}
<code className="rounded bg-muted px-1.5 py-0.5">docs/datenschutzhinweise.md</code>.
</p>
) : (
<MarkdownSeite inhalt={inhalt} />
)}
</div>
<p className="mt-10 border-t pt-6 text-sm text-muted-foreground"> <p className="mt-10 border-t pt-6 text-sm text-muted-foreground">
<Link href="/anmelden" className="underline hover:no-underline"> <Link href="/anmelden" className="underline hover:no-underline">
+16 -1
View File
@@ -3,7 +3,16 @@
import * as React from 'react' import * as React from 'react'
import Link from 'next/link' import Link from 'next/link'
import { signOut } from 'next-auth/react' import { signOut } from 'next-auth/react'
import { LogOut, Monitor, Moon, Settings, ShieldCheck, Sun, User as UserIcon } from 'lucide-react' import {
BookOpen,
LogOut,
Monitor,
Moon,
Settings,
ShieldCheck,
Sun,
User as UserIcon,
} from 'lucide-react'
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@@ -96,6 +105,12 @@ export function BenutzerMenue({
Einstellungen Einstellungen
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href="/anleitung">
<BookOpen className="size-4" />
Anleitung
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuLabel className="text-xs font-normal text-muted-foreground"> <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">
+20
View File
@@ -0,0 +1,20 @@
'use client'
import { Printer } from 'lucide-react'
import { Button } from '@/components/ui/button'
/**
* Druckt die aktuelle Seite.
*
* Muss eine Client-Komponente sein - window.print() gibt es auf dem
* Server nicht. Der Knopf selbst traegt "no-print", damit er auf dem
* Ausdruck nicht auftaucht.
*/
export function DruckKnopf({ beschriftung = 'Drucken' }: { beschriftung?: string }) {
return (
<Button variant="outline" className="no-print" onClick={() => window.print()}>
<Printer aria-hidden="true" />
{beschriftung}
</Button>
)
}
+324
View File
@@ -0,0 +1,324 @@
import * as React from 'react'
/**
* Anzeige einer Markdown-Datei aus dem docs-Verzeichnis.
*
* Bewusst ohne Markdown-Bibliothek: Die Anwendung zeigt genau zwei solche
* Dokumente an - die Datenschutzhinweise und die Anleitung. Beide schreibe
* ich selbst, ich weiss also, welche Auszeichnungen darin vorkommen. Eine
* Bibliothek waere hier vor allem zusaetzliche Abhaengigkeit und
* zusaetzliche Angriffsflaeche.
*
* Unterstuetzt: Ueberschriften, Absaetze, Aufzaehlungen (auch nummeriert),
* Tabellen, Zitatbloecke, Codebloecke, Trennlinien sowie **fett**,
* `Code` und [Links](ziel) im Fliesstext.
*
* Nicht unterstuetzt und auch nicht noetig: eingebettetes HTML, Bilder,
* Fussnoten. Rohes HTML wird NICHT durchgereicht - der Text wird immer als
* Text ausgegeben, nie als Markup.
*/
type Block =
| { art: 'ueberschrift'; ebene: 2 | 3 | 4; text: string }
| { art: 'absatz'; text: string }
| { art: 'liste'; nummeriert: boolean; punkte: string[] }
| { art: 'tabelle'; kopf: string[]; zeilen: string[][] }
| { art: 'zitat'; zeilen: string[] }
| { art: 'code'; zeilen: string[] }
| { art: 'trenner' }
/** Zerlegt den Fliesstext in fett, Code und Links. */
function inline(text: string, schluessel: string): React.ReactNode[] {
const teile: React.ReactNode[] = []
// Reihenfolge zaehlt: Code zuerst, damit Sternchen darin nicht greifen.
const muster = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\[[^\]]+\]\([^)]+\))/g
let letzte = 0
let treffer: RegExpExecArray | null
let nummer = 0
while ((treffer = muster.exec(text)) !== null) {
if (treffer.index > letzte) {
teile.push(text.slice(letzte, treffer.index))
}
const stueck = treffer[0]
const id = `${schluessel}-${nummer++}`
if (stueck.startsWith('`')) {
teile.push(
<code key={id} className="rounded bg-muted px-1.5 py-0.5 font-mono text-[0.9em]">
{stueck.slice(1, -1)}
</code>,
)
} else if (stueck.startsWith('**')) {
teile.push(
<strong key={id} className="font-semibold">
{stueck.slice(2, -2)}
</strong>,
)
} else {
const beschriftung = stueck.slice(1, stueck.indexOf(']'))
const ziel = stueck.slice(stueck.indexOf('](') + 2, -1)
const extern = /^https?:\/\//.test(ziel)
teile.push(
<a
key={id}
href={ziel}
className="text-primary underline underline-offset-2 hover:no-underline"
{...(extern ? { target: '_blank', rel: 'noreferrer noopener' } : {})}
>
{beschriftung}
</a>,
)
}
letzte = treffer.index + stueck.length
}
if (letzte < text.length) teile.push(text.slice(letzte))
return teile
}
function zerlegen(markdown: string, ohneTitel: boolean): Block[] {
const zeilen = markdown.split(/\r?\n/)
const bloecke: Block[] = []
let absatz: string[] = []
const absatzSchliessen = () => {
if (absatz.length === 0) return
bloecke.push({ art: 'absatz', text: absatz.join(' ') })
absatz = []
}
for (let i = 0; i < zeilen.length; i++) {
const roh = zeilen[i] ?? ''
const zeile = roh.trimEnd()
const gekuerzt = zeile.trim()
// --- Codeblock ---------------------------------------------------------
if (gekuerzt.startsWith('```')) {
absatzSchliessen()
const inhalt: string[] = []
i += 1
while (i < zeilen.length && !(zeilen[i] ?? '').trim().startsWith('```')) {
inhalt.push(zeilen[i] ?? '')
i += 1
}
bloecke.push({ art: 'code', zeilen: inhalt })
continue
}
if (gekuerzt === '') {
absatzSchliessen()
continue
}
if (/^-{3,}$/.test(gekuerzt) || /^\*{3,}$/.test(gekuerzt)) {
absatzSchliessen()
bloecke.push({ art: 'trenner' })
continue
}
// --- Ueberschriften ----------------------------------------------------
const ueberschrift = gekuerzt.match(/^(#{1,4})\s+(.*)$/)
if (ueberschrift) {
absatzSchliessen()
const ebene = ueberschrift[1]!.length
// Die H1 ist der Dokumenttitel - den setzt die Seite selbst.
if (ebene === 1 && ohneTitel) continue
bloecke.push({
art: 'ueberschrift',
ebene: (Math.min(Math.max(ebene, 2), 4) as 2 | 3 | 4),
text: ueberschrift[2]!,
})
continue
}
// --- Zitatblock --------------------------------------------------------
if (gekuerzt.startsWith('>')) {
absatzSchliessen()
const inhalt: string[] = []
while (i < zeilen.length && (zeilen[i] ?? '').trim().startsWith('>')) {
inhalt.push((zeilen[i] ?? '').trim().replace(/^>\s?/, ''))
i += 1
}
i -= 1
bloecke.push({ art: 'zitat', zeilen: [inhalt.join(' ')] })
continue
}
// --- Tabelle -----------------------------------------------------------
if (gekuerzt.startsWith('|')) {
absatzSchliessen()
const spalten = (z: string) =>
z
.trim()
.replace(/^\|/, '')
.replace(/\|$/, '')
.split('|')
.map((s) => s.trim())
const kopf = spalten(gekuerzt)
i += 1 // Trennzeile ueberspringen
const zeilenInhalt: string[][] = []
while (i + 1 < zeilen.length && (zeilen[i + 1] ?? '').trim().startsWith('|')) {
i += 1
zeilenInhalt.push(spalten(zeilen[i] ?? ''))
}
bloecke.push({ art: 'tabelle', kopf, zeilen: zeilenInhalt })
continue
}
// --- Aufzaehlung -------------------------------------------------------
const punkt = gekuerzt.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
if (punkt) {
absatzSchliessen()
const nummeriert = /^\d+\./.test(gekuerzt)
const punkte: string[] = [punkt[1]!]
while (i + 1 < zeilen.length) {
const naechste = (zeilen[i + 1] ?? '').trim()
const weiterer = naechste.match(/^(?:[-*]|\d+\.)\s+(.*)$/)
if (weiterer && /^\d+\./.test(naechste) === nummeriert) {
punkte.push(weiterer[1]!)
i += 1
continue
}
// Eingerueckte Fortsetzung gehoert zum letzten Punkt.
if (naechste !== '' && /^\s{2,}\S/.test(zeilen[i + 1] ?? '')) {
punkte[punkte.length - 1] += ` ${naechste}`
i += 1
continue
}
break
}
bloecke.push({ art: 'liste', nummeriert, punkte })
continue
}
absatz.push(gekuerzt)
}
absatzSchliessen()
return bloecke
}
export function MarkdownSeite({
inhalt,
ohneTitel = true,
className,
}: {
inhalt: string
/** Die erste H1 weglassen - die Seite setzt ihre Ueberschrift selbst. */
ohneTitel?: boolean
className?: string
}) {
const bloecke = zerlegen(inhalt, ohneTitel)
return (
<article className={className}>
{bloecke.map((block, i) => {
switch (block.art) {
case 'ueberschrift': {
const Tag = `h${block.ebene}` as 'h2' | 'h3' | 'h4'
const groesse =
block.ebene === 2
? 'mt-10 text-xl font-semibold'
: block.ebene === 3
? 'mt-7 text-lg font-semibold'
: 'mt-5 text-base font-semibold'
return (
<Tag key={i} className={`${groesse} scroll-mt-20 first:mt-0`}>
{block.text}
</Tag>
)
}
case 'absatz':
return (
<p key={i} className="mt-3 leading-relaxed">
{inline(block.text, String(i))}
</p>
)
case 'liste': {
const Tag = block.nummeriert ? 'ol' : 'ul'
return (
<Tag
key={i}
className={`mt-3 space-y-1.5 pl-6 ${
block.nummeriert ? 'list-decimal' : 'list-disc'
}`}
>
{block.punkte.map((punkt, j) => (
<li key={j} className="leading-relaxed">
{inline(punkt, `${i}-${j}`)}
</li>
))}
</Tag>
)
}
case 'tabelle':
return (
// Tabellen scrollen im eigenen Rahmen - am Handy darf nie die
// ganze Seite seitlich wandern.
<div key={i} className="mt-4 w-full overflow-x-auto rounded-lg border">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
{block.kopf.map((spalte, j) => (
<th key={j} className="px-3 py-2 text-left font-semibold">
{inline(spalte, `${i}-k-${j}`)}
</th>
))}
</tr>
</thead>
<tbody>
{block.zeilen.map((zeile, j) => (
<tr key={j} className="border-b last:border-0">
{zeile.map((zelle, k) => (
<td key={k} className="px-3 py-2 align-top">
{inline(zelle, `${i}-${j}-${k}`)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
case 'zitat':
return (
<blockquote
key={i}
className="mt-4 rounded-r-md border-l-4 border-primary/40 bg-muted/40 py-3 pl-4 pr-3"
>
{block.zeilen.map((zeile, j) => (
<p key={j} className="leading-relaxed">
{inline(zeile, `${i}-${j}`)}
</p>
))}
</blockquote>
)
case 'code':
return (
<pre
key={i}
className="mt-4 overflow-x-auto rounded-lg border bg-muted/60 p-3 text-sm"
>
<code className="font-mono">{block.zeilen.join('\n')}</code>
</pre>
)
case 'trenner':
return <hr key={i} className="my-8" />
}
})}
</article>
)
}
+19 -5
View File
@@ -58,14 +58,25 @@ function einmal<T>(fn: () => T): () => T {
const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => { const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
const schluessel = new Map<number, Buffer>() const schluessel = new Map<number, Buffer>()
// ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln // ENC_KEY_V1, ENC_KEY_V2, ... - alte Versionen bleiben zum Entschluesseln
// stehen, verschluesselt wird immer mit der aktiven Version. // stehen, verschluesselt wird immer mit der aktiven Version.
for (const [name] of Object.entries(process.env)) { for (const [name, wert] of Object.entries(process.env)) {
const treffer = name.match(/^ENC_KEY_V(\d+)$/) const treffer = name.match(/^ENC_KEY_V(\d+)$/)
if (treffer) { if (!treffer) continue
schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name))
} // Eine LEERE Variable ist kein Schluessel.
//
// docker compose reicht ENC_KEY_V2 immer durch - mit leerem Wert, wenn
// in der .env keiner steht. Wuerde hier allein der Name zaehlen, waere
// jede Installation ohne zweiten Schluessel kaputt, und zwar mit der
// irrefuehrenden Meldung "ENC_KEY_V2 fehlt" - obwohl niemand einen
// zweiten Schluessel wollte.
if (!wert || wert.trim() === '') continue
schluessel.set(Number.parseInt(treffer[1]!, 10), schluessel32(name))
} }
if (schluessel.size === 0) { if (schluessel.size === 0) {
throw new Error( throw new Error(
'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' + 'Kein Verschluesselungsschluessel gefunden. Es muss mindestens ENC_KEY_V1 ' +
@@ -78,8 +89,11 @@ const holeVerschluesselungsschluessel = einmal((): Map<number, Buffer> => {
const holeAktiveVersion = einmal((): number => { const holeAktiveVersion = einmal((): number => {
const version = zahl('ENC_KEY_AKTIV', 1) const version = zahl('ENC_KEY_AKTIV', 1)
if (!holeVerschluesselungsschluessel().has(version)) { if (!holeVerschluesselungsschluessel().has(version)) {
const vorhanden = [...holeVerschluesselungsschluessel().keys()].sort().join(', ')
throw new Error( throw new Error(
`ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt.`, `ENC_KEY_AKTIV=${version}, aber ENC_KEY_V${version} ist nicht gesetzt oder leer. ` +
`Vorhanden sind die Versionen: ${vorhanden}. Beim Rotieren zuerst den ` +
`neuen Schluessel eintragen, danach ENC_KEY_AKTIV umstellen.`,
) )
} }
return version return version
+19 -4
View File
@@ -8,21 +8,36 @@ import { z } from 'zod'
* ist, nicht was falsch war. * ist, nicht was falsch war.
*/ */
/** Leere Zeichenketten aus einem Formular sollen zu null werden, nicht zu "". */ /**
* Optionales Textfeld.
*
* Drei Eingaben muessen zu null werden:
* "" das Feld war da, blieb aber leer
* null ausdruecklich geleert
* undefined das Feld war im Formular ueberhaupt nicht dabei
*
* Der letzte Fall ist der wichtige: Das kurze Formular der Ersteinrichtung
* schickt nur eine Handvoll Felder, das vollstaendige unter Stammdaten
* alle. Beide gehen durch dasselbe Schema. Ohne .optional() wuerde die
* Ersteinrichtung an zwei Dutzend Feldern scheitern, die gar nicht auf dem
* Bildschirm stehen - und die Meldung liesse sich keinem Feld zuordnen.
*/
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)) .optional()
.nullable() .nullable()
.transform((wert) => (wert === undefined || wert === '' ? null : wert))
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */ /** HTML-Datumsfelder liefern "" oder "2026-01-15" - oder gar nichts. */
const optionalesDatum = z const optionalesDatum = z
.string() .string()
.trim() .trim()
.transform((wert) => (wert === '' ? null : wert)) .optional()
.nullable() .nullable()
.transform((wert) => (wert === undefined || wert === '' ? null : wert))
.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.',
}) })
+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.0.0' export const VERSION = '1.0.4'
/** /**
* Kurzer Git-Stand, beim Bauen hineingereicht: * Kurzer Git-Stand, beim Bauen hineingereicht:
+45 -14
View File
@@ -53,6 +53,49 @@ function felderAusFehler(fehler: z.ZodError): Record<string, string> {
return ergebnis return ergebnis
} }
/**
* Baut die Antwort auf einen Pruefungsfehler.
*
* Der wichtige Teil ist die Unterscheidung: Beanstandet die Pruefung ein
* Feld, das gar nicht abgeschickt wurde, dann passt das Formular nicht zum
* Schema - und die Meldung liesse sich auf dem Bildschirm keinem Feld
* zuordnen. Der Nutzer saehe "Bitte pruefe die markierten Felder", ohne
* dass irgendetwas markiert waere, und kaeme nie weiter.
*
* Das ist ein Fehler im Programm, keiner in der Eingabe. Er wird deshalb
* benannt und ins Serverlog geschrieben, statt ihn dem Nutzer als
* Eingabefehler unterzuschieben.
*/
function antwortAufFehler(fehler: z.ZodError, formular: FormData): BetreuteZustand {
const felder = felderAusFehler(fehler)
const gesendet = new Set(formular.keys())
const nichtZuordenbar = Object.entries(felder).filter(([feld]) => !gesendet.has(feld))
if (nichtZuordenbar.length > 0) {
const liste = nichtZuordenbar.map(([feld, meldung]) => `${feld}: ${meldung}`).join('; ')
console.error(
`[stammdaten] Das Formular passt nicht zum Schema. Beanstandet wurden ` +
`Felder, die es gar nicht abgeschickt hat: ${liste}`,
)
return {
fehler:
'Das Formular passt nicht zur Prüfung — betroffen sind Felder, die ' +
'hier gar nicht stehen: ' +
nichtZuordenbar.map(([feld]) => feld).join(', ') +
'. Das ist ein Fehler in der Anwendung, nicht in deiner Eingabe. ' +
'Einzelheiten stehen im Serverlog.',
felder,
werte: werteMerken(formular),
}
}
return {
fehler: 'Bitte prüfe die markierten Felder.',
felder,
werte: werteMerken(formular),
}
}
function werteMerken(formular: FormData): Record<string, string> { function werteMerken(formular: FormData): Record<string, string> {
const werte: Record<string, string> = {} const werte: Record<string, string> = {}
for (const [name, wert] of formular.entries()) { for (const [name, wert] of formular.entries()) {
@@ -77,13 +120,7 @@ export async function betreuteAnlegen(
} }
const gelesen = BetreuteSchema.safeParse(formularLesen(formular)) const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
if (!gelesen.success) { if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
return {
fehler: 'Bitte prüfe die markierten Felder.',
felder: felderAusFehler(gelesen.error),
werte: werteMerken(formular),
}
}
const { offen, verschluesselt } = aufteilen(gelesen.data) const { offen, verschluesselt } = aufteilen(gelesen.data)
@@ -134,13 +171,7 @@ export async function betreuteAendern(
const { benutzer, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben') const { benutzer, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
const gelesen = BetreuteSchema.safeParse(formularLesen(formular)) const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
if (!gelesen.success) { if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
return {
fehler: 'Bitte prüfe die markierten Felder.',
felder: felderAusFehler(gelesen.error),
werte: werteMerken(formular),
}
}
const { offen, verschluesselt } = aufteilen(gelesen.data) const { offen, verschluesselt } = aufteilen(gelesen.data)
+137
View File
@@ -0,0 +1,137 @@
import { describe, expect, it } from 'vitest'
import { BetreuteSchema, formularLesen } from '@/lib/validierung/betreute'
/**
* Eingabepruefung der Stammdaten.
*
* Anlass für diese Datei war ein Fehler im Betrieb: Die Ersteinrichtung
* ließ sich nicht abschließen. Das Formular meldete „Bitte prüfe die
* markierten Felder", markierte aber keines — weil die Meldungen zu
* Feldern gehörten, die dort gar nicht stehen.
*
* Ursache: Das kurze Formular der Ersteinrichtung schickt neun Felder,
* das vollständige unter Stammdaten alle. Beide gehen durch dasselbe
* Schema, und die optionalen Felder waren zwar `nullable`, aber nicht
* `optional` — ein fehlendes Feld ist `undefined`, nicht `null`.
*/
/** Die Felder, die das kurze Formular der Ersteinrichtung tatsächlich schickt. */
function ersteinrichtungsFormular(): FormData {
const f = new FormData()
f.set('vorname', 'Franziska')
f.set('nachname', 'Wilde')
f.set('geburtsdatum', '1963-09-23')
f.set('betreuungsgericht', 'Lichtenfels')
f.set('aktenzeichenGericht', 'XVII 355/25')
f.set('bestellungAm', '2025-11-07')
f.append('aufgabenkreise', 'VERMOEGENSSORGE')
f.append('aufgabenkreise', 'GESUNDHEITSSORGE')
f.set('befreiterBetreuer', 'on')
f.set('ehrenamtlich', 'on')
return f
}
describe('Ersteinrichtung', () => {
it('nimmt das kurze Formular an, obwohl es die meisten Felder nicht schickt', () => {
const gelesen = BetreuteSchema.safeParse(formularLesen(ersteinrichtungsFormular()))
if (!gelesen.success) {
const felder = Object.keys(gelesen.error.flatten().fieldErrors).join(', ')
throw new Error(`Abgelehnt wegen: ${felder}`)
}
expect(gelesen.data.vorname).toBe('Franziska')
expect(gelesen.data.nachname).toBe('Wilde')
expect(gelesen.data.geburtsdatum.toISOString()).toBe('1963-09-23T00:00:00.000Z')
expect(gelesen.data.bestellungAm?.toISOString()).toBe('2025-11-07T00:00:00.000Z')
expect(gelesen.data.aufgabenkreise).toEqual(['VERMOEGENSSORGE', 'GESUNDHEITSSORGE'])
expect(gelesen.data.befreiterBetreuer).toBe(true)
expect(gelesen.data.ehrenamtlich).toBe(true)
})
it('macht aus nicht gesendeten Feldern null, nicht undefined', () => {
const gelesen = BetreuteSchema.parse(formularLesen(ersteinrichtungsFormular()))
// Diese Felder standen nie auf dem Bildschirm. Sie muessen als null in
// der Datenbank landen, sonst schreibt Prisma sie gar nicht.
expect(gelesen.heimName).toBeNull()
expect(gelesen.diagnosen).toBeNull()
expect(gelesen.geburtsname).toBeNull()
expect(gelesen.heimEinzugAm).toBeNull()
expect(gelesen.bestellungBis).toBeNull()
expect(gelesen.aktenzeichenBezirk).toBeNull()
expect(gelesen.pflegegrad).toBeNull()
expect(gelesen.schwerbehinderungGdB).toBeNull()
})
it('haelt auch das vollstaendige Formular aus', () => {
const f = ersteinrichtungsFormular()
// So kommt es aus dem Bearbeitungsformular: alle Felder da, viele leer.
for (const feld of [
'anrede', 'geburtsname', 'geburtsort',
'heimName', 'heimStrasse', 'heimPlz', 'heimOrt', 'heimZimmer', 'heimTelefon',
'fruehereStrasse', 'frueherePlz', 'fruehererOrt',
'krankenkasse', 'krankenversichertennummer', 'pflegekasse',
'schwerbehinderungMerkzeichen', 'diagnosen', 'ermittelterWille',
'aufgabenkreiseFreitext', 'aktenzeichenBezirk', 'aktenzeichenSozialamt',
'rentenversicherungsnummer', 'steuerId', 'notizen',
]) {
f.set(feld, '')
}
for (const feld of ['heimEinzugAm', 'pflegegradSeit', 'pflegegradBescheidVom', 'bestellungBis']) {
f.set(feld, '')
}
f.set('pflegegrad', '')
f.set('schwerbehinderungGdB', '')
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
expect(gelesen.success, 'Leere Felder duerfen nicht abgelehnt werden').toBe(true)
})
it('nimmt ausgefuellte Angaben unveraendert an', () => {
const f = ersteinrichtungsFormular()
f.set('heimName', ' Seniorenzentrum St. Katharina ')
f.set('pflegegrad', '3')
f.set('diagnosen', 'COPD GOLD III')
const gelesen = BetreuteSchema.parse(formularLesen(f))
// Fuehrende und nachlaufende Leerzeichen fliegen raus.
expect(gelesen.heimName).toBe('Seniorenzentrum St. Katharina')
expect(gelesen.pflegegrad).toBe(3)
expect(gelesen.diagnosen).toBe('COPD GOLD III')
})
it('besteht weiterhin auf Vorname, Nachname und Geburtsdatum', () => {
const f = new FormData()
f.set('vorname', '')
f.set('nachname', '')
f.set('geburtsdatum', '')
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
expect(gelesen.success).toBe(false)
if (!gelesen.success) {
const felder = gelesen.error.flatten().fieldErrors
expect(Object.keys(felder).sort()).toEqual(['geburtsdatum', 'nachname', 'vorname'])
}
})
it('weist ein Geburtsdatum in der Zukunft ab', () => {
const f = ersteinrichtungsFormular()
f.set('geburtsdatum', '2099-01-01')
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
expect(gelesen.success).toBe(false)
if (!gelesen.success) {
expect(gelesen.error.flatten().fieldErrors.geburtsdatum?.[0]).toMatch(/Zukunft/)
}
})
it('weist ein unfertiges Datum ab, statt es stillschweigend zu verwerfen', () => {
const f = ersteinrichtungsFormular()
f.set('bestellungAm', '2025-11')
const gelesen = BetreuteSchema.safeParse(formularLesen(f))
expect(gelesen.success).toBe(false)
})
})
+105
View File
@@ -0,0 +1,105 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
/**
* Auswertung der Umgebungsvariablen.
*
* Der Anlass für diese Datei war ein Fehler im Betrieb: docker compose
* reicht `ENC_KEY_V2` immer durch — mit leerem Wert, wenn in der .env
* keiner steht. Die Schlüsselsammlung erkannte Schlüssel aber am
* Variablennamen statt am Inhalt, fand also ein leeres `ENC_KEY_V2` und
* brach ab. Die Meldung lautete „ENC_KEY_V2 fehlt", obwohl niemand einen
* zweiten Schlüssel wollte.
*
* Jede Prüfung hier lädt `@/lib/env` frisch, weil die Werte beim ersten
* Zugriff gemerkt werden.
*/
const ECHT = Buffer.alloc(32, 7).toString('base64')
const ECHT_ZWEI = Buffer.alloc(32, 9).toString('base64')
let vorher: NodeJS.ProcessEnv
async function frischesEnv() {
// Leert die Modulregistrierung, damit der naechste Import das Modul neu
// auswertet - die Schluessel werden sonst nur beim ersten Mal gelesen.
vi.resetModules()
const { env } = await import('@/lib/env')
return env
}
beforeEach(() => {
vorher = { ...process.env }
for (const name of Object.keys(process.env)) {
if (/^ENC_KEY_V\d+$/.test(name)) delete process.env[name]
}
delete process.env.ENC_KEY_AKTIV
})
afterEach(() => {
process.env = vorher
})
describe('Verschlüsselungsschlüssel aus der Umgebung', () => {
it('nimmt einen einzelnen Schlüssel an', async () => {
process.env.ENC_KEY_V1 = ECHT
const env = await frischesEnv()
expect([...env.verschluesselungsschluessel.keys()]).toEqual([1])
expect(env.aktiveSchluesselversion).toBe(1)
})
it('überspringt eine leere Schlüsselvariable', async () => {
// Genau das setzt docker compose, wenn in der .env kein zweiter
// Schlüssel steht: ENC_KEY_V2: ${ENC_KEY_V2:-}
process.env.ENC_KEY_V1 = ECHT
process.env.ENC_KEY_V2 = ''
const env = await frischesEnv()
expect([...env.verschluesselungsschluessel.keys()]).toEqual([1])
expect(env.aktiveSchluesselversion).toBe(1)
})
it('überspringt eine Variable, in der nur Leerzeichen stehen', async () => {
process.env.ENC_KEY_V1 = ECHT
process.env.ENC_KEY_V2 = ' '
const env = await frischesEnv()
expect([...env.verschluesselungsschluessel.keys()]).toEqual([1])
})
it('nimmt beide Schlüssel an, wenn wirklich rotiert wird', async () => {
process.env.ENC_KEY_V1 = ECHT
process.env.ENC_KEY_V2 = ECHT_ZWEI
process.env.ENC_KEY_AKTIV = '2'
const env = await frischesEnv()
expect([...env.verschluesselungsschluessel.keys()].sort()).toEqual([1, 2])
expect(env.aktiveSchluesselversion).toBe(2)
})
it('sagt beim Rotieren deutlich, wenn der neue Schlüssel fehlt', async () => {
process.env.ENC_KEY_V1 = ECHT
process.env.ENC_KEY_V2 = ''
process.env.ENC_KEY_AKTIV = '2'
const env = await frischesEnv()
expect(() => env.aktiveSchluesselversion).toThrow(/ENC_KEY_AKTIV=2/)
// Die Meldung nennt, was da ist, und die richtige Reihenfolge.
expect(() => env.aktiveSchluesselversion).toThrow(/Vorhanden sind die Versionen: 1/)
expect(() => env.aktiveSchluesselversion).toThrow(/zuerst den neuen Schluessel/)
})
it('beschwert sich, wenn gar kein Schlüssel gesetzt ist', async () => {
process.env.ENC_KEY_V2 = ''
const env = await frischesEnv()
expect(() => env.verschluesselungsschluessel).toThrow(/mindestens ENC_KEY_V1/)
})
it('weist einen Schlüssel falscher Länge ab', async () => {
process.env.ENC_KEY_V1 = Buffer.alloc(16, 1).toString('base64')
const env = await frischesEnv()
expect(() => env.verschluesselungsschluessel).toThrow(/32 Bytes/)
})
})