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.
238 lines
8.2 KiB
PL/PgSQL
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;
|