Files
AfiS-Aktensystem/web/lib/search/service.ts
T
Harlekin eef145c97b Suche fängt bei „Alle" an, Bereiche in der Kopfzeile sind Knöpfe
**Suche.** Sie startete auf den Fallakten, und man musste die Art vorher
wählen — die falsche Reihenfolge: wer einen Namen eintippt, weiss noch nicht,
ob er eine Fallakte, ein Dokument oder eine Personalakte sucht. Er will wissen,
wo der Name vorkommt.

„Alle" ist jetzt der Auslieferungszustand und zeigt je Art fünf Treffer als
eigenen Block, mit dem Weg in die volle Liste daneben. Getrennte Blöcke statt
einer gemischten Liste nach Rang: ein Aktenzeichen neben einem Namen neben
einer Dokumentnummer hat keine erkennbare Ordnung. Die einzelnen Arten bleiben
als Filter, und nur dort gibt es Facetten und Blättern — beides bezieht sich
auf eine Grundmenge, ein Statusfilter über Fall- und Personalakten zugleich
wäre bei der einen ein Filter und bei der anderen ein Ausschluss.

**Kopfzeile.** Die Bereiche waren Textlinks: von einem Fliesstextlink nicht zu
unterscheiden und ohne Hinweis darauf, wo man gerade ist. Jetzt dieselbe
Knopfform wie alles Bedienbare, der aktive Bereich gefüllt.

Der gefüllte Zustand trägt hellen Text statt der Akzentfarbe: auf der getönten
Fläche wäre Akzent auf Akzent und der Kontrast fiele unter die Untergrenze.
Dazu etwas grosszügigere Polsterung an beiden Knopfgrössen, .btn-count für
Zahlen in Knöpfen, und kein Doppelring mehr am Profilbild.

Nachgesehen mit dem gebauten CSS im Browser, hell und dunkel.
2026-09-03 21:59:06 +02:00

313 lines
10 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 }
}