diff --git a/package.json b/package.json index c971e5e..65f7d46 100644 --- a/package.json +++ b/package.json @@ -20,9 +20,12 @@ "db:volltext": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/volltextsuche.sql", "db:seed": "prisma db seed", "db:studio": "prisma studio", - "setup": "prisma migrate deploy && npm run db:volltext && npm run db:seed", + "setup": "prisma migrate deploy && npm run db:sql && npm run db:seed", "schluessel": "node scripts/schluessel-erzeugen.mjs", - "postinstall": "prisma generate" + "postinstall": "prisma generate", + "db:audit-schutz": "prisma db execute --schema prisma/schema.prisma --file prisma/sql/audit-schutz.sql", + "db:sql": "npm run db:volltext && npm run db:audit-schutz", + "protokoll": "node scripts/bauprotokoll.mjs" }, "prisma": { "seed": "tsx prisma/seed.ts" diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..96485cd --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,1567 @@ +// ============================================================================= +// Betreuungs-Cockpit - Datenmodell +// ============================================================================= +// +// Konventionen in diesem Schema: +// +// 1. Geldbetraege liegen IMMER als BigInt in Cent vor ("...Cent"). Kein Float, +// keine Decimal-Konvertierung im Anzeigepfad - die Rechnungslegung nach +// § 1865 BGB darf keine Rundungsfehler erben. +// +// 2. Felder mit dem Suffix "Verschluesselt" enthalten einen AES-256-GCM- +// Umschlag (siehe src/lib/crypto/feld.ts), niemals Klartext. Sie lassen +// sich nicht filtern oder sortieren. Wo trotzdem gesucht werden muss, +// steht daneben ein "...Index"-Feld mit einem HMAC-Blindindex. +// +// 3. Alles, was fachlich zu einer betreuten Person gehoert, traegt eine +// betreuteId. Der Zugriffsschutz prueft grundsaetzlich gegen dieses Feld +// (Row-Level-Pruefung), nie nur im UI. +// +// 4. Loeschen ist ein Papierkorb: "geloeschtAm" wird gesetzt, der Datensatz +// bleibt 30 Tage bestehen und wird danach hart entfernt. +// +// 5. Datumsfelder ohne Uhrzeitbezug (Fristen, Bescheide, Stichtage) sind +// @db.Date - sonst verschiebt die Zeitzone eine Frist um einen Tag. +// +// ============================================================================= + +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +// ============================================================================= +// Benutzer, Rollen, Zugriff +// ============================================================================= + +enum Rolle { + BETREUER // voller Zugriff auf die zugeordneten Betreuten + MITLESER // nur lesend, je Modul freigegeben (z.B. Geschwister) + ADMIN // Benutzerverwaltung, Systemeinstellungen +} + +enum Modul { + STAMMDATEN + VORGAENGE + FRISTEN + DOKUMENTE + FINANZEN + SOZIALHILFE + IMMOBILIE + VERTRAEGE + GESUNDHEIT + KONTAKTE + BERICHTE +} + +model User { + id String @id @default(cuid()) + email String @unique + name String + + // Argon2id. Die Parameter stehen im Hash-String selbst. + passwortHash String + passwortGeaendertAm DateTime @default(now()) + + // TOTP ist Pflicht. Das Secret liegt verschluesselt, nicht im Klartext. + totpSecretVerschluesselt String? + totpAktiviertAm DateTime? + // Einmal-Wiederherstellungscodes, einzeln Argon2id-gehasht. + wiederherstellungscodes String[] @default([]) + + rolle Rolle @default(BETREUER) + aktiv Boolean @default(true) + letzteAnmeldung DateTime? + + // Wird hochgezaehlt, um alle bestehenden Sitzungen ungueltig zu machen + // (Passwortwechsel, Rollenaenderung, Verdacht auf Kompromittierung). + sitzungsGeneration Int @default(0) + + // Oberflaechen-Einstellungen + theme String @default("system") // "system" | "hell" | "dunkel" + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + zugaenge BetreuungsZugang[] + auditEintraege AuditLog[] + angelegteVorgaenge Vorgang[] @relation("VorgangAngelegtVon") + berichte Bericht[] + + @@index([email]) + @@map("benutzer") +} + +/// Verbindet einen Benutzer mit einer betreuten Person und legt fest, +/// welche Module ein Mitleser sehen darf. +model BetreuungsZugang { + id String @id @default(cuid()) + + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + rolle Rolle @default(BETREUER) + + /// Nur fuer MITLESER ausgewertet. BETREUER sieht immer alles. + freigegebeneModule Modul[] @default([]) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + @@unique([userId, betreuteId]) + @@index([betreuteId]) + @@map("betreuungs_zugang") +} + +/// Fehlgeschlagene und erfolgreiche Anmeldeversuche - Grundlage fuer das +/// Rate-Limiting am Login. Wird nach 30 Tagen aufgeraeumt. +model LoginVersuch { + id String @id @default(cuid()) + email String + ipHash String? + erfolg Boolean + grund String? + zeitpunkt DateTime @default(now()) + + @@index([email, zeitpunkt]) + @@index([zeitpunkt]) + @@map("login_versuch") +} + +// ============================================================================= +// Betreute Person +// ============================================================================= + +enum Aufgabenkreis { + VERMOEGENSSORGE + GESUNDHEITSSORGE + AUFENTHALTSBESTIMMUNG + WOHNUNGSANGELEGENHEITEN + BEHOERDENANGELEGENHEITEN + POST_UND_FERNMELDEVERKEHR + RECHTS_ANTRAGS_UND_BEHOERDENVERTRETUNG + ENTSCHEIDUNG_UEBER_FREIHEITSENTZIEHENDE_MASSNAHMEN + SONSTIGES +} + +model Betreute { + id String @id @default(cuid()) + + // --- Person ------------------------------------------------------------- + vorname String + nachname String + geburtsname String? + geburtsdatum DateTime @db.Date + geburtsort String? + anrede String? // "Frau", "Herr", ... + + // --- Aufenthalt / Heim --------------------------------------------------- + heimName String? + heimStrasse String? + heimPlz String? + heimOrt String? + heimZimmer String? + heimTelefon String? + heimEinzugAm DateTime? @db.Date + + // Letzte eigene Wohnanschrift - bleibt fuer Behoerden relevant. + fruehereStrasse String? + frueherePlz String? + fruehererOrt String? + + // --- Gesundheit (besondere Kategorie nach Art. 9 DSGVO) ------------------ + krankenkasse String? + krankenversichertennummerVerschluesselt String? + pflegekasse String? + pflegegrad Int? + pflegegradSeit DateTime? @db.Date + pflegegradBescheidVom DateTime? @db.Date + schwerbehinderungGdB Int? + schwerbehinderungMerkzeichen String? + diagnosenVerschluesselt String? + + /// Der nach § 1821 BGB zu ermittelnde Wille der betreuten Person. + /// Seit der Reform 2023 Leitschnur des Betreuerhandelns. + ermittelterWilleVerschluesselt String? + + patientenverfuegungVorhanden Boolean @default(false) + vorsorgevollmachtVorhanden Boolean @default(false) + + // --- Betreuungsgericht --------------------------------------------------- + betreuungsgericht String? + aktenzeichenGericht String? + /// Steuert das Betreuungsjahr und damit fast alle gesetzlichen Fristen. + bestellungAm DateTime? @db.Date + bestellungBis DateTime? @db.Date + aufgabenkreise Aufgabenkreis[] @default([]) + aufgabenkreiseFreitext String? + + /// Befreiter Betreuer nach § 1859 BGB (Angehoerige) - steuert, ob die + /// knappe Vermoegensuebersicht oder die volle Rechnungslegung faellig ist. + befreiterBetreuer Boolean @default(true) + ehrenamtlich Boolean @default(true) + + // --- Sozialleistungstraeger --------------------------------------------- + aktenzeichenBezirk String? + aktenzeichenSozialamt String? + rentenversicherungsnummerVerschluesselt String? + steuerIdVerschluesselt String? + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + zugaenge BetreuungsZugang[] + vorgaenge Vorgang[] + fristen Frist[] + dokumente Dokument[] + konten Konto[] + buchungen Buchung[] + vermoegensposten Vermoegensposten[] + immobilien Immobilie[] + vertraege Vertrag[] + kontakte Kontakt[] + kontaktprotokolle Kontaktprotokoll[] + gesundheitsereignisse Gesundheitsereignis[] + genehmigungen Genehmigung[] + sozialhilfeAntraege SozialhilfeAntrag[] + leistungen Leistung[] + heimkosten Heimkostenmonat[] + heimvertrag Heimvertrag? + tags Tag[] + verknuepfungen DokumentVerknuepfung[] + auditEintraege AuditLog[] + berichte Bericht[] + + @@index([nachname, vorname]) + @@map("betreute") +} + +// ============================================================================= +// Vorgaenge (Fallakte) und Genehmigungen +// ============================================================================= + +enum VorgangKategorie { + GERICHT + SOZIALHILFE + IMMOBILIE + VERSICHERUNG + HEIM_PFLEGE + VERTRAEGE + STEUERN + SONSTIGES +} + +enum VorgangStatus { + OFFEN + IN_ARBEIT + WARTET_AUF_DRITTE + WARTET_AUF_GENEHMIGUNG + ERLEDIGT +} + +model Vorgang { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + titel String + beschreibungVerschluesselt String? + kategorie VorgangKategorie + status VorgangStatus @default(OFFEN) + prioritaet Int @default(2) // 1 hoch, 2 normal, 3 niedrig + + /// Behoerde oder Gegenstelle, die am Zug ist. + gegenstelle String? + aktenzeichen String? + + verantwortlich String? + angelegtVonId String? + angelegtVon User? @relation("VorgangAngelegtVon", fields: [angelegtVonId], references: [id], onDelete: SetNull) + + /// "Was ist der naechste konkrete Handgriff?" - treibt das Dashboard. + naechsterSchritt String? + faelligAm DateTime? @db.Date + + /// Wann wurde zuletzt mit der Gegenstelle gesprochen/geschrieben? + /// Grundlage fuer die Kachel "wartet auf andere" und die Sachstandsanfrage. + letzterKontaktAm DateTime? @db.Date + + /// Loest die feste Ablaufkette Mitteilung -> Antrag -> Anhoerung -> + /// Beschluss -> Rechtskraft -> Vollzug aus. + genehmigungspflichtig Boolean @default(false) + + /// Aus welcher Vorlage wurde der Vorgang erzeugt (fuer Auswertung)? + vorlageSchluessel String? + + erledigtAm DateTime? + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + fristen Frist[] + buchungen Buchung[] + genehmigungen Genehmigung[] + kontaktprotokolle Kontaktprotokoll[] + aufgaben Aufgabe[] + verknuepfungen DokumentVerknuepfung[] + tags Tag[] @relation("VorgangTags") + kontakte Kontakt[] @relation("VorgangKontakte") + + @@index([betreuteId, status]) + @@index([betreuteId, kategorie]) + @@index([faelligAm]) + @@map("vorgang") +} + +/// Einzelner Haken innerhalb eines Vorgangs. Vorgangs-Vorlagen bringen +/// ihre Aufgabenliste vorbefuellt mit. +model Aufgabe { + id String @id @default(cuid()) + + vorgangId String + vorgang Vorgang @relation(fields: [vorgangId], references: [id], onDelete: Cascade) + + titel String + beschreibung String? + erledigt Boolean @default(false) + erledigtAm DateTime? + faelligAm DateTime? @db.Date + reihenfolge Int @default(0) + + erstelltAm DateTime @default(now()) + + @@index([vorgangId]) + @@map("aufgabe") +} + +enum GenehmigungStatus { + GEPLANT // erkannt, noch nichts unternommen + MITGETEILT // Mitteilung ans Gericht raus + BEANTRAGT + ANGEHOERT // Anhoerung der Betreuten / des Verfahrenspflegers erfolgt + ERTEILT + VERSAGT + RECHTSKRAEFTIG + VOLLZOGEN // erst moeglich, wenn ein Beschlussdokument hinterlegt ist +} + +model Genehmigung { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + vorgangId String + vorgang Vorgang @relation(fields: [vorgangId], references: [id], onDelete: Cascade) + + titel String + + /// Freitext, z.B. "§ 1850 Nr. 1 BGB" - bewusst nicht als Enum, damit + /// auch seltene Tatbestaende erfasst werden koennen. + rechtsgrundlage String + status GenehmigungStatus @default(GEPLANT) + + mitteilungAm DateTime? @db.Date + antragAm DateTime? @db.Date + anhoerungAm DateTime? @db.Date + beschlussAm DateTime? @db.Date + rechtskraftAm DateTime? @db.Date + vollzugAm DateTime? @db.Date + + /// Ohne dieses Beschlussdokument darf der Status nicht auf VOLLZOGEN + /// wechseln (§ 1858 BGB: Genehmigung vor dem Rechtsgeschaeft). + beschlussDokumentId String? @unique + beschlussDokument Dokument? @relation("Beschlussdokument", fields: [beschlussDokumentId], references: [id], onDelete: SetNull) + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + + @@index([betreuteId, status]) + @@index([vorgangId]) + @@map("genehmigung") +} + +// ============================================================================= +// Fristen +// ============================================================================= + +enum FristTyp { + GESETZLICH // z.B. Jahresbericht § 1863 BGB + BEHOERDLICH // vom Bezirk oder Gericht gesetzt + SELBST_GESETZT + WIEDERVORLAGE +} + +enum FristStatus { + OFFEN + ERLEDIGT + VERSCHOBEN + ENTFALLEN +} + +model Frist { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + vorgangId String? + vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull) + + titel String + beschreibung String? + datum DateTime @db.Date + typ FristTyp @default(SELBST_GESETZT) + status FristStatus @default(OFFEN) + + /// Freitext, z.B. "§ 1863 Abs. 3 BGB". + rechtsgrundlage String? + + /// Tage vor dem Termin, an denen gewarnt wird. + vorwarnungTage Int[] @default([60, 30, 7, 1]) + + /// iCalendar-RRULE, z.B. "FREQ=YEARLY;INTERVAL=1". Beim Erledigen einer + /// wiederkehrenden Frist wird daraus die naechste erzeugt. + wiederholungRrule String? + + /// Zeigt auf die Frist, aus der diese hervorgegangen ist. + vorgaengerFristId String? @unique + vorgaengerFrist Frist? @relation("Fristkette", fields: [vorgaengerFristId], references: [id], onDelete: SetNull) + nachfolgerFrist Frist? @relation("Fristkette") + + /// Ausschlussfrist: laeuft sie ab, ist der Anspruch weg + /// (z.B. Aufwandspauschale § 1878 BGB). Wird deutlich rot markiert. + ausschlussfrist Boolean @default(false) + + /// Automatisch aus dem Bestellungsdatum abgeleitete Pflichtfrist. + /// Verhindert doppeltes Anlegen beim erneuten Ableiten. + systemSchluessel String? + + /// Wann wurde zuletzt eine Erinnerung verschickt (Vorwarnstufe in Tagen)? + letzteErinnerungStufe Int? + + erledigtAm DateTime? + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + + @@unique([betreuteId, systemSchluessel]) + @@index([betreuteId, status, datum]) + @@index([datum]) + @@map("frist") +} + +// ============================================================================= +// Dokumente +// ============================================================================= + +enum DokumentRichtung { + EINGANG + AUSGANG + INTERN +} + +enum DokumentArt { + BESCHEID + ANTRAG + SCHREIBEN + RECHNUNG + KONTOAUSZUG + VERTRAG + BESCHLUSS + URKUNDE + GUTACHTEN + AUSWEIS + PROTOKOLL + QUITTUNG + MAHNUNG + VOLLMACHT + ATTEST + GRUNDBUCHAUSZUG + FOTO + SONSTIGES +} + +enum PosteingangStatus { + NEU + GESICHTET + ZUGEORDNET + BEANTWORTET +} + +model Dokument { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + titel String + art DokumentArt @default(SONSTIGES) + richtung DokumentRichtung @default(EINGANG) + + /// Datum des Schreibens (steht oben auf dem Brief). + schreibenVom DateTime? @db.Date + /// Wann es bei mir eingegangen ist - massgeblich fuer Rechtsbehelfsfristen. + eingangAm DateTime? @db.Date + versandAm DateTime? @db.Date + + absender String? + empfaenger String? + aktenzeichen String? + + // --- Datei im Objektspeicher -------------------------------------------- + /// Schluessel im S3-Bucket. Ausgeliefert wird nur ueber kurzlebige + /// signierte URLs, niemals oeffentlich. + objektSchluessel String? + dateiname String? + mimeTyp String? + groesseBytes Int? + /// SHA-256 der Datei - Nachweis der Unveraendertheit gegenueber Gericht. + pruefsumme String? + seitenzahl Int? + + /// Volltext aus der OCR (Tesseract, deutsch). Bleibt im Klartext, weil er + /// sonst nicht durchsuchbar waere; die Datei selbst liegt verschluesselt + /// im Objektspeicher. In der README als bewusste Abwaegung dokumentiert. + ocrText String? + ocrStatus String? // "ausstehend" | "laeuft" | "fertig" | "fehlgeschlagen" | "nicht_verfuegbar" + + /// Von Postgres per Trigger gepflegter Suchvektor (deutsche Konfiguration). + suchvektor Unsupported("tsvector")? + + posteingangStatus PosteingangStatus @default(NEU) + + /// Wo liegt das Original im Papierordner? Papier bleibt Papier. + ablageortPapier String? + + /// Wiedervorlage bzw. Ablaufdatum (z.B. Gueltigkeit Betreuerausweis). + wiedervorlageAm DateTime? @db.Date + ablaufAm DateTime? @db.Date + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + belegFuer Buchung[] @relation("Buchungsbeleg") + beschlussFuer Genehmigung? @relation("Beschlussdokument") + nachweisFuer Wertstand[] @relation("Wertnachweis") + tags Tag[] @relation("DokumentTags") + + @@index([betreuteId, posteingangStatus]) + @@index([betreuteId, eingangAm]) + @@index([suchvektor], type: Gin) + @@map("dokument") +} + +/// Verbindet ein Dokument mit genau einer Fachentitaet. Bewusst als eigene +/// Tabelle mit echten Fremdschluesseln - eine Akte muss referenzsicher sein. +/// Die Anwendung stellt sicher, dass genau eine Ziel-Spalte gesetzt ist. +model DokumentVerknuepfung { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + dokumentId String + dokument Dokument @relation(fields: [dokumentId], references: [id], onDelete: Cascade) + + vorgangId String? + vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: Cascade) + fristId String? + frist Frist? @relation(fields: [fristId], references: [id], onDelete: Cascade) + buchungId String? + buchung Buchung? @relation(fields: [buchungId], references: [id], onDelete: Cascade) + vermoegenspostenId String? + vermoegensposten Vermoegensposten? @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade) + immobilieId String? + immobilie Immobilie? @relation(fields: [immobilieId], references: [id], onDelete: Cascade) + vertragId String? + vertrag Vertrag? @relation(fields: [vertragId], references: [id], onDelete: Cascade) + kontaktprotokollId String? + kontaktprotokoll Kontaktprotokoll? @relation(fields: [kontaktprotokollId], references: [id], onDelete: Cascade) + gesundheitsereignisId String? + gesundheitsereignis Gesundheitsereignis? @relation(fields: [gesundheitsereignisId], references: [id], onDelete: Cascade) + genehmigungId String? + genehmigung Genehmigung? @relation(fields: [genehmigungId], references: [id], onDelete: Cascade) + nachforderungId String? + nachforderung Nachforderung? @relation(fields: [nachforderungId], references: [id], onDelete: Cascade) + bescheidId String? + bescheid Bescheid? @relation(fields: [bescheidId], references: [id], onDelete: Cascade) + kontaktId String? + kontakt Kontakt? @relation(fields: [kontaktId], references: [id], onDelete: Cascade) + + bemerkung String? + + erstelltAm DateTime @default(now()) + + @@index([dokumentId]) + @@index([betreuteId]) + @@index([vorgangId]) + @@map("dokument_verknuepfung") +} + +model Tag { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + name String + farbe String @default("slate") + + erstelltAm DateTime @default(now()) + + dokumente Dokument[] @relation("DokumentTags") + vorgaenge Vorgang[] @relation("VorgangTags") + + @@unique([betreuteId, name]) + @@map("tag") +} + +// ============================================================================= +// Geld: Konten, Kassenbuch, Vermoegen +// ============================================================================= + +enum KontoArt { + GIRO + SPAR + TAGESGELD + FESTGELD + DEPOT + VERWAHRGELD_HEIM // Taschengeldkonto in der Einrichtung + BARKASSE + SONSTIGES +} + +model Konto { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + bezeichnung String + bank String? + art KontoArt @default(GIRO) + + ibanVerschluesselt String? + /// HMAC-Blindindex der IBAN - erlaubt "gibt es dieses Konto schon?", + /// ohne die IBAN im Klartext zu speichern. + ibanIndex String? + bic String? + + /// Konto des Betreuers, nicht der Betreuten. Buchungen darauf werden + /// als Auslage gefuehrt und loesen eine Warnung aus. + istBetreuerkonto Boolean @default(false) + + eroeffnetAm DateTime? @db.Date + geschlossenAm DateTime? @db.Date + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + buchungen Buchung[] + saldostaende Saldostand[] + vermoegensposten Vermoegensposten[] + + @@index([betreuteId]) + @@index([ibanIndex]) + @@map("konto") +} + +/// Kontostand zu einem Stichtag. Wird nie ueberschrieben - jede +/// Vermoegensuebersicht muss spaeter reproduzierbar bleiben. +model Saldostand { + id String @id @default(cuid()) + + kontoId String + konto Konto @relation(fields: [kontoId], references: [id], onDelete: Cascade) + + stichtag DateTime @db.Date + betragCent BigInt + quelle String? // "Kontoauszug Nr. 3", "Online-Banking", ... + + erstelltAm DateTime @default(now()) + + @@unique([kontoId, stichtag]) + @@index([kontoId, stichtag]) + @@map("saldostand") +} + +enum BuchungRichtung { + EINNAHME + AUSGABE + UMBUCHUNG +} + +enum BuchungKategorie { + RENTE + PFLEGEKASSE + SOZIALHILFE + LANDESPFLEGEGELD + ZINSEN + MIETEINNAHME + SONSTIGE_EINNAHME + HEIMKOSTEN + BARBETRAG + BEKLEIDUNG + ARZT_UND_MEDIKAMENTE + ZUZAHLUNG + VERSICHERUNG + IMMOBILIE_BETRIEBSKOSTEN + IMMOBILIE_INSTANDHALTUNG + GRUNDSTEUER + STEUERN + GEBUEHREN_GERICHT + PORTO_UND_KOPIEN + FAHRTKOSTEN + TELEFON_INTERNET + RUNDFUNKBEITRAG + BESTATTUNGSVORSORGE + BANKGEBUEHREN + SONSTIGE_AUSGABE + UMBUCHUNG +} + +model Buchung { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + kontoId String? + konto Konto? @relation(fields: [kontoId], references: [id], onDelete: SetNull) + + vorgangId String? + vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull) + + datum DateTime @db.Date + wertstellung DateTime? @db.Date + betragCent BigInt + richtung BuchungRichtung + kategorie BuchungKategorie @default(SONSTIGE_AUSGABE) + gegenstelle String? + verwendungszweckVerschluesselt String? + + belegId String? + beleg Dokument? @relation("Buchungsbeleg", fields: [belegId], references: [id], onDelete: SetNull) + /// Buchung ohne Beleg wird in der Liste markiert - Belegzwang. + belegFehltBegruendung String? + + /// false = Auslage des Betreuers, die erstattet werden muss. + betrifftBetreute Boolean @default(true) + /// Verschiebt das Vermoegen (z.B. Verkaufserloes), nicht nur laufende + /// Einnahme/Ausgabe. Relevant fuer die Vermoegensuebersicht. + vermoegenswirksam Boolean @default(false) + + /// Herkunft eines CSV-/CAMT-Imports, verhindert Doppelbuchungen. + importReferenz String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + + @@unique([kontoId, importReferenz]) + @@index([betreuteId, datum]) + @@index([betreuteId, kategorie]) + @@map("buchung") +} + +enum VermoegensArt { + KONTO + IMMOBILIE + VERSICHERUNG + FAHRZEUG + WERTGEGENSTAND + FORDERUNG + VERBINDLICHKEIT + SONSTIGES +} + +/// Bewertung eines Postens im Sozialhilfeverfahren. Das ist MEINE +/// Dokumentation und Argumentation, keine automatische Rechtsauskunft. +enum SozialhilfeBewertung { + UNGEPRUEFT + VERWERTBAR + GESCHUETZT + STRITTIG +} + +model Vermoegensposten { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + bezeichnung String + art VermoegensArt + + // Optionale Verbindung zum konkreten Objekt. + kontoId String? + konto Konto? @relation(fields: [kontoId], references: [id], onDelete: SetNull) + immobilieId String? + immobilie Immobilie? @relation(fields: [immobilieId], references: [id], onDelete: SetNull) + vertragId String? + vertrag Vertrag? @relation(fields: [vertragId], references: [id], onDelete: SetNull) + + /// Passiva werden als eigener Posten mit Art VERBINDLICHKEIT gefuehrt; + /// der Wert bleibt positiv, das Vorzeichen ergibt sich aus der Art. + bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT) + /// Warum halte ich den Posten fuer geschuetzt? Verweis auf § 90 SGB XII, + /// Schonvermoegen, Bestattungsvorsorge, selbst bewohntes Hausgrundstueck. + bewertungBegruendungVerschluesselt String? + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + wertstaende Wertstand[] + verknuepfungen DokumentVerknuepfung[] + + @@index([betreuteId, art]) + @@map("vermoegensposten") +} + +/// Ein Wert zu einem Stichtag. Historie, nichts wird ueberschrieben. +model Wertstand { + id String @id @default(cuid()) + + vermoegenspostenId String + vermoegensposten Vermoegensposten @relation(fields: [vermoegenspostenId], references: [id], onDelete: Cascade) + + stichtag DateTime @db.Date + wertCent BigInt + /// Woher stammt der Wert? "Rueckkaufswertmitteilung vom 12.03.2026", + /// "Maklergutachten", "Bodenrichtwert", "eigene Schaetzung". + wertquelle String + + nachweisDokumentId String? + nachweisDokument Dokument? @relation("Wertnachweis", fields: [nachweisDokumentId], references: [id], onDelete: SetNull) + + bemerkung String? + erstelltAm DateTime @default(now()) + + @@unique([vermoegenspostenId, stichtag]) + @@index([vermoegenspostenId, stichtag]) + @@map("wertstand") +} + +// ============================================================================= +// Immobilie +// ============================================================================= + +enum ImmobilienStatusSozialhilfe { + UNGEKLAERT + SELBST_BEWOHNT_GESCHUETZT + HAERTEFALL_GELTEND_GEMACHT + DARLEHEN_91_SGB_XII + VERKAUF_LAEUFT + VERWERTET +} + +model Immobilie { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + bezeichnung String + strasse String? + plz String? + ort String? + + grundbuchamt String? + grundbuchBlatt String? + gemarkung String? + flurstueck String? + eigentumsanteil String? // "1/1", "1/2", ... + wohnflaecheQm Int? + grundstueckQm Int? + baujahr Int? + + bewohntStatus String? // "leerstehend", "vermietet", "selbst bewohnt" + leerstandSeit DateTime? @db.Date + + statusSozialhilfe ImmobilienStatusSozialhilfe @default(UNGEKLAERT) + statusBegruendungVerschluesselt String? + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + belastungen Belastung[] + checklistenPunkte ImmobilienCheckpunkt[] + vermoegensposten Vermoegensposten[] + verknuepfungen DokumentVerknuepfung[] + + @@index([betreuteId]) + @@map("immobilie") +} + +model Belastung { + id String @id @default(cuid()) + + immobilieId String + immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade) + + art String // "Grundschuld", "Wohnrecht", "Niessbrauch", "Reallast" + abteilung String? // Grundbuch-Abteilung II oder III + laufendeNr String? + glaeubiger String? + betragCent BigInt? + bemerkung String? + geloeschtImGrundbuchAm DateTime? @db.Date + + erstelltAm DateTime @default(now()) + + @@index([immobilieId]) + @@map("belastung") +} + +/// Pflichten bei Leerstand: Versicherung informiert, Frostschutz, +/// Winterdienst, Zaehlerstaende, Schluesselverzeichnis, Fotodoku. +model ImmobilienCheckpunkt { + id String @id @default(cuid()) + + immobilieId String + immobilie Immobilie @relation(fields: [immobilieId], references: [id], onDelete: Cascade) + + titel String + beschreibung String? + erledigt Boolean @default(false) + erledigtAm DateTime? @db.Date + faelligAm DateTime? @db.Date + /// Wiederkehrend (z.B. Kontrollgang, Zaehlerstand) als RRULE. + wiederholungRrule String? + reihenfolge Int @default(0) + + erstelltAm DateTime @default(now()) + + @@index([immobilieId]) + @@map("immobilien_checkpunkt") +} + +// ============================================================================= +// Versicherungen und Vertraege +// ============================================================================= + +enum VertragArt { + LEBENSVERSICHERUNG + STERBEGELDVERSICHERUNG + BESTATTUNGSVORSORGE + RENTENVERSICHERUNG_PRIVAT + UNFALLVERSICHERUNG + HAFTPFLICHT + HAUSRAT + WOHNGEBAEUDE + KRANKENZUSATZ + PFLEGEZUSATZ + RECHTSSCHUTZ + STERBEKASSE + STROM + GAS + WASSER + TELEFON_INTERNET + RUNDFUNKBEITRAG + MITGLIEDSCHAFT + ABONNEMENT + SONSTIGES +} + +enum VertragStatus { + AKTIV + BEITRAGSFREI + RUHEND + GEKUENDIGT + ABGETRETEN + VERWERTET + ABGELAUFEN +} + +enum ZahlRhythmus { + MONATLICH + VIERTELJAEHRLICH + HALBJAEHRLICH + JAEHRLICH + EINMALIG +} + +model Vertrag { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + bezeichnung String + gesellschaft String? + art VertragArt + status VertragStatus @default(AKTIV) + + vertragsnummerVerschluesselt String? + + beginnAm DateTime? @db.Date + endeAm DateTime? @db.Date + /// Kuendigungsfrist in Monaten zum Ende der Laufzeit. + kuendigungsfristMonate Int? + kuendigungZum DateTime? @db.Date + gekuendigtAm DateTime? @db.Date + + beitragCent BigInt? + rhythmus ZahlRhythmus? + + /// Bewertung fuer das Sozialamt - meine Argumentation, keine Auskunft. + bewertungSozialhilfe SozialhilfeBewertung @default(UNGEPRUEFT) + bewertungBegruendungVerschluesselt String? + + /// Wann wurde zuletzt ein Rueckkaufswert angefordert? Steuert die + /// Wiedervorlage nach vier Wochen. + rueckkaufswertAngefragtAm DateTime? @db.Date + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + vermoegensposten Vermoegensposten[] + verknuepfungen DokumentVerknuepfung[] + + @@index([betreuteId, status]) + @@map("vertrag") +} + +// ============================================================================= +// Kontakte und Kontaktprotokoll +// ============================================================================= + +enum KontaktRolle { + RECHTSPFLEGER + RICHTER + BETREUUNGSBEHOERDE + SACHBEARBEITER_BEZIRK + SACHBEARBEITER_SOZIALAMT + HEIMLEITUNG + PFLEGEDIENSTLEITUNG + SOZIALDIENST_HEIM + HAUSARZT + FACHARZT + KRANKENKASSE + PFLEGEKASSE + RENTENVERSICHERUNG + NOTAR + MAKLER + VERSICHERUNGSMAKLER + VERSICHERUNG + STEUERBERATER + RECHTSANWALT + BANK + HANDWERKER + ANGEHOERIGER + SONSTIGES +} + +model Kontakt { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + name String + organisation String? + rolle KontaktRolle @default(SONSTIGES) + + telefon String? + mobil String? + fax String? + email String? + strasse String? + plz String? + ort String? + /// Aktenzeichen, unter dem dieser Kontakt den Vorgang fuehrt. + aktenzeichen String? + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + protokolle Kontaktprotokoll[] + vorgaenge Vorgang[] @relation("VorgangKontakte") + verknuepfungen DokumentVerknuepfung[] + + @@index([betreuteId, rolle]) + @@map("kontakt") +} + +enum KontaktArt { + BESUCH + TELEFONAT + EMAIL + BRIEF + VIDEOCALL + FAX + VORSPRACHE +} + +/// Grundlage des Jahresberichts nach § 1863 BGB: persoenliche Kontakte, +/// Wuensche der betreuten Person, daraus folgende Aufgaben. +model Kontaktprotokoll { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + kontaktId String? + kontakt Kontakt? @relation(fields: [kontaktId], references: [id], onDelete: SetNull) + vorgangId String? + vorgang Vorgang? @relation(fields: [vorgangId], references: [id], onDelete: SetNull) + + datum DateTime + art KontaktArt + dauerMinuten Int? + gespraechspartner String? + /// true = persoenlicher Kontakt zur betreuten Person selbst. + mitBetreuter Boolean @default(false) + + inhaltVerschluesselt String? + /// Geaeusserte Wuensche - fuer § 1821 BGB und den Jahresbericht. + wuenscheVerschluesselt String? + folgeaufgabenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + + @@index([betreuteId, datum]) + @@map("kontaktprotokoll") +} + +// ============================================================================= +// Gesundheit und Pflege +// ============================================================================= + +enum GesundheitsArt { + ARZTBESUCH + KRANKENHAUS + EXAZERBATION + STURZ + MEDIKATIONSAENDERUNG + SAUERSTOFFVERSORGUNG + INHALATION + HILFSMITTEL + IMPFUNG + REHA + PFLEGEBEGUTACHTUNG + HEILMITTEL + SONSTIGES +} + +model Gesundheitsereignis { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + datum DateTime @db.Date + bisDatum DateTime? @db.Date // z.B. Ende eines Krankenhausaufenthalts + art GesundheitsArt + + titel String + beschreibungVerschluesselt String? + behandler String? + einrichtung String? + + /// Abwesenheitstage mindern den Heimeigenanteil - deshalb hier zaehlbar. + abwesenheitstage Int? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + + @@index([betreuteId, datum]) + @@map("gesundheitsereignis") +} + +model Heimvertrag { + id String @id @default(cuid()) + + betreuteId String @unique + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + einrichtung String + traeger String? + strasse String? + plz String? + ort String? + + vertragAm DateTime? @db.Date + einzugAm DateTime? @db.Date + auszugAm DateTime? @db.Date + kuendigungsfristTage Int? @default(14) + + // Aktuelle Saetze - historische Werte stehen in Heimkostenmonat. + pflegeEigenanteilCent BigInt? // einrichtungseinheitlicher Eigenanteil + unterkunftVerpflegungCent BigInt? + investitionskostenCent BigInt? + ausbildungsumlageCent BigInt? + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + @@map("heimvertrag") +} + +/// Monatliche Gegenueberstellung Heimrechnung gegen Deckungsquellen. +model Heimkostenmonat { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + jahr Int + monat Int // 1..12 + + // Kostenseite + pflegeEigenanteilCent BigInt @default(0) + unterkunftVerpflegungCent BigInt @default(0) + investitionskostenCent BigInt @default(0) + ausbildungsumlageCent BigInt @default(0) + zusatzleistungenCent BigInt @default(0) + + // Deckungsseite + pflegekasseCent BigInt @default(0) // § 43 SGB XI + /// Leistungszuschlag nach § 43c SGB XI, abhaengig von der Verweildauer. + leistungszuschlagProzent Int @default(0) + leistungszuschlagCent BigInt @default(0) + renteCent BigInt @default(0) + sonstigeEinkuenfteCent BigInt @default(0) + sozialhilfeCent BigInt @default(0) + eigenmittelCent BigInt @default(0) + + // Der betreuten Person verbleibende Betraege + barbetragCent BigInt @default(0) + bekleidungspauschaleCent BigInt @default(0) + + abwesenheitstage Int @default(0) + + rechnungDokumentId String? + bemerkung String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + + @@unique([betreuteId, jahr, monat]) + @@index([betreuteId, jahr]) + @@map("heimkostenmonat") +} + +// ============================================================================= +// Sozialhilfe (Bezirk Oberfranken) +// ============================================================================= + +enum NachforderungStatus { + OFFEN + IN_BESCHAFFUNG + UEBERSANDT + ANERKANNT + NICHT_VORHANDEN +} + +enum BescheidArt { + BEWILLIGUNG + ABLEHNUNG + AENDERUNG + AUFHEBUNG + ERSTATTUNG + VORSCHUSS + WIDERSPRUCHSBESCHEID +} + +enum BescheidPruefStatus { + UNGEPRUEFT + GEPRUEFT_OK + WIDERSPRUCH_EINGELEGT + WIDERSPRUCH_ERLEDIGT + FRIST_VERSTRICHEN +} + +model SozialhilfeAntrag { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + traeger String @default("Bezirk Oberfranken") + leistungsart String @default("Hilfe zur Pflege nach dem 7. Kapitel SGB XII") + aktenzeichen String? + + sachbearbeiter String? + telefon String? + email String? + + antragAm DateTime? @db.Date + eingangBeimTraegerAm DateTime? @db.Date + bewilligtVon DateTime? @db.Date + bewilligtBis DateTime? @db.Date + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + nachforderungen Nachforderung[] + bescheide Bescheid[] + + @@index([betreuteId]) + @@map("sozialhilfe_antrag") +} + +/// Jede vom Traeger angeforderte Unterlage als eigene Zeile. +model Nachforderung { + id String @id @default(cuid()) + + antragId String + antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade) + + bezeichnung String + erlaeuterung String? + status NachforderungStatus @default(OFFEN) + + angefordertAm DateTime? @db.Date + fristBis DateTime? @db.Date + uebersandtAm DateTime? @db.Date + /// Wenn die Unterlage nicht existiert: warum nicht? Geht als Erklaerung + /// mit ins Unterlagenpaket. + begruendungNichtVorhanden String? + + reihenfolge Int @default(0) + /// Vorbefuellte Standardunterlage aus der Checkliste. + ausCheckliste Boolean @default(false) + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + + @@index([antragId, status]) + @@map("nachforderung") +} + +model Bescheid { + id String @id @default(cuid()) + + antragId String + antrag SozialhilfeAntrag @relation(fields: [antragId], references: [id], onDelete: Cascade) + + art BescheidArt + bescheidVom DateTime @db.Date + /// Massgeblich fuer den Fristbeginn - nicht das Datum auf dem Bescheid. + zugegangenAm DateTime? @db.Date + zeitraumVon DateTime? @db.Date + zeitraumBis DateTime? @db.Date + betragCent BigInt? + + /// In der Regel ein Monat ab Bekanntgabe (§ 84 SGG). Bei fehlender oder + /// falscher Rechtsbehelfsbelehrung ein Jahr - deshalb speicherbar. + rechtsbehelfsfristMonate Int @default(1) + widerspruchMoeglichBis DateTime? @db.Date + pruefStatus BescheidPruefStatus @default(UNGEPRUEFT) + widerspruchEingelegtAm DateTime? @db.Date + + notizenVerschluesselt String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + verknuepfungen DokumentVerknuepfung[] + + @@index([antragId, bescheidVom]) + @@map("bescheid") +} + +enum LeistungStatus { + NICHT_GEPRUEFT + PRUEFUNG_LAEUFT + BEANTRAGT + BEWILLIGT + ABGELEHNT + LAEUFT + NICHT_ZUTREFFEND +} + +/// Katalog weiterer Leistungen: Landespflegegeld, Zuzahlungsbefreiung, +/// Rundfunkbeitragsbefreiung, Schwerbehindertenausweis, Grundsicherung. +model Leistung { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + bezeichnung String + traeger String? + rechtsgrundlage String? + status LeistungStatus @default(NICHT_GEPRUEFT) + + beantragtAm DateTime? @db.Date + bewilligtAm DateTime? @db.Date + gueltigBis DateTime? @db.Date + betragCent BigInt? + rhythmus ZahlRhythmus? + + /// Muss jaehrlich neu beantragt werden (Landespflegegeld, Zuzahlung). + jaehrlichNeuBeantragen Boolean @default(false) + + /// Stabiler Schluessel aus dem Katalog, verhindert Dubletten beim Seed. + katalogSchluessel String? + + notizen String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + @@unique([betreuteId, katalogSchluessel]) + @@index([betreuteId, status]) + @@map("leistung") +} + +// ============================================================================= +// Berichte +// ============================================================================= + +enum BerichtArt { + VERMOEGENSVERZEICHNIS // § 1835 BGB + JAHRESBERICHT // § 1863 BGB + VERMOEGENSUEBERSICHT // § 1859 BGB (befreiter Betreuer) + RECHNUNGSLEGUNG // § 1865 BGB + BELEGMAPPE + UNTERLAGENPAKET_BEZIRK + VOLLEXPORT +} + +/// Erzeugte Berichte werden protokolliert - was habe ich wann in welcher +/// Fassung abgegeben? Der Entwurfstext bleibt fuer die Ueberarbeitung stehen. +model Bericht { + id String @id @default(cuid()) + + betreuteId String + betreute Betreute @relation(fields: [betreuteId], references: [id], onDelete: Cascade) + + art BerichtArt + titel String + zeitraumVon DateTime? @db.Date + zeitraumBis DateTime? @db.Date + stichtag DateTime? @db.Date + + /// Vom Nutzer ueberarbeiteter Freitext (Jahresbericht) - verschluesselt. + entwurfVerschluesselt String? + + /// Abgelegte PDF-Fassung im Objektspeicher. + objektSchluessel String? + dateiname String? + pruefsumme String? + + erstelltVonId String? + erstelltVon User? @relation(fields: [erstelltVonId], references: [id], onDelete: SetNull) + + abgegebenAm DateTime? @db.Date + abgegebenAn String? + + erstelltAm DateTime @default(now()) + aktualisiertAm DateTime @updatedAt + geloeschtAm DateTime? + + @@index([betreuteId, art]) + @@map("bericht") +} + +// ============================================================================= +// Audit-Log +// ============================================================================= + +enum AuditAktion { + ERSTELLT + GEAENDERT + GELOESCHT + WIEDERHERGESTELLT + ENDGUELTIG_GELOESCHT + ANGESEHEN + EXPORTIERT + DOKUMENT_HERUNTERGELADEN + ANMELDUNG + ANMELDUNG_FEHLGESCHLAGEN + ABMELDUNG + ZWEITER_FAKTOR_EINGERICHTET + PASSWORT_GEAENDERT + ROLLE_GEAENDERT + FREIGABE_GEAENDERT +} + +/// Unveraenderlich und nur anhaengend. Ein Datenbank-Trigger +/// (prisma/sql/audit-schutz.sql) verhindert UPDATE und DELETE. +/// Zugleich mein Nachweis gegenueber Gericht und moeglichen Miterben. +model AuditLog { + id String @id @default(cuid()) + zeitpunkt DateTime @default(now()) + + userId String? + user User? @relation(fields: [userId], references: [id], onDelete: SetNull) + /// Momentaufnahme - bleibt lesbar, auch wenn der Benutzer geloescht wird. + userEmail String? + userName String? + + betreuteId String? + betreute Betreute? @relation(fields: [betreuteId], references: [id], onDelete: SetNull) + + aktion AuditAktion + entitaet String // Modellname, z.B. "Betreute" + entitaetId String? + bezeichnung String? // lesbarer Titel des betroffenen Datensatzes + + /// Geaenderte Felder als JSON {feld: {alt, neu}} - verschluesselt, weil + /// dort Gesundheits- und Finanzdaten stehen koennen. + aenderungVerschluesselt String? + + ipHash String? + userAgent String? + + @@index([betreuteId, zeitpunkt]) + @@index([userId, zeitpunkt]) + @@index([entitaet, entitaetId]) + @@map("audit_log") +} diff --git a/prisma/sql/audit-schutz.sql b/prisma/sql/audit-schutz.sql new file mode 100644 index 0000000..01e8d30 --- /dev/null +++ b/prisma/sql/audit-schutz.sql @@ -0,0 +1,76 @@ +-- ============================================================================= +-- Audit-Log unveraenderlich machen +-- ============================================================================= +-- +-- Aufruf: npm run db:audit-schutz +-- +-- Das Audit-Log ist mein Nachweis gegenueber Betreuungsgericht und moeglichen +-- Miterben. Es muss deshalb nur anhaengend sein: UPDATE und DELETE werden auf +-- Datenbankebene verweigert - auch dann, wenn die Anwendung selbst es +-- versuchen wuerde oder jemand mit psql direkt an der Datenbank sitzt. +-- +-- Einzige Ausnahme: das Aufraeumen von Eintraegen, die aelter als die +-- Aufbewahrungsfrist sind. Dafuer gibt es die Funktion +-- audit_log_aufraeumen(intervall), die den Schutz kontrolliert umgeht. +-- +-- Das Skript ist idempotent. +-- ============================================================================= + +CREATE OR REPLACE FUNCTION audit_log_ist_unveraenderlich() +RETURNS trigger AS $$ +BEGIN + -- Die Aufraeum-Funktion setzt diese Sitzungsvariable, um gezielt loeschen + -- zu duerfen. Alles andere wird abgewiesen. + IF current_setting('betreuung.audit_aufraeumen', true) = 'ja' THEN + RETURN OLD; + END IF; + + RAISE EXCEPTION + 'Das Audit-Log ist unveraenderlich. % auf audit_log ist nicht zulaessig.', + TG_OP + USING ERRCODE = 'insufficient_privilege'; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS audit_log_kein_update ON audit_log; +CREATE TRIGGER audit_log_kein_update + BEFORE UPDATE ON audit_log + FOR EACH ROW + EXECUTE FUNCTION audit_log_ist_unveraenderlich(); + +DROP TRIGGER IF EXISTS audit_log_kein_delete ON audit_log; +CREATE TRIGGER audit_log_kein_delete + BEFORE DELETE ON audit_log + FOR EACH ROW + EXECUTE FUNCTION audit_log_ist_unveraenderlich(); + +-- ----------------------------------------------------------------------------- +-- Kontrolliertes Aufraeumen alter Eintraege. +-- Beispiel: SELECT audit_log_aufraeumen('10 years'); +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION audit_log_aufraeumen(aufbewahrung interval) +RETURNS bigint AS $$ +DECLARE + geloescht bigint; +BEGIN + PERFORM set_config('betreuung.audit_aufraeumen', 'ja', true); + DELETE FROM audit_log WHERE zeitpunkt < now() - aufbewahrung; + GET DIAGNOSTICS geloescht = ROW_COUNT; + PERFORM set_config('betreuung.audit_aufraeumen', 'nein', true); + RETURN geloescht; +END; +$$ LANGUAGE plpgsql; + +-- ----------------------------------------------------------------------------- +-- Aufraeumen der Login-Versuche (Rate-Limiting-Historie, 30 Tage genuegen). +-- ----------------------------------------------------------------------------- +CREATE OR REPLACE FUNCTION login_versuche_aufraeumen() +RETURNS bigint AS $$ +DECLARE + geloescht bigint; +BEGIN + DELETE FROM login_versuch WHERE zeitpunkt < now() - interval '30 days'; + GET DIAGNOSTICS geloescht = ROW_COUNT; + RETURN geloescht; +END; +$$ LANGUAGE plpgsql; diff --git a/prisma/sql/volltextsuche.sql b/prisma/sql/volltextsuche.sql new file mode 100644 index 0000000..68dcf65 --- /dev/null +++ b/prisma/sql/volltextsuche.sql @@ -0,0 +1,55 @@ +-- ============================================================================= +-- Volltextsuche ueber Dokumente (deutsche Konfiguration) +-- ============================================================================= +-- +-- Aufruf: npm run db:volltext +-- +-- Die Spalte "suchvektor" legt Prisma als Unsupported("tsvector") an. Befuellt +-- wird sie hier per Trigger, damit jede Aenderung an Titel, Absender, +-- Aktenzeichen oder OCR-Text sofort in der Suche ankommt. +-- +-- Gewichtung: +-- A = Titel (schlaegt am staerksten durch) +-- B = Absender, Aktenzeichen, Dateiname +-- C = OCR-Volltext +-- +-- Das Skript ist idempotent und kann nach jeder Migration erneut laufen. +-- ============================================================================= + +-- Fuzzy-Suche fuer Absendernamen ("Bezirk Oberfr." findet "Bezirk Oberfranken"). +CREATE EXTENSION IF NOT EXISTS pg_trgm; +-- Erlaubt das Entfernen von Akzenten in der Suche. +CREATE EXTENSION IF NOT EXISTS unaccent; + +CREATE OR REPLACE FUNCTION dokument_suchvektor_aktualisieren() +RETURNS trigger AS $$ +BEGIN + NEW.suchvektor := + setweight(to_tsvector('german', coalesce(NEW.titel, '')), 'A') + || setweight(to_tsvector('german', coalesce(NEW.absender, '')), 'B') + || setweight(to_tsvector('german', coalesce(NEW.empfaenger, '')), 'B') + || setweight(to_tsvector('german', coalesce(NEW.aktenzeichen, '')), 'B') + || setweight(to_tsvector('german', coalesce(NEW.dateiname, '')), 'B') + || setweight(to_tsvector('german', coalesce(NEW."ocrText", '')), 'C'); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS dokument_suchvektor ON dokument; + +CREATE TRIGGER dokument_suchvektor + BEFORE INSERT OR UPDATE OF titel, absender, empfaenger, aktenzeichen, dateiname, "ocrText" + ON dokument + FOR EACH ROW + EXECUTE FUNCTION dokument_suchvektor_aktualisieren(); + +-- Bestehende Zeilen einmalig nachziehen (z.B. nach einem Restore). +UPDATE dokument SET titel = titel WHERE suchvektor IS NULL; + +-- Trigram-Index fuer die unscharfe Suche nach Absendern. +CREATE INDEX IF NOT EXISTS dokument_absender_trgm + ON dokument USING gin (absender gin_trgm_ops); + +-- Trigram-Index fuer die Vorgangssuche. +CREATE INDEX IF NOT EXISTS vorgang_titel_trgm + ON vorgang USING gin (titel gin_trgm_ops); diff --git a/scripts/bauprotokoll.mjs b/scripts/bauprotokoll.mjs new file mode 100644 index 0000000..0904027 --- /dev/null +++ b/scripts/bauprotokoll.mjs @@ -0,0 +1,164 @@ +#!/usr/bin/env node +/** + * Bauprotokoll aus der Git-Historie erzeugen. + * + * Aufruf: npm run protokoll -> Tabelle auf der Konsole + * npm run protokoll -- --md -> Markdown fuer docs/BAUPROTOKOLL.md + * + * Grundlage sind die Commit-Trailer "Etappe:" und "Phase:". Jeder Commit + * traegt sie, damit sich im Nachhinein nachvollziehen laesst, wie lange + * welche Etappe gedauert hat - fuer die Kostenrechnung und als Nachweis, + * was wann entstanden ist. + * + * Die Dauer einer Etappe wird gemessen vom Ende der vorherigen Etappe + * (bzw. vom Baubeginn) bis zum letzten Commit der Etappe. Pausen zwischen + * zwei Commits, die laenger als PAUSE_SCHWELLE_MIN dauern, werden als + * Pause gewertet und nicht als Arbeitszeit gezaehlt. + */ +import { execSync } from 'node:child_process' + +/** Laengere Luecken gelten als Pause und zaehlen nicht als Arbeitszeit. */ +const PAUSE_SCHWELLE_MIN = 30 + +const TRENNER = '' // ASCII Unit Separator - kommt in Commit-Texten nicht vor +const SATZ_TRENNER = '' // Record Separator + +function commitsLesen() { + const roh = execSync( + `git log --reverse --format="%H${TRENNER}%at${TRENNER}%s${TRENNER}%b${SATZ_TRENNER}"`, + { encoding: 'utf8', maxBuffer: 32 * 1024 * 1024 }, + ) + + return roh + .split(SATZ_TRENNER) + .map((eintrag) => eintrag.trim()) + .filter(Boolean) + .map((eintrag) => { + const [hash, zeitstempel, betreff, koerper = ''] = eintrag.split(TRENNER) + const etappe = koerper.match(/^Etappe:\s*(.+)$/m)?.[1]?.trim() ?? 'ohne' + const phase = koerper.match(/^Phase:\s*(.+)$/m)?.[1]?.trim() ?? '' + return { + hash: hash.slice(0, 8), + zeit: new Date(Number(zeitstempel) * 1000), + betreff, + etappe, + phase, + } + }) +} + +/** + * Arbeitszeit als Summe der Abstaende zwischen aufeinanderfolgenden + * Commits, wobei zu grosse Abstaende auf die Pausenschwelle gedeckelt + * werden. Der allererste Commit bekommt die Schwelle als Anlaufzeit + * gutgeschrieben - vorher wurde ja auch schon gearbeitet. + */ +function arbeitsminuten(commits) { + if (commits.length === 0) return 0 + let summe = PAUSE_SCHWELLE_MIN + for (let i = 1; i < commits.length; i++) { + const diff = (commits[i].zeit - commits[i - 1].zeit) / 60000 + summe += Math.min(diff, PAUSE_SCHWELLE_MIN) + } + return summe +} + +function dauerText(minuten) { + const m = Math.round(minuten) + const std = Math.floor(m / 60) + const rest = m % 60 + if (std === 0) return `${rest} min` + return `${std} h ${String(rest).padStart(2, '0')} min` +} + +function formatZeit(datum) { + return datum.toLocaleString('de-DE', { + day: '2-digit', + month: '2-digit', + year: 'numeric', + hour: '2-digit', + minute: '2-digit', + }) +} + +const commits = commitsLesen() +if (commits.length === 0) { + console.error('Keine Commits gefunden.') + process.exit(1) +} + +// Nach Etappe gruppieren, Reihenfolge des ersten Auftretens beibehalten. +const gruppen = new Map() +for (const commit of commits) { + if (!gruppen.has(commit.etappe)) gruppen.set(commit.etappe, []) + gruppen.get(commit.etappe).push(commit) +} + +const zeilen = [...gruppen.entries()].map(([etappe, eintraege]) => ({ + etappe, + phase: eintraege.find((e) => e.phase)?.phase ?? '', + commits: eintraege.length, + von: eintraege[0].zeit, + bis: eintraege[eintraege.length - 1].zeit, + minuten: arbeitsminuten(eintraege), +})) + +const gesamtMinuten = zeilen.reduce((s, z) => s + z.minuten, 0) +const gesamtCommits = commits.length +const spanneMinuten = (commits[commits.length - 1].zeit - commits[0].zeit) / 60000 + +const alsMarkdown = process.argv.includes('--md') + +if (alsMarkdown) { + const out = [] + out.push('# Bauprotokoll') + out.push('') + out.push('Automatisch aus der Git-Historie erzeugt: `npm run protokoll -- --md`.') + out.push('Grundlage sind die Commit-Trailer `Etappe:` und `Phase:`.') + out.push('') + out.push( + `Reine Arbeitszeit wird als Summe der Commit-Abstaende gemessen, gedeckelt auf ` + + `${PAUSE_SCHWELLE_MIN} Minuten je Abstand. Laengere Luecken gelten als Pause.`, + ) + out.push('') + out.push('| Etappe | Phase | Commits | Beginn | Ende | Arbeitszeit |') + out.push('|---|---|---:|---|---|---:|') + for (const z of zeilen) { + out.push( + `| ${z.etappe} | ${z.phase} | ${z.commits} | ${formatZeit(z.von)} | ` + + `${formatZeit(z.bis)} | ${dauerText(z.minuten)} |`, + ) + } + out.push( + `| **Gesamt** | | **${gesamtCommits}** | ${formatZeit(commits[0].zeit)} | ` + + `${formatZeit(commits[commits.length - 1].zeit)} | **${dauerText(gesamtMinuten)}** |`, + ) + out.push('') + out.push(`Kalendarische Spanne vom ersten bis zum letzten Commit: ${dauerText(spanneMinuten)}.`) + out.push('') + out.push('## Commits im Einzelnen') + out.push('') + out.push('| Zeitpunkt | Etappe | Commit | Betreff |') + out.push('|---|---|---|---|') + for (const c of commits) { + out.push(`| ${formatZeit(c.zeit)} | ${c.etappe} | \`${c.hash}\` | ${c.betreff} |`) + } + console.log(out.join('\n')) +} else { + console.log('') + console.log('Bauprotokoll') + console.log('='.repeat(74)) + for (const z of zeilen) { + console.log( + `Etappe ${z.etappe.padEnd(4)} ${z.phase.padEnd(16)} ` + + `${String(z.commits).padStart(3)} Commits ${dauerText(z.minuten).padStart(10)}`, + ) + } + console.log('-'.repeat(74)) + console.log( + `Gesamt${' '.repeat(17)}${String(gesamtCommits).padStart(3)} Commits ` + + `${dauerText(gesamtMinuten).padStart(10)}`, + ) + console.log(`Kalendarische Spanne: ${dauerText(spanneMinuten)}`) + console.log('') +}