diff --git a/packages/db/prisma/migrations/20260903140000_suche/migration.sql b/packages/db/prisma/migrations/20260903140000_suche/migration.sql
new file mode 100644
index 0000000..7a5f80b
--- /dev/null
+++ b/packages/db/prisma/migrations/20260903140000_suche/migration.sql
@@ -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;
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index a611af7..9ed0051 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -51,6 +51,7 @@ model User {
personnelFiles PersonnelFile[]
participations CaseParticipant[]
assignments PersonnelAssignment[]
+ savedViews SavedView[]
exclusions CaseExclusion[]
watches Watch[]
readMarkers ReadMarker[]
@@ -807,6 +808,43 @@ model NotificationOutbox {
@@index([caseId])
}
+/// Suchindex, **je Freigabestufe ein Vektor**.
+///
+/// Der Vektor zur Stufe N enthaelt alles, was ein Leser dieser Stufe sehen
+/// darf: Kopfdaten, Abschnitte und Eintraege bis einschliesslich N, und aus
+/// dem Fliesstext die geschwaerzten Stellen bis N. Eine Suche nimmt genau
+/// eine Zeile — die zur Stufe des Suchenden — und kann deshalb nichts finden,
+/// was er nicht lesen darf.
+///
+/// Der Vorgaenger war ein einziger Vektor mit nur dem, was jeder sehen darf.
+/// Der war leck-sicher, aber unbrauchbar: ab Stufe 1 fand die Suche nichts von
+/// dem, was den Fall ausmacht. Siehe docs/13-entscheidungen.md.
+model SearchIndex {
+ targetType String // "case" | "document" | "personnel"
+ targetId String
+ minClearance Int // 0..5
+ vector Unsupported("tsvector")
+
+ @@id([targetType, targetId, minClearance])
+ @@index([targetType, minClearance])
+}
+
+/// Eine gespeicherte Suche. Die mitgelieferten Ansichten stehen im Programm,
+/// hier stehen nur die selbst angelegten.
+model SavedView {
+ id String @id @default(uuid(7))
+ userId String
+ name String
+ /// Die Filter als Abfrageparameter, so wie sie in der Adresse stehen.
+ query String
+ sortOrder Int @default(0)
+ createdAt DateTime @default(now())
+
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
+
+ @@index([userId, sortOrder])
+}
+
/// Lebenszeichen der Dienste, die nicht im Web-Container laufen. Der Worker
/// schreibt hier seinen Gateway-Zustand hinein, damit /api/health ihn pruefen
/// kann, ohne Discord zu befragen. Siehe docs/11-betrieb.md.
diff --git a/packages/db/tests/regeln.test.ts b/packages/db/tests/regeln.test.ts
index a8e3d03..9dc8ccc 100644
--- a/packages/db/tests/regeln.test.ts
+++ b/packages/db/tests/regeln.test.ts
@@ -214,6 +214,43 @@ suite('Datenbankregeln', () => {
).rejects.toThrow()
})
+ it('löst Schwärzungen im Suchindex stufenweise auf', async () => {
+ // Der Vektor zur Stufe N muss enthalten, was diese Stufe lesen darf — und
+ // nichts darüber. Sonst findet ein Leser der Stufe 4 einen Namen nicht,
+ // den er auf der Seite sieht, oder ein Leser der Stufe 1 einen, den er
+ // dort nur als Balken sieht.
+ const [zeile] = await db.$queryRawUnsafe<{ tief: string; hoch: string }[]>(
+ `SELECT strip_redactions_above($1, 1) AS tief,
+ strip_redactions_above($1, 4) AS hoch`,
+ 'Offen [[c4]]Perreira[[/c4]] Schluss',
+ )
+
+ expect(zeile?.tief).not.toContain('Perreira')
+ expect(zeile?.tief).toContain('Offen')
+ expect(zeile?.hoch).toContain('Perreira')
+ // Die Marker selbst gehören nie in den Index.
+ expect(zeile?.hoch).not.toContain('[[c4]]')
+ })
+
+ it('schreibt für jede Stufe eine Indexzeile und zieht sie nach', async () => {
+ const stufen = await db.$queryRawUnsafe<{ minClearance: number }[]>(
+ `SELECT "minClearance" FROM "SearchIndex"
+ WHERE "targetType" = 'case' AND "targetId" = $1 ORDER BY "minClearance"`,
+ caseId,
+ )
+ expect(stufen.map((s) => s.minClearance)).toEqual([0, 1, 2, 3, 4, 5])
+
+ // Eine Änderung am Titel muss durchschlagen.
+ await db.case.update({ where: { id: caseId }, data: { title: 'Umbenannter Prüffall' } })
+ const [treffer] = await db.$queryRawUnsafe<{ gefunden: boolean }[]>(
+ `SELECT "vector" @@ websearch_to_tsquery('german', 'Umbenannter') AS gefunden
+ FROM "SearchIndex"
+ WHERE "targetType" = 'case' AND "targetId" = $1 AND "minClearance" = 0`,
+ caseId,
+ )
+ expect(treffer?.gefunden).toBe(true)
+ })
+
it('nimmt geschwärzte Bereiche nicht in den Suchvektor auf', async () => {
await db.caseEntry.create({
data: {
diff --git a/web/app/(app)/layout.tsx b/web/app/(app)/layout.tsx
index 5142511..1896cfd 100644
--- a/web/app/(app)/layout.tsx
+++ b/web/app/(app)/layout.tsx
@@ -2,6 +2,7 @@ import Link from 'next/link'
import { signOut } from '@/auth'
import { requireUser } from '@/lib/auth/current'
import { de } from '@/lib/i18n/de'
+import { PaletteTrigger } from '@/components/palette/PaletteTrigger'
export default async function AppLayout({ children }: { children: React.ReactNode }) {
const user = await requireUser()
@@ -13,6 +14,8 @@ export default async function AppLayout({ children }: { children: React.ReactNod
return (
+ {/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
+
@@ -25,6 +28,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? (
{de.nav.personnel}
) : null}
+ {de.nav.search}
{de.channel.heading}
{de.notify.heading}
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
diff --git a/web/app/(app)/suche/SavedViews.tsx b/web/app/(app)/suche/SavedViews.tsx
new file mode 100644
index 0000000..7bc3136
--- /dev/null
+++ b/web/app/(app)/suche/SavedViews.tsx
@@ -0,0 +1,106 @@
+'use client'
+
+import { useState, useTransition } from 'react'
+import Link from 'next/link'
+import { searchText } from '@/lib/i18n/shell'
+import { commonText } from '@/lib/i18n/common'
+import { deleteViewAction, saveViewAction } from './actions'
+
+/**
+ * Ansichten: die mitgelieferten und die eigenen.
+ *
+ * Beide sind dasselbe — eine Suchadresse. Die mitgelieferten stehen im
+ * Programm, weil sie von der Gegenwart abhängen („diese Woche"), die eigenen
+ * in der Datenbank.
+ */
+export function SavedViews({
+ builtins,
+ saved,
+ currentQuery,
+}: {
+ builtins: { key: string; params: string }[]
+ saved: { id: string; name: string; query: string }[]
+ currentQuery: string
+}) {
+ const [name, setName] = useState('')
+ const [offen, setOffen] = useState(false)
+ const [fehler, setFehler] = useState(null)
+ const [pending, startTransition] = useTransition()
+
+ return (
+