Files
betreuungs-cockpit/src/lib/validierung/kontakte.ts
T
Florian Wilde 9e68d10787 Ansprechpartner am Vorgang hinterlegen
Ein namentlich adressiertes Schreiben landet bei der Person, die den
Vorgang kennt, statt in der Poststelle. Am Vorgang laesst sich jetzt ein
Kontakt aus dem Verzeichnis als Ansprechpartner benennen; die
Sachstandsanfrage redet ihn an, und sein Name steht ueber der Behoerde.

Die Sachstandsanfrage konnte das schon - sie bekam den Namen nur nie
uebergeben. entwurf.ts lud einen der verknuepften Kontakte und verwarf
ihn wieder. Wer angeredet wird, ist jetzt eine Entscheidung und kein
Zufall.

Aufbau:
- Migration: ansprechpartnerId am Vorgang (SetNull - ein Vorgang darf
  nicht daran haengen, dass es den Kontakt noch gibt) und anrede am
  Kontakt.
- Die Anrede als reine Funktion in kontakte/anrede.ts. Geraten wird nie:
  ohne hinterlegte Anrede wird mit vollem Namen angeredet. Ein leerer
  Name mit gesetzter Anrede faellt auf die allgemeine Anrede zurueck -
  "Sehr geehrte Frau ," darf nie herausgehen.
- Der Wechsel des Ansprechpartners steht mit NAMEN im Protokoll. Zwei
  cuids nebeneinander waeren protokolliert, aber nicht nachvollziehbar,
  und nachvollziehbar war die Anforderung.
- Der Ansprechpartner wird gegen die Akte geprueft, bevor er gespeichert
  wird - sein Name steht anschliessend im Briefkopf.

Nebenbei behoben: Der Schreiben-Editor verwarf die empfaengerAnschrift
aus dem Entwurf, sein State startete immer leer. Jetzt kommt die
Anschrift des Ansprechpartners im Formular an.

Geprueft: neun Einheitstests fuer die Anrede, fuenf End-to-End-Tests von
der Auswahl bis zum Protokolleintrag.

Refs #9
2026-08-23 16:31:10 +02:00

296 lines
8.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<KontaktRolle, string> = {
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<KontaktArt, string> = {
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<typeof KontaktSchema>
/** Diese Felder wandern verschluesselt in die Datenbank. */
export const KONTAKT_GEHEIME_FELDER = ['notizen'] as const
export function kontaktFormularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
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<typeof KontaktprotokollSchema>
/** Diese Felder wandern verschluesselt in die Datenbank. */
export const PROTOKOLL_GEHEIME_FELDER = ['inhalt', 'wuensche', 'folgeaufgaben'] as const
export function protokollFormularLesen(formular: FormData): Record<string, unknown> {
const roh: Record<string, unknown> = {}
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}`
}