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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Harlekin
2026-09-02 21:23:01 +02:00
co-authored by Claude Opus 5
parent 34a8b96dd3
commit 71c20f4b95
22 changed files with 1745 additions and 7 deletions
+6 -5
View File
@@ -116,11 +116,12 @@ Das ist der schwierigste Teil. Wenn er steht, ist der Rest Formularbau.
## Phase 7 — Suche
- [ ] `search_vector` mit Trigger und Gewichtung, GIN-Index
- [ ] Suchseite mit Filtern, Debounce, AbortController, gedimmte alte Treffer
- [ ] Sichtbarkeitsprüfung in derselben Abfrage, auch für Facettenzähler
- [ ] Kommandopalette auf Strg+K
- [ ] Gespeicherte Ansichten
- [x] `search_vector` mit Trigger und Gewichtung, GIN-Index — je Freigabestufe einer
- [x] Suchseite mit Filtern, Debounce, AbortController, gedimmte alte Treffer
- [x] Sichtbarkeitsprüfung in derselben Abfrage, auch für Facettenzähler
- [x] Kommandopalette auf Strg+K
- [x] Gespeicherte Ansichten
- [ ] „Zuletzt geöffnet" als Ansicht — braucht eine eigene Spur, siehe docs/13
## Phase 8 — Export
+5 -2
View File
@@ -4,8 +4,11 @@
Postgres Full-Text-Search, deutsche Konfiguration. Kein Elasticsearch.
- `search_vector` auf `Case` per Trigger aus Aktenzeichen und Titel (Gewicht A),
Abschnittswerten (B) und Verlaufseinträgen (C)
- `SearchIndex` per Trigger aus Aktenzeichen und Titel (Gewicht A), Abschnittswerten (B) und
Verlaufseinträgen (C)
- **Je Freigabestufe ein Vektor.** Die Zeile zur Stufe N enthält alles, was ein Leser dieser
Stufe sehen darf; eine Suche nimmt genau diese eine Zeile. Geschwärzte Stellen werden
stufenweise aufgelöst. Begründung in `docs/13-entscheidungen.md`
- GIN-Index, `websearch_to_tsquery('german', $1)`
- Zusätzlich Trigram-Index (`pg_trgm`) auf `caseNumber` und `displayName` für Tippfehler
+70
View File
@@ -648,3 +648,73 @@ geändert da; das Aufräumen war von Hand nötig.
Die Lehre steht hier, weil sie sich wiederholen wird: **vor jedem neuen Modell, Enum oder Feld
im Schema nachsehen, ob es das schon gibt.** Das Schema ist über achthundert Zeilen lang, und
der Teil, den man gerade nicht bearbeitet, ist der, den man vergisst.
## Suche
### Ein Vektor je Freigabestufe
Der Suchvektor aus Phase 2 enthielt nur, was **jeder** sehen darf (`minClearance = 0`). Das
war leck-sicher und unbrauchbar zugleich: ab Stufe 1 fand die Suche nichts von dem, was einen
Fall ausmacht. Der Kommentar in der Migration sagte schon, dass Phase 7 getrennte Vektoren je
Stufe ergänzt — hier sind sie.
`SearchIndex` hält je Ziel sechs Zeilen, eine für jede Stufe. Die Zeile zur Stufe N enthält
alles, was ein Leser dieser Stufe sehen darf: Kopfdaten, Abschnitte und Einträge bis
einschliesslich N. Eine Suche nimmt **genau eine Zeile** — die zur eigenen Stufe — und kann
deshalb nichts finden, was sie nicht lesen darf.
Sechs Zeilen je Fall statt einer ist bei ein paar hundert Akten kein Thema, und die Abfrage
wird dadurch einfacher, nicht schwerer: kein `DISTINCT`, keine Aggregation über mehrere
Zeilen, ein einzelner Gleichheitsvergleich auf `minClearance`.
### Schwärzungen werden stufenweise aufgelöst
`strip_redactions` warf jede markierte Stelle weg. Für einen gestuften Index ist das falsch in
beide Richtungen: ein Leser der Stufe 4 fände einen Namen nicht, den er auf der Seite im
Klartext sieht, und ohne die Funktion fände ein Leser der Stufe 1 einen, den er dort nur als
Balken sieht.
`strip_redactions_above(text, stufe)` entfernt deshalb nur, was **über** der Stufe liegt, und
löst die übrigen Marker auf. Ein Durchgang je Stufe darüber, weil die Stufe als Ziffer im
Marker steht und `regexp_replace` nicht auf einen Fund hin verzweigen kann.
### Die Suchbedingung ist nicht die Zugriffsprüfung
Die Suche läuft in zwei Schritten, und der Unterschied ist der Punkt:
1. **Was passt** — eine Abfrage auf `SearchIndex`, die höchstens 500 Kennungen nach Rang
liefert.
2. **Was der Suchende sehen darf und wie viele es sind** — eine Abfrage mit
`visibleCaseWhere` und den Filtern. Treffer, Trefferzahl **und Facettenzähler** entstehen
dort gemeinsam.
Der erste Schritt ist eine Suchbedingung, kein Filter im Sinne von docs/08 — die
Zugriffsprüfung steht ausschliesslich im zweiten und stammt aus derselben Funktion wie in der
Fallliste. Es gibt keine zweite Fassung davon, und es sollte auch nie eine geben: eine in SQL
nachgebaute Sichtbarkeitsregel wäre genau die Sorte Duplikat, die irgendwann auseinanderläuft.
Die Begrenzung auf 500 kann bei sehr allgemeinen Begriffen etwas abschneiden. Die Oberfläche
sagt das dann ausdrücklich, statt eine vollständige Liste vorzutäuschen.
### Filter stehen in der Adresse, Ansichten sind Lesezeichen
Jeder Zustand der Suchseite ist eine Adresse. Damit ist er teilbar, der Zurück-Knopf tut das
Richtige, und eine gespeicherte Ansicht ist nichts weiter als eine gemerkte Adresse.
Die **mitgelieferten** Ansichten stehen im Programm und nicht in der Datenbank, weil sie von
der Gegenwart abhängen: „Wiedervorlage diese Woche" und „unbearbeitet seit 14 Tagen" wären als
gespeicherte Zeitpunkte schon am nächsten Tag falsch.
„Zuletzt geöffnet" aus docs/08 fehlt: dafür müsste jeder Aufruf jeder Akte mitgeschrieben
werden, und das Zugriffsprotokoll führt nur Stufe 3 aufwärts. Eine eigene Spur dafür anzulegen
wäre eine Überwachungsfunktion, die niemand bestellt hat — wenn sie gebraucht wird, sollte das
eine bewusste Entscheidung sein und keine Nebenwirkung.
### Die Kommandopalette wiegt nichts
Strg+K steckt in jeder Seite, und die Fallakte hat kein Bündel übrig. Deshalb ist der Teil im
Grundgerüst nur ein Tastaturereignis; die Palette selbst wird geholt, wenn jemand sie öffnet.
Das kostet die anderen Seiten rund hundert Byte.
Sie sucht bewusst schmal — Aktenzeichen, Titel, Namen, kein Volltext über Inhalte. Wer sie
öffnet, will springen, nicht recherchieren.
@@ -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;
+38
View File
@@ -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.
+37
View File
@@ -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: {
+4
View File
@@ -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 (
<div className="min-h-dvh">
{/* Strg+K. Winzig; die Palette selbst wird erst beim Öffnen geholt. */}
<PaletteTrigger />
<header className="border-b border-line">
<div className="mx-auto flex max-w-5xl flex-wrap items-center gap-x-6 gap-y-2 px-6 py-3">
<Link href="/uebersicht" className="font-condensed text-text no-underline">
@@ -25,6 +28,7 @@ export default async function AppLayout({ children }: { children: React.ReactNod
{user.permissions.isAdmin || user.permissions.permissions.has('personnel.read') ? (
<Link href="/personalakten">{de.nav.personnel}</Link>
) : null}
<Link href="/suche">{de.nav.search}</Link>
<Link href="/kanaele">{de.channel.heading}</Link>
<Link href="/einstellungen">{de.notify.heading}</Link>
{user.permissions.isAdmin || user.permissions.permissions.has('roles.manage') ? (
+106
View File
@@ -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<string | null>(null)
const [pending, startTransition] = useTransition()
return (
<div className="mt-4 flex flex-wrap items-center gap-x-4 gap-y-2 text-2xs">
<span className="font-condensed text-text-dim">{searchText.views}</span>
{builtins.map((view) => (
<Link key={view.key} href={`/suche?${view.params}`} className="font-condensed">
{searchText.builtin[view.key] ?? view.key}
</Link>
))}
{saved.map((view) => (
<span key={view.id} className="inline-flex items-baseline gap-1">
<Link href={`/suche?${view.query}`} className="font-condensed">
{view.name}
</Link>
<button
type="button"
disabled={pending}
title={searchText.deleteView}
onClick={() => startTransition(async () => void (await deleteViewAction({ id: view.id })))}
className="text-text-dim"
>
×
</button>
</span>
))}
{offen ? (
<span className="inline-flex items-center gap-2">
<input
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={searchText.saveViewName}
aria-label={searchText.saveViewName}
maxLength={60}
className="border border-line bg-raised px-2 py-1"
/>
<button
type="button"
disabled={pending || name.trim() === ''}
onClick={() =>
startTransition(async () => {
const ergebnis = await saveViewAction({ name, query: currentQuery })
if (ergebnis.ok) {
setName('')
setOffen(false)
} else {
setFehler(ergebnis.error)
}
})
}
className="border border-line bg-raised px-2 py-1 font-condensed text-accent disabled:text-text-dim"
>
{commonText.save}
</button>
<button
type="button"
onClick={() => setOffen(false)}
className="font-condensed text-text-dim"
>
{commonText.cancel}
</button>
</span>
) : (
<button
type="button"
onClick={() => setOffen(true)}
className="ml-auto font-condensed text-text-dim"
>
{searchText.saveView}
</button>
)}
{fehler ? <span className="font-condensed text-stamp-4">{fehler}</span> : null}
</div>
)
}
+57
View File
@@ -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<ReturnType<typeof setTimeout> | 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 (
<div className="mt-4" data-pending={pending ? '' : undefined}>
<input
type="search"
value={text}
onChange={(event) => 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"
/>
</div>
)
}
+48
View File
@@ -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<ActionResult> {
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<ActionResult> {
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 }
}
+240
View File
@@ -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<Record<string, string | string[] | undefined>>
}) {
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 (
<main className="mx-auto max-w-5xl px-6 py-10">
<div className="flex flex-wrap items-baseline justify-between gap-4">
<h1 className="font-condensed">{de.search.heading}</h1>
<span className="font-mono text-xs text-text-dim">
{de.search.countLabel(ergebnis.total)}
</span>
</div>
<SearchBox filters={filters} />
<nav className="mt-4 flex flex-wrap gap-4 font-condensed text-xs">
{SEARCH_KINDS.map((kind) => (
<Link
key={kind}
href={`/suche?${toSearchParams({ ...filters, kind, cursor: undefined })}`}
className={kind === filters.kind ? 'text-accent' : undefined}
>
{de.search.kinds[kind]}
</Link>
))}
</nav>
<SavedViews
builtins={builtinViews()}
saved={gespeichert}
currentQuery={toSearchParams({ ...filters, cursor: undefined })}
/>
{'facets' in ergebnis ? (
<div className="mt-6 flex flex-wrap gap-x-8 gap-y-3 border-t border-line pt-4 text-2xs">
<Facette
label={de.case.status}
werte={ergebnis.facets.status.map((f) => ({
...f,
label: de.case.statusNames[f.value] ?? f.value,
}))}
param="status"
filters={filters}
/>
<Facette
label={de.case.type}
werte={ergebnis.facets.templateKey.map((f) => ({ ...f, label: f.value }))}
param="templateKey"
filters={filters}
/>
<Facette
label={de.clearance.classification}
werte={ergebnis.facets.classification.map((f) => ({
...f,
label: `${f.value} · ${levelName.get(Number(f.value)) ?? ''}`,
}))}
param="classification"
filters={filters}
/>
</div>
) : null}
{ergebnis.truncated ? (
<p className="mt-4 text-2xs text-stamp-3">{de.search.truncated}</p>
) : null}
{ergebnis.hits.length === 0 ? (
<p className="mt-10 text-text-dim">
{filters.q ? de.search.empty : de.search.emptyNoQuery}
</p>
) : (
<ul className="mt-6 border-t border-line">
{ergebnis.hits.map((treffer) => (
<li key={treffer.id} className="border-b border-line">
{'caseNumber' in treffer ? (
<Link
href={`/faelle/${treffer.caseNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(treffer.classification)}
>
<span className="font-mono text-xs text-text-dim">{treffer.caseNumber}</span>
<span className="grow">{treffer.title}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.case.statusNames[treffer.status] ?? treffer.status}
</span>
<StampMark
level={treffer.classification}
name={levelName.get(treffer.classification) ?? de.common.unknown}
/>
</Link>
) : 'documentNumber' in treffer ? (
<Link
href={`/dokumente/${treffer.documentNumber}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(treffer.classification)}
>
<span className="font-mono text-xs text-text-dim">
{treffer.documentNumber}
</span>
<span className="grow">{treffer.title}</span>
<span className="font-condensed text-2xs text-text-dim">
{de.document.statusNames[treffer.status] ?? treffer.status}
</span>
</Link>
) : (
<Link
href={`/personalakten/${treffer.id}`}
prefetch
className="flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline"
style={classificationEdgeStyle(treffer.classification)}
>
<span className="grow">{treffer.displayName}</span>
{treffer.rank ? (
<span className="font-condensed text-2xs text-text-dim">{treffer.rank}</span>
) : null}
<StatusTags tags={treffer.statusTags} known={statusTags} />
</Link>
)}
</li>
))}
</ul>
)}
{'cursor' in ergebnis && ergebnis.cursor ? (
<p className="mt-6">
<Link
href={`/suche?${toSearchParams({ ...filters, cursor: ergebnis.cursor })}`}
className="font-condensed text-xs"
>
{de.search.more}
</Link>
</p>
) : null}
</main>
)
}
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<typeof parseFilters>
}) {
if (werte.length === 0) return null
return (
<div>
<p className="font-condensed text-text-dim">{label}</p>
<ul className="mt-1 space-y-0.5">
{werte.slice(0, 6).map((wert) => {
const aktiv = String(filters[param] ?? '') === wert.value
return (
<li key={wert.value}>
<Link
href={`/suche?${toSearchParams({
...filters,
[param]: aktiv ? undefined : wert.value,
cursor: undefined,
})}`}
className={aktiv ? 'text-accent' : 'text-text'}
>
{wert.label} <span className="font-mono text-text-dim">{wert.count}</span>
</Link>
</li>
)
})}
</ul>
</div>
)
}
+59
View File
@@ -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<Response> {
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 })
}
+195
View File
@@ -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<HTMLInputElement>(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 (
<div
role="dialog"
aria-modal="true"
aria-label={paletteText.open}
className="fixed inset-0 z-50 flex items-start justify-center bg-ink-900/70 pt-[12vh]"
onClick={onClose}
>
<div
className="w-full max-w-xl border border-line bg-raised"
onClick={(event) => event.stopPropagation()}
>
<input
ref={feld}
value={text}
onChange={(event) => 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"
/>
<div
className="max-h-[50vh] overflow-y-auto"
style={{ opacity: laeuft ? 0.5 : 1 }}
>
{flach.length === 0 ? (
<p className="px-4 py-3 text-2xs text-text-dim">{paletteText.empty}</p>
) : (
treffer.map((gruppe) => (
<div key={gruppe.label}>
<p className="px-4 pt-3 pb-1 font-condensed text-2xs text-text-dim">
{gruppe.label}
</p>
<ul>
{gruppe.eintraege.map((eintrag) => {
const index = flach.indexOf(eintrag)
return (
<li key={eintrag.href}>
<button
type="button"
onMouseEnter={() => setAuswahl(index)}
onClick={() => springen(eintrag)}
className={`flex w-full items-baseline gap-3 px-4 py-2 text-left text-sm ${
index === auswahl ? 'bg-ink-700' : ''
}`}
style={
eintrag.classification !== undefined
? {
borderLeft: `var(--spacing-edge) solid ${stampColor(eintrag.classification)}`,
}
: undefined
}
>
<span className="font-mono text-xs">{eintrag.primary}</span>
{eintrag.secondary ? (
<span className="grow text-text-dim">{eintrag.secondary}</span>
) : null}
</button>
</li>
)
})}
</ul>
</div>
))
)}
</div>
</div>
</div>
)
}
+34
View File
@@ -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 <CommandPalette onClose={() => setOffen(false)} />
}
+1
View File
@@ -34,6 +34,7 @@ test.describe('Geschuetzte Bereiche', () => {
'/personalakten',
'/personalakten/neu',
'/kanaele',
'/suche',
'/einstellungen',
'/dokumente/AfiS-2026-0001',
'/admin/rollen',
+1
View File
@@ -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<string, string>
+4
View File
@@ -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
+55
View File
@@ -99,3 +99,58 @@ export const notifyText = {
OFF: 'Nichts',
} as Record<string, string>,
} as const
export const searchText = {
heading: 'Suche',
placeholder: 'Aktenzeichen, Titel, Inhalt',
kinds: {
case: 'Fallakten',
document: 'Dokumente',
personnel: 'Personalakten',
} as Record<string, string>,
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<string, string>,
} 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<string, string>,
/**
* 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
+62
View File
@@ -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<string, string | undefined>): 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<SearchFilters>): 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()
}
+217
View File
@@ -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<string[] | null> {
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<string, unknown>)[key]),
count: row._count._all,
}))
.sort((a, b) => b.count - a.count)
}
+40
View File
@@ -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() }) }
}
+229
View File
@@ -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<Permission>(options.permissions ?? []),
isAdmin: options.isAdmin ?? false,
hasMembership: true,
}
}
function filter(q: string, rest: Partial<ReturnType<typeof parseFilters>> = {}) {
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<string, { count: number }[]>
}): Promise<boolean> {
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')
})
})