diff --git a/packages/db/prisma/migrations/20260904120000_beteiligte_in_der_suche/migration.sql b/packages/db/prisma/migrations/20260904120000_beteiligte_in_der_suche/migration.sql new file mode 100644 index 0000000..a27d514 --- /dev/null +++ b/packages/db/prisma/migrations/20260904120000_beteiligte_in_der_suche/migration.sql @@ -0,0 +1,129 @@ +-- Beteiligte in den Suchindex der Fallakte. +-- +-- Wer nach einem Namen sucht, will die Akten finden, in denen die Person +-- vorkommt — auch dann, wenn sie weder im Titel steht noch im Text erwähnt +-- wird, sondern nur als Zeuge oder Betroffener eingetragen ist. +-- +-- Das verrät nichts Neues: der Name eines Beteiligten steht ohnehin in der +-- Beteiligtenliste jeder Akte, die man lesen darf. Zurückgehalten wird dort +-- allein der Verweis auf seine Personalakte, wenn die höher eingestuft ist +-- ("der Beteiligte ist Teil der Akte, seine Personalakte nicht", siehe +-- lib/cases/participants.ts). Deshalb steht der Name in allen sechs Vektoren +-- und nicht erst ab einer Stufe. +-- +-- Gewicht B, wie die Abschnitte: ein Name sagt etwas über die Akte aus, aber +-- weniger als ihr Titel. + +CREATE OR REPLACE FUNCTION refresh_case_search(target_id text) RETURNS void AS $$ +DECLARE + stufe int; + kopf tsvector; + beteiligte 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; + + -- Die Namen der Beteiligten, gleich ob sie an einem Konto oder an einer + -- Personalakte hängen. Sie hängen nicht von der Stufe ab und werden deshalb + -- einmal vor der Schleife berechnet. + SELECT coalesce( + to_tsvector('german', string_agg(coalesce(u."displayName", pf."displayName", ''), ' ')), + ''::tsvector + ) INTO beteiligte + FROM "CaseParticipant" cp + LEFT JOIN "User" u ON u."id" = cp."userId" + LEFT JOIN "PersonnelFile" pf ON pf."id" = cp."personnelFileId" + WHERE cp."caseId" = target_id; + + 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(beteiligte, 'B') || setweight(abschnitte, 'B') + || setweight(eintraege, 'C') + ); + END LOOP; +END; +$$ LANGUAGE plpgsql; + +-- Der Verteiler kennt zwei neue Anlässe: eine geänderte Beteiligung, und einen +-- geänderten Namen. Beim Namen sind alle Akten der Person betroffen — sonst +-- fände die Suche nach einer Umbenennung weiter den alten Namen. +CREATE OR REPLACE FUNCTION search_index_trigger() RETURNS trigger AS $$ +DECLARE + ziel text; + fall record; +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 = 'CaseParticipant' 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"); + FOR fall IN + SELECT DISTINCT cp."caseId" AS id FROM "CaseParticipant" cp + WHERE cp."personnelFileId" = NEW."id" + LOOP + PERFORM refresh_case_search(fall.id); + END LOOP; + ELSIF TG_TABLE_NAME = 'User' THEN + FOR fall IN + SELECT DISTINCT cp."caseId" AS id FROM "CaseParticipant" cp + WHERE cp."userId" = NEW."id" + LOOP + PERFORM refresh_case_search(fall.id); + END LOOP; + END IF; + RETURN NULL; +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER search_on_case_participant + AFTER INSERT OR UPDATE OR DELETE ON "CaseParticipant" + FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); + +-- Der Worker gleicht Anzeigenamen mit Discord ab; ohne diesen Auslöser fände +-- die Suche nach einer Umbenennung weiter den alten Namen. +CREATE TRIGGER search_on_user_name + AFTER UPDATE OF "displayName" ON "User" + FOR EACH ROW EXECUTE FUNCTION search_index_trigger(); + +-- Bestand einmal nachziehen, sonst tragen die vorhandenen Akten den alten +-- Vektor ohne Beteiligte. +SELECT refresh_case_search(c."id") FROM "Case" c; diff --git a/web/app/(app)/suche/page.tsx b/web/app/(app)/suche/page.tsx index 33f1da8..1345a60 100644 --- a/web/app/(app)/suche/page.tsx +++ b/web/app/(app)/suche/page.tsx @@ -10,6 +10,7 @@ import { } from '@/lib/search/query' import { PREVIEW_SIZE, + matchingParticipants, searchCases, searchCounts, searchDocuments, @@ -85,6 +86,13 @@ export default async function SearchPage({ const statusTags = alle || filters.kind === 'personnel' ? await listStatusTags() : [] + // Warum ein Fall zu einem Namen passt: seit die Beteiligten im Index stehen, + // trifft die Suche auch Akten, in denen der Name nirgends im Text steht. + const beteiligungen = await matchingParticipants( + (faelle?.hits ?? []).map((treffer) => treffer.id), + filters.q, + ) + return (
@@ -191,7 +199,12 @@ export default async function SearchPage({ @@ -225,7 +238,12 @@ export default async function SearchPage({ @@ -268,10 +286,13 @@ function Treffer({ treffer, levelName, statusTags, + beteiligung, }: { treffer: TrefferView levelName: Map statusTags: { key: string; name: string; colorToken: string }[] + /** Gesetzt, wenn der Treffer an einem Beteiligten hängt und nicht am Text. */ + beteiligung?: { name: string; role: string }[] }) { const zeile = 'flex flex-wrap items-baseline gap-x-4 gap-y-1 py-3 pl-3 text-text no-underline' @@ -284,7 +305,21 @@ function Treffer({ style={classificationEdgeStyle(treffer.classification)} > {treffer.caseNumber} - {treffer.title} + + {treffer.title} + {beteiligung && beteiligung.length > 0 ? ( + + {beteiligung + .map((eintrag) => + de.search.asParticipant( + eintrag.name, + de.participant.roles[eintrag.role] ?? eintrag.role, + ), + ) + .join(' · ')} + + ) : null} + {de.case.statusNames[treffer.status] ?? treffer.status} diff --git a/web/lib/i18n/shell.ts b/web/lib/i18n/shell.ts index 586d0e0..9ec0ea7 100644 --- a/web/lib/i18n/shell.ts +++ b/web/lib/i18n/shell.ts @@ -139,6 +139,7 @@ export const searchText = { personnel: 'Personalakten', } as Record, showAllOfKind: (label: string) => `Alle ${label} zeigen`, + asParticipant: (name: string, rolle: string) => `${name} als ${rolle}`, filters: 'Filter', reset: 'Filter zurücksetzen', empty: 'Kein Treffer. Anderen Begriff oder weniger Filter versuchen.', diff --git a/web/lib/search/service.ts b/web/lib/search/service.ts index 9460af2..de9ab53 100644 --- a/web/lib/search/service.ts +++ b/web/lib/search/service.ts @@ -310,3 +310,65 @@ export async function searchCounts( return { case: faelle, document: dokumente, personnel: personen } } + +/** + * Warum ein Fall zu einem Namen passt. + * + * Seit die Beteiligten im Index stehen, findet die Suche nach „Jonathan + * Fletcher" auch Akten, in denen der Name nirgends im Text vorkommt. Ohne + * einen Hinweis sieht das aus wie ein Fehlgriff — die Trefferzeile zeigt + * deshalb, in welcher Rolle die Person dort steht. + * + * Gesucht wird nur unter den Akten, die als Treffer ohnehin schon durch die + * Sichtbarkeitsprüfung gekommen sind. Name und Rolle eines Beteiligten darf + * jeder lesen, der die Akte lesen darf; zurückgehalten wird allein der Verweis + * auf seine Personalakte, und den gibt es hier gar nicht. + */ +export type Beteiligung = { name: string; role: string } + +export async function matchingParticipants( + caseIds: string[], + q: string, +): Promise> { + const woerter = q + .replace(/["']/g, ' ') + .split(/\s+/) + .map((wort) => wort.trim()) + .filter((wort) => wort.length >= 3) + .slice(0, 5) + + if (caseIds.length === 0 || woerter.length === 0) return new Map() + + const rows = await db.caseParticipant.findMany({ + where: { + caseId: { in: caseIds }, + OR: woerter.flatMap((wort) => [ + { user: { is: { displayName: { contains: wort, mode: 'insensitive' as const } } } }, + { + personnelFile: { + is: { displayName: { contains: wort, mode: 'insensitive' as const } }, + }, + }, + ]), + }, + select: { + caseId: true, + role: true, + user: { select: { displayName: true } }, + personnelFile: { select: { displayName: true } }, + }, + }) + + const karte = new Map() + for (const row of rows) { + const name = row.user?.displayName ?? row.personnelFile?.displayName + if (!name) continue + const bisher = karte.get(row.caseId) ?? [] + // Dieselbe Person kann in mehreren Rollen eingetragen sein; jede zählt. + if (!bisher.some((e) => e.name === name && e.role === row.role)) { + bisher.push({ name, role: row.role }) + } + karte.set(row.caseId, bisher) + } + return karte +} diff --git a/web/tests/suche.test.ts b/web/tests/suche.test.ts index ee617d4..4a383d3 100644 --- a/web/tests/suche.test.ts +++ b/web/tests/suche.test.ts @@ -1,7 +1,12 @@ // @vitest-environment node import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { db } from '@/lib/db' -import { searchCases, searchCounts, searchPersonnel } from '@/lib/search/service' +import { + matchingParticipants, + searchCases, + searchCounts, + searchPersonnel, +} from '@/lib/search/service' import { parseFilters, toSearchParams } from '@/lib/search/query' import type { EffectivePermissions, Permission } from '@/lib/auth/permissions' @@ -217,6 +222,70 @@ suite('Volltextsuche', () => { expect(ohneRecht.personnel).toBeNull() }) + it('findet eine Akte über den Namen eines Beteiligten', async () => { + // Der eigentliche Fall: der Name steht weder im Titel noch im Text, die + // Person ist nur als Betroffene eingetragen. + const name = `Jonathan Feldmann ${Date.now().toString(36)}` + const person = await db.personnelFile.create({ + data: { displayName: name, classification: 1 }, + }) + const vorlage = await db.documentTemplate.findFirstOrThrow({ + where: { key: { startsWith: marke } }, + }) + const akte = await db.case.create({ + data: { + templateId: vorlage.id, + templateKey: vorlage.key, + createdById: nutzer.stufe4, + caseNumber: `Btl-2026-${Date.now().toString(36)}`, + title: 'Vorgang ohne Namen im Titel', + classification: 1, + status: 'OPEN', + }, + }) + + // Vorher: nichts zu finden. + const vorher = await searchCases(perms(nutzer.stufe1, 1), filter(name)) + expect(vorher.hits.map((h) => h.id)).not.toContain(akte.id) + + await db.caseParticipant.create({ + data: { caseId: akte.id, personnelFileId: person.id, role: 'SUBJECT' }, + }) + + // Nachher: der Auslöser hat den Index nachgezogen. + const nachher = await searchCases(perms(nutzer.stufe1, 1), filter(name)) + expect(nachher.hits.map((h) => h.id)).toContain(akte.id) + + // Und die Trefferzeile kann sagen, warum. + const warum = await matchingParticipants([akte.id], name) + expect(warum.get(akte.id)).toEqual([{ name, role: 'SUBJECT' }]) + + // Wird die Beteiligung wieder aufgehoben, verschwindet der Treffer. + await db.caseParticipant.deleteMany({ where: { caseId: akte.id, personnelFileId: person.id } }) + const danach = await searchCases(perms(nutzer.stufe1, 1), filter(name)) + expect(danach.hits.map((h) => h.id)).not.toContain(akte.id) + }) + + it('macht über einen Beteiligten keine zu hoch eingestufte Akte auffindbar', async () => { + // Der Name eines Beteiligten steht in jeder Akte, die man lesen darf — + // aber die Akte selbst muss man lesen dürfen. Sonst wäre die Suche nach + // einem Namen ein Weg, die Existenz einer Akte zu erraten. + const name = `Verdeckte Person ${Date.now().toString(36)}` + const person = await db.personnelFile.create({ + data: { displayName: name, classification: 1 }, + }) + await db.caseParticipant.create({ + data: { caseId: geheimId, personnelFileId: person.id, role: 'WITNESS' }, + }) + + const niedrig = await searchCases(perms(nutzer.stufe1, 1), filter(name)) + expect(niedrig.hits.map((h) => h.id)).not.toContain(geheimId) + expect(niedrig.total).toBe(0) + + const hoch = await searchCases(perms(nutzer.stufe4, 4), filter(name)) + expect(hoch.hits.map((h) => h.id)).toContain(geheimId) + }) + it('aktualisiert den Index, wenn ein Eintrag geändert wird', async () => { const neu = `Umgeschrieben${Date.now()}` await db.caseEntry.update({