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
375 Vorkommen in 210 Dateien. Betrifft Kommentare, Oberflaechentexte und die README; an keiner Stelle Bedeutung, nur Zeichensetzung.
375 lines
12 KiB
TypeScript
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
|
|
}
|