Workspace und Datenmodell
pnpm-Workspace mit web/, worker/, pdf/ und packages/db. Das Datenbankpaket ist eigenständig, weil Web-App und Worker denselben Prisma-Client brauchen. Schema nach docs/04-datenmodell.md, mit drei Abweichungen, die dort begründet sind: CaseParticipant bekommt einen eigenen Schlüssel statt eines zusammengesetzten mit NULL-Spalten, die Aktenzeichen laufen über CaseCounter statt über eine Sequenz pro Jahr, und ServiceHeartbeat kommt dazu, damit /api/health den Gateway-Zustand kennt, ohne Discord zu fragen. Die zweite Migration enthält, was Prisma nicht ausdrücken kann: - kein DELETE auf CaseEntry, CaseEntryVersion und AuditLog - authorId, createdAt, caseId und type eines Eintrags sind unveränderlich - an einer Eintragsversion darf nur minClearance angehoben werden - Volltextsuche mit deutscher Konfiguration; geschwärzte Bereiche werden vor dem Indizieren entfernt, sonst verrät ein Treffer den Inhalt - pg_notify bei Rollen- und Mapping-Änderungen Acht Tests prüfen diese Regeln gegen eine echte Postgres-Instanz, nicht gegen die Anwendungslogik. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4446e3f0ac
commit
51984f17d4
@@ -0,0 +1,231 @@
|
||||
-- Was Prisma nicht ausdrücken kann. Grundlage: docs/04-datenmodell.md.
|
||||
-- Diese Datei wird beim Anlegen der Migration an die generierte SQL angehängt.
|
||||
|
||||
-- ===========================================================================
|
||||
-- 1. Verlaufseinträge sind anhängend. Zweite Sicherung neben der Anwendung,
|
||||
-- falls jemand direkt an der Datenbank hantiert.
|
||||
-- ===========================================================================
|
||||
|
||||
CREATE RULE entry_no_delete AS ON DELETE TO "CaseEntry" DO INSTEAD NOTHING;
|
||||
CREATE RULE entry_version_no_delete AS ON DELETE TO "CaseEntryVersion" DO INSTEAD NOTHING;
|
||||
|
||||
-- Die Versionstabelle darf nicht umgeschrieben werden. Einzige Ausnahme ist das
|
||||
-- Anheben von minClearance: wird ein Eintrag höher klassifiziert, erben alle
|
||||
-- bestehenden Versionen den neuen Wert, sonst liest jemand mit niedrigerer Stufe
|
||||
-- den ursprünglichen Text einfach in der Historie. Siehe docs/05-akten-und-vorlagen.md.
|
||||
-- Ein pauschales DO INSTEAD NOTHING auf UPDATE würde genau diese Nachführung
|
||||
-- verhindern und damit ein Leck offen lassen.
|
||||
CREATE OR REPLACE FUNCTION entry_version_guard() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."id" IS DISTINCT FROM OLD."id"
|
||||
OR NEW."entryId" IS DISTINCT FROM OLD."entryId"
|
||||
OR NEW."version" IS DISTINCT FROM OLD."version"
|
||||
OR NEW."body" IS DISTINCT FROM OLD."body"
|
||||
OR NEW."changeReason" IS DISTINCT FROM OLD."changeReason"
|
||||
OR NEW."editedById" IS DISTINCT FROM OLD."editedById"
|
||||
OR NEW."editedAt" IS DISTINCT FROM OLD."editedAt" THEN
|
||||
RAISE EXCEPTION 'CaseEntryVersion ist anhängend, nur minClearance darf nachgeführt werden';
|
||||
END IF;
|
||||
|
||||
IF NEW."minClearance" < OLD."minClearance" THEN
|
||||
RAISE EXCEPTION 'minClearance einer Version darf nur angehoben werden';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER entry_version_guard_trg
|
||||
BEFORE UPDATE ON "CaseEntryVersion"
|
||||
FOR EACH ROW EXECUTE FUNCTION entry_version_guard();
|
||||
|
||||
-- Autor und Entstehungszeit eines Eintrags sind unantastbar, ebenso Typ und Akte.
|
||||
-- Wer das umgehen will, muss zurückziehen und neu schreiben, und genau das ist
|
||||
-- dann auch sichtbar.
|
||||
CREATE OR REPLACE FUNCTION entry_immutable_guard() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF NEW."authorId" IS DISTINCT FROM OLD."authorId"
|
||||
OR NEW."createdAt" IS DISTINCT FROM OLD."createdAt"
|
||||
OR NEW."caseId" IS DISTINCT FROM OLD."caseId"
|
||||
OR NEW."type" IS DISTINCT FROM OLD."type" THEN
|
||||
RAISE EXCEPTION 'authorId, createdAt, caseId und type eines CaseEntry sind unveränderlich';
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER entry_immutable_guard_trg
|
||||
BEFORE UPDATE ON "CaseEntry"
|
||||
FOR EACH ROW EXECUTE FUNCTION entry_immutable_guard();
|
||||
|
||||
-- Der Zugriffsprotokoll wird nie gelöscht.
|
||||
CREATE RULE audit_no_delete AS ON DELETE TO "AuditLog" DO INSTEAD NOTHING;
|
||||
CREATE RULE audit_no_update AS ON UPDATE TO "AuditLog" DO INSTEAD NOTHING;
|
||||
|
||||
-- ===========================================================================
|
||||
-- 2. Beteiligte: Eindeutigkeit über partielle Indizes, weil userId und
|
||||
-- personnelFileId beide NULL sein dürfen und Postgres NULL in einem
|
||||
-- Primärschlüssel nicht zulässt.
|
||||
-- ===========================================================================
|
||||
|
||||
CREATE UNIQUE INDEX participant_user_uniq
|
||||
ON "CaseParticipant" ("caseId", "role", "userId")
|
||||
WHERE "userId" IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX participant_file_uniq
|
||||
ON "CaseParticipant" ("caseId", "role", "personnelFileId")
|
||||
WHERE "personnelFileId" IS NOT NULL;
|
||||
|
||||
-- Ein Beteiligter ohne jede Zuordnung ergibt keinen Sinn.
|
||||
ALTER TABLE "CaseParticipant"
|
||||
ADD CONSTRAINT participant_has_target
|
||||
CHECK ("userId" IS NOT NULL OR "personnelFileId" IS NOT NULL);
|
||||
|
||||
-- Eine Chatnachricht hängt entweder an einer Akte oder an einem Kanal.
|
||||
ALTER TABLE "ChatMessage"
|
||||
ADD CONSTRAINT chat_has_scope
|
||||
CHECK (("caseId" IS NULL) <> ("channelId" IS NULL));
|
||||
|
||||
-- Freigabestufen laufen von 0 bis 5.
|
||||
ALTER TABLE "Case" ADD CONSTRAINT case_classification_range CHECK ("classification" BETWEEN 0 AND 5);
|
||||
ALTER TABLE "CaseSection" ADD CONSTRAINT section_clearance_range CHECK ("minClearance" BETWEEN 0 AND 5);
|
||||
ALTER TABLE "CaseEntry" ADD CONSTRAINT entry_clearance_range CHECK ("minClearance" BETWEEN 0 AND 5);
|
||||
ALTER TABLE "PersonnelFile" ADD CONSTRAINT personnel_clearance_range CHECK ("classification" BETWEEN 0 AND 5);
|
||||
ALTER TABLE "RoleMapping" ADD CONSTRAINT rolemapping_clearance_range CHECK ("clearanceLevel" BETWEEN 0 AND 5);
|
||||
ALTER TABLE "Channel" ADD CONSTRAINT channel_clearance_range CHECK ("minClearance" BETWEEN 0 AND 5);
|
||||
|
||||
-- Die Einstellungstabelle bleibt ein Einzeiler.
|
||||
ALTER TABLE "AppSetting" ADD CONSTRAINT appsetting_single_row CHECK ("id" = 1);
|
||||
|
||||
-- ===========================================================================
|
||||
-- 3. Volltextsuche, deutsche Konfiguration.
|
||||
-- ===========================================================================
|
||||
|
||||
-- Schwärzungs-Auszeichnung aus dem Freitext entfernen, bevor indiziert wird.
|
||||
-- Sonst findet ein Nutzer mit Stufe 1 einen Begriff, der nur in einem als
|
||||
-- [[c4]]…[[/c4]] markierten Bereich steht, und schließt aus dem Treffer auf
|
||||
-- den Inhalt. Siehe docs/05-akten-und-vorlagen.md.
|
||||
CREATE OR REPLACE FUNCTION strip_redactions(input text) RETURNS text AS $$
|
||||
SELECT regexp_replace(COALESCE(input, ''), '\[\[c[0-9]\]\].*?\[\[/c[0-9]\]\]', ' ', 'g');
|
||||
$$ LANGUAGE sql IMMUTABLE;
|
||||
|
||||
-- Gewichtung: A = Aktenzeichen und Titel, B = Abschnitte, C = Verlaufseinträge.
|
||||
--
|
||||
-- Aufgenommen wird nur, was für jeden lesbar ist, der die Akte überhaupt sehen
|
||||
-- darf: Abschnitte und Einträge mit minClearance = 0. Höher klassifizierte
|
||||
-- Feldinhalte gehören nicht in einen gemeinsamen Vektor, sonst verrät die
|
||||
-- Trefferliste sie an niedrigere Stufen. Phase 7 ergänzt getrennte Vektoren je
|
||||
-- Stufe, siehe docs/08-suche-und-export.md.
|
||||
CREATE OR REPLACE FUNCTION case_search_vector(target_id text) RETURNS tsvector AS $$
|
||||
SELECT
|
||||
setweight(to_tsvector('german',
|
||||
coalesce((SELECT c."caseNumber" || ' ' || c."title" FROM "Case" c WHERE c."id" = target_id), '')
|
||||
), 'A')
|
||||
||
|
||||
setweight(coalesce((
|
||||
SELECT jsonb_to_tsvector('german', jsonb_agg(to_jsonb(strip_redactions(s."value" #>> '{}'))), '["string"]')
|
||||
FROM "CaseSection" s
|
||||
WHERE s."caseId" = target_id AND s."minClearance" = 0
|
||||
), ''::tsvector), 'B')
|
||||
||
|
||||
setweight(coalesce((
|
||||
SELECT to_tsvector('german', string_agg(strip_redactions(e."body"), ' '))
|
||||
FROM "CaseEntry" e
|
||||
WHERE e."caseId" = target_id AND e."minClearance" = 0 AND e."retractedAt" IS NULL
|
||||
), ''::tsvector), 'C');
|
||||
$$ LANGUAGE sql STABLE;
|
||||
|
||||
-- Verzweigt mit IF statt mit einem CASE-Ausdruck: PL/pgSQL wertet einen
|
||||
-- CASE-Ausdruck als ein einziges SQL-Statement aus und verlangt dann auch im
|
||||
-- nicht genommenen Zweig, dass NEW."caseId" existiert. Auf "Case" gibt es dieses
|
||||
-- Feld nicht, der Insert schlüge fehl.
|
||||
CREATE OR REPLACE FUNCTION case_refresh_search_vector() RETURNS trigger AS $$
|
||||
DECLARE
|
||||
target text;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'Case' THEN
|
||||
target := NEW."id";
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
target := OLD."caseId";
|
||||
ELSE
|
||||
target := NEW."caseId";
|
||||
END IF;
|
||||
|
||||
IF target IS NOT NULL THEN
|
||||
-- Die Rückwirkung auf "Case" setzt nur "searchVector"; der Trigger auf
|
||||
-- "Case" hängt an UPDATE OF "title","caseNumber" und feuert deshalb nicht
|
||||
-- erneut. Keine Rekursion.
|
||||
UPDATE "Case" SET "searchVector" = case_search_vector(target) WHERE "id" = target;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER case_search_on_case
|
||||
AFTER INSERT OR UPDATE OF "title", "caseNumber" ON "Case"
|
||||
FOR EACH ROW EXECUTE FUNCTION case_refresh_search_vector();
|
||||
|
||||
CREATE TRIGGER case_search_on_section
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "CaseSection"
|
||||
FOR EACH ROW EXECUTE FUNCTION case_refresh_search_vector();
|
||||
|
||||
-- Auch bei UPDATE, sonst findet die Suche nach einer nachträglichen Änderung
|
||||
-- den alten Text. Siehe docs/05-akten-und-vorlagen.md.
|
||||
CREATE TRIGGER case_search_on_entry
|
||||
AFTER INSERT OR UPDATE ON "CaseEntry"
|
||||
FOR EACH ROW EXECUTE FUNCTION case_refresh_search_vector();
|
||||
|
||||
CREATE OR REPLACE FUNCTION personnel_refresh_search_vector() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW."searchVector" :=
|
||||
setweight(to_tsvector('german',
|
||||
coalesce(NEW."displayName", '') || ' ' || coalesce(NEW."serviceNumber", '')), 'A')
|
||||
||
|
||||
setweight(to_tsvector('german',
|
||||
coalesce(NEW."rank", '') || ' ' || coalesce(NEW."department", '')), 'B');
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER personnel_search_trg
|
||||
BEFORE INSERT OR UPDATE OF "displayName", "serviceNumber", "rank", "department"
|
||||
ON "PersonnelFile"
|
||||
FOR EACH ROW EXECUTE FUNCTION personnel_refresh_search_vector();
|
||||
|
||||
CREATE INDEX case_search_idx ON "Case" USING GIN ("searchVector");
|
||||
CREATE INDEX personnel_search_idx ON "PersonnelFile" USING GIN ("searchVector");
|
||||
CREATE INDEX entry_body_idx ON "CaseEntry" USING GIN (to_tsvector('german', "body"));
|
||||
|
||||
-- Trigram gegen Tippfehler im Aktenzeichen und in Namen.
|
||||
CREATE INDEX case_number_trgm_idx ON "Case" USING GIN ("caseNumber" gin_trgm_ops);
|
||||
CREATE INDEX personnel_name_trgm_idx ON "PersonnelFile" USING GIN ("displayName" gin_trgm_ops);
|
||||
|
||||
-- Häufigste Abfrage der Startseite: nur offene Fälle.
|
||||
CREATE INDEX case_open_idx ON "Case" ("updatedAt" DESC) WHERE "status" IN ('OPEN', 'REVIEW');
|
||||
|
||||
-- ===========================================================================
|
||||
-- 4. Echtzeit. Ein einziger LISTEN-Client verteilt im Prozess weiter,
|
||||
-- siehe docs/06-chat-und-realtime.md.
|
||||
-- ===========================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_member_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('member_changed', COALESCE(NEW."userId", OLD."userId"));
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER user_role_notify
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "UserRole"
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_member_changed();
|
||||
|
||||
CREATE OR REPLACE FUNCTION notify_role_mapping_changed() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
PERFORM pg_notify('role_mapping_changed', COALESCE(NEW."discordRoleId", OLD."discordRoleId"));
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER role_mapping_notify
|
||||
AFTER INSERT OR UPDATE OR DELETE ON "RoleMapping"
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_role_mapping_changed();
|
||||
Reference in New Issue
Block a user