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
+47
View File
@@ -0,0 +1,47 @@
import 'server-only'
import { db, nichtGeloescht } from '@/lib/db'
import {
statistikBerechnen,
textbausteinErzeugen,
textkontextAusAnrede,
type Kontaktstatistik,
} from '@/lib/kontakte/statistik'
/**
* Die Auswertung, aus der spaeter der Jahresbericht nach § 1863 BGB
* gespeist wird: wie oft bestand Kontakt, wie oft davon persoenlich, wie
* lange insgesamt - und ein fertiger Absatz in ganzen Saetzen.
*
* Gerechnet wird auf den unverschluesselten Feldern (Datum, Art, Dauer,
* mitBetreuter). Die Freitexte bleiben unangetastet: fuer die Statistik
* werden sie nicht gebraucht, und was nicht entschluesselt wird, kann auch
* nicht versehentlich irgendwo landen.
*/
export type KontaktAuswertung = Kontaktstatistik & {
/** Fertige Saetze zur Uebernahme in den Jahresbericht. */
textbaustein: string
}
export async function kontaktstatistik(
betreuteId: string,
von: Date,
bis: Date,
): Promise<KontaktAuswertung> {
const [protokolle, betreute] = await Promise.all([
db.kontaktprotokoll.findMany({
where: { betreuteId, datum: { gte: von, lte: bis }, ...nichtGeloescht },
orderBy: { datum: 'asc' },
select: { datum: true, art: true, dauerMinuten: true, mitBetreuter: true },
}),
db.betreute.findFirst({
where: { id: betreuteId, ...nichtGeloescht },
select: { anrede: true, heimName: true },
}),
])
const statistik = statistikBerechnen(protokolle, von, bis)
const kontext = textkontextAusAnrede(betreute?.anrede, betreute?.heimName)
return { ...statistik, textbaustein: textbausteinErzeugen(statistik, kontext) }
}
+299
View File
@@ -0,0 +1,299 @@
import { differenceInCalendarMonths } from 'date-fns'
import type { KontaktArt } from '@prisma/client'
/**
* Auswertung der Kontaktprotokolle - ohne Datenbank, ohne React.
*
* Der Jahresbericht nach § 1863 BGB will wissen, ob und wie oft
* persoenlicher Kontakt zur betreuten Person bestand. Diese Datei zaehlt
* das aus und schreibt daraus ganze Saetze, die sich in den Bericht
* uebernehmen lassen. Was hier steht, ist eine Zusammenfassung meiner
* eigenen Aufzeichnungen - keine Rechtsauskunft darueber, ob der Umfang
* ausreicht.
*/
export type ProtokollFuerStatistik = {
datum: Date
art: KontaktArt
dauerMinuten: number | null
mitBetreuter: boolean
}
export type Monatszeile = {
jahr: number
monat: number
gesamt: number
/** Davon persoenlicher Kontakt zur betreuten Person selbst. */
persoenlich: number
}
export type Kontaktstatistik = {
von: Date
bis: Date
/** Volle bzw. angefangene Kalendermonate im Zeitraum. */
monateImZeitraum: number
gesamt: number
/** Protokolle mit mitBetreuter = true. */
persoenlich: number
/** Kalendertage, an denen ein persoenlicher Kontakt stattfand. */
persoenlicheTage: number
/** Besuchstage bei der betreuten Person (Besuch oder Vorsprache vor Ort). */
besuchstage: number
telefonate: number
schriftlich: number
/** Monate mit mindestens einem persoenlichen Kontakt. */
monateMitPersoenlichemKontakt: number
jeArt: Record<KontaktArt, number>
jeMonat: Monatszeile[]
gesamtdauerMinuten: number
/** Zu wie vielen Protokollen ist ueberhaupt eine Dauer erfasst? */
mitDauerErfasst: number
}
/** Persoenliche Begegnung vor Ort. */
const ARTEN_BESUCH: KontaktArt[] = ['BESUCH', 'VORSPRACHE']
/** Gespraech auf Distanz. */
const ARTEN_TELEFON: KontaktArt[] = ['TELEFONAT', 'VIDEOCALL']
/** Schriftverkehr. */
const ARTEN_SCHRIFTLICH: KontaktArt[] = ['EMAIL', 'BRIEF', 'FAX']
const ALLE_ARTEN: KontaktArt[] = [
'BESUCH',
'TELEFONAT',
'EMAIL',
'BRIEF',
'VIDEOCALL',
'FAX',
'VORSPRACHE',
]
function tagesschluessel(datum: Date): string {
return `${datum.getUTCFullYear()}-${datum.getUTCMonth()}-${datum.getUTCDate()}`
}
/**
* Ab welchem Anteil von Monaten mit persoenlichem Kontakt formuliere ich
* "regelmaessig"? Drei Viertel - wer in neun von zwoelf Monaten da war,
* war regelmaessig da.
*/
const REGELMAESSIG_AB_ANTEIL = 0.75
export function statistikBerechnen(
protokolle: ProtokollFuerStatistik[],
von: Date,
bis: Date,
): Kontaktstatistik {
const jeArt = Object.fromEntries(ALLE_ARTEN.map((art) => [art, 0])) as Record<KontaktArt, number>
const monate = new Map<string, Monatszeile>()
const persoenlicheTage = new Set<string>()
const besuchstage = new Set<string>()
const monateMitPersoenlich = new Set<string>()
let gesamt = 0
let persoenlich = 0
let telefonate = 0
let schriftlich = 0
let gesamtdauerMinuten = 0
let mitDauerErfasst = 0
for (const eintrag of protokolle) {
gesamt += 1
jeArt[eintrag.art] = (jeArt[eintrag.art] ?? 0) + 1
if (ARTEN_TELEFON.includes(eintrag.art)) telefonate += 1
if (ARTEN_SCHRIFTLICH.includes(eintrag.art)) schriftlich += 1
if (eintrag.dauerMinuten !== null && eintrag.dauerMinuten > 0) {
gesamtdauerMinuten += eintrag.dauerMinuten
mitDauerErfasst += 1
}
const jahr = eintrag.datum.getUTCFullYear()
const monat = eintrag.datum.getUTCMonth() + 1
const monatsschluessel = `${jahr}-${String(monat).padStart(2, '0')}`
const zeile = monate.get(monatsschluessel) ?? { jahr, monat, gesamt: 0, persoenlich: 0 }
zeile.gesamt += 1
if (eintrag.mitBetreuter) {
persoenlich += 1
zeile.persoenlich += 1
persoenlicheTage.add(tagesschluessel(eintrag.datum))
monateMitPersoenlich.add(monatsschluessel)
if (ARTEN_BESUCH.includes(eintrag.art)) besuchstage.add(tagesschluessel(eintrag.datum))
}
monate.set(monatsschluessel, zeile)
}
const jeMonat = [...monate.values()].sort((a, b) =>
a.jahr === b.jahr ? a.monat - b.monat : a.jahr - b.jahr,
)
return {
von,
bis,
monateImZeitraum: Math.max(1, differenceInCalendarMonths(bis, von) + 1),
gesamt,
persoenlich,
persoenlicheTage: persoenlicheTage.size,
besuchstage: besuchstage.size,
telefonate,
schriftlich,
monateMitPersoenlichemKontakt: monateMitPersoenlich.size,
jeArt,
jeMonat,
gesamtdauerMinuten,
mitDauerErfasst,
}
}
// -----------------------------------------------------------------------------
// Textbaustein fuer den Jahresbericht
// -----------------------------------------------------------------------------
export type Textkontext = {
/** "die Betreute", "der Betreute", "die betreute Person". */
nominativ: string
/** "die Betreute", "den Betreuten", "die betreute Person". */
akkusativ: string
/** Fertige Praepositionalphrase: "zur Betreuten", "zum Betreuten". */
kontaktZu: string
/** "das Pflegeheim Sonnenhof" - wird zu "im Pflegeheim Sonnenhof". */
heimName: string | null
}
export const TEXTKONTEXT_NEUTRAL: Textkontext = {
nominativ: 'die betreute Person',
akkusativ: 'die betreute Person',
kontaktZu: 'zur betreuten Person',
heimName: null,
}
/** Aus der Anrede der Stammdaten die passende Bezeichnung bilden. */
export function textkontextAusAnrede(
anrede: string | null | undefined,
heimName: string | null | undefined,
): Textkontext {
const gereinigt = (anrede ?? '').trim().toLowerCase()
if (gereinigt.startsWith('frau')) {
return {
nominativ: 'die Betreute',
akkusativ: 'die Betreute',
kontaktZu: 'zur Betreuten',
heimName: heimName ?? null,
}
}
if (gereinigt.startsWith('herr')) {
return {
nominativ: 'der Betreute',
akkusativ: 'den Betreuten',
kontaktZu: 'zum Betreuten',
heimName: heimName ?? null,
}
}
return { ...TEXTKONTEXT_NEUTRAL, heimName: heimName ?? null }
}
function datumDeutsch(datum: Date): string {
const tag = String(datum.getUTCDate()).padStart(2, '0')
const monat = String(datum.getUTCMonth() + 1).padStart(2, '0')
return `${tag}.${monat}.${datum.getUTCFullYear()}`
}
function mehrzahl(anzahl: number, einzahl: string, mehrzahlForm: string): string {
return `${anzahl} ${anzahl === 1 ? einzahl : mehrzahlForm}`
}
/** "3 Stunden 15 Minuten", "45 Minuten". */
export function dauerAusgeschrieben(minuten: number): string {
if (minuten <= 0) return '0 Minuten'
const stunden = Math.floor(minuten / 60)
const rest = minuten % 60
if (stunden === 0) return mehrzahl(rest, 'Minute', 'Minuten')
if (rest === 0) return mehrzahl(stunden, 'Stunde', 'Stunden')
return `${mehrzahl(stunden, 'Stunde', 'Stunden')} ${mehrzahl(rest, 'Minute', 'Minuten')}`
}
/**
* Ganze Saetze fuer den Jahresbericht. Bewusst zurueckhaltend formuliert:
* der Text beschreibt, was dokumentiert ist, und behauptet nichts darueber
* hinaus. Fehlt persoenlicher Kontakt, sagt der Text das auch - ein
* geschoenter Bericht hilft niemandem.
*/
export function textbausteinErzeugen(
statistik: Kontaktstatistik,
kontext: Textkontext = TEXTKONTEXT_NEUTRAL,
): string {
const zeitraum = `Im Berichtszeitraum vom ${datumDeutsch(statistik.von)} bis ${datumDeutsch(
statistik.bis,
)}`
if (statistik.gesamt === 0) {
return (
`${zeitraum} ist kein Kontakt dokumentiert. Sofern Kontakte stattgefunden haben, ` +
`sind sie noch nachzutragen.`
)
}
const saetze: string[] = []
// Satz 1: Gab es persoenlichen Kontakt, und war er regelmaessig?
if (statistik.persoenlich === 0) {
saetze.push(`${zeitraum} ist kein persönlicher Kontakt ${kontext.kontaktZu} dokumentiert.`)
} else {
const anteil = statistik.monateMitPersoenlichemKontakt / statistik.monateImZeitraum
const regelmaessig = anteil >= REGELMAESSIG_AB_ANTEIL
saetze.push(
`${zeitraum} bestand ${regelmaessig ? 'regelmäßiger ' : ''}persönlicher Kontakt ` +
`${kontext.kontaktZu}.`,
)
}
// Satz 2: Besuche, Telefonate, Schriftverkehr in Zahlen.
const teile: string[] = []
if (statistik.besuchstage > 0) {
const ort = kontext.heimName ? ` in ${kontext.heimName}` : ''
teile.push(
`habe ${kontext.akkusativ} an ${mehrzahl(
statistik.besuchstage,
'Tag',
'Tagen',
)} persönlich${ort} besucht`,
)
}
if (statistik.telefonate > 0) {
teile.push(`${mehrzahl(statistik.telefonate, 'Telefonat', 'Telefonate')} geführt`)
}
if (statistik.schriftlich > 0) {
teile.push(
`${mehrzahl(statistik.schriftlich, 'schriftlichen Kontakt', 'schriftliche Kontakte')} gehabt`,
)
}
if (teile.length === 1) {
saetze.push(`Ich ${teile[0]}.`)
} else if (teile.length > 1) {
const letzter = teile[teile.length - 1]!
saetze.push(`Ich ${teile.slice(0, -1).join(', ')} und ${letzter}.`)
}
// Satz 3: Umfang insgesamt.
saetze.push(
`Insgesamt sind ${mehrzahl(statistik.gesamt, 'Kontakt', 'Kontakte')} dokumentiert` +
(statistik.gesamtdauerMinuten > 0
? `, davon ${mehrzahl(statistik.mitDauerErfasst, 'Kontakt', 'Kontakte')} mit einer ` +
`erfassten Dauer von zusammen ${dauerAusgeschrieben(statistik.gesamtdauerMinuten)}.`
: '.'),
)
// Satz 4: Verteilung ueber die Monate - die Frage "war es regelmaessig?".
if (statistik.persoenlich > 0) {
saetze.push(
`Persönlicher Kontakt fand in ${statistik.monateMitPersoenlichemKontakt} von ` +
`${mehrzahl(statistik.monateImZeitraum, 'Monat', 'Monaten')} des Berichtszeitraums statt.`,
)
}
return saetze.join(' ')
}