From cb0eabb26659782bf3e7a176f29f1ada1c7d1948 Mon Sep 17 00:00:00 2001 From: Harlekin Date: Wed, 2 Sep 2026 21:23:01 +0200 Subject: [PATCH] Phase 7 abgeschlossen: Suche mit gestuftem Index, Facetten, Palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../20260903140000_suche/migration.sql | 237 +++++++++++++++++ packages/db/prisma/schema.prisma | 38 +++ packages/db/tests/regeln.test.ts | 37 +++ web/app/(app)/layout.tsx | 4 + web/app/(app)/suche/SavedViews.tsx | 106 ++++++++ web/app/(app)/suche/SearchBox.tsx | 57 +++++ web/app/(app)/suche/actions.ts | 48 ++++ web/app/(app)/suche/page.tsx | 240 ++++++++++++++++++ web/app/api/search/quick/route.ts | 59 +++++ web/components/palette/CommandPalette.tsx | 195 ++++++++++++++ web/components/palette/PaletteTrigger.tsx | 34 +++ web/e2e/smoke.spec.ts | 1 + web/lib/i18n/common.ts | 1 + web/lib/i18n/de.ts | 4 + web/lib/i18n/shell.ts | 55 ++++ web/lib/search/query.ts | 62 +++++ web/lib/search/service.ts | 217 ++++++++++++++++ web/lib/search/views.ts | 40 +++ web/tests/suche.test.ts | 229 +++++++++++++++++ 19 files changed, 1664 insertions(+) create mode 100644 packages/db/prisma/migrations/20260903140000_suche/migration.sql create mode 100644 web/app/(app)/suche/SavedViews.tsx create mode 100644 web/app/(app)/suche/SearchBox.tsx create mode 100644 web/app/(app)/suche/actions.ts create mode 100644 web/app/(app)/suche/page.tsx create mode 100644 web/app/api/search/quick/route.ts create mode 100644 web/components/palette/CommandPalette.tsx create mode 100644 web/components/palette/PaletteTrigger.tsx create mode 100644 web/lib/search/query.ts create mode 100644 web/lib/search/service.ts create mode 100644 web/lib/search/views.ts create mode 100644 web/tests/suche.test.ts 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 ( +
+ {searchText.views} + + {builtins.map((view) => ( + + {searchText.builtin[view.key] ?? view.key} + + ))} + + {saved.map((view) => ( + + + {view.name} + + + + ))} + + {offen ? ( + + setName(event.target.value)} + placeholder={searchText.saveViewName} + aria-label={searchText.saveViewName} + maxLength={60} + className="border border-line bg-raised px-2 py-1" + /> + + + + ) : ( + + )} + + {fehler ? {fehler} : null} +
+ ) +} diff --git a/web/app/(app)/suche/SearchBox.tsx b/web/app/(app)/suche/SearchBox.tsx new file mode 100644 index 0000000..2949163 --- /dev/null +++ b/web/app/(app)/suche/SearchBox.tsx @@ -0,0 +1,57 @@ +'use client' + +import { useEffect, useRef, useState, useTransition } from 'react' +import { useRouter } from 'next/navigation' +import { searchText } from '@/lib/i18n/shell' +import { toSearchParams, type SearchFilters } from '@/lib/search/query' + +const DEBOUNCE_MS = 250 + +/** + * Das Suchfeld. + * + * Tippen schreibt in die Adresse, mit 250 ms Verzögerung — dadurch ist jeder + * Zustand teilbar und der Zurück-Knopf tut das Richtige. Während die neue + * Seite lädt, bleiben die alten Treffer stehen und werden gedimmt, statt zu + * verschwinden. Siehe docs/09-performance-und-ux.md. + * + * Einen `AbortController` braucht es hier nicht: der Router verwirft eine + * überholte Navigation von selbst, und `useTransition` sagt uns, ob noch eine + * läuft. + */ +export function SearchBox({ filters }: { filters: SearchFilters }) { + const router = useRouter() + const [text, setText] = useState(filters.q) + const [pending, startTransition] = useTransition() + const timer = useRef | null>(null) + + // Kommt der Begriff von aussen (Zurück-Knopf, gespeicherte Ansicht), muss + // das Feld mitgehen. + useEffect(() => { + setText(filters.q) + }, [filters.q]) + + function suchen(wert: string) { + setText(wert) + if (timer.current) clearTimeout(timer.current) + timer.current = setTimeout(() => { + startTransition(() => { + router.replace(`/suche?${toSearchParams({ ...filters, q: wert, cursor: undefined })}`) + }) + }, DEBOUNCE_MS) + } + + return ( +
+ suchen(event.target.value)} + placeholder={searchText.placeholder} + aria-label={searchText.placeholder} + autoFocus + className="w-full max-w-xl border border-line bg-raised px-3 py-2 text-sm" + /> +
+ ) +} diff --git a/web/app/(app)/suche/actions.ts b/web/app/(app)/suche/actions.ts new file mode 100644 index 0000000..ef75b86 --- /dev/null +++ b/web/app/(app)/suche/actions.ts @@ -0,0 +1,48 @@ +'use server' + +import { revalidatePath } from 'next/cache' +import { z } from 'zod' +import { db } from '@/lib/db' +import { requireUser } from '@/lib/auth/current' +import { de } from '@/lib/i18n/de' + +export type ActionResult = { ok: true } | { ok: false; error: string } + +const saveSchema = z.object({ + name: z.string().min(1).max(60), + query: z.string().max(500), +}) + +/** Eine gespeicherte Ansicht ist ein Lesezeichen auf eine Suchadresse. */ +export async function saveViewAction(input: unknown): Promise { + const user = await requireUser() + const parsed = saveSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + const anzahl = await db.savedView.count({ where: { userId: user.id } }) + if (anzahl >= 30) return { ok: false, error: de.actionErrors['too-many-views']! } + + await db.savedView.create({ + data: { + userId: user.id, + name: parsed.data.name.trim(), + query: parsed.data.query, + sortOrder: anzahl, + }, + }) + revalidatePath('/suche') + return { ok: true } +} + +const idSchema = z.object({ id: z.string().min(1) }) + +export async function deleteViewAction(input: unknown): Promise { + const user = await requireUser() + const parsed = idSchema.safeParse(input) + if (!parsed.success) return { ok: false, error: de.actionErrors.default! } + + // Nur die eigenen: die Kennung allein reicht nicht als Berechtigung. + await db.savedView.deleteMany({ where: { id: parsed.data.id, userId: user.id } }) + revalidatePath('/suche') + return { ok: true } +} diff --git a/web/app/(app)/suche/page.tsx b/web/app/(app)/suche/page.tsx new file mode 100644 index 0000000..2798545 --- /dev/null +++ b/web/app/(app)/suche/page.tsx @@ -0,0 +1,240 @@ +import type { Metadata } from 'next' +import Link from 'next/link' +import { db } from '@/lib/db' +import { requireUser } from '@/lib/auth/current' +import { parseFilters, toSearchParams, SEARCH_KINDS } from '@/lib/search/query' +import { searchCases, searchDocuments, searchPersonnel } from '@/lib/search/service' +import { builtinViews } from '@/lib/search/views' +import { de } from '@/lib/i18n/de' +import { StampMark, classificationEdgeStyle } from '@/components/StampMark' +import { StatusTags } from '@/components/StatusTags' +import { SearchBox } from './SearchBox' +import { SavedViews } from './SavedViews' + +export const metadata: Metadata = { title: de.search.heading } +export const dynamic = 'force-dynamic' + +/** + * Suchseite. + * + * Alles läuft über Adressparameter: das macht jeden Zustand teilbar, den + * Zurück-Knopf richtig und eine gespeicherte Ansicht zu dem, was sie ist — + * ein Lesezeichen. Siehe lib/search/query.ts. + */ +export default async function SearchPage({ + searchParams, +}: { + searchParams: Promise> +}) { + const user = await requireUser() + const roh = await searchParams + const filters = parseFilters( + Object.fromEntries( + Object.entries(roh).map(([k, v]) => [k, Array.isArray(v) ? v[0] : v]), + ), + ) + + const [levels, gespeichert] = await Promise.all([ + db.clearanceLevelConfig.findMany({ orderBy: { level: 'asc' } }), + db.savedView.findMany({ + where: { userId: user.id }, + orderBy: [{ sortOrder: 'asc' }, { createdAt: 'asc' }], + select: { id: true, name: true, query: true }, + }), + ]) + const levelName = new Map(levels.map((l) => [l.level, l.name])) + + const ergebnis = + filters.kind === 'document' + ? await searchDocuments(user.permissions, filters) + : filters.kind === 'personnel' + ? await searchPersonnel(user.permissions, filters) + : await searchCases(user.permissions, filters) + + const statusTags = filters.kind === 'personnel' ? await listStatusTags() : [] + + return ( +
+
+

{de.search.heading}

+ + {de.search.countLabel(ergebnis.total)} + +
+ + + + + + + + {'facets' in ergebnis ? ( +
+ ({ + ...f, + label: de.case.statusNames[f.value] ?? f.value, + }))} + param="status" + filters={filters} + /> + ({ ...f, label: f.value }))} + param="templateKey" + filters={filters} + /> + ({ + ...f, + label: `${f.value} · ${levelName.get(Number(f.value)) ?? ''}`, + }))} + param="classification" + filters={filters} + /> +
+ ) : null} + + {ergebnis.truncated ? ( +

{de.search.truncated}

+ ) : null} + + {ergebnis.hits.length === 0 ? ( +

+ {filters.q ? de.search.empty : de.search.emptyNoQuery} +

+ ) : ( +
    + {ergebnis.hits.map((treffer) => ( +
  • + {'caseNumber' in treffer ? ( + + {treffer.caseNumber} + {treffer.title} + + {de.case.statusNames[treffer.status] ?? treffer.status} + + + + ) : 'documentNumber' in treffer ? ( + + + {treffer.documentNumber} + + {treffer.title} + + {de.document.statusNames[treffer.status] ?? treffer.status} + + + ) : ( + + {treffer.displayName} + {treffer.rank ? ( + {treffer.rank} + ) : null} + + + )} +
  • + ))} +
+ )} + + {'cursor' in ergebnis && ergebnis.cursor ? ( +

+ + {de.search.more} + +

+ ) : null} +
+ ) +} + +async function listStatusTags() { + return db.personnelStatusTag.findMany({ + where: { isActive: true }, + orderBy: { sortOrder: 'asc' }, + select: { key: true, name: true, colorToken: true }, + }) +} + +/** + * Eine Facette. Die Zahlen stammen aus derselben Abfrage wie die Liste — ein + * Zähler, der mehr zählt als die Liste zeigt, wäre selbst eine Information. + */ +function Facette({ + label, + werte, + param, + filters, +}: { + label: string + werte: { value: string; label: string; count: number }[] + param: 'status' | 'templateKey' | 'classification' + filters: ReturnType +}) { + if (werte.length === 0) return null + + return ( +
+

{label}

+
    + {werte.slice(0, 6).map((wert) => { + const aktiv = String(filters[param] ?? '') === wert.value + return ( +
  • + + {wert.label} {wert.count} + +
  • + ) + })} +
+
+ ) +} diff --git a/web/app/api/search/quick/route.ts b/web/app/api/search/quick/route.ts new file mode 100644 index 0000000..3614224 --- /dev/null +++ b/web/app/api/search/quick/route.ts @@ -0,0 +1,59 @@ +import { NextResponse } from 'next/server' +import { db } from '@/lib/db' +import { getCurrentUser } from '@/lib/auth/current' +import { visibleCaseWhere } from '@/lib/access/caseFilter' +import { visiblePersonnelWhere } from '@/lib/access/personnelFilter' + +export const runtime = 'nodejs' +export const dynamic = 'force-dynamic' + +/** So viele Treffer je Art zeigt die Palette. Mehr passt nicht auf den Schirm. */ +const LIMIT = 5 + +/** + * Schnellsuche für die Kommandopalette. + * + * Bewusst schmal: Aktenzeichen, Titel und Namen, kein Volltext über Inhalte. + * Wer die Palette öffnet, will springen, nicht recherchieren — dafür gibt es + * `/suche`. + * + * Der Trigram-Index auf `caseNumber` und `displayName` fängt Tippfehler ab, + * siehe docs/08-suche-und-export.md. + */ +export async function GET(request: Request): Promise { + const user = await getCurrentUser() + if (!user) return NextResponse.json({ cases: [], personnel: [] }, { status: 404 }) + + const begriff = (new URL(request.url).searchParams.get('q') ?? '').trim().slice(0, 100) + if (begriff.length < 2) return NextResponse.json({ cases: [], personnel: [] }) + + const enthaelt = { contains: begriff, mode: 'insensitive' as const } + const darfPersonal = + user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') + + const [faelle, personen] = await Promise.all([ + db.case.findMany({ + where: { + AND: [ + visibleCaseWhere(user.permissions), + { OR: [{ caseNumber: enthaelt }, { title: enthaelt }] }, + ], + }, + orderBy: { updatedAt: 'desc' }, + take: LIMIT, + select: { id: true, caseNumber: true, title: true, classification: true }, + }), + darfPersonal + ? db.personnelFile.findMany({ + where: { + AND: [visiblePersonnelWhere(user.permissions), { displayName: enthaelt }], + }, + orderBy: { displayName: 'asc' }, + take: LIMIT, + select: { id: true, displayName: true, rank: true, classification: true }, + }) + : Promise.resolve([]), + ]) + + return NextResponse.json({ cases: faelle, personnel: personen }) +} diff --git a/web/components/palette/CommandPalette.tsx b/web/components/palette/CommandPalette.tsx new file mode 100644 index 0000000..cb7799f --- /dev/null +++ b/web/components/palette/CommandPalette.tsx @@ -0,0 +1,195 @@ +'use client' + +import { useEffect, useRef, useState } from 'react' +import { useRouter } from 'next/navigation' +import { paletteText } from '@/lib/i18n/shell' +import { stampColor } from '@/components/StampMark' + +type Treffer = { href: string; primary: string; secondary?: string; classification?: number } + +const SEITEN: Treffer[] = paletteText.pages.map((seite) => ({ + href: seite.href, + primary: seite.label, +})) + +/** + * Die Kommandopalette. + * + * Aktenzeichen eintippen und hinspringen, mehr will sie nicht. Die Treffer + * kommen serverseitig gefiltert; was hier ankommt, darf der Nutzer sehen. + * + * Eine laufende Anfrage wird bei neuer Eingabe abgebrochen — sonst überholt + * eine langsame Antwort eine schnellere und zeigt Treffer zu einem Begriff, + * der nicht mehr im Feld steht. Siehe docs/09-performance-und-ux.md. + */ +export function CommandPalette({ onClose }: { onClose: () => void }) { + const router = useRouter() + const [text, setText] = useState('') + const [treffer, setTreffer] = useState<{ label: string; eintraege: Treffer[] }[]>([]) + const [auswahl, setAuswahl] = useState(0) + const [laeuft, setLaeuft] = useState(false) + const feld = useRef(null) + + useEffect(() => { + feld.current?.focus() + }, []) + + useEffect(() => { + const begriff = text.trim() + const seiten = SEITEN.filter((seite) => + seite.primary.toLowerCase().includes(begriff.toLowerCase()), + ) + + if (begriff.length < 2) { + setTreffer([{ label: paletteText.sections.pages!, eintraege: SEITEN }]) + setAuswahl(0) + return + } + + const controller = new AbortController() + const timer = setTimeout(async () => { + setLaeuft(true) + try { + const antwort = await fetch(`/api/search/quick?q=${encodeURIComponent(begriff)}`, { + signal: controller.signal, + }) + const daten = (await antwort.json()) as { + cases: { id: string; caseNumber: string; title: string; classification: number }[] + personnel: { id: string; displayName: string; rank: string | null; classification: number }[] + } + + const gruppen: { label: string; eintraege: Treffer[] }[] = [] + if (daten.cases.length > 0) { + gruppen.push({ + label: paletteText.sections.cases!, + eintraege: daten.cases.map((akte) => ({ + href: `/faelle/${akte.caseNumber}`, + primary: akte.caseNumber, + secondary: akte.title, + classification: akte.classification, + })), + }) + } + if (daten.personnel.length > 0) { + gruppen.push({ + label: paletteText.sections.personnel!, + eintraege: daten.personnel.map((person) => ({ + href: `/personalakten/${person.id}`, + primary: person.displayName, + secondary: person.rank ?? undefined, + classification: person.classification, + })), + }) + } + if (seiten.length > 0) { + gruppen.push({ label: paletteText.sections.pages!, eintraege: seiten }) + } + + setTreffer(gruppen) + setAuswahl(0) + } catch { + // Abgebrochen oder Netz weg: die alten Treffer bleiben stehen. + } finally { + setLaeuft(false) + } + }, 150) + + return () => { + clearTimeout(timer) + controller.abort() + } + }, [text]) + + const flach = treffer.flatMap((gruppe) => gruppe.eintraege) + + function springen(ziel: Treffer | undefined) { + if (!ziel) return + onClose() + router.push(ziel.href) + } + + return ( +
+
event.stopPropagation()} + > + setText(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Escape') onClose() + if (event.key === 'ArrowDown') { + event.preventDefault() + setAuswahl((i) => Math.min(i + 1, flach.length - 1)) + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setAuswahl((i) => Math.max(i - 1, 0)) + } + if (event.key === 'Enter') { + event.preventDefault() + springen(flach[auswahl]) + } + }} + placeholder={paletteText.placeholder} + aria-label={paletteText.placeholder} + className="w-full border-b border-line bg-transparent px-4 py-3 text-sm outline-none" + /> + +
+ {flach.length === 0 ? ( +

{paletteText.empty}

+ ) : ( + treffer.map((gruppe) => ( +
+

+ {gruppe.label} +

+
    + {gruppe.eintraege.map((eintrag) => { + const index = flach.indexOf(eintrag) + return ( +
  • + +
  • + ) + })} +
+
+ )) + )} +
+
+
+ ) +} diff --git a/web/components/palette/PaletteTrigger.tsx b/web/components/palette/PaletteTrigger.tsx new file mode 100644 index 0000000..ac555f2 --- /dev/null +++ b/web/components/palette/PaletteTrigger.tsx @@ -0,0 +1,34 @@ +'use client' + +import dynamic from 'next/dynamic' +import { useEffect, useState } from 'react' + +const CommandPalette = dynamic( + () => import('./CommandPalette').then((modul) => modul.CommandPalette), + { ssr: false }, +) + +/** + * Strg+K. + * + * Absichtlich winzig: dieser Teil steckt in jeder Seite, und die Fallakte hat + * kein Bündel übrig. Die Palette selbst wird erst geholt, wenn jemand sie + * öffnet — vorher ist sie ein Tastaturereignis und sonst nichts. + */ +export function PaletteTrigger() { + const [offen, setOffen] = useState(false) + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'k') { + event.preventDefault() + setOffen((vorher) => !vorher) + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, []) + + if (!offen) return null + return setOffen(false)} /> +} diff --git a/web/e2e/smoke.spec.ts b/web/e2e/smoke.spec.ts index 047d3f9..e081bba 100644 --- a/web/e2e/smoke.spec.ts +++ b/web/e2e/smoke.spec.ts @@ -34,6 +34,7 @@ test.describe('Geschuetzte Bereiche', () => { '/personalakten', '/personalakten/neu', '/kanaele', + '/suche', '/einstellungen', '/dokumente/AfiS-2026-0001', '/admin/rollen', diff --git a/web/lib/i18n/common.ts b/web/lib/i18n/common.ts index 299e965..43901a0 100644 --- a/web/lib/i18n/common.ts +++ b/web/lib/i18n/common.ts @@ -44,6 +44,7 @@ export const actionErrorsText = { 'message-withdrawn': 'Die Nachricht wurde zurückgezogen.', 'adopt-needs-case': 'Nur eine Nachricht aus einem Aktenchat wird zum Vermerk.', 'channel-key-taken': 'Dieses Kürzel gibt es schon.', + 'too-many-views': 'Mehr als dreissig Ansichten werden unübersichtlich. Lösche eine.', 'channel-key-invalid': 'Kürzel: Kleinbuchstaben, Ziffern und Bindestrich, mindestens zwei Zeichen.', default: 'Nicht gespeichert. Erneut versuchen.', } as Record diff --git a/web/lib/i18n/de.ts b/web/lib/i18n/de.ts index df86e32..c36ec66 100644 --- a/web/lib/i18n/de.ts +++ b/web/lib/i18n/de.ts @@ -21,6 +21,8 @@ import { overviewText, healthText, notifyText, + searchText, + paletteText, } from '@/lib/i18n/shell' import { commonText, actionErrorsText, errorsText, clearanceText } from '@/lib/i18n/common' import { @@ -60,4 +62,6 @@ export const de = { settings: settingsText, health: healthText, notify: notifyText, + search: searchText, + palette: paletteText, } as const diff --git a/web/lib/i18n/shell.ts b/web/lib/i18n/shell.ts index a6170dd..9d21866 100644 --- a/web/lib/i18n/shell.ts +++ b/web/lib/i18n/shell.ts @@ -99,3 +99,58 @@ export const notifyText = { OFF: 'Nichts', } as Record, } as const + +export const searchText = { + heading: 'Suche', + placeholder: 'Aktenzeichen, Titel, Inhalt', + kinds: { + case: 'Fallakten', + document: 'Dokumente', + personnel: 'Personalakten', + } as Record, + filters: 'Filter', + reset: 'Filter zurücksetzen', + empty: 'Kein Treffer. Anderen Begriff oder weniger Filter versuchen.', + emptyNoQuery: 'Begriff eingeben oder einen Filter wählen.', + countLabel: (n: number) => (n === 1 ? '1 Treffer' : `${n} Treffer`), + more: 'Weitere laden', + truncated: + 'Sehr viele Treffer. Angezeigt werden die besten; mit einem genaueren Begriff wird die Liste vollständig.', + views: 'Ansichten', + saveView: 'Ansicht speichern', + saveViewName: 'Name der Ansicht', + deleteView: 'Ansicht löschen', + builtin: { + mine: 'Meine Fälle', + dueThisWeek: 'Wiedervorlage diese Woche', + idle: 'Unbearbeitet seit 14 Tagen', + open: 'Offene Fälle', + sinceLastVisit: 'Seit deinem letzten Besuch geändert', + } as Record, +} as const + +export const paletteText = { + open: 'Springen', + placeholder: 'Aktenzeichen, Akte oder Seite', + hint: 'Strg+K', + empty: 'Nichts gefunden.', + close: 'Schliessen', + sections: { + cases: 'Fallakten', + personnel: 'Personalakten', + pages: 'Seiten', + } as Record, + /** + * Die Sprungziele der Palette. Sie stehen hier und nicht in der Komponente, + * weil sie Oberflächentext sind — die Adressen daneben sind es nicht. + */ + pages: [ + { href: '/uebersicht', label: 'Übersicht' }, + { href: '/faelle', label: 'Fallakten' }, + { href: '/faelle/neu', label: 'Neue Fallakte' }, + { href: '/personalakten', label: 'Personalakten' }, + { href: '/kanaele', label: 'Kanäle' }, + { href: '/suche', label: 'Suche' }, + { href: '/einstellungen', label: 'Benachrichtigungen' }, + ], +} as const diff --git a/web/lib/search/query.ts b/web/lib/search/query.ts new file mode 100644 index 0000000..f9386d9 --- /dev/null +++ b/web/lib/search/query.ts @@ -0,0 +1,62 @@ +/** + * Die Filter einer Suche, als Adressparameter und als Objekt. + * + * Ohne Abhängigkeiten: die Suchseite baut damit ihre Adresse, und gespeicherte + * Ansichten sind nichts anderes als eine solche Adresse. Dass beides dieselbe + * Darstellung benutzt, ist der Punkt — eine Ansicht ist ein Lesezeichen. + */ + +export const SEARCH_KINDS = ['case', 'document', 'personnel'] as const +export type SearchKind = (typeof SEARCH_KINDS)[number] + +export type SearchFilters = { + q: string + kind: SearchKind + status?: string + templateKey?: string + classification?: number + /** Nur Akten, an denen der Suchende beteiligt ist. */ + mine?: boolean + /** Wiedervorlage bis einschliesslich diesem Tag. */ + dueBefore?: string + /** Seit diesem Zeitpunkt geändert, ISO. */ + changedSince?: string + /** Seit diesem Zeitpunkt **nicht** geändert, ISO. */ + idleSince?: string + cursor?: string +} + +export function parseFilters(params: URLSearchParams | Record): SearchFilters { + const get = (name: string): string | undefined => { + const wert = params instanceof URLSearchParams ? params.get(name) : params[name] + return wert && wert.length > 0 ? wert : undefined + } + + const kind = get('kind') + const stufe = get('classification') + + return { + q: (get('q') ?? '').slice(0, 200), + kind: (SEARCH_KINDS as readonly string[]).includes(kind ?? '') + ? (kind as SearchKind) + : 'case', + status: get('status'), + templateKey: get('templateKey'), + classification: stufe !== undefined && /^[0-5]$/.test(stufe) ? Number(stufe) : undefined, + mine: get('mine') === '1', + dueBefore: get('dueBefore'), + changedSince: get('changedSince'), + idleSince: get('idleSince'), + cursor: get('cursor'), + } +} + +/** Zurück in eine Adresse. Leere Filter fallen weg, damit sie kurz bleibt. */ +export function toSearchParams(filters: Partial): string { + const params = new URLSearchParams() + for (const [name, wert] of Object.entries(filters)) { + if (wert === undefined || wert === '' || wert === false) continue + params.set(name, wert === true ? '1' : String(wert)) + } + return params.toString() +} diff --git a/web/lib/search/service.ts b/web/lib/search/service.ts new file mode 100644 index 0000000..b9208e8 --- /dev/null +++ b/web/lib/search/service.ts @@ -0,0 +1,217 @@ +import { db } from '@/lib/db' +import { visibleCaseWhere } from '@/lib/access/caseFilter' +import { visibleDocumentWhere, visiblePersonnelWhere } from '@/lib/access/personnelFilter' +import type { SearchFilters } from '@/lib/search/query' +import type { EffectivePermissions } from '@/lib/auth/permissions' +import type { Prisma } from '@afis/db' + +/** + * Volltextsuche über Postgres, deutsche Konfiguration. Kein Elasticsearch. + * + * Zwei Schritte, und der Unterschied ist wichtig: + * + * 1. **Was passt** — eine Abfrage auf `SearchIndex`, in der Zeile zur Stufe + * des Suchenden. Sie kann nichts liefern, was er nicht lesen darf, weil + * höher eingestufte Inhalte gar nicht erst in dieser Zeile stehen. + * 2. **Was er sehen darf und wie viele es sind** — eine Abfrage mit + * `visibleCaseWhere` und den Filtern. Treffer, Trefferzahl und die + * Facettenzähler entstehen dort **gemeinsam**, damit sich aus einer Lücke + * nichts ableiten lässt. Siehe docs/08-suche-und-export.md. + * + * Der erste Schritt ist die Suchbedingung, nicht die Zugriffsprüfung. Die + * steht ausschliesslich im zweiten und stammt aus derselben Funktion wie in + * der Fallliste — es gibt keine zweite Fassung davon. + */ + +/** So viele Treffer holt der Volltext höchstens, nach Rang sortiert. */ +const FTS_LIMIT = 500 + +export const PAGE_SIZE = 25 + +export type Facet = { value: string; count: number } + +async function matchingIds( + targetType: 'case' | 'document' | 'personnel', + q: string, + clearanceLevel: number, +): Promise { + const begriff = q.trim() + // Ohne Suchbegriff filtert die Seite nur — dann gibt es keine Vorauswahl. + if (begriff.length === 0) return null + + const rows = await db.$queryRaw<{ targetId: string }[]>` + SELECT "targetId" + FROM "SearchIndex" + WHERE "targetType" = ${targetType} + AND "minClearance" = ${Math.min(Math.max(clearanceLevel, 0), 5)} + AND "vector" @@ websearch_to_tsquery('german', ${begriff}) + ORDER BY ts_rank("vector", websearch_to_tsquery('german', ${begriff})) DESC + LIMIT ${FTS_LIMIT} + ` + return rows.map((row) => row.targetId) +} + +function caseWhere( + p: EffectivePermissions, + filters: SearchFilters, + ids: string[] | null, +): Prisma.CaseWhereInput { + const bedingungen: Prisma.CaseWhereInput[] = [visibleCaseWhere(p)] + + if (ids !== null) bedingungen.push({ id: { in: ids } }) + if (filters.status) bedingungen.push({ status: filters.status as never }) + if (filters.templateKey) bedingungen.push({ templateKey: filters.templateKey }) + if (filters.classification !== undefined) { + bedingungen.push({ classification: filters.classification }) + } + if (filters.mine) bedingungen.push({ participants: { some: { userId: p.userId } } }) + if (filters.dueBefore) { + bedingungen.push({ dueAt: { not: null, lte: new Date(filters.dueBefore) } }) + } + if (filters.changedSince) bedingungen.push({ updatedAt: { gte: new Date(filters.changedSince) } }) + if (filters.idleSince) bedingungen.push({ updatedAt: { lt: new Date(filters.idleSince) } }) + + return { AND: bedingungen } +} + +export type CaseHit = { + id: string + caseNumber: string + title: string + status: string + classification: number + updatedAt: Date +} + +export async function searchCases(p: EffectivePermissions, filters: SearchFilters) { + const ids = await matchingIds('case', filters.q, p.clearanceLevel) + const where = caseWhere(p, filters, ids) + + const cursorFilter: Prisma.CaseWhereInput = filters.cursor + ? (() => { + const [at, id] = filters.cursor!.split('_') + if (!at || !id) return {} + const zeit = new Date(Number(at)) + return { OR: [{ updatedAt: { lt: zeit } }, { updatedAt: zeit, id: { lt: id } }] } + })() + : {} + + const [treffer, total, nachStatus, nachArt, nachStufe] = await Promise.all([ + db.case.findMany({ + where: { AND: [where, cursorFilter] }, + orderBy: [{ updatedAt: 'desc' }, { id: 'desc' }], + take: PAGE_SIZE + 1, + select: { + id: true, + caseNumber: true, + title: true, + status: true, + classification: true, + updatedAt: true, + }, + }), + db.case.count({ where }), + // Die Facetten laufen über **dieselbe** Bedingung. Ein Zähler, der mehr + // zählt als die Liste zeigt, wäre selbst eine Information. + db.case.groupBy({ by: ['status'], where, _count: { _all: true } }), + db.case.groupBy({ by: ['templateKey'], where, _count: { _all: true } }), + db.case.groupBy({ by: ['classification'], where, _count: { _all: true } }), + ]) + + const hasMore = treffer.length > PAGE_SIZE + const page = hasMore ? treffer.slice(0, PAGE_SIZE) : treffer + const last = page.at(-1) + + return { + hits: page as CaseHit[], + total, + cursor: hasMore && last ? `${last.updatedAt.getTime()}_${last.id}` : null, + /** Bei einer Suche ohne Begriff sind alle sichtbaren Akten die Grundmenge. */ + truncated: ids !== null && ids.length === FTS_LIMIT, + facets: { + status: zuFacet(nachStatus, 'status'), + templateKey: zuFacet(nachArt, 'templateKey'), + classification: zuFacet(nachStufe, 'classification'), + }, + } +} + +export async function searchDocuments(p: EffectivePermissions, filters: SearchFilters) { + const ids = await matchingIds('document', filters.q, p.clearanceLevel) + const where: Prisma.DocumentWhereInput = { + AND: [ + visibleDocumentWhere(p), + ...(ids !== null ? [{ id: { in: ids } }] : []), + ...(filters.templateKey ? [{ templateKey: filters.templateKey }] : []), + ...(filters.classification !== undefined + ? [{ classification: filters.classification }] + : []), + ], + } + + const [hits, total] = await Promise.all([ + db.document.findMany({ + where, + orderBy: { updatedAt: 'desc' }, + take: PAGE_SIZE, + select: { + id: true, + documentNumber: true, + title: true, + status: true, + layout: true, + classification: true, + updatedAt: true, + }, + }), + db.document.count({ where }), + ]) + + return { hits, total, truncated: ids !== null && ids.length === FTS_LIMIT } +} + +export async function searchPersonnel(p: EffectivePermissions, filters: SearchFilters) { + const ids = await matchingIds('personnel', filters.q, p.clearanceLevel) + const where: Prisma.PersonnelFileWhereInput = { + AND: [ + visiblePersonnelWhere(p), + ...(ids !== null ? [{ id: { in: ids } }] : []), + ...(filters.classification !== undefined + ? [{ classification: filters.classification }] + : []), + ], + } + + const [hits, total] = await Promise.all([ + db.personnelFile.findMany({ + where, + orderBy: { displayName: 'asc' }, + take: PAGE_SIZE, + select: { + id: true, + displayName: true, + rank: true, + department: true, + statusTags: true, + classification: true, + }, + }), + db.personnelFile.count({ where }), + ]) + + return { hits, total, truncated: ids !== null && ids.length === FTS_LIMIT } +} + +/** + * `groupBy` liefert je Zeile den gruppierten Wert und die Zahl. Welcher + * Schlüssel das ist, weiss erst der Aufrufer, deshalb hier eine schmale + * Umformung statt einer generischen Typakrobatik. + */ +function zuFacet(rows: { _count: { _all: number } }[], key: string): Facet[] { + return rows + .map((row) => ({ + value: String((row as unknown as Record)[key]), + count: row._count._all, + })) + .sort((a, b) => b.count - a.count) +} diff --git a/web/lib/search/views.ts b/web/lib/search/views.ts new file mode 100644 index 0000000..cf0e990 --- /dev/null +++ b/web/lib/search/views.ts @@ -0,0 +1,40 @@ +import { toSearchParams } from '@/lib/search/query' + +/** + * Die mitgelieferten Ansichten aus docs/08-suche-und-export.md. + * + * Sie stehen im Programm und nicht in der Datenbank, weil sie von der + * **Gegenwart** abhängen: „diese Woche" und „seit 14 Tagen" wären als + * gespeicherte Zeitpunkte schon am nächsten Tag falsch. Selbst angelegte + * Ansichten sind dagegen genau das — ein Lesezeichen auf eine Adresse. + */ +export type BuiltinView = { key: string; params: string } + +export function builtinViews(now: Date = new Date()): BuiltinView[] { + const inTagen = (tage: number): string => { + const datum = new Date(now) + datum.setDate(datum.getDate() + tage) + return datum.toISOString().slice(0, 10) + } + + return [ + { key: 'mine', params: toSearchParams({ mine: true }) }, + { key: 'dueThisWeek', params: toSearchParams({ dueBefore: inTagen(7) }) }, + { + key: 'idle', + params: toSearchParams({ + idleSince: new Date(now.getTime() - 14 * 86_400_000).toISOString(), + status: 'OPEN', + }), + }, + { key: 'open', params: toSearchParams({ status: 'OPEN' }) }, + ] +} + +/** + * „Seit deinem letzten Besuch geändert" braucht die Lesemarke und steht + * deshalb nicht in der Liste oben — die Seite ergänzt sie, wenn es eine gibt. + */ +export function sinceLastVisitView(lastReadAt: Date): BuiltinView { + return { key: 'sinceLastVisit', params: toSearchParams({ changedSince: lastReadAt.toISOString() }) } +} diff --git a/web/tests/suche.test.ts b/web/tests/suche.test.ts new file mode 100644 index 0000000..2a6002d --- /dev/null +++ b/web/tests/suche.test.ts @@ -0,0 +1,229 @@ +// @vitest-environment node +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { db } from '@/lib/db' +import { searchCases, searchPersonnel } from '@/lib/search/service' +import { parseFilters, toSearchParams } from '@/lib/search/query' +import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' + +/** + * Die Suche ist der Ort, an dem eine Zugriffsregel am leisesten bricht: ein + * Treffer verrät schon durch seine Existenz, dass es etwas gibt. + * + * Geprüft wird deshalb dreierlei: dass höher eingestufte **Inhalte** nicht + * gefunden werden, dass geschwärzte Stellen erst ab der richtigen Stufe + * findbar sind, und dass die Facettenzähler nicht mehr zählen, als die Liste + * zeigt. + */ +const url = process.env.DIRECT_DATABASE_URL ?? process.env.DATABASE_URL +const suite = url ? describe : describe.skip + +const marke = `suche-${Date.now()}` +const wort = { + offen: `Zwiebelturm${Date.now()}`, + gestuft: `Nachtblende${Date.now()}`, + geschwaerzt: `Perreira${Date.now()}`, +} + +function perms( + userId: string, + clearanceLevel: number, + options: { isAdmin?: boolean; permissions?: Permission[] } = {}, +): EffectivePermissions { + return { + userId, + clearanceLevel, + permissions: new Set(options.permissions ?? []), + isAdmin: options.isAdmin ?? false, + hasMembership: true, + } +} + +function filter(q: string, rest: Partial> = {}) { + return { ...parseFilters({}), q, ...rest } +} + +suite('Volltextsuche', () => { + const nutzer = { stufe1: `${marke}-stufe1`, stufe4: `${marke}-stufe4` } + let offenId = '' + let geheimId = '' + + beforeAll(async () => { + const vorlage = await db.documentTemplate.create({ + data: { key: `${marke}-vorlage`, version: 1, name: 'Prüfvorlage', fields: [] }, + }) + for (const id of Object.values(nutzer)) { + await db.user.create({ data: { id, username: id, displayName: id } }) + } + + const gemeinsam = { + templateId: vorlage.id, + templateKey: vorlage.key, + createdById: nutzer.stufe4, + } + + const offen = await db.case.create({ + data: { + ...gemeinsam, + caseNumber: `Such-2026-${String(Date.now()).slice(-4)}`, + title: `Vorgang ${wort.offen}`, + classification: 1, + status: 'OPEN', + }, + }) + offenId = offen.id + + const geheim = await db.case.create({ + data: { + ...gemeinsam, + caseNumber: `Ghm-2026-${String(Date.now()).slice(-4)}`, + title: 'Unauffälliger Titel', + classification: 4, + status: 'OPEN', + }, + }) + geheimId = geheim.id + + // Ein Eintrag auf Stufe 4 in der **offenen** Akte: die Akte darf jeder + // sehen, diesen Eintrag nicht. + await db.caseEntry.create({ + data: { + id: `${marke}-hoch`, + caseId: offen.id, + type: 'NOTE', + body: `Deckname ${wort.gestuft}`, + authorId: nutzer.stufe4, + minClearance: 4, + }, + }) + + // Und ein Eintrag mit einer geschwärzten Stelle darin. + await db.caseEntry.create({ + data: { + id: `${marke}-geschwaerzt`, + caseId: offen.id, + type: 'NOTE', + body: `Zeuge war [[c3]]${wort.geschwaerzt}[[/c3]] vor Ort`, + authorId: nutzer.stufe4, + minClearance: 0, + }, + }) + }) + + afterAll(async () => { + await db.$disconnect() + }) + + it('findet, was auf der eigenen Stufe steht', async () => { + const ergebnis = await searchCases(perms(nutzer.stufe1, 1), filter(wort.offen)) + expect(ergebnis.hits.map((h) => h.id)).toContain(offenId) + expect(ergebnis.total).toBe(1) + }) + + it('findet einen höher eingestuften Eintrag nicht, obwohl die Akte sichtbar ist', async () => { + // Der klassische Leck: die Akte darf er sehen, den Eintrag nicht — und + // ein Treffer würde verraten, dass darin etwas über "Nachtblende" steht. + const fuerStufe1 = await searchCases(perms(nutzer.stufe1, 1), filter(wort.gestuft)) + expect(fuerStufe1.hits).toHaveLength(0) + expect(fuerStufe1.total).toBe(0) + + const fuerStufe4 = await searchCases(perms(nutzer.stufe4, 4), filter(wort.gestuft)) + expect(fuerStufe4.hits.map((h) => h.id)).toContain(offenId) + }) + + it('findet eine geschwärzte Stelle erst ab der Stufe, ab der sie lesbar ist', async () => { + // Der Eintrag selbst ist für alle lesbar, die Stelle darin nicht. Wer sie + // auf der Seite als Balken sieht, soll sie auch nicht finden können. + for (const stufe of [0, 1, 2]) { + const ergebnis = await searchCases(perms(nutzer.stufe1, stufe), filter(wort.geschwaerzt)) + expect(ergebnis.total).toBe(0) + } + for (const stufe of [3, 4, 5]) { + const ergebnis = await searchCases(perms(nutzer.stufe4, stufe), filter(wort.geschwaerzt)) + expect(ergebnis.hits.map((h) => h.id)).toContain(offenId) + } + }) + + it('zeigt eine zu hoch eingestufte Akte auch bei passendem Titel nicht', async () => { + const ergebnis = await searchCases(perms(nutzer.stufe1, 1), filter('Unauffälliger')) + expect(ergebnis.hits.map((h) => h.id)).not.toContain(geheimId) + expect(await zaehlerStimmt(ergebnis)).toBe(true) + }) + + it('zählt in den Facetten nicht mehr, als die Liste zeigt', async () => { + const p = perms(nutzer.stufe1, 1) + const ergebnis = await searchCases(p, filter('')) + + // Die Summe je Facette muss der Trefferzahl entsprechen. Ein Zähler, der + // verborgene Akten mitzählt, wäre selbst eine Information. + for (const facette of Object.values(ergebnis.facets)) { + const summe = facette.reduce((a, f) => a + f.count, 0) + expect(summe).toBe(ergebnis.total) + } + + // Und die Stufe-4-Akte taucht in keiner Facette auf. + expect(ergebnis.facets.classification.map((f) => f.value)).not.toContain('4') + }) + + it('findet Personalakten über Name und Dienstgrad', async () => { + const person = await db.personnelFile.create({ + data: { displayName: `${marke} Marlene Vogt`, rank: 'Inspektorin', classification: 1 }, + }) + + const ergebnis = await searchPersonnel(perms(nutzer.stufe1, 1), { + ...filter('Inspektorin'), + kind: 'personnel', + }) + expect(ergebnis.hits.map((h) => h.id)).toContain(person.id) + }) + + it('aktualisiert den Index, wenn ein Eintrag geändert wird', async () => { + const neu = `Umgeschrieben${Date.now()}` + await db.caseEntry.update({ + where: { id: `${marke}-hoch` }, + data: { body: `Deckname ${neu}` }, + }) + + // Sonst fände die Suche nach einer nachträglichen Änderung den alten Text. + const alt = await searchCases(perms(nutzer.stufe4, 4), filter(wort.gestuft)) + expect(alt.total).toBe(0) + const jetzt = await searchCases(perms(nutzer.stufe4, 4), filter(neu)) + expect(jetzt.hits.map((h) => h.id)).toContain(offenId) + }) + + async function zaehlerStimmt(ergebnis: { + total: number + facets: Record + }): Promise { + return Object.values(ergebnis.facets).every( + (facette) => facette.reduce((a, f) => a + f.count, 0) === ergebnis.total, + ) + } +}) + +describe('Filter als Adresse', () => { + it('überlebt den Weg durch die Adresse unverändert', () => { + const original = { + q: 'Korruption', + kind: 'case' as const, + status: 'OPEN', + classification: 3, + mine: true, + } + const zurueck = parseFilters(new URLSearchParams(toSearchParams(original))) + + expect(zurueck.q).toBe('Korruption') + expect(zurueck.status).toBe('OPEN') + expect(zurueck.classification).toBe(3) + expect(zurueck.mine).toBe(true) + }) + + it('lässt leere Filter aus der Adresse heraus', () => { + expect(toSearchParams({ q: '', kind: 'case', mine: false })).toBe('kind=case') + }) + + it('nimmt keine erfundene Stufe und keine erfundene Art an', () => { + const gefiltert = parseFilters({ classification: '9', kind: 'unfug' }) + expect(gefiltert.classification).toBeUndefined() + expect(gefiltert.kind).toBe('case') + }) +})