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:
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
@@ -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);
|
||||
Reference in New Issue
Block a user