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 { WORKING_ROLES } from '@/lib/cases/terms' 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 /** * So viele Treffer je Art zeigt die gemeinsame Ansicht. Weniger als eine volle * Seite: dort geht es darum, **wo** etwas liegt, nicht darum, alles zu lesen. * Der Weg in die volle Liste steht daneben. */ export const PREVIEW_SIZE = 5 /** * Personalakten setzen neben der Freigabestufe ein eigenes Recht voraus. * * Das stand in der Kommandopalette und auf der Aktenseite, fehlte aber in der * Suche — ein Nutzer mit hoher Stufe und ohne `personnel.read` konnte sich * über `/suche?kind=personnel` eine Liste aller Personalakten holen. Die * Prüfung gehört an **jede** Stelle, die diese Akten herausgibt, und deshalb * in diese Datei und nicht in die Seite. */ function darfPersonalakten(p: EffectivePermissions): boolean { return p.isAdmin || p.permissions.has('personnel.read') } 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, role: { in: [...WORKING_ROLES] } } }, }) } 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, options: { take?: number } = {}, ) { const seite = options.take ?? PAGE_SIZE 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: seite + 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 > seite const page = hasMore ? treffer.slice(0, seite) : 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'), }, } } function documentWhere( p: EffectivePermissions, filters: SearchFilters, ids: string[] | null, ): Prisma.DocumentWhereInput { return { AND: [ visibleDocumentWhere(p), ...(ids !== null ? [{ id: { in: ids } }] : []), ...(filters.templateKey ? [{ templateKey: filters.templateKey }] : []), ...(filters.classification !== undefined ? [{ classification: filters.classification }] : []), ], } } export async function searchDocuments( p: EffectivePermissions, filters: SearchFilters, options: { take?: number } = {}, ) { const ids = await matchingIds('document', filters.q, p.clearanceLevel) const where = documentWhere(p, filters, ids) const [hits, total] = await Promise.all([ db.document.findMany({ where, orderBy: { updatedAt: 'desc' }, take: options.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 } } function personnelWhere( p: EffectivePermissions, filters: SearchFilters, ids: string[] | null, ): Prisma.PersonnelFileWhereInput { return { AND: [ visiblePersonnelWhere(p), ...(ids !== null ? [{ id: { in: ids } }] : []), ...(filters.classification !== undefined ? [{ classification: filters.classification }] : []), ], } } export async function searchPersonnel( p: EffectivePermissions, filters: SearchFilters, options: { take?: number } = {}, ) { // Ohne das Recht gibt es diese Akten für ihn nicht — auch nicht als Zahl. if (!darfPersonalakten(p)) { return { hits: [], total: 0, truncated: false } } const ids = await matchingIds('personnel', filters.q, p.clearanceLevel) const where = personnelWhere(p, filters, ids) const [hits, total] = await Promise.all([ db.personnelFile.findMany({ where, orderBy: { displayName: 'asc' }, take: options.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) } /** * Wie viele Treffer es je Art gibt. * * Der Anlass war eine Beobachtung aus dem Betrieb: wer nach einem Namen sucht, * landet auf den Fallakten und **sieht nicht**, dass es zu demselben Namen * auch eine Personalakte gibt. Die Suche fand sie, aber hinter einem Reiter, * den niemand anklickt, wenn nichts darauf hindeutet. * * Die Zahlen entstehen mit **denselben** Bedingungen wie die Trefferlisten. * Eine Zahl, die mehr zählt als die Liste hergibt, wäre selbst eine * Information — siehe harte Regel 6. * * `null` heisst: diese Art gibt es für diesen Nutzer nicht. Nicht `0` — eine * Null wäre die Auskunft „hier ist nichts", und die steht ihm nicht zu. */ export async function searchCounts( p: EffectivePermissions, filters: SearchFilters, ): Promise<{ case: number; document: number; personnel: number | null }> { const [fallIds, dokumentIds, personIds] = await Promise.all([ matchingIds('case', filters.q, p.clearanceLevel), matchingIds('document', filters.q, p.clearanceLevel), darfPersonalakten(p) ? matchingIds('personnel', filters.q, p.clearanceLevel) : null, ]) const [faelle, dokumente, personen] = await Promise.all([ db.case.count({ where: caseWhere(p, filters, fallIds) }), db.document.count({ where: documentWhere(p, filters, dokumentIds) }), darfPersonalakten(p) ? db.personnelFile.count({ where: personnelWhere(p, filters, personIds) }) : Promise.resolve(null), ]) return { case: faelle, document: dokumente, personnel: personen } }