Phase 7 abgeschlossen: Suche mit gestuftem Index, Facetten, Palette

Der bisherige Suchvektor enthielt nur, was jeder sehen darf. Das war
leck-sicher und unbrauchbar zugleich: ab Stufe 1 fand die Suche nichts von
dem, was einen Fall ausmacht.

SearchIndex hält jetzt sechs Zeilen je Ziel, eine pro Freigabestufe. Die Zeile
zur Stufe N enthält alles, was ein Leser dieser Stufe sehen darf. Eine Suche
nimmt genau diese eine Zeile und kann deshalb nichts finden, was sie nicht
lesen darf — auch keinen höher eingestuften Eintrag in einer sichtbaren Akte.

Schwärzungen werden dabei stufenweise aufgelöst: strip_redactions_above
entfernt nur, was über der Stufe liegt. Sonst fände ein Leser der Stufe 4
einen Namen nicht, den er auf der Seite im Klartext sieht, und ein Leser der
Stufe 1 einen, den er dort nur als Balken sieht.

Die Suchbedingung ist nicht die Zugriffsprüfung: der Volltext liefert
Kandidaten, die Sichtbarkeit kommt aus visibleCaseWhere — derselben Funktion
wie in der Fallliste, und Treffer, Trefferzahl und Facettenzähler entstehen
gemeinsam in einer Abfrage. Eine in SQL nachgebaute Sichtbarkeitsregel wäre
die Sorte Duplikat, die auseinanderläuft.

Filter stehen in der Adresse. Damit ist jeder Zustand teilbar, der
Zurück-Knopf tut das Richtige, und eine gespeicherte Ansicht ist ein
Lesezeichen. Die mitgelieferten Ansichten stehen im Programm, weil sie von der
Gegenwart abhängen.

Strg+K öffnet die Kommandopalette. Im Grundgerüst steckt nur das
Tastaturereignis, die Palette selbst wird beim Öffnen geholt — das kostet die
anderen Seiten rund hundert Byte.

Offen geblieben: "Zuletzt geöffnet" als Ansicht. Dafür müsste jeder Aufruf
jeder Akte mitgeschrieben werden; das wäre eine Überwachungsfunktion, die
niemand bestellt hat. In TASKS vermerkt.

165 Tests im Web-Paket, 17 in der Datenbank, 7 im Worker, 34 e2e.
Grösste Route bei 119,7 kB gegen 120 erlaubte.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-02 21:23:01 +02:00
co-authored by Claude Opus 5
parent 34a8b96dd3
commit 71c20f4b95
22 changed files with 1745 additions and 7 deletions
@@ -0,0 +1,237 @@
-- 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;