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