Compare commits

..
2 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
9 changed files with 395 additions and 28 deletions
+23 -7
View File
@@ -19,15 +19,31 @@ AUTH_URL=http://localhost:3000
# Hinter einem Reverse Proxy auf true setzen.
AUTH_TRUST_HOST=true
# --- Erreichbarkeit ---------------------------------------------------------
# Auf welcher Adresse und welchem Port horcht die Anwendung?
# --- Erreichbarkeit: HIER MUSST DU DICH ENTSCHEIDEN -------------------------
#
# Proxy auf DEMSELBEN Server: 127.0.0.1 (Standard, nichts zu tun)
# Proxy auf einem ANDEREN: die Adresse des privaten Netzes bzw. des
# WireGuard-Tunnels, z.B. 10.0.0.5
# Auf welcher Adresse soll die Anwendung erreichbar sein? Das haengt davon
# ab, WO dein Reverse Proxy laeuft. Waehl genau eine Zeile aus:
#
# 0.0.0.0 nur, wenn eine Firewall den Port ausschliesslich fuer die IP des
# Proxy-Servers oeffnet. Die Anwendung beendet kein TLS.
# 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
+90 -1
View File
@@ -26,6 +26,93 @@ Wiederherstellung*.
_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
@@ -264,7 +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.2...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
+43
View File
@@ -205,6 +205,14 @@ S3_SECRET_ACCESS_KEY=<aus Schritt 2>
# --- Sicherung ---
SICHERUNG_PASSWORT=<aus Schritt 2>
# --- Erreichbarkeit ---
# Steht dein Proxy auf einem ANDEREN Server, muss hier die Adresse hinein,
# ueber die er diesen Server erreicht. Bleibt es bei 127.0.0.1, kommt er
# NICHT heran - und der Fehler sieht aus, als sei der Proxy schuld.
# Welcher Wert der richtige ist, steht in Schritt 7a.
APP_BIND=127.0.0.1
APP_PORT=3000
```
> **`AUTH_URL` muss exakt der Adresse entsprechen**, unter der du die
@@ -875,6 +883,41 @@ Fehlt eine Umgebungsvariable, sagt die Anwendung beim Start, welche.
Die `.env` liegt nicht neben der `docker-compose.yml`, oder die Zeile ist
leer. Prüf mit dem Befehl aus Schritt 3.
### Der Proxy erreicht die Anwendung nicht („502", „Connection refused")
Fast immer steht in der `.env` noch `APP_BIND=127.0.0.1`, während der
Proxy auf einem anderen Server läuft. Dann horcht die Anwendung nur auf
der Loopback-Adresse — erreichbar ausschließlich vom Anwendungsserver
selbst.
Prüf, worauf sie tatsächlich horcht:
```bash
docker compose ps
# unter PORTS muss die Adresse stehen, die der Proxy erreicht,
# NICHT 127.0.0.1
```
Und ob sie überhaupt antwortet — erst lokal, dann vom Proxy-Server aus:
```bash
# auf dem Anwendungsserver
curl -s http://127.0.0.1:3000/api/gesundheit
# auf dem Proxy-Server
curl -s http://<APP_BIND-Adresse>:3000/api/gesundheit
```
Antwortet der erste, aber nicht der zweite, ist `APP_BIND` falsch oder
eine Firewall dazwischen. Setz den richtigen Wert (Schritt 7a) und
```bash
docker compose up -d
```
Ein Neubau des Abbilds ist dafür **nicht** nötig — es ist nur eine
Umgebungsänderung, der Behälter wird neu erzeugt.
### Port 3000 ist schon belegt
```bash
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "betreuungs-cockpit",
"version": "1.0.2",
"version": "1.0.4",
"private": true,
"description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung",
"engines": {
+36
View File
@@ -25,6 +25,42 @@ BEGIN
RETURN OLD;
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
'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.',
TG_OP
+19 -4
View File
@@ -8,21 +8,36 @@ import { z } from 'zod'
* 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) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.optional()
.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
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.optional()
.nullable()
.transform((wert) => (wert === undefined || wert === '' ? null : wert))
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
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. */
export const VERSION = '1.0.2'
export const VERSION = '1.0.4'
/**
* Kurzer Git-Stand, beim Bauen hineingereicht:
+45 -14
View File
@@ -53,6 +53,49 @@ function felderAusFehler(fehler: z.ZodError): Record<string, string> {
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> {
const werte: Record<string, string> = {}
for (const [name, wert] of formular.entries()) {
@@ -77,13 +120,7 @@ export async function betreuteAnlegen(
}
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
if (!gelesen.success) {
return {
fehler: 'Bitte prüfe die markierten Felder.',
felder: felderAusFehler(gelesen.error),
werte: werteMerken(formular),
}
}
if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
const { offen, verschluesselt } = aufteilen(gelesen.data)
@@ -134,13 +171,7 @@ export async function betreuteAendern(
const { benutzer, betreute } = await erfordereZugang(betreuteId, 'STAMMDATEN', 'schreiben')
const gelesen = BetreuteSchema.safeParse(formularLesen(formular))
if (!gelesen.success) {
return {
fehler: 'Bitte prüfe die markierten Felder.',
felder: felderAusFehler(gelesen.error),
werte: werteMerken(formular),
}
}
if (!gelesen.success) return antwortAufFehler(gelesen.error, formular)
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)
})
})