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
296 lines
8.7 KiB
TypeScript
296 lines
8.7 KiB
TypeScript
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}`
|
||
}
|