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 = { 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 = { 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 // ----------------------------------------------------------------------------- // 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 // ----------------------------------------------------------------------------- // 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, ): z.output { 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) }