-- 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();