docs: README, Datenschutztexte, Dockerfile und Sicherungsskripte

Damit ist Etappe 1 abgeschlossen und betriebsfertig dokumentiert.

README mit beiden Betriebswegen. Docker Compose auf eigenem Server ist
der Hauptweg; Vercel und Neon sind vollstaendig beschrieben, aber mit
einer klaren Begruendung, warum ich davon abrate: beide Anbieter sind
US-Unternehmen und unterliegen auch bei EU-Region potenziell dem
CLOUD-Act. Bei Gesundheitsdaten einer nicht entscheidungsfaehigen Person
ist das eine Abwaegung, die man bewusst treffen muss.

Reverse-Proxy-Beispiele fuer Caddy, nginx und Traefik - der Proxy selbst
ist nicht Teil des Compose-Setups, weil er auf dem Zielserver schon
steht. Die Datenbank hat keine Portfreigabe.

Sicherung: pg_dump schreibt direkt in die Verschluesselung, der Klartext
landet nie auf der Platte. Danach eine Selbstpruefung - laesst sich die
Datei nicht wieder entschluesseln, endet der Lauf mit Fehler statt mit
einem stillen Scheinerfolg. Der jeweils erste Stand eines Monats bleibt
dauerhaft stehen.

Die README sagt ausdruecklich, was das Backup NICHT enthaelt: den
Schluessel ENC_KEY_V1 und die Dateien im Objektspeicher. Dazu eine
Anleitung, die Wiederherstellung einmal im Jahr zu ueben - mit dem
letzten Schritt, in der Anwendung nachzusehen, ob die Diagnosen im
Klartext erscheinen. Erst dann stimmt nicht nur die Datenbank, sondern
auch der Schluessel.

Verarbeitungsverzeichnis und Datenschutzhinweise als ausfuellbare
Textbausteine. Die Hinweise sind in einfacher Sprache geschrieben - sie
sollen der betreuten Person vorgelesen werden koennen. Die Seite
/datenschutz liest denselben Text aus docs/, damit es nicht zwei
Fassungen gibt, die auseinanderlaufen.

Dockerfile in drei Stufen mit Tesseract, deutschen Sprachdaten und
poppler-utils. Zeitzone Europe/Berlin ist gesetzt - ohne sie rechnet der
Server in UTC und verschiebt Fristen um einen Tag.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 02:35:08 +02:00
parent 6b4df4c9cb
commit 3833a9dd8f
141 changed files with 30302 additions and 66 deletions
@@ -0,0 +1,248 @@
'use client'
import { useActionState, useState } from 'react'
import type { SozialhilfeBewertung, VertragArt } from '@prisma/client'
import { Input } from '@/components/ui/input'
import { Select } from '@/components/ui/select'
import { Textarea } from '@/components/ui/textarea'
import { Feld, Feldgruppe } from '@/components/feld'
import { Rechtshinweis } from '@/components/rechtshinweis'
import { Absendeknopf, Zustandsmeldung } from '@/components/substanz-formular'
import {
BEWERTUNG_BESCHRIFTUNG,
BEWERTUNG_ERKLAERUNG,
RHYTHMUS_BESCHRIFTUNG,
VERTRAG_ART_BESCHRIFTUNG,
VERTRAG_STATUS_BESCHRIFTUNG,
} from '@/lib/vertraege/konstanten'
import { BEWERTUNGEN, VERTRAG_ARTEN, VERTRAG_STATUS, ZAHL_RHYTHMEN } from '@/lib/validierung/vertraege'
import { vertragSpeichern } from '@/server/actions/vertraege'
import type { Formularzustand } from '@/lib/validierung/substanz'
export type VertragWerte = {
id?: string
bezeichnung: string
gesellschaft: string
art: string
status: string
vertragsnummer: string
beginnAm: string
endeAm: string
laufzeitJahre: string
kuendigungsfristMonate: string
kuendigungZum: string
gekuendigtAm: string
beitrag: string
rhythmus: string
bewertungSozialhilfe: string
bewertungBegruendung: string
notizen: string
}
export const LEERER_VERTRAG: VertragWerte = {
bezeichnung: '',
gesellschaft: '',
art: 'LEBENSVERSICHERUNG',
status: 'AKTIV',
vertragsnummer: '',
beginnAm: '',
endeAm: '',
laufzeitJahre: '',
kuendigungsfristMonate: '3',
kuendigungZum: '',
gekuendigtAm: '',
beitrag: '',
rhythmus: 'MONATLICH',
bewertungSozialhilfe: 'UNGEPRUEFT',
bewertungBegruendung: '',
notizen: '',
}
export function VertragFormular({
betreuteId,
werte,
}: {
betreuteId: string
werte: VertragWerte
}) {
const [zustand, aktion] = useActionState<Formularzustand, FormData>(vertragSpeichern, {})
const [bewertung, setBewertung] = useState(werte.bewertungSozialhilfe)
const istNeu = !werte.id
return (
<form action={aktion} className="space-y-8" noValidate>
<input type="hidden" name="betreuteId" value={betreuteId} />
{werte.id && <input type="hidden" name="vertragId" value={werte.id} />}
<Zustandsmeldung zustand={zustand} />
<Feldgruppe titel="Vertrag" beschreibung="Wer, was und unter welcher Nummer.">
<Feld
id="bezeichnung"
beschriftung="Bezeichnung"
erklaerung="Ein Name, unter dem du den Vertrag wiedererkennst."
fehler={zustand.felder?.bezeichnung}
pflicht
>
<Input name="bezeichnung" defaultValue={werte.bezeichnung} required maxLength={150} />
</Feld>
<Feld id="gesellschaft" beschriftung="Gesellschaft" fehler={zustand.felder?.gesellschaft}>
<Input name="gesellschaft" defaultValue={werte.gesellschaft} maxLength={150} />
</Feld>
<Feld id="art" beschriftung="Art" fehler={zustand.felder?.art} pflicht>
<Select name="art" defaultValue={werte.art}>
{VERTRAG_ARTEN.map((a) => (
<option key={a} value={a}>
{VERTRAG_ART_BESCHRIFTUNG[a as VertragArt]}
</option>
))}
</Select>
</Feld>
<Feld id="status" beschriftung="Status" fehler={zustand.felder?.status}>
<Select name="status" defaultValue={werte.status}>
{VERTRAG_STATUS.map((s) => (
<option key={s} value={s}>
{VERTRAG_STATUS_BESCHRIFTUNG[s]}
</option>
))}
</Select>
</Feld>
<Feld
id="vertragsnummer"
beschriftung="Vertragsnummer"
erklaerung="Wird verschlüsselt gespeichert. Ohne sie ordnet die Gesellschaft kein Schreiben zu."
fehler={zustand.felder?.vertragsnummer}
className="sm:col-span-2"
>
<Input name="vertragsnummer" defaultValue={werte.vertragsnummer} maxLength={80} />
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Laufzeit und Kündigung"
beschreibung="Aus diesen Angaben rechnet die App den nächstmöglichen Kündigungstermin und den spätesten Absendetag."
>
<Feld id="beginnAm" beschriftung="Laufzeitbeginn" fehler={zustand.felder?.beginnAm}>
<Input name="beginnAm" type="date" defaultValue={werte.beginnAm} />
</Feld>
<Feld
id="endeAm"
beschriftung="Vereinbartes Ende"
erklaerung="Nur bei fester Ablaufzeit, z. B. einer Kapitallebensversicherung."
fehler={zustand.felder?.endeAm}
>
<Input name="endeAm" type="date" defaultValue={werte.endeAm} />
</Feld>
<Feld
id="laufzeitJahre"
beschriftung="Laufzeit in Jahren"
erklaerung="Um wie viel verlängert sich der Vertrag? Leer lassen für den Regelfall von einem Jahr."
fehler={zustand.felder?.laufzeitJahre}
>
<Input name="laufzeitJahre" inputMode="numeric" defaultValue={werte.laufzeitJahre} />
</Feld>
<Feld
id="kuendigungsfristMonate"
beschriftung="Kündigungsfrist in Monaten"
erklaerung="Wie lange vor dem Ende muss die Kündigung zugehen? Bei Versicherungen meist drei Monate."
fehler={zustand.felder?.kuendigungsfristMonate}
>
<Input
name="kuendigungsfristMonate"
inputMode="numeric"
defaultValue={werte.kuendigungsfristMonate}
/>
</Feld>
<Feld
id="kuendigungZum"
beschriftung="Gekündigt zum"
fehler={zustand.felder?.kuendigungZum}
>
<Input name="kuendigungZum" type="date" defaultValue={werte.kuendigungZum} />
</Feld>
<Feld id="gekuendigtAm" beschriftung="Kündigung versandt am" fehler={zustand.felder?.gekuendigtAm}>
<Input name="gekuendigtAm" type="date" defaultValue={werte.gekuendigtAm} />
</Feld>
</Feldgruppe>
<Feldgruppe titel="Beitrag" beschreibung="Grundlage für den Jahresbeitrag in der Übersicht.">
<Feld id="beitrag" beschriftung="Beitrag" fehler={zustand.felder?.beitrag}>
<Input name="beitrag" inputMode="decimal" defaultValue={werte.beitrag} placeholder="25,40" />
</Feld>
<Feld id="rhythmus" beschriftung="Rhythmus" fehler={zustand.felder?.rhythmus}>
<Select name="rhythmus" defaultValue={werte.rhythmus}>
<option value="">— nicht angegeben —</option>
{ZAHL_RHYTHMEN.map((r) => (
<option key={r} value={r}>
{RHYTHMUS_BESCHRIFTUNG[r]}
</option>
))}
</Select>
</Feld>
</Feldgruppe>
<Feldgruppe
titel="Bewertung fürs Sozialamt"
beschreibung="Deine Einordnung und deine Argumentation — keine Rechtsauskunft."
>
<Feld
id="bewertungSozialhilfe"
beschriftung="Einordnung"
erklaerung={BEWERTUNG_ERKLAERUNG[bewertung as SozialhilfeBewertung]}
fehler={zustand.felder?.bewertungSozialhilfe}
>
<Select
name="bewertungSozialhilfe"
value={bewertung}
onChange={(e) => setBewertung(e.target.value)}
>
{BEWERTUNGEN.map((b) => (
<option key={b} value={b}>
{BEWERTUNG_BESCHRIFTUNG[b]}
</option>
))}
</Select>
</Feld>
<Feld
id="bewertungBegruendung"
beschriftung="Begründung"
erklaerung="Wird verschlüsselt gespeichert. Diese Zeilen stehen im Zweifel Jahre später im Widerspruchsverfahren."
fehler={zustand.felder?.bewertungBegruendung}
pflicht={bewertung !== 'UNGEPRUEFT'}
className="sm:col-span-2"
>
<Textarea
name="bewertungBegruendung"
defaultValue={werte.bewertungBegruendung}
rows={4}
maxLength={4000}
/>
</Feld>
</Feldgruppe>
<Feld
id="notizen"
beschriftung="Notizen"
erklaerung="Wird verschlüsselt gespeichert."
fehler={zustand.felder?.notizen}
>
<Textarea name="notizen" defaultValue={werte.notizen} rows={4} maxLength={4000} />
</Feld>
<Rechtshinweis rechtsgrundlage="§ 90 Abs. 3 SGB XII" kurz />
<Absendeknopf>{istNeu ? 'Vertrag anlegen' : 'Änderungen speichern'}</Absendeknopf>
</form>
)
}