feat: Vollstaendiges Datenmodell als Prisma-Schema

Alle Fachentitaeten der Betreuung in einem Schema - bewusst komplett,
damit spaetere Etappen keine Migration am Fundament brauchen.

Kernentscheidungen:
- Geldbetraege durchgaengig als BigInt in Cent. Eine Rechnungslegung nach
  § 1865 BGB darf keine Rundungsfehler erben.
- Felder mit Suffix Verschluesselt tragen einen AES-256-GCM-Umschlag.
  Wo trotzdem gesucht werden muss (IBAN), steht ein HMAC-Blindindex daneben.
- Fristen und Stichtage sind @db.Date, nicht DateTime - sonst verschiebt
  die Zeitzone eine Frist um einen Tag.
- Frist.systemSchluessel macht die aus dem Bestellungsdatum abgeleiteten
  Pflichtfristen idempotent.
- Genehmigung.beschlussDokumentId ist die technische Voraussetzung dafuer,
  dass ein Vorgang ohne Beschluss nicht auf VOLLZOGEN gesetzt werden kann.
- DokumentVerknuepfung mit echten Fremdschluesseln statt loser Typ/ID-
  Spalte: eine Akte muss referenzsicher sein.
- Wertstand und Saldostand sind Historien - Werte werden nie ueberschrieben.

Dazu zwei SQL-Skripte, die Prisma nicht abbilden kann:
- volltextsuche.sql: tsvector-Trigger (deutsch) mit Gewichtung A/B/C,
  plus Trigram-Indizes fuer unscharfe Absendersuche.
- audit-schutz.sql: Trigger, die UPDATE und DELETE auf audit_log
  verweigern. Das Log ist Nachweis gegenueber Gericht und Miterben,
  also nur anhaengend - auch gegen psql.

Etappe: 1
Phase: Fundament
This commit is contained in:
Florian Wilde
2026-08-22 01:36:36 +02:00
parent cc72f5efd7
commit 6c1915ab9c
5 changed files with 1867 additions and 2 deletions
+76
View File
@@ -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;
+55
View File
@@ -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);