diff --git a/CHANGELOG.md b/CHANGELOG.md index 995fa4d..b85a95d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,61 @@ 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 @@ -296,7 +351,8 @@ 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.3...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 diff --git a/package.json b/package.json index bcf3982..cba56d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betreuungs-cockpit", - "version": "1.0.3", + "version": "1.0.4", "private": true, "description": "Arbeits- und Dokumentationswerkzeug fuer die ehrenamtliche rechtliche Betreuung", "engines": { diff --git a/prisma/sql/audit-schutz.sql b/prisma/sql/audit-schutz.sql index 01e8d30..820bfb8 100644 --- a/prisma/sql/audit-schutz.sql +++ b/prisma/sql/audit-schutz.sql @@ -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 diff --git a/src/lib/validierung/betreute.ts b/src/lib/validierung/betreute.ts index f52eca3..e3c8e6b 100644 --- a/src/lib/validierung/betreute.ts +++ b/src/lib/validierung/betreute.ts @@ -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.', }) diff --git a/src/lib/version.ts b/src/lib/version.ts index cc67e17..e9ef270 100644 --- a/src/lib/version.ts +++ b/src/lib/version.ts @@ -17,7 +17,7 @@ */ /** Muss zur "version" in der package.json passen. */ -export const VERSION = '1.0.3' +export const VERSION = '1.0.4' /** * Kurzer Git-Stand, beim Bauen hineingereicht: diff --git a/src/server/actions/betreute.ts b/src/server/actions/betreute.ts index c990eb3..e0dc4c4 100644 --- a/src/server/actions/betreute.ts +++ b/src/server/actions/betreute.ts @@ -53,6 +53,49 @@ function felderAusFehler(fehler: z.ZodError): Record { 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 { const werte: Record = {} 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) diff --git a/tests/stammdaten.test.ts b/tests/stammdaten.test.ts new file mode 100644 index 0000000..c3fa005 --- /dev/null +++ b/tests/stammdaten.test.ts @@ -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) + }) +})