-- Phase 7: Suche. -- -- Der bisherige Suchvektor enthielt nur, was jeder sehen darf (minClearance -- = 0). Das war leck-sicher, aber unbrauchbar: ab Stufe 1 fand die Suche -- nichts von dem, was den Fall ausmacht. -- -- Stattdessen jetzt ein Vektor **je Freigabestufe**. Der zur Stufe N enthält -- alles bis einschliesslich N. Eine Suche nimmt genau die Zeile zur Stufe des -- Suchenden und kann deshalb nichts finden, was er nicht lesen darf. CREATE TABLE "SearchIndex" ( "targetType" TEXT NOT NULL, "targetId" TEXT NOT NULL, "minClearance" INTEGER NOT NULL, "vector" tsvector NOT NULL, CONSTRAINT "SearchIndex_pkey" PRIMARY KEY ("targetType", "targetId", "minClearance") ); CREATE INDEX "SearchIndex_vector_idx" ON "SearchIndex" USING GIN ("vector"); CREATE INDEX "SearchIndex_targetType_minClearance_idx" ON "SearchIndex"("targetType", "minClearance"); CREATE TABLE "SavedView" ( "id" TEXT NOT NULL, "userId" TEXT NOT NULL, "name" TEXT NOT NULL, "query" TEXT NOT NULL, "sortOrder" INTEGER NOT NULL DEFAULT 0, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, CONSTRAINT "SavedView_pkey" PRIMARY KEY ("id") ); CREATE INDEX "SavedView_userId_sortOrder_idx" ON "SavedView"("userId", "sortOrder"); ALTER TABLE "SavedView" ADD CONSTRAINT "SavedView_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; -- =========================================================================== -- Schwärzungen stufenweise auflösen -- -- `strip_redactions` entfernt jede markierte Stelle. Für den Vektor einer -- Stufe muss aber stehen bleiben, was diese Stufe lesen darf - sonst findet -- ein Leser der Stufe 4 einen Namen nicht, den er auf der Seite sieht. -- =========================================================================== CREATE OR REPLACE FUNCTION strip_redactions_above(input text, max_level int) RETURNS text AS $$ DECLARE ergebnis text := coalesce(input, ''); stufe int; BEGIN -- Erst alles wegwerfen, was über der Stufe liegt. Die Stufe steht als eine -- Ziffer im Marker, deshalb je Stufe ein Durchgang. FOR stufe IN (max_level + 1)..5 LOOP ergebnis := regexp_replace( ergebnis, '\[\[c' || stufe || '\]\].*?\[\[/c' || stufe || '\]\]', ' ', 'g' ); END LOOP; -- Was übrig ist, darf gelesen werden: nur die Marker selbst entfernen. RETURN regexp_replace(ergebnis, '\[\[/?c[0-9]\]\]', ' ', 'g'); END; $$ LANGUAGE plpgsql IMMUTABLE; -- =========================================================================== -- Fallakte: sechs Vektoren -- -- Gewichtung wie in docs/08: A = Aktenzeichen und Titel, B = Abschnitte, -- C = Verlaufseinträge. -- =========================================================================== CREATE OR REPLACE FUNCTION refresh_case_search(target_id text) RETURNS void AS $$ DECLARE stufe int; kopf tsvector; abschnitte tsvector; eintraege tsvector; BEGIN DELETE FROM "SearchIndex" WHERE "targetType" = 'case' AND "targetId" = target_id; SELECT setweight(to_tsvector('german', c."caseNumber" || ' ' || c."title"), 'A') INTO kopf FROM "Case" c WHERE c."id" = target_id; IF kopf IS NULL THEN RETURN; -- Der Fall ist weg, dann gehört auch kein Index dazu. END IF; FOR stufe IN 0..5 LOOP SELECT coalesce( to_tsvector('german', string_agg(strip_redactions_above(s."value" #>> '{}', stufe), ' ')), ''::tsvector ) INTO abschnitte FROM "CaseSection" s WHERE s."caseId" = target_id AND s."minClearance" <= stufe; SELECT coalesce( to_tsvector('german', string_agg(strip_redactions_above(e."body", stufe), ' ')), ''::tsvector ) INTO eintraege FROM "CaseEntry" e WHERE e."caseId" = target_id AND e."minClearance" <= stufe AND e."retractedAt" IS NULL; INSERT INTO "SearchIndex" ("targetType", "targetId", "minClearance", "vector") VALUES ( 'case', target_id, stufe, kopf || setweight(abschnitte, 'B') || setweight(eintraege, 'C') ); END LOOP; END; $$ LANGUAGE plpgsql; CREATE OR REPLACE FUNCTION refresh_document_search(target_id text) RETURNS void AS $$ DECLARE stufe int; kopf tsvector; abschnitte tsvector; BEGIN DELETE FROM "SearchIndex" WHERE "targetType" = 'document' AND "targetId" = target_id; SELECT setweight(to_tsvector('german', d."documentNumber" || ' ' || d."title"), 'A') INTO kopf FROM "Document" d WHERE d."id" = target_id; IF kopf IS NULL THEN RETURN; END IF; FOR stufe IN 0..5 LOOP SELECT coalesce( to_tsvector('german', string_agg(strip_redactions_above(s."value" #>> '{}', stufe), ' ')), ''::tsvector ) INTO abschnitte FROM "DocumentSection" s WHERE s."documentId" = target_id AND s."minClearance" <= stufe; INSERT INTO "SearchIndex" ("targetType", "targetId", "minClearance", "vector") VALUES ('document', target_id, stufe, kopf || setweight(abschnitte, 'B')); END LOOP; END; $$ LANGUAGE plpgsql; -- Die Personalakte hat keine gestuften Inhalte; ihre Kopfdaten stehen für -- jede Stufe gleich. Die Zeile wird trotzdem sechsmal geschrieben, damit die -- Abfrage über alle drei Arten dieselbe bleibt. CREATE OR REPLACE FUNCTION refresh_personnel_search(target_id text) RETURNS void AS $$ DECLARE stufe int; vec tsvector; BEGIN DELETE FROM "SearchIndex" WHERE "targetType" = 'personnel' AND "targetId" = target_id; SELECT setweight(to_tsvector('german', p."displayName"), 'A') || setweight(to_tsvector('german', coalesce(p."rank", '') || ' ' || coalesce(p."department", '') || ' ' || coalesce(array_to_string(p."statusTags", ' '), '')), 'B') || setweight(to_tsvector('german', coalesce(p."note", '')), 'C') INTO vec FROM "PersonnelFile" p WHERE p."id" = target_id; IF vec IS NULL THEN RETURN; END IF; FOR stufe IN 0..5 LOOP INSERT INTO "SearchIndex" ("targetType", "targetId", "minClearance", "vector") VALUES ('personnel', target_id, stufe, vec); END LOOP; END; $$ LANGUAGE plpgsql; -- =========================================================================== -- Auslöser -- -- Wie beim alten Vektor: auch bei UPDATE, sonst findet die Suche nach einer -- nachträglichen Änderung den alten Text. -- =========================================================================== CREATE OR REPLACE FUNCTION search_index_trigger() RETURNS trigger AS $$ DECLARE ziel text; BEGIN IF TG_TABLE_NAME = 'Case' THEN PERFORM refresh_case_search(NEW."id"); ELSIF TG_TABLE_NAME IN ('CaseSection', 'CaseEntry') THEN ziel := CASE WHEN TG_OP = 'DELETE' THEN OLD."caseId" ELSE NEW."caseId" END; IF ziel IS NOT NULL THEN PERFORM refresh_case_search(ziel); END IF; ELSIF TG_TABLE_NAME = 'Document' THEN PERFORM refresh_document_search(NEW."id"); ELSIF TG_TABLE_NAME = 'DocumentSection' THEN ziel := CASE WHEN TG_OP = 'DELETE' THEN OLD."documentId" ELSE NEW."documentId" END; IF ziel IS NOT NULL THEN PERFORM refresh_document_search(ziel); END IF; ELSIF TG_TABLE_NAME = 'PersonnelFile' THEN PERFORM refresh_personnel_search(NEW."id"); END IF; RETURN NULL; END; $$ LANGUAGE plpgsql; CREATE TRIGGER search_on_case AFTER INSERT OR UPDATE OF "title", "caseNumber" ON "Case" FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); CREATE TRIGGER search_on_case_section AFTER INSERT OR UPDATE OR DELETE ON "CaseSection" FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); CREATE TRIGGER search_on_case_entry AFTER INSERT OR UPDATE ON "CaseEntry" FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); CREATE TRIGGER search_on_document AFTER INSERT OR UPDATE OF "title", "documentNumber" ON "Document" FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); CREATE TRIGGER search_on_document_section AFTER INSERT OR UPDATE OR DELETE ON "DocumentSection" FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); CREATE TRIGGER search_on_personnel AFTER INSERT OR UPDATE OF "displayName", "rank", "department", "statusTags", "note" ON "PersonnelFile" FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); -- Bestand einmal aufbauen. SELECT refresh_case_search(c."id") FROM "Case" c; SELECT refresh_document_search(d."id") FROM "Document" d; SELECT refresh_personnel_search(p."id") FROM "PersonnelFile" p;