Files
Harlekin e0b03ea336
Prüfung / Lint, Typen, Tests (push) Canceled after 0s
Prüfung / Images bauen (pdf) (push) Canceled after 0s
Prüfung / Images bauen (web) (push) Canceled after 0s
Prüfung / Images bauen (worker) (push) Canceled after 0s
Images / bauen (pdf, docker/pdf.Dockerfile) (push) Canceled after 0s
Images / bauen (web, docker/web.Dockerfile) (push) Canceled after 0s
Images / bauen (worker, docker/worker.Dockerfile) (push) Canceled after 0s
Gedankenstriche durch Bindestriche ersetzt
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die
README; an keiner Stelle Bedeutung, nur Zeichensetzung.
2026-09-04 08:54:52 +02:00

375 lines
12 KiB
TypeScript

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<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, 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<string, unknown>)[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 }
}
/**
* 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<Map<string, Beteiligung[]>> {
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<string, Beteiligung[]>()
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
}