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 export type EntwurfSpeichernEingabe = z.infer export type BelegmappeEingabe = z.infer export type UnterlagenpaketEingabe = z.infer export type VollexportEingabe = z.infer // ----------------------------------------------------------------------------- // Dateinamen im Export // ----------------------------------------------------------------------------- const UMSCHRIFT: Record = { ä: '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 = { '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//__. * * 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}`) }