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') + }) +})