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:
Florian Wilde
2026-08-22 02:16:22 +02:00
parent dcd3a8ce71
commit 22b3a9b9e9
134 changed files with 30753 additions and 17 deletions
+284
View File
@@ -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}`
}