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
+286
View File
@@ -0,0 +1,286 @@
import { z } from 'zod'
/**
* Eingabepruefung fuer die Berichts- und Exportfunktionen - und die
* Namensgebung fuer Dateien im Vollexport.
*
* Warum stehen die Dateinamenshelfer hier und nicht in vollexport.ts?
* Weil sie reine Funktionen sind, die ohne Datenbank, ohne Archivierer und
* ohne Objektspeicher getestet werden koennen - und weil ein Fehler an
* dieser Stelle teuer ist: Aus einem Dokumenttitel wie
* "Bescheid ../../etc/passwd" darf im ZIP niemals ein Pfad ausserhalb des
* Zielordners werden.
*/
/** cuid/uuid-artige Kennungen - nichts, was in einem Pfad gefaehrlich waere. */
export const kennung = z
.string()
.trim()
.regex(/^[A-Za-z0-9_-]{1,64}$/, 'Ungültige Kennung.')
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
export const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Bitte ein Datum im Format TT.MM.JJJJ wählen.')
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
export const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
// -----------------------------------------------------------------------------
// Schemata
// -----------------------------------------------------------------------------
export const jahresberichtErzeugenSchema = z
.object({
betreuteId: kennung,
zeitraumVon: pflichtDatum,
zeitraumBis: pflichtDatum,
})
.refine((wert) => wert.zeitraumVon.getTime() <= wert.zeitraumBis.getTime(), {
path: ['zeitraumBis'],
message: 'Das Ende des Berichtszeitraums muss nach seinem Beginn liegen.',
})
export const entwurfSpeichernSchema = z.object({
berichtId: kennung,
entwurf: z
.string()
.max(200_000, 'Der Entwurf ist zu lang. Bitte kürze ihn oder lade Anlagen separat hoch.'),
})
export const berichtAbgabeSchema = z.object({
berichtId: kennung,
abgegebenAm: pflichtDatum,
abgegebenAn: z.string().trim().min(2, 'Bitte angeben, wem der Bericht vorgelegt wurde.').max(200),
})
export const belegmappeSchema = z
.object({
betreuteId: kennung,
quelle: z.enum(['zeitraum', 'vorgang'], {
errorMap: () => ({ message: 'Bitte Zeitraum oder Vorgang wählen.' }),
}),
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
vorgangId: z.string().trim().optional(),
titel: z.string().trim().max(200).optional(),
})
.superRefine((wert, kontext) => {
if (wert.quelle === 'zeitraum') {
if (!wert.zeitraumVon || !wert.zeitraumBis) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['zeitraumVon'],
message: 'Für eine Belegmappe über einen Zeitraum brauchst du Beginn und Ende.',
})
return
}
if (wert.zeitraumVon.getTime() > wert.zeitraumBis.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['zeitraumBis'],
message: 'Das Ende muss nach dem Beginn liegen.',
})
}
} else if (!wert.vorgangId || !/^[A-Za-z0-9_-]{1,64}$/.test(wert.vorgangId)) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['vorgangId'],
message: 'Bitte einen Vorgang auswählen.',
})
}
})
export const unterlagenpaketSchema = z.object({
betreuteId: kennung,
antragId: kennung,
nachforderungIds: z
.array(kennung)
.min(1, 'Bitte mindestens eine Unterlage auswählen, die mitgeschickt werden soll.'),
anschreibenZusatz: z.string().trim().max(4000).optional().default(''),
})
export const vollexportSchema = z.object({
betreuteId: kennung,
mitDokumenten: z.boolean().default(true),
mitAuditLog: z.boolean().default(true),
mitBerichten: z.boolean().default(true),
})
export type JahresberichtErzeugenEingabe = z.infer<typeof jahresberichtErzeugenSchema>
export type EntwurfSpeichernEingabe = z.infer<typeof entwurfSpeichernSchema>
export type BelegmappeEingabe = z.infer<typeof belegmappeSchema>
export type UnterlagenpaketEingabe = z.infer<typeof unterlagenpaketSchema>
export type VollexportEingabe = z.infer<typeof vollexportSchema>
// -----------------------------------------------------------------------------
// Dateinamen im Export
// -----------------------------------------------------------------------------
const UMSCHRIFT: Record<string, string> = {
ä: 'ae',
ö: 'oe',
ü: 'ue',
Ä: 'Ae',
Ö: 'Oe',
Ü: 'Ue',
ß: 'ss',
é: 'e',
è: 'e',
ê: 'e',
á: 'a',
à: 'a',
â: 'a',
ó: 'o',
ò: 'o',
ô: 'o',
ú: 'u',
ù: 'u',
û: 'u',
ñ: 'n',
ç: 'c',
}
/** Namen, die Windows nie als Datei akzeptiert - egal mit welcher Endung. */
const RESERVIERT = new Set([
'con',
'prn',
'aux',
'nul',
...Array.from({ length: 9 }, (_, i) => `com${i + 1}`),
...Array.from({ length: 9 }, (_, i) => `lpt${i + 1}`),
])
export const DATEINAME_MAXLAENGE = 80
/**
* Macht aus beliebigem Text ein Stueck Dateiname, das in einem ZIP, unter
* Windows, unter macOS und unter Linux funktioniert.
*
* Erlaubt bleiben ausschliesslich Buchstaben ohne Akzent, Ziffern,
* Bindestrich und Unterstrich. Alles andere - Schraegstriche, Punkte,
* Doppelpunkte, Steuerzeichen, Umlaute, Leerzeichen - wird umgeschrieben
* oder ersetzt. Damit kann das Ergebnis weder ein Verzeichnis wechseln
* noch einen Datenstrom (":stream") oeffnen.
*/
export function sichererDateiname(roh: string, ersatz = 'ohne-titel'): string {
const umgeschrieben = [...(roh ?? '')]
.map((zeichen) => UMSCHRIFT[zeichen] ?? zeichen)
.join('')
// Kombinierende Akzente aus zusammengesetzten Zeichen entfernen.
.normalize('NFKD')
.replace(/[̀-ͯ]/g, '')
const gesaeubert = umgeschrieben
.replace(/[^A-Za-z0-9]+/g, '-')
.replace(/-{2,}/g, '-')
.replace(/^[-_]+|[-_]+$/g, '')
.slice(0, DATEINAME_MAXLAENGE)
.replace(/^[-_]+|[-_]+$/g, '')
if (gesaeubert === '') return ersatz
if (RESERVIERT.has(gesaeubert.toLowerCase())) return `_${gesaeubert}`
return gesaeubert
}
/** Endung ohne Punkt, klein, hoechstens fuenf Zeichen - sonst "bin". */
export function sichereEndung(kandidat: string | null | undefined, ersatz = 'bin'): string {
const roh = (kandidat ?? '').toLowerCase()
const letzter = roh.includes('.') ? roh.slice(roh.lastIndexOf('.') + 1) : roh
const sauber = letzter.replace(/[^a-z0-9]/g, '')
if (sauber === '' || sauber.length > 5) return ersatz
return sauber
}
/** "2026-03-14" - der Datumsteil im Dateinamen, sortierbar. */
export function isoTag(datum: Date | null | undefined, ersatz = 'ohne-datum'): string {
if (!datum || Number.isNaN(datum.getTime())) return ersatz
return datum.toISOString().slice(0, 10)
}
export type ExportDokument = {
datum: Date | null
absender: string | null
titel: string
dateiname: string | null
mimeTyp: string | null
}
const MIME_ENDUNG: Record<string, string> = {
'application/pdf': 'pdf',
'image/jpeg': 'jpg',
'image/png': 'png',
'image/heic': 'heic',
'image/webp': 'webp',
'image/tiff': 'tif',
}
/**
* Der Pfad eines Dokuments im Vollexport:
*
* dokumente/<jahr>/<datum>_<absender>_<titel>.<endung>
*
* Ohne bekanntes Datum landet die Datei unter "ohne-jahr" - besser als ein
* erfundenes Jahr, in dem sie spaeter niemand sucht.
*/
export function exportDokumentPfad(dokument: ExportDokument): string {
const jahr = dokument.datum && !Number.isNaN(dokument.datum.getTime())
? String(dokument.datum.getUTCFullYear())
: 'ohne-jahr'
const teile = [
isoTag(dokument.datum),
sichererDateiname(dokument.absender ?? '', 'ohne-absender'),
sichererDateiname(dokument.titel, 'ohne-titel'),
]
const endung = sichereEndung(
dokument.dateiname ?? MIME_ENDUNG[dokument.mimeTyp ?? ''] ?? '',
MIME_ENDUNG[dokument.mimeTyp ?? ''] ?? 'bin',
)
return `dokumente/${jahr}/${teile.join('_')}.${endung}`
}
/**
* Letzte Sicherung vor dem Schreiben ins Archiv: Ein Pfad darf nur aus
* harmlosen Segmenten bestehen, nie absolut sein und nie ".." enthalten.
* Wird fuer JEDEN Eintrag im ZIP aufgerufen, auch fuer die selbst
* erzeugten - eine Regel, die nur manchmal gilt, gilt nicht.
*/
export function pfadIstSicher(pfad: string): boolean {
if (typeof pfad !== 'string' || pfad === '' || pfad.length > 400) return false
if (pfad.includes('\\') || pfad.includes('\0') || pfad.startsWith('/')) return false
if (/^[A-Za-z]:/.test(pfad)) return false
const segmente = pfad.split('/')
return segmente.every((teil) => teil !== '' && teil !== '.' && teil !== '..' && !/[<>:"|?*]/.test(teil))
}
/** Wie pfadIstSicher, wirft aber - fuer den Einsatz im Exportlauf. */
export function pfadErfordern(pfad: string): string {
if (!pfadIstSicher(pfad)) {
throw new Error(`Der Pfad "${pfad}" ist für ein Archiv nicht zulässig.`)
}
return pfad
}
/** Dateiname eines erzeugten Berichts: "2026-08-22_jahresbericht_musterfrau.pdf". */
export function berichtDateiname(
art: string,
nachname: string,
stichtag: Date,
endung: 'pdf' | 'docx' | 'zip',
): string {
return [
isoTag(stichtag),
sichererDateiname(art, 'bericht'),
sichererDateiname(nachname, 'betreute'),
].join('_').concat(`.${endung}`)
}
+315
View File
@@ -0,0 +1,315 @@
import { z } from 'zod'
/**
* Eingabepruefung fuer die Dokumentenakte.
*
* Geprueft wird IMMER serverseitig - das Formular im Browser ist nur
* Bequemlichkeit. Alle Meldungen sagen, was zu tun ist, nicht was falsch
* war.
*
* Was hier bewusst NICHT steht: die Pruefung der Datei selbst. Groesse und
* Dateityp entscheidet `src/lib/speicher` an den Magic Bytes - ein Zod-
* Schema koennte dazu nur wiederholen, was der Browser behauptet.
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.optional()
.transform((wert) => wert ?? null)
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
const optionalesDatum = z
.string()
.trim()
.nullable()
.optional()
.transform((wert) => (wert === '' || wert === undefined ? 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.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
export const DOKUMENT_ARTEN = [
'BESCHEID',
'ANTRAG',
'SCHREIBEN',
'RECHNUNG',
'KONTOAUSZUG',
'VERTRAG',
'BESCHLUSS',
'URKUNDE',
'GUTACHTEN',
'AUSWEIS',
'PROTOKOLL',
'QUITTUNG',
'MAHNUNG',
'VOLLMACHT',
'ATTEST',
'GRUNDBUCHAUSZUG',
'FOTO',
'SONSTIGES',
] as const
export const DOKUMENT_RICHTUNGEN = ['EINGANG', 'AUSGANG', 'INTERN'] as const
export const POSTEINGANG_STUFEN = ['NEU', 'GESICHTET', 'ZUGEORDNET', 'BEANTWORTET'] as const
export const POSTEINGANG_BESCHRIFTUNG: Record<(typeof POSTEINGANG_STUFEN)[number], string> = {
NEU: 'Neu',
GESICHTET: 'Gesichtet',
ZUGEORDNET: 'Zugeordnet',
BEANTWORTET: 'Beantwortet',
}
export const POSTEINGANG_ERKLAERUNG: Record<(typeof POSTEINGANG_STUFEN)[number], string> = {
NEU: 'Liegt im Posteingang und wurde noch nicht angesehen.',
GESICHTET: 'Angesehen, aber noch keinem Vorgang zugeordnet.',
ZUGEORDNET: 'Einem Vorgang, einer Frist oder einer Buchung zugeordnet.',
BEANTWORTET: 'Erledigt – beantwortet, bezahlt oder abgelegt.',
}
/** Alle Ziele, mit denen sich ein Dokument verknuepfen laesst. */
export const VERKNUEPFUNGSZIELE = [
'vorgang',
'frist',
'buchung',
'vermoegensposten',
'immobilie',
'vertrag',
'kontaktprotokoll',
'gesundheitsereignis',
'genehmigung',
'nachforderung',
'bescheid',
'kontakt',
] as const
export type Verknuepfungsziel = (typeof VERKNUEPFUNGSZIELE)[number]
export const ZIEL_BESCHRIFTUNG: Record<Verknuepfungsziel, string> = {
vorgang: 'Vorgang',
frist: 'Frist',
buchung: 'Buchung',
vermoegensposten: 'Vermögensposten',
immobilie: 'Immobilie',
vertrag: 'Vertrag',
kontaktprotokoll: 'Gesprächsnotiz',
gesundheitsereignis: 'Gesundheitsereignis',
genehmigung: 'Genehmigung',
nachforderung: 'Nachforderung',
bescheid: 'Bescheid',
kontakt: 'Kontakt',
}
/** Die Spalte in DokumentVerknuepfung, die zu einem Ziel gehoert. */
export const ZIEL_SPALTE: Record<Verknuepfungsziel, string> = {
vorgang: 'vorgangId',
frist: 'fristId',
buchung: 'buchungId',
vermoegensposten: 'vermoegenspostenId',
immobilie: 'immobilieId',
vertrag: 'vertragId',
kontaktprotokoll: 'kontaktprotokollId',
gesundheitsereignis: 'gesundheitsereignisId',
genehmigung: 'genehmigungId',
nachforderung: 'nachforderungId',
bescheid: 'bescheidId',
kontakt: 'kontaktId',
}
const kennung = z
.string()
.trim()
.min(1)
.max(64)
.regex(/^[A-Za-z0-9_-]+$/, 'Diese Kennung sieht nicht wie eine gültige Kennung aus.')
// -----------------------------------------------------------------------------
// Metadaten
// -----------------------------------------------------------------------------
export const DokumentMetadatenSchema = z
.object({
titel: z
.string()
.trim()
.min(2, 'Bitte einen Titel angeben – daran findest du das Dokument später wieder.')
.max(200, 'Bitte höchstens 200 Zeichen.'),
art: z.enum(DOKUMENT_ARTEN),
richtung: z.enum(DOKUMENT_RICHTUNGEN),
schreibenVom: optionalesDatum,
eingangAm: optionalesDatum,
versandAm: optionalesDatum,
absender: optionalerText(200),
empfaenger: optionalerText(200),
aktenzeichen: optionalerText(100),
/** Wo liegt das Original im Papierordner? Papier bleibt Papier. */
ablageortPapier: optionalerText(150),
wiedervorlageAm: optionalesDatum,
ablaufAm: optionalesDatum,
posteingangStatus: z.enum(POSTEINGANG_STUFEN).optional(),
notizen: optionalerText(4000),
/** Bestehende Tag-Kennungen. */
tagIds: z.array(kennung).max(20).optional(),
/** Neue Tags, mit Komma getrennt eingegeben. */
neueTags: optionalerText(200),
})
.refine(
(daten) =>
!daten.schreibenVom ||
!daten.eingangAm ||
daten.schreibenVom.getTime() <= daten.eingangAm.getTime(),
{
message: 'Das Schreiben kann nicht nach seinem Eingang datiert sein. Bitte prüfen.',
path: ['eingangAm'],
},
)
export type DokumentMetadaten = z.output<typeof DokumentMetadatenSchema>
// -----------------------------------------------------------------------------
// Upload
// -----------------------------------------------------------------------------
/**
* Beim Hochladen darf fast alles fehlen: Ziel ist, dass ein Foto in zwei
* Klicks in der Akte liegt. Die Metadaten kommen im zweiten Schritt aus den
* Erkennungsvorschlaegen.
*/
export const UploadSchema = z.object({
titel: optionalerText(200),
art: z.enum(DOKUMENT_ARTEN).optional(),
richtung: z.enum(DOKUMENT_RICHTUNGEN).optional(),
eingangAm: optionalesDatum,
absender: optionalerText(200),
aktenzeichen: optionalerText(100),
ablageortPapier: optionalerText(150),
/** Direkt beim Hochladen mit einem Vorgang verknuepfen. */
vorgangId: kennung.optional().nullable(),
})
export type Uploadeingabe = z.output<typeof UploadSchema>
// -----------------------------------------------------------------------------
// Verknuepfung
// -----------------------------------------------------------------------------
export const VerknuepfungSchema = z.object({
dokumentId: kennung,
ziel: z.enum(VERKNUEPFUNGSZIELE),
zielId: kennung,
bemerkung: optionalerText(200),
})
export const PosteingangSchema = z.object({
dokumentId: kennung,
status: z.enum(POSTEINGANG_STUFEN),
})
// -----------------------------------------------------------------------------
// Frist aus einem Erkennungsvorschlag
// -----------------------------------------------------------------------------
/**
* Aus einem Fristvorschlag wird nur dann eine Frist, wenn der Nutzer sie
* ausdruecklich uebernimmt. Das Schema prueft genau das, was er dabei
* bestaetigt hat - Datum und Titel sind Pflicht.
*/
export const FristUebernehmenSchema = z.object({
dokumentId: kennung,
titel: z
.string()
.trim()
.min(3, 'Bitte einen Titel angeben, an dem du die Frist wiedererkennst.')
.max(200),
datum: z
.string()
.trim()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'Bitte ein Datum im Format TT.MM.JJJJ wählen.')
.transform((wert) => new Date(`${wert}T00:00:00.000Z`)),
typ: z.enum(['GESETZLICH', 'BEHOERDLICH', 'SELBST_GESETZT', 'WIEDERVORLAGE']).default('BEHOERDLICH'),
beschreibung: optionalerText(1000),
rechtsgrundlage: optionalerText(120),
/** Rechtsbehelfsfristen sind Ausschlussfristen - sie verfallen. */
ausschlussfrist: z.coerce.boolean().optional().default(false),
vorgangId: kennung.optional().nullable(),
})
// -----------------------------------------------------------------------------
// Filter der Liste
// -----------------------------------------------------------------------------
export const ListenfilterSchema = z.object({
suche: optionalerText(200),
art: z.enum(DOKUMENT_ARTEN).optional().nullable(),
richtung: z.enum(DOKUMENT_RICHTUNGEN).optional().nullable(),
posteingangStatus: z.enum(POSTEINGANG_STUFEN).optional().nullable(),
von: optionalesDatum,
bis: optionalesDatum,
tagId: kennung.optional().nullable(),
seite: z.coerce.number().int().min(1).max(1000).optional().default(1),
})
/**
* Liest die Filter aus den Suchparametern der Seite. Unbrauchbare Werte
* werden stillschweigend verworfen - eine kaputte URL soll die Liste nicht
* mit einer Fehlermeldung ersetzen.
*/
export function filterAusParametern(
parameter: Record<string, string | string[] | undefined>,
): z.output<typeof ListenfilterSchema> {
const einzeln = (name: string): string | undefined => {
const wert = parameter[name]
const roh = Array.isArray(wert) ? wert[0] : wert
return roh && roh.trim() !== '' ? roh : undefined
}
const gelesen = ListenfilterSchema.safeParse({
suche: einzeln('suche'),
art: einzeln('art'),
richtung: einzeln('richtung'),
posteingangStatus: einzeln('status'),
von: einzeln('von'),
bis: einzeln('bis'),
tagId: einzeln('tag'),
seite: einzeln('seite') ?? 1,
})
if (gelesen.success) return gelesen.data
return {
suche: einzeln('suche')?.slice(0, 200) ?? null,
art: null,
richtung: null,
posteingangStatus: null,
von: null,
bis: null,
tagId: null,
seite: 1,
}
}
/** Aus "Bezirk, Pflege , Wichtig" werden drei saubere Tag-Namen. */
export function tagsAufteilen(roh: string | null | undefined): string[] {
if (!roh) return []
return [
...new Set(
roh
.split(',')
.map((name) => name.trim().replace(/\s+/g, ' '))
.filter((name) => name.length >= 2 && name.length <= 40),
),
].slice(0, 20)
}
+5 -4
View File
@@ -101,7 +101,7 @@ const optionalesDatum = z
})
const pflichtDatum = z
.string()
.string({ required_error: 'Bitte ein Datum angeben.' })
.trim()
.transform((wert, ctx) => {
const datum = tagAusText(wert)
@@ -115,7 +115,7 @@ const pflichtDatum = z
/** Ein Formularfeld mit Geldbetrag wird hier zu Cent - und nie woanders. */
const betragFeld = (optionen: { nurPositiv?: boolean } = {}) =>
z
.string()
.string({ required_error: 'Bitte einen Betrag angeben.' })
.trim()
.transform((wert, ctx) => {
try {
@@ -142,8 +142,9 @@ const betragFeld = (optionen: { nurPositiv?: boolean } = {}) =>
/** Checkbox: vorhanden heisst an, fehlend heisst aus. */
const schalter = z
.union([z.literal('on'), z.literal('true'), z.literal('1'), z.literal(''), z.undefined()])
.transform((wert) => wert === 'on' || wert === 'true' || wert === '1')
.string()
.optional()
.transform((wert) => wert === 'on' || wert === 'true' || wert === '1' || wert === 'ja')
// -----------------------------------------------------------------------------
// Konto
+183
View File
@@ -0,0 +1,183 @@
import { z } from 'zod'
import type { Modul, Rolle } from '@prisma/client'
import { ALLE_MODULE, BESONDERS_SENSIBEL, darf, type Zugang } from '@/lib/auth/rollen'
/**
* Benutzerverwaltung und Modulfreigaben.
*
* Die eigentliche Zugriffsentscheidung faellt in src/lib/auth/rollen.ts.
* Hier steht, was beim AENDERN gilt: welche Eingaben zulaessig sind, was
* eine Freigabe an einen Mitleser bedeutet und wie sich eine Aenderung
* beschreiben laesst, damit sie im Audit-Log und in der Oberflaeche
* verstaendlich wird.
*
* Ein Satz steht ueber allem: Ein Mitleser schreibt nie. Nicht ueber die
* Oberflaeche, nicht ueber eine Server Action, nicht mit einer freigegebenen
* Modulliste. Das ist keine Frage der Konfiguration.
*/
export const ROLLEN = ['BETREUER', 'MITLESER', 'ADMIN'] as const
const rolleSchema = z.enum(ROLLEN, {
errorMap: () => ({ message: 'Bitte eine gültige Rolle wählen.' }),
})
const modulSchema = z.enum(ALLE_MODULE as [Modul, ...Modul[]], {
errorMap: () => ({ message: 'Unbekannter Bereich.' }),
})
export const kennung = z
.string()
.trim()
.regex(/^[A-Za-z0-9_-]{1,64}$/, 'Ungültige Kennung.')
export const benutzerAnlegenSchema = z.object({
name: z.string().trim().min(2, 'Bitte den vollständigen Namen angeben.').max(120),
email: z
.string()
.trim()
.toLowerCase()
.email('Das sieht nicht nach einer E-Mail-Adresse aus.')
.max(200),
rolle: rolleSchema,
passwort: z
.string()
.min(12, 'Mindestens 12 Zeichen. Ein Satz mit vier Wörtern ist leichter zu merken und sicherer.')
.max(200),
})
export const rolleAendernSchema = z.object({
userId: kennung,
rolle: rolleSchema,
})
export const benutzerAktivSchema = z.object({
userId: kennung,
aktiv: z.boolean(),
})
export const sitzungenBeendenSchema = z.object({ userId: kennung })
export const freigabenSchema = z.object({
userId: kennung,
betreuteId: kennung,
rolle: rolleSchema,
freigegebeneModule: z.array(modulSchema).default([]),
})
export const papierkorbSchema = z.object({
betreuteId: kennung,
entitaet: z.string().trim().min(2).max(60),
entitaetId: kennung,
})
export type BenutzerAnlegenEingabe = z.infer<typeof benutzerAnlegenSchema>
export type FreigabenEingabe = z.infer<typeof freigabenSchema>
export type PapierkorbEingabe = z.infer<typeof papierkorbSchema>
// -----------------------------------------------------------------------------
// Reine Regeln
// -----------------------------------------------------------------------------
export function istBesondersSensibel(modul: Modul): boolean {
return BESONDERS_SENSIBEL.includes(modul)
}
/**
* Eine Modulliste ergibt nur fuer Mitleser Sinn: Betreuer und Administrator
* sehen ohnehin alles. Wird jemand vom Mitleser zum Betreuer gemacht, wird
* die Liste geleert - sonst steht spaeter eine alte, irrefuehrende Auswahl
* in der Datenbank, die bei einer Rueckstufung ploetzlich wieder gilt.
*/
export function freigabenNormalisieren(rolle: Rolle, module: readonly Modul[]): Modul[] {
if (rolle !== 'MITLESER') return []
const gueltig = module.filter((modul) => ALLE_MODULE.includes(modul))
return ALLE_MODULE.filter((modul) => gueltig.includes(modul))
}
/** Was sieht dieser Zugang tatsaechlich? Grundlage fuer Navigation und Tests. */
export function sichtbareModule(zugang: Zugang | null): Modul[] {
return ALLE_MODULE.filter((modul) => darf(zugang, modul, 'lesen'))
}
/** Wo darf dieser Zugang schreiben? Fuer einen Mitleser: nirgends. */
export function beschreibbareModule(zugang: Zugang | null): Modul[] {
return ALLE_MODULE.filter((modul) => darf(zugang, modul, 'schreiben'))
}
export type Freigabeaenderung = {
hinzugefuegt: Modul[]
entfernt: Modul[]
/** Untermenge von hinzugefuegt - die Module, vor denen gewarnt wird. */
sensibelHinzugefuegt: Modul[]
rolleVorher: Rolle
rolleNachher: Rolle
rolleGeaendert: boolean
}
/**
* Vergleicht alte und neue Freigabe. Das Ergebnis geht in den Audit-Eintrag
* und in die Rueckmeldung an den Benutzer - "GESUNDHEIT freigegeben" ist
* eine Information, "Freigaben geaendert" ist keine.
*/
export function freigabeAenderung(
vorher: { rolle: Rolle; freigegebeneModule: readonly Modul[] },
nachher: { rolle: Rolle; freigegebeneModule: readonly Modul[] },
): Freigabeaenderung {
const alt = freigabenNormalisieren(vorher.rolle, vorher.freigegebeneModule)
const neu = freigabenNormalisieren(nachher.rolle, nachher.freigegebeneModule)
const hinzugefuegt = neu.filter((modul) => !alt.includes(modul))
const entfernt = alt.filter((modul) => !neu.includes(modul))
return {
hinzugefuegt,
entfernt,
sensibelHinzugefuegt: hinzugefuegt.filter(istBesondersSensibel),
rolleVorher: vorher.rolle,
rolleNachher: nachher.rolle,
rolleGeaendert: vorher.rolle !== nachher.rolle,
}
}
/**
* Darf dieser Benutzer die Verwaltung ueberhaupt bedienen? Nur der
* Administrator legt Benutzer an, aendert Rollen und beendet fremde
* Sitzungen.
*/
export function darfBenutzerVerwalten(rolle: Rolle): boolean {
return rolle === 'ADMIN'
}
/**
* Darf dieser Benutzer Freigaben fuer eine betreute Person vergeben?
* Der Betreuer dieser Person und der Administrator - ein Mitleser nie,
* sonst gaebe er sich selbst mehr Rechte.
*/
export function darfFreigabenVergeben(zugang: Zugang | null): boolean {
if (!zugang) return false
return zugang.rolle === 'BETREUER' || zugang.rolle === 'ADMIN'
}
/**
* Der letzte Administrator darf sich weder herabstufen noch abschalten -
* sonst kommt niemand mehr an die Benutzerverwaltung heran.
*/
export function letzterAdminGeschuetzt(angaben: {
zielRolleVorher: Rolle
zielRolleNachher: Rolle
zielBleibtAktiv: boolean
anzahlAktiverAdmins: number
}): string | null {
const verliertAdmin =
angaben.zielRolleVorher === 'ADMIN' &&
(angaben.zielRolleNachher !== 'ADMIN' || !angaben.zielBleibtAktiv)
if (verliertAdmin && angaben.anzahlAktiverAdmins <= 1) {
return (
'Das ist der letzte aktive Administrator. Lege zuerst einen weiteren an – ' +
'sonst kommt niemand mehr an die Benutzerverwaltung.'
)
}
return null
}
+171
View File
@@ -0,0 +1,171 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import {
BELASTUNG_ARTEN,
BEWOHNT_STATUS,
GRUNDBUCH_ABTEILUNGEN,
WERTQUELLEN_IMMOBILIE,
} from '@/lib/immobilie/konstanten'
import { VERBLEIB_MOEGLICHKEITEN } from '@/lib/immobilie/inventar'
/**
* Eingabepruefung fuer das Immobilienmodul.
*
* Gepruefte Grundsaetze: Ein Wert ohne Quelle wird nicht angenommen, ein
* Status im Sozialhilfeverfahren ohne Begruendung ebenfalls nicht -
* beides wuerde spaeter im Gespraech mit dem Bezirk oder dem Gericht
* nicht standhalten.
*/
const IMMOBILIEN_STATUS = [
'UNGEKLAERT',
'SELBST_BEWOHNT_GESCHUETZT',
'HAERTEFALL_GELTEND_GEMACHT',
'DARLEHEN_91_SGB_XII',
'VERKAUF_LAEUFT',
'VERWERTET',
] as const
export const ImmobilieSchema = z
.object({
bezeichnung: pflichtText(150, 'Bitte eine Bezeichnung angeben, z. B. „Elternhaus Bayreuth“.'),
strasse: optionalerText(150),
plz: optionalerText(10),
ort: optionalerText(100),
grundbuchamt: optionalerText(150),
grundbuchBlatt: optionalerText(50),
gemarkung: optionalerText(100),
flurstueck: optionalerText(50),
eigentumsanteil: optionalerText(20),
wohnflaecheQm: optionaleZahl(1, 100_000, 'Bitte eine Wohnfläche in ganzen Quadratmetern.'),
grundstueckQm: optionaleZahl(1, 10_000_000, 'Bitte eine Fläche in ganzen Quadratmetern.'),
baujahr: optionaleZahl(1000, 2100, 'Bitte ein Baujahr zwischen 1000 und 2100.'),
bewohntStatus: z.enum(BEWOHNT_STATUS).nullable().catch(null),
leerstandSeit: optionalesDatum,
notizen: optionalerText(4000),
/** Beim Anlegen: Leerstands-Checkliste gleich mit übernehmen? */
checklisteUebernehmen: z.boolean().default(false),
})
.superRefine((daten, kontext) => {
if (daten.bewohntStatus === 'leerstehend' && !daten.leerstandSeit) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['leerstandSeit'],
message:
'Bitte angeben, seit wann das Objekt leersteht — davon hängen die ' +
'Fälligkeiten der Pflichten bei Leerstand ab.',
})
}
if (daten.leerstandSeit && daten.leerstandSeit.getTime() > Date.now() + 86_400_000) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['leerstandSeit'],
message: 'Der Leerstand kann nicht in der Zukunft beginnen.',
})
}
})
export type ImmobilieEingabe = z.infer<typeof ImmobilieSchema>
export const BelastungSchema = z
.object({
art: z.enum(BELASTUNG_ARTEN),
abteilung: z.enum(GRUNDBUCH_ABTEILUNGEN).nullable().catch(null),
laufendeNr: optionalerText(20),
glaeubiger: optionalerText(150),
betrag: optionalerBetrag,
bemerkung: optionalerText(1000),
geloeschtImGrundbuchAm: optionalesDatum,
})
.superRefine((daten, kontext) => {
if (
(daten.art === 'Grundschuld' || daten.art === 'Hypothek') &&
daten.betrag === null
) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['betrag'],
message:
'Bei einer Grundschuld oder Hypothek gehört der eingetragene Betrag dazu — ' +
'das ist der Nennbetrag aus Abteilung III, nicht die Restschuld.',
})
}
if (
(daten.art === 'Grundschuld' || daten.art === 'Hypothek') &&
!daten.glaeubiger
) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['glaeubiger'],
message: 'Bitte den eingetragenen Gläubiger angeben (meist die Bank).',
})
}
})
export type BelastungEingabe = z.infer<typeof BelastungSchema>
/**
* Wertstand: Stichtag, Betrag und Quelle. Die Quelle ist Pflicht - ein
* Wert ohne Herkunft ist gegenueber Gericht und Bezirk wertlos.
*/
export const WertstandSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_IMMOBILIE, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type WertstandEingabe = z.infer<typeof WertstandSchema>
export const StatusSozialhilfeSchema = z
.object({
statusSozialhilfe: z.enum(IMMOBILIEN_STATUS),
begruendung: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.statusSozialhilfe !== 'UNGEKLAERT' && !daten.begruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['begruendung'],
message:
'Bitte begründen, warum das Objekt so eingeordnet ist. Diese Begründung ist ' +
'deine Argumentation gegenüber dem Sozialhilfeträger — ohne sie steht im ' +
'Zweifel nur eine Behauptung in der Akte.',
})
}
})
export type StatusSozialhilfeEingabe = z.infer<typeof StatusSozialhilfeSchema>
export const CheckpunktSchema = z.object({
titel: pflichtText(200, 'Bitte einen Titel für den Punkt angeben.'),
beschreibung: optionalerText(2000),
faelligAm: optionalesDatum,
wiederholungRrule: optionalerText(200),
})
export type CheckpunktEingabe = z.infer<typeof CheckpunktSchema>
export const InventarSchema = z.object({
bezeichnung: pflichtText(150, 'Bitte den Gegenstand benennen.'),
raum: optionalerText(100),
geschaetzterWert: optionalerBetrag,
verbleib: z.enum(VERBLEIB_MOEGLICHKEITEN).nullable().catch(null),
bemerkung: optionalerText(1000),
})
export type InventarEingabe = z.infer<typeof InventarSchema>
+123
View File
@@ -0,0 +1,123 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
/**
* Eingabepruefung fuer Heim, Pflege und Gesundheitsverlauf.
*
* Freitexte zu Gesundheitsereignissen sind besondere Kategorien nach
* Art. 9 DSGVO. Sie werden hier nur geprueft; verschluesselt werden sie in
* der Server Action ueber sealen('Gesundheitsereignis', ...).
*/
export const GESUNDHEITS_ARTEN = [
'ARZTBESUCH',
'KRANKENHAUS',
'EXAZERBATION',
'STURZ',
'MEDIKATIONSAENDERUNG',
'SAUERSTOFFVERSORGUNG',
'INHALATION',
'HILFSMITTEL',
'IMPFUNG',
'REHA',
'PFLEGEBEGUTACHTUNG',
'HEILMITTEL',
'SONSTIGES',
] as const
export const HeimvertragSchema = z
.object({
einrichtung: pflichtText(150, 'Bitte den Namen der Einrichtung angeben.'),
traeger: optionalerText(150),
strasse: optionalerText(150),
plz: optionalerText(10),
ort: optionalerText(100),
vertragAm: optionalesDatum,
einzugAm: optionalesDatum,
auszugAm: optionalesDatum,
kuendigungsfristTage: optionaleZahl(
0,
365,
'Bitte die Kündigungsfrist in ganzen Tagen angeben.',
),
pflegeEigenanteil: optionalerBetrag,
unterkunftVerpflegung: optionalerBetrag,
investitionskosten: optionalerBetrag,
ausbildungsumlage: optionalerBetrag,
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.einzugAm && daten.auszugAm && daten.auszugAm.getTime() < daten.einzugAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['auszugAm'],
message: 'Der Auszug liegt vor dem Einzug.',
})
}
})
export type HeimvertragEingabe = z.infer<typeof HeimvertragSchema>
/**
* Gesundheitsereignis. Die Zusatzangaben der Schnellerfassung werden nicht
* hier geprueft, sondern in der Server Action zu einem Beschreibungstext
* zusammengesetzt (src/lib/pflege/ereignis-details.ts) - sonst braeuchte
* jedes Detailfeld ein eigenes Schema.
*/
export const GesundheitsereignisSchema = z
.object({
datum: pflichtDatum,
bisDatum: optionalesDatum,
art: z.enum(GESUNDHEITS_ARTEN),
titel: pflichtText(200, 'Bitte einen kurzen Titel angeben.'),
behandler: optionalerText(150),
einrichtung: optionalerText(150),
abwesenheitstage: optionaleZahl(0, 366, 'Bitte eine Zahl zwischen 0 und 366.'),
beschreibung: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.bisDatum && daten.bisDatum.getTime() < daten.datum.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bisDatum'],
message: 'Das Ende liegt vor dem Beginn.',
})
}
if (daten.art === 'KRANKENHAUS' && !daten.bisDatum && daten.abwesenheitstage === null) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bisDatum'],
message:
'Bitte das Entlassdatum eintragen — oder, solange der Aufenthalt noch läuft, ' +
'die bisherigen Abwesenheitstage. Ohne eine der beiden Angaben werden die ' +
'Tage nicht gezählt, und die Minderung des Eigenanteils fällt unter den Tisch.',
})
}
})
export type GesundheitsereignisEingabe = z.infer<typeof GesundheitsereignisSchema>
/** Der nach § 1821 BGB ermittelte Wille - verschlüsselt gespeichert. */
export const ErmittelterWilleSchema = z.object({
ermittelterWille: optionalerText(8000),
})
export type ErmittelterWilleEingabe = z.infer<typeof ErmittelterWilleSchema>
/** Termin für die Überprüfung des Pflegegrades. */
export const HoeherstufungSchema = z.object({
pruefungAm: pflichtDatum,
begruendung: optionalerText(2000),
})
export type HoeherstufungEingabe = z.infer<typeof HoeherstufungSchema>
+437
View File
@@ -0,0 +1,437 @@
import { z } from 'zod'
import { betragZuCent } from '@/lib/sozialhilfe/betrag'
/**
* Eingabeprüfung für das Sozialhilfemodul.
*
* Geprüft wird immer serverseitig. Die Meldungen sagen, was zu tun ist -
* nicht, welche Regel verletzt wurde. Wer am Handy im Heim steht und eine
* Nachforderung erfasst, hat keine Lust auf "invalid input".
*/
const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Geldbetrag aus einem Textfeld. Leer wird zu null, nicht zu 0 - "keine
* Angabe" und "null Euro" sind fachlich zweierlei.
*/
const optionalerBetrag = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return null
const cent = betragZuCent(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
return cent
})
/** Betrag, der in einer Gegenüberstellung stehen muss - leer heißt 0. */
const betragOderNull = z
.string()
.trim()
.default('')
.transform((wert, ctx) => {
if (wert === '') return 0n
const cent = betragZuCent(wert)
if (cent === null) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
return z.NEVER
}
if (cent < 0n) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'In dieser Aufstellung stehen nur positive Beträge.',
})
return z.NEVER
}
return cent
})
// -----------------------------------------------------------------------------
// Antragsakte
// -----------------------------------------------------------------------------
export const AntragSchema = z
.object({
traeger: z
.string()
.trim()
.min(2, 'Bitte den Träger angeben, z.B. „Bezirk Oberfranken“.')
.max(150),
leistungsart: z
.string()
.trim()
.min(2, 'Bitte die Leistungsart angeben.')
.max(200),
aktenzeichen: optionalerText(80),
sachbearbeiter: optionalerText(150),
telefon: optionalerText(50),
email: z
.string()
.trim()
.max(200)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.default(null)
.refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(wert), {
message: 'Bitte eine gültige E-Mail-Adresse angeben oder das Feld leer lassen.',
}),
antragAm: optionalesDatum,
eingangBeimTraegerAm: optionalesDatum,
bewilligtVon: optionalesDatum,
bewilligtBis: optionalesDatum,
notizen: optionalerText(4000),
})
.refine(
(daten) =>
!daten.bewilligtVon || !daten.bewilligtBis || daten.bewilligtVon <= daten.bewilligtBis,
{
message: 'Das Ende des Bewilligungszeitraums liegt vor dem Beginn.',
path: ['bewilligtBis'],
},
)
.refine(
(daten) =>
!daten.antragAm ||
!daten.eingangBeimTraegerAm ||
daten.antragAm <= daten.eingangBeimTraegerAm,
{
message: 'Der Eingang beim Träger kann nicht vor dem Antragsdatum liegen.',
path: ['eingangBeimTraegerAm'],
},
)
export type AntragEingabe = z.infer<typeof AntragSchema>
// -----------------------------------------------------------------------------
// Nachforderung
// -----------------------------------------------------------------------------
export const NACHFORDERUNG_STATUS_WERTE = [
'OFFEN',
'IN_BESCHAFFUNG',
'UEBERSANDT',
'ANERKANNT',
'NICHT_VORHANDEN',
] as const
export const NachforderungSchema = z
.object({
bezeichnung: z
.string()
.trim()
.min(2, 'Bitte benennen, welche Unterlage der Träger will.')
.max(200),
erlaeuterung: optionalerText(2000),
status: z.enum(NACHFORDERUNG_STATUS_WERTE).default('OFFEN'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
uebersandtAm: optionalesDatum,
begruendungNichtVorhanden: optionalerText(2000),
reihenfolge: z.coerce.number().int().min(0).max(10_000).default(0),
dokumentId: optionalerText(40),
/** Soll aus der Frist des Trägers eine Frist im Fristenmodul werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message:
'Bitte begründen, warum es die Unterlage nicht gibt. Die Begründung geht mit ins ' +
'Unterlagenpaket — ohne sie sieht der Träger nur eine Lücke.',
path: ['begruendungNichtVorhanden'],
},
)
.refine((daten) => daten.status !== 'UEBERSANDT' || daten.uebersandtAm !== null, {
message:
'Bitte das Übersendungsdatum angeben — es ist der Nachweis der Mitwirkung, wenn der ' +
'Träger später von fehlenden Unterlagen ausgeht.',
path: ['uebersandtAm'],
})
.refine((daten) => !daten.fristAnlegen || daten.fristBis !== null, {
message: 'Für eine Frist im Fristenmodul brauche ich ein Fristdatum.',
path: ['fristBis'],
})
export type NachforderungEingabe = z.infer<typeof NachforderungSchema>
export const NachforderungStatusSchema = z
.object({
nachforderungId: z.string().trim().min(1),
status: z.enum(NACHFORDERUNG_STATUS_WERTE),
begruendungNichtVorhanden: optionalerText(2000),
uebersandtAm: optionalesDatum,
})
.refine(
(daten) =>
daten.status !== 'NICHT_VORHANDEN' ||
(daten.begruendungNichtVorhanden !== null &&
daten.begruendungNichtVorhanden.trim().length >= 5),
{
message: 'Bitte begründen, warum es die Unterlage nicht gibt.',
path: ['begruendungNichtVorhanden'],
},
)
export const ChecklisteSchema = z.object({
antragId: z.string().trim().min(1),
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Position auswählen.'),
angefordertAm: optionalesDatum,
fristBis: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Bescheid
// -----------------------------------------------------------------------------
export const BESCHEID_ART_WERTE = [
'BEWILLIGUNG',
'ABLEHNUNG',
'AENDERUNG',
'AUFHEBUNG',
'ERSTATTUNG',
'VORSCHUSS',
'WIDERSPRUCHSBESCHEID',
] as const
export const PRUEFSTATUS_WERTE = [
'UNGEPRUEFT',
'GEPRUEFT_OK',
'WIDERSPRUCH_EINGELEGT',
'WIDERSPRUCH_ERLEDIGT',
'FRIST_VERSTRICHEN',
] as const
export const BescheidSchema = z
.object({
art: z.enum(BESCHEID_ART_WERTE),
bescheidVom: pflichtDatum,
zugegangenAm: optionalesDatum,
zeitraumVon: optionalesDatum,
zeitraumBis: optionalesDatum,
betrag: optionalerBetrag,
/**
* 1 = Regelfall (§ 84 SGG), 12 = fehlende oder fehlerhafte
* Rechtsbehelfsbelehrung (§ 66 SGG). Wird ausgewählt, nie geraten.
*/
rechtsbehelfsfristMonate: z.coerce
.number()
.int()
.refine((wert) => wert === 1 || wert === 12, {
message: 'Es kommen nur ein Monat (Regelfall) oder ein Jahr (fehlende Belehrung) in Betracht.',
})
.default(1),
widerspruchEingelegtAm: optionalesDatum,
notizen: optionalerText(4000),
dokumentId: optionalerText(40),
/** Soll die Frist "Widerspruch möglich bis" angelegt werden? */
fristAnlegen: z.coerce.boolean().default(false),
})
.refine(
(daten) => !daten.zugegangenAm || daten.zugegangenAm >= daten.bescheidVom,
{
message:
'Der Bescheid kann nicht vor seinem Ausstellungsdatum zugegangen sein. Prüfe den ' +
'Poststempel.',
path: ['zugegangenAm'],
},
)
.refine(
(daten) => !daten.zeitraumVon || !daten.zeitraumBis || daten.zeitraumVon <= daten.zeitraumBis,
{ message: 'Das Ende des Zeitraums liegt vor dem Beginn.', path: ['zeitraumBis'] },
)
.refine((daten) => !daten.fristAnlegen || daten.zugegangenAm !== null, {
message:
'Ohne Zugangsdatum lässt sich die Widerspruchsfrist nicht berechnen — und damit auch ' +
'keine Frist anlegen.',
path: ['zugegangenAm'],
})
export type BescheidEingabe = z.infer<typeof BescheidSchema>
export const BescheidStatusSchema = z.object({
bescheidId: z.string().trim().min(1),
pruefStatus: z.enum(PRUEFSTATUS_WERTE),
widerspruchEingelegtAm: optionalesDatum,
})
// -----------------------------------------------------------------------------
// Vermögensbewertung
// -----------------------------------------------------------------------------
export const BEWERTUNG_WERTE = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const BewertungSchema = z
.object({
herkunft: z.enum(['VERMOEGEN', 'VERTRAG']),
id: z.string().trim().min(1),
bewertung: z.enum(BEWERTUNG_WERTE),
begruendung: optionalerText(6000),
})
.refine(
(daten) =>
daten.bewertung === 'UNGEPRUEFT' ||
(daten.begruendung !== null && daten.begruendung.trim().length >= 10),
{
message:
'Eine Bewertung ohne Begründung hilft im Widerspruchsverfahren nicht weiter. ' +
'Übernimm einen der Textbausteine und passe ihn an.',
path: ['begruendung'],
},
)
// -----------------------------------------------------------------------------
// Heimkosten
// -----------------------------------------------------------------------------
export const HeimkostenSchema = z.object({
jahr: z.coerce
.number()
.int()
.min(2000, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.')
.max(2100, 'Das Jahr liegt außerhalb des sinnvollen Bereichs.'),
monat: z.coerce.number().int().min(1, 'Bitte einen Monat wählen.').max(12),
pflegeEigenanteil: betragOderNull,
unterkunftVerpflegung: betragOderNull,
investitionskosten: betragOderNull,
ausbildungsumlage: betragOderNull,
zusatzleistungen: betragOderNull,
pflegekasse: betragOderNull,
leistungszuschlagProzent: z.coerce
.number()
.int()
.min(0, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.max(100, 'Der Zuschlag liegt zwischen 0 und 100 Prozent.')
.default(0),
leistungszuschlag: betragOderNull,
rente: betragOderNull,
sonstigeEinkuenfte: betragOderNull,
sozialhilfe: betragOderNull,
eigenmittel: betragOderNull,
barbetrag: betragOderNull,
bekleidungspauschale: betragOderNull,
abwesenheitstage: z.coerce
.number()
.int()
.min(0, 'Abwesenheitstage können nicht negativ sein.')
.max(31, 'Ein Monat hat höchstens 31 Tage.')
.default(0),
bemerkung: optionalerText(2000),
})
export type HeimkostenEingabeFormular = z.infer<typeof HeimkostenSchema>
// -----------------------------------------------------------------------------
// Weitere Leistungen
// -----------------------------------------------------------------------------
export const LEISTUNG_STATUS_WERTE = [
'NICHT_GEPRUEFT',
'PRUEFUNG_LAEUFT',
'BEANTRAGT',
'BEWILLIGT',
'ABGELEHNT',
'LAEUFT',
'NICHT_ZUTREFFEND',
] as const
export const LeistungSchema = z
.object({
leistungId: optionalerText(40),
katalogSchluessel: optionalerText(80),
bezeichnung: z.string().trim().min(2, 'Bitte die Leistung benennen.').max(200),
traeger: optionalerText(200),
rechtsgrundlage: optionalerText(200),
status: z.enum(LEISTUNG_STATUS_WERTE).default('NICHT_GEPRUEFT'),
beantragtAm: optionalesDatum,
bewilligtAm: optionalesDatum,
gueltigBis: optionalesDatum,
betrag: optionalerBetrag,
jaehrlichNeuBeantragen: z.coerce.boolean().default(false),
notizen: optionalerText(2000),
})
.refine((daten) => !daten.bewilligtAm || !daten.beantragtAm || daten.bewilligtAm >= daten.beantragtAm, {
message: 'Die Bewilligung kann nicht vor dem Antrag liegen.',
path: ['bewilligtAm'],
})
export const KatalogUebernehmenSchema = z.object({
schluessel: z.array(z.string().trim().min(1)).min(1, 'Bitte mindestens eine Leistung auswählen.'),
})
// -----------------------------------------------------------------------------
// Formularhilfen
// -----------------------------------------------------------------------------
/** Kontrollkästchen liefern "on" oder gar nichts. */
export function kaestchen(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** Liest ein FormData flach in ein Objekt - ohne Mehrfachwerte. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Zod-Feldfehler in die flache Form bringen, die die Formulare erwarten.
*/
export function feldfehler(fehler: z.ZodError): Record<string, string> {
const flach = fehler.flatten().fieldErrors
const ergebnis: Record<string, string> = {}
for (const [feld, meldungen] of Object.entries(flach)) {
const erste = meldungen?.[0]
if (erste) ergebnis[feld] = erste
}
return ergebnis
}
+167
View File
@@ -0,0 +1,167 @@
import { z } from 'zod'
/**
* Gemeinsame Bausteine der Eingabepruefung fuer Immobilie, Vertraege und
* Pflege (Etappe 6).
*
* Sie stehen hier und nicht dreimal nebeneinander, weil ein Cent-Parser,
* den es in drei Fassungen gibt, frueher oder spaeter in einer davon
* falsch rundet - und ein falsch gerundeter Cent laeuft bis in die
* Rechnungslegung nach § 1865 BGB durch.
*/
/** Leere Zeichenketten aus einem Formular werden null, nicht "". */
export const optionalerText = (max = 200) =>
z
.string()
.trim()
.max(max, `Bitte höchstens ${max} Zeichen.`)
.transform((wert) => (wert === '' ? null : wert))
.nullable()
export const pflichtText = (max = 200, meldung = 'Bitte ausfüllen.') =>
z.string().trim().min(1, meldung).max(max, `Bitte höchstens ${max} Zeichen.`)
/** HTML-Datumsfelder liefern "" oder "2026-01-15". */
export const optionalesDatum = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.refine((wert) => wert === null || /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => (wert === null ? null : new Date(`${wert}T00:00:00.000Z`)))
export const pflichtDatum = z
.string()
.trim()
.min(1, 'Bitte ein Datum angeben.')
.refine((wert) => /^\d{4}-\d{2}-\d{2}$/.test(wert), {
message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.',
})
.transform((wert) => new Date(`${wert}T00:00:00.000Z`))
/**
* Liest einen Geldbetrag aus einem Formularfeld als BigInt in Cent.
*
* Akzeptiert die deutsche Schreibweise ("1.234,56"), die englische
* ("1234.56") und blanke Zahlen. Gerechnet wird auf den Ziffern, nicht
* ueber Number - eine Multiplikation mit 100 auf einer Fliesskommazahl
* macht aus 8,15 Euro zuverlaessig 814 Cent.
*/
export function centAusText(text: string): bigint | null {
const sauber = text.trim().replace(/\s/g, '').replace(/€/g, '')
if (sauber === '') return null
const negativ = sauber.startsWith('-')
const ohneVorzeichen = negativ ? sauber.slice(1) : sauber
// Letztes Komma oder letzter Punkt trennt die Nachkommastellen, sofern
// danach ein oder zwei Ziffern stehen. "1.234" ist Tausender, "1.23" nicht.
const treffer = /^(\d{1,3}(?:[.\s]\d{3})+|\d+)(?:[.,](\d{1,2}))?$/.exec(ohneVorzeichen)
if (!treffer) {
const einfach = /^(\d+)[.,](\d{1,2})$/.exec(ohneVorzeichen)
if (!einfach) return null
const euro = BigInt(einfach[1]!)
const cent = BigInt((einfach[2] ?? '0').padEnd(2, '0'))
const wert = euro * 100n + cent
return negativ ? -wert : wert
}
const euro = BigInt(treffer[1]!.replace(/[.\s]/g, ''))
const cent = BigInt((treffer[2] ?? '0').padEnd(2, '0'))
const wert = euro * 100n + cent
return negativ ? -wert : wert
}
/** Zod-Feld fuer einen optionalen Betrag. */
export const optionalerBetrag = z
.string()
.trim()
.transform((wert) => (wert === '' ? null : wert))
.nullable()
.superRefine((wert, kontext) => {
if (wert === null) return
if (centAusText(wert) === null) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
}
})
.transform((wert) => (wert === null ? null : centAusText(wert)))
export const pflichtBetrag = z
.string()
.trim()
.min(1, 'Bitte einen Betrag angeben.')
.superRefine((wert, kontext) => {
if (centAusText(wert) === null) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
message: 'Bitte einen Betrag wie 1.234,56 eingeben.',
})
}
})
.transform((wert) => centAusText(wert)!)
/** Optionale ganze Zahl aus einem Formularfeld. */
export const optionaleZahl = (min: number, max: number, meldung?: string) =>
z
.union([z.string(), z.number(), z.null(), z.undefined()])
.transform((wert) => {
if (wert === null || wert === undefined) return null
const text = String(wert).trim()
return text === '' ? null : text
})
.superRefine((wert, kontext) => {
if (wert === null) return
const zahl = Number(wert)
if (!Number.isInteger(zahl) || zahl < min || zahl > max) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
message: meldung ?? `Bitte eine ganze Zahl zwischen ${min} und ${max}.`,
})
}
})
.transform((wert) => (wert === null ? null : Number(wert)))
/** Kontrollkaestchen liefern "on" oder gar nichts. */
export function haken(formular: FormData, name: string): boolean {
const wert = formular.get(name)
return wert === 'on' || wert === 'true' || wert === '1'
}
/** FormData in ein flaches Objekt - Dateien werden ignoriert. */
export function formularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
for (const [name, wert] of formular.entries()) {
if (typeof wert === 'string') roh[name] = wert
}
return roh
}
/**
* Der Rueckgabetyp aller Formular-Aktionen dieser Etappe. Fehler werden
* zurueckgegeben, nie geworfen - ein geworfener Fehler zeigt dem Nutzer
* eine Fehlerseite und verliert seine Eingaben.
*/
export type Formularzustand = {
fehler?: string
erfolg?: string
felder?: Record<string, string>
}
/** Uebersetzt einen Zod-Fehler in den Formularzustand. */
export function zodFehler(
fehler: z.ZodError,
meldung = 'Bitte prüfe deine Eingaben.',
): Formularzustand {
const felder: Record<string, string> = {}
for (const [name, meldungen] of Object.entries(fehler.flatten().fieldErrors)) {
const erste = meldungen?.[0]
if (erste) felder[name] = erste
}
return { fehler: meldung, felder }
}
+172
View File
@@ -0,0 +1,172 @@
import { z } from 'zod'
import {
optionalerBetrag,
optionalerText,
optionaleZahl,
optionalesDatum,
pflichtBetrag,
pflichtDatum,
pflichtText,
} from '@/lib/validierung/substanz'
import { WERTQUELLEN_VERTRAG } from '@/lib/vertraege/konstanten'
/**
* Eingabepruefung fuer Versicherungen und Vertraege.
*/
export const VERTRAG_ARTEN = [
'LEBENSVERSICHERUNG',
'STERBEGELDVERSICHERUNG',
'BESTATTUNGSVORSORGE',
'RENTENVERSICHERUNG_PRIVAT',
'UNFALLVERSICHERUNG',
'HAFTPFLICHT',
'HAUSRAT',
'WOHNGEBAEUDE',
'KRANKENZUSATZ',
'PFLEGEZUSATZ',
'RECHTSSCHUTZ',
'STERBEKASSE',
'STROM',
'GAS',
'WASSER',
'TELEFON_INTERNET',
'RUNDFUNKBEITRAG',
'MITGLIEDSCHAFT',
'ABONNEMENT',
'SONSTIGES',
] as const
export const VERTRAG_STATUS = [
'AKTIV',
'BEITRAGSFREI',
'RUHEND',
'GEKUENDIGT',
'ABGETRETEN',
'VERWERTET',
'ABGELAUFEN',
] as const
export const ZAHL_RHYTHMEN = [
'MONATLICH',
'VIERTELJAEHRLICH',
'HALBJAEHRLICH',
'JAEHRLICH',
'EINMALIG',
] as const
export const BEWERTUNGEN = ['UNGEPRUEFT', 'VERWERTBAR', 'GESCHUETZT', 'STRITTIG'] as const
export const VertragSchema = z
.object({
bezeichnung: pflichtText(
150,
'Bitte eine Bezeichnung angeben, z. B. „Sterbegeldversicherung Allianz“.',
),
gesellschaft: optionalerText(150),
art: z.enum(VERTRAG_ARTEN),
status: z.enum(VERTRAG_STATUS).default('AKTIV'),
/** Wird verschlüsselt gespeichert. */
vertragsnummer: optionalerText(80),
beginnAm: optionalesDatum,
endeAm: optionalesDatum,
kuendigungsfristMonate: optionaleZahl(
0,
120,
'Bitte die Kündigungsfrist in ganzen Monaten angeben.',
),
laufzeitJahre: optionaleZahl(1, 99, 'Bitte die Laufzeit in ganzen Jahren angeben.'),
kuendigungZum: optionalesDatum,
gekuendigtAm: optionalesDatum,
beitrag: optionalerBetrag,
rhythmus: z.enum(ZAHL_RHYTHMEN).nullable().catch(null),
bewertungSozialhilfe: z.enum(BEWERTUNGEN).default('UNGEPRUEFT'),
bewertungBegruendung: optionalerText(4000),
notizen: optionalerText(4000),
})
.superRefine((daten, kontext) => {
if (daten.beginnAm && daten.endeAm && daten.endeAm.getTime() < daten.beginnAm.getTime()) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['endeAm'],
message: 'Das Vertragsende liegt vor dem Beginn.',
})
}
if (daten.beitrag !== null && !daten.rhythmus) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['rhythmus'],
message: 'Zu einem Beitrag gehört der Zahlungsrhythmus — sonst lässt sich der Jahresbeitrag nicht rechnen.',
})
}
if (daten.bewertungSozialhilfe !== 'UNGEPRUEFT' && !daten.bewertungBegruendung) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['bewertungBegruendung'],
message:
'Bitte begründen, warum du den Vertrag so einordnest. Der Bezirk fragt danach, ' +
'und im Zweifel steht diese Begründung Jahre später im Widerspruchsverfahren.',
})
}
if (daten.status === 'GEKUENDIGT' && !daten.gekuendigtAm) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['gekuendigtAm'],
message: 'Bitte das Datum der Kündigung angeben.',
})
}
})
export type VertragEingabe = z.infer<typeof VertragSchema>
/** Rückkaufswert zu einem Stichtag - Historie, nichts wird überschrieben. */
export const RueckkaufswertSchema = z.object({
stichtag: pflichtDatum,
wert: pflichtBetrag,
wertquelle: z.enum(WERTQUELLEN_VERTRAG, {
errorMap: () => ({ message: 'Bitte angeben, woher der Wert stammt.' }),
}),
bemerkung: optionalerText(1000),
})
export type RueckkaufswertEingabe = z.infer<typeof RueckkaufswertSchema>
export const RueckkaufswertAnfrageSchema = z.object({
stichtag: pflichtDatum,
empfaengerAnschrift: optionalerText(500),
})
export type RueckkaufswertAnfrageEingabe = z.infer<typeof RueckkaufswertAnfrageSchema>
/**
* Kündigung vermerken. Der Versandnachweis ist Pflicht: Ohne ihn lässt
* sich der Zugang nicht beweisen, und beweisen muss ihn derjenige, der
* gekündigt hat.
*/
export const KuendigungVermerkenSchema = z
.object({
gekuendigtAm: pflichtDatum,
kuendigungZum: optionalesDatum,
versandart: pflichtText(100, 'Bitte angeben, wie die Kündigung versandt wurde.'),
nachweisVorhanden: z.boolean().default(false),
genehmigungGeklaert: z.boolean().default(false),
bemerkung: optionalerText(2000),
})
.superRefine((daten, kontext) => {
if (!daten.nachweisVorhanden) {
kontext.addIssue({
code: z.ZodIssueCode.custom,
path: ['nachweisVorhanden'],
message:
'Ohne Versandnachweis wird die Kündigung nicht als erledigt geführt. ' +
'Lade Einschreibenbeleg oder Faxprotokoll als Dokument hoch und hake dann an.',
})
}
})
export type KuendigungVermerkenEingabe = z.infer<typeof KuendigungVermerkenSchema>