feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig. Ersteinrichtung in drei Schritten, bewusst ausserhalb des Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert. Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf. Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich sonst dauerhaft aussperren. Erst danach gibt es die Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash. Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts gerechnet statt ueber eine Differenz, damit Schaltjahre und der 29. Februar von selbst stimmen. Daran haengen Jahresbericht, Rechnungslegung und die Ausschlussfrist der Aufwandspauschale. Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch dann speicherbar bleiben, wenn das Fristenmodul fehlt. Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu. Die Aenderungen liegen verschluesselt, weil dort Diagnosen und Kontostaende stehen koennen. Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang. Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026, leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk mit acht Nachforderungen. Nur ausserhalb der Produktion. Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1. Etappe: 1 Phase: Fundament
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
import { z } from 'zod'
|
||||
import type { 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`))
|
||||
.refine((datum) => !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[]
|
||||
|
||||
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.' }),
|
||||
}),
|
||||
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',
|
||||
'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}`
|
||||
}
|
||||
Reference in New Issue
Block a user