Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

238 lines
8.2 KiB
PL/PgSQL

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