import { z } from 'zod' import type { KontaktAnrede, KontaktArt, KontaktRolle } from '@prisma/client' /** * Eingabepruefung fuer Kontakte und Kontaktprotokolle. * * Die Pruefung laeuft immer serverseitig; das Formular im Browser ist nur * Bequemlichkeit. Meldungen sagen, was zu tun ist - nicht, was falsch war. * * Diese Datei kommt ohne "server-only" aus: die Beschriftungen der Rollen * und Kontaktarten werden auch im Browser gebraucht. */ // ----------------------------------------------------------------------------- // Bausteine // ----------------------------------------------------------------------------- /** Leere Zeichenketten aus einem Formular werden zu null, nicht zu "". */ const optionalerText = (max = 200) => z .string() .trim() .max(max, `Bitte höchstens ${max} Zeichen.`) .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) const optionaleEmail = z .string() .trim() .max(200, 'Bitte höchstens 200 Zeichen.') .transform((wert) => (wert === '' ? null : wert.toLowerCase())) .nullable() .default(null) .refine((wert) => wert === null || /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(wert), { message: 'Bitte eine gültige E-Mail-Adresse angeben – oder das Feld leer lassen.', }) /** * Ein Tag aus einem HTML-Datumsfeld ("2026-08-22"). * * Gespeichert wird 12:00 UTC, nicht Mitternacht: Ein Kontaktprotokoll ist * fachlich ein Tag, und um die Mittagszeit herum kippt keine Zeitzone der * Welt den Eintrag auf den Vor- oder Folgetag. */ const TAG_MUSTER = /^\d{4}-\d{2}-\d{2}$/ const pflichtTag = z .string() .trim() .min(1, 'Bitte ein Datum angeben.') .refine((wert) => TAG_MUSTER.test(wert), { message: 'Bitte ein Datum im Format TT.MM.JJJJ wählen.', }) .transform((wert) => new Date(`${wert}T12:00:00.000Z`)) // Nach dem Umwandeln noch einmal pruefen: "2026-02-31" passt zwar auf // das Muster, ergibt aber kein Datum. .refine((datum) => datum instanceof Date && !Number.isNaN(datum.getTime()), { message: 'Dieses Datum gibt es nicht.', }) const optionaleId = z .string() .trim() .max(40) .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) // ----------------------------------------------------------------------------- // Kontaktrollen // ----------------------------------------------------------------------------- /** * Reihenfolge im Verzeichnis: wer im Alltag am haeufigsten gebraucht wird, * steht oben. Gericht und Bezirk zuerst - dort entscheidet sich das meiste. */ export const KONTAKT_ROLLEN = [ 'RECHTSPFLEGER', 'RICHTER', 'BETREUUNGSBEHOERDE', 'SACHBEARBEITER_BEZIRK', 'SACHBEARBEITER_SOZIALAMT', 'HEIMLEITUNG', 'PFLEGEDIENSTLEITUNG', 'SOZIALDIENST_HEIM', 'HAUSARZT', 'FACHARZT', 'KRANKENKASSE', 'PFLEGEKASSE', 'RENTENVERSICHERUNG', 'NOTAR', 'RECHTSANWALT', 'STEUERBERATER', 'BANK', 'VERSICHERUNG', 'VERSICHERUNGSMAKLER', 'MAKLER', 'HANDWERKER', 'ANGEHOERIGER', 'SONSTIGES', ] as const satisfies readonly KontaktRolle[] /** Anreden fuer die Briefanrede - Reihenfolge der Auswahlliste. */ export const KONTAKT_ANREDEN = [ 'OHNE', 'FRAU', 'HERR', ] as const satisfies readonly KontaktAnrede[] export const KONTAKT_ROLLE_BESCHRIFTUNG: Record = { RECHTSPFLEGER: 'Rechtspfleger', RICHTER: 'Richter', BETREUUNGSBEHOERDE: 'Betreuungsbehörde', SACHBEARBEITER_BEZIRK: 'Sachbearbeiter Bezirk', SACHBEARBEITER_SOZIALAMT: 'Sachbearbeiter Sozialamt', HEIMLEITUNG: 'Heimleitung', PFLEGEDIENSTLEITUNG: 'Pflegedienstleitung', SOZIALDIENST_HEIM: 'Sozialdienst im Heim', HAUSARZT: 'Hausarzt', FACHARZT: 'Facharzt', KRANKENKASSE: 'Krankenkasse', PFLEGEKASSE: 'Pflegekasse', RENTENVERSICHERUNG: 'Rentenversicherung', NOTAR: 'Notar', MAKLER: 'Makler', VERSICHERUNGSMAKLER: 'Versicherungsmakler', VERSICHERUNG: 'Versicherung', STEUERBERATER: 'Steuerberater', RECHTSANWALT: 'Rechtsanwalt', BANK: 'Bank', HANDWERKER: 'Handwerker', ANGEHOERIGER: 'Angehöriger', SONSTIGES: 'Sonstiges', } // ----------------------------------------------------------------------------- // Kontaktarten // ----------------------------------------------------------------------------- export const KONTAKT_ARTEN = [ 'BESUCH', 'TELEFONAT', 'EMAIL', 'BRIEF', 'VIDEOCALL', 'FAX', 'VORSPRACHE', ] as const satisfies readonly KontaktArt[] export const KONTAKT_ART_BESCHRIFTUNG: Record = { BESUCH: 'Besuch', TELEFONAT: 'Telefonat', EMAIL: 'E-Mail', BRIEF: 'Brief', VIDEOCALL: 'Videocall', FAX: 'Fax', VORSPRACHE: 'Vorsprache', } /** Die fünf Arten, die in der Schnellerfassung als grosse Knopfreihe stehen. */ export const KONTAKT_ARTEN_SCHNELLWAHL = [ 'BESUCH', 'TELEFONAT', 'EMAIL', 'BRIEF', 'VIDEOCALL', ] as const satisfies readonly KontaktArt[] /** Dauer-Schnellwahl in Minuten. Ein freies Feld gibt es zusaetzlich. */ export const DAUER_SCHNELLWAHL = [15, 30, 45, 60] as const // ----------------------------------------------------------------------------- // Kontakt // ----------------------------------------------------------------------------- export const KontaktSchema = z.object({ name: z .string() .trim() .min(2, 'Bitte den Namen angeben – mindestens zwei Zeichen.') .max(150, 'Bitte höchstens 150 Zeichen.'), organisation: optionalerText(150), rolle: z.enum(KONTAKT_ROLLEN, { errorMap: () => ({ message: 'Bitte eine Rolle aus der Liste wählen.' }), }), anrede: z.enum(KONTAKT_ANREDEN).default('OHNE'), telefon: optionalerText(50), mobil: optionalerText(50), fax: optionalerText(50), email: optionaleEmail, strasse: optionalerText(150), plz: optionalerText(10), ort: optionalerText(100), aktenzeichen: optionalerText(80), notizen: optionalerText(4000), }) export type KontaktEingabe = z.infer /** Diese Felder wandern verschluesselt in die Datenbank. */ export const KONTAKT_GEHEIME_FELDER = ['notizen'] as const export function kontaktFormularLesen(formular: FormData): Record { const roh: Record = {} for (const feld of [ 'name', 'organisation', 'anrede', 'rolle', 'telefon', 'mobil', 'fax', 'email', 'strasse', 'plz', 'ort', 'aktenzeichen', 'notizen', ]) { const wert = formular.get(feld) if (wert !== null) roh[feld] = wert } return roh } // ----------------------------------------------------------------------------- // Kontaktprotokoll // ----------------------------------------------------------------------------- /** Laenger als ein voller Tag ist kein einzelner Kontakt. */ const DAUER_MAX_MINUTEN = 1440 export const KontaktprotokollSchema = z.object({ datum: pflichtTag, art: z.enum(KONTAKT_ARTEN, { errorMap: () => ({ message: 'Bitte auswählen, wie der Kontakt stattgefunden hat.' }), }), dauerMinuten: z .union([z.literal(''), z.coerce.number()]) .transform((wert) => (wert === '' ? null : wert)) .nullable() .default(null) .refine((wert) => wert === null || (Number.isInteger(wert) && wert > 0), { message: 'Bitte die Dauer in ganzen Minuten angeben.', }) .refine((wert) => wert === null || wert <= DAUER_MAX_MINUTEN, { message: 'Mehr als 24 Stunden sind kein einzelner Kontakt – bitte aufteilen.', }), gespraechspartner: optionalerText(150), mitBetreuter: z.coerce.boolean().default(false), kontaktId: optionaleId, vorgangId: optionaleId, inhalt: optionalerText(8000), wuensche: optionalerText(4000), folgeaufgaben: optionalerText(4000), }) export type KontaktprotokollEingabe = z.infer /** Diese Felder wandern verschluesselt in die Datenbank. */ export const PROTOKOLL_GEHEIME_FELDER = ['inhalt', 'wuensche', 'folgeaufgaben'] as const export function protokollFormularLesen(formular: FormData): Record { const roh: Record = {} for (const feld of [ 'datum', 'art', 'dauerMinuten', 'gespraechspartner', 'kontaktId', 'vorgangId', 'inhalt', 'wuensche', 'folgeaufgaben', ]) { const wert = formular.get(feld) if (wert !== null) roh[feld] = wert } // Ein Kontrollkaestchen liefert "on" oder gar nichts. roh.mitBetreuter = formular.get('mitBetreuter') === 'on' return roh } /** * Ein gespeicherter Zeitstempel zurueck in den Wert eines HTML-Datumsfelds. * Gegenstueck zu pflichtTag - beide rechnen in UTC, damit das Bearbeiten * eines Protokolls es nicht auf den Vortag schiebt. */ export function tagFuerFormular(datum: Date): string { const jahr = datum.getUTCFullYear() const monat = String(datum.getUTCMonth() + 1).padStart(2, '0') const tag = String(datum.getUTCDate()).padStart(2, '0') return `${jahr}-${monat}-${tag}` }