Files
betreuungs-cockpit/src/lib/dokumente/suche.ts
T
Florian Wilde 22b3a9b9e9 feat: Stammdaten, Ersteinrichtung, Zweitfaktor und Protokollansicht
Damit ist Etappe 1 fachlich vollstaendig.

Ersteinrichtung in drei Schritten, bewusst ausserhalb des
Anwendungsrahmens: der setzt Benutzer, zweiten Faktor UND eine betreute
Person voraus - beim allerersten Start gibt es nichts davon. Die Aktion
fuer den ersten Zugang sperrt sich selbst, sobald ein Benutzer existiert.

Zweiter Faktor: Das Geheimnis wird zuerst nur abgelegt, ohne
totpAktiviertAm. Erst ein echter Code aus der App schaltet ihn scharf.
Wer sich beim Abtippen vertut oder die falsche App nimmt, wuerde sich
sonst dauerhaft aussperren. Erst danach gibt es die
Wiederherstellungscodes - einmal angezeigt, danach nur noch als Hash.

Betreuungsjahr (src/lib/stammdaten/betreuungsjahr.ts): laeuft ab dem Tag
der Bestellung, nicht ab dem Kalenderjahr. Schrittweise vorwaerts
gerechnet statt ueber eine Differenz, damit Schaltjahre und der
29. Februar von selbst stimmen. Daran haengen Jahresbericht,
Rechnungslegung und die Ausschlussfrist der Aufwandspauschale.

Aendert sich das Bestellungsdatum, werden die Pflichtfristen neu
abgeleitet - ueber einen dynamischen Import, damit die Stammdaten auch
dann speicherbar bleiben, wenn das Fristenmodul fehlt.

Protokollansicht zeigt je Eintrag die geaenderten Felder als alt -> neu.
Die Aenderungen liegen verschluesselt, weil dort Diagnosen und
Kontostaende stehen koennen.

Seed legt bewusst keine echten Personendaten an, nur den ersten Zugang.
Mit SEED_DEMO=true kommt ein vollstaendiger Beispielfall dazu - Erika
Musterfrau, COPD GOLD III, Pflegegrad 3, Heimeinzug Januar 2026,
leerstehendes Haus, zwei Versicherungen, laufender Antrag beim Bezirk
mit acht Nachforderungen. Nur ausserhalb der Produktion.

Docker Compose ohne Reverse Proxy - der ist auf dem Zielserver
vorhanden. Die Datenbank hat keine Portfreigabe und ist nur ueber das
interne Netz erreichbar; die Anwendung horcht auf 127.0.0.1.

Etappe: 1
Phase: Fundament
2026-08-22 02:16:22 +02:00

376 lines
12 KiB
TypeScript

import 'server-only'
import { Prisma, type DokumentArt, type DokumentRichtung, type PosteingangStatus } from '@prisma/client'
import { db, nichtGeloescht } from '@/lib/db'
/**
* Volltextsuche ueber die Dokumente einer betreuten Person.
*
* Gesucht wird in der von Postgres gepflegten Spalte `suchvektor`
* (prisma/sql/volltextsuche.sql, deutsche Konfiguration, Trigger auf
* Titel/Absender/Aktenzeichen/Dateiname/OCR-Text).
*
* Zwei Dinge sind hier nicht verhandelbar:
*
* 1. Jede Abfrage ist auf `betreuteId` eingegrenzt und blendet den
* Papierkorb aus. Es gibt keinen Weg durch diese Datei, der Dokumente
* einer anderen Person zurueckgeben koennte.
* 2. Der Suchbegriff wird als **Parameter** uebergeben, nie in den
* SQL-Text geklebt. `websearch_to_tsquery` nimmt beliebigen Nutzertext
* entgegen, ohne bei Anfuehrungszeichen oder Klammern zu stolpern.
*/
export type Suchtreffer = {
id: string
titel: string
art: DokumentArt
richtung: DokumentRichtung
absender: string | null
aktenzeichen: string | null
dateiname: string | null
eingangAm: Date | null
schreibenVom: Date | null
posteingangStatus: PosteingangStatus
/** Fundstelle mit hervorgehobenem Treffer (<mark>…</mark>). */
ausschnitt: string
/** Rangwert von ts_rank_cd - nur zum Sortieren und Anzeigen. */
rang: number
}
/**
* Bereitet die Eingabe des Nutzers auf.
*
* `websearch_to_tsquery` versteht die Schreibweise, die man aus
* Suchmaschinen kennt: Woerter, "Wortgruppen in Anfuehrungszeichen",
* `oder`, ein vorangestelltes Minus zum Ausschliessen. Deshalb wird hier
* bewusst wenig veraendert - nur Steuerzeichen und uebermaessige Laenge
* werden abgeraeumt.
*
* Gibt null zurueck, wenn nichts Suchbares uebrig bleibt.
*/
export function suchanfrageAufbereiten(roh: string | null | undefined): string | null {
if (!roh) return null
const sauber = roh
// eslint-disable-next-line no-control-regex
.replace(/[\u0000-\u001f\u007f]/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 200)
if (sauber.length < 2) return null
// Ein einzelnes offenes Anfuehrungszeichen wuerde die halbe Anfrage zur
// Wortgruppe machen - lieber entfernen als den Nutzer raten lassen.
const anfuehrungszeichen = (sauber.match(/"/g) ?? []).length
return anfuehrungszeichen % 2 === 0 ? sauber : sauber.replace(/"/g, '')
}
export type Suchoptionen = {
grenze?: number
art?: DokumentArt | null
richtung?: DokumentRichtung | null
posteingangStatus?: PosteingangStatus | null
von?: Date | null
bis?: Date | null
tagId?: string | null
}
/**
* Volltextsuche. Rangfolge ueber ts_rank_cd, Fundstelle ueber ts_headline.
* Leere oder unbrauchbare Anfrage -> leeres Ergebnis, kein Fehler.
*/
export async function dokumenteSuchen(
betreuteId: string,
anfrage: string,
optionen: Suchoptionen = {},
): Promise<Suchtreffer[]> {
const begriff = suchanfrageAufbereiten(anfrage)
if (!begriff) return []
const grenze = Math.min(Math.max(optionen.grenze ?? 50, 1), 200)
const bedingungen: Prisma.Sql[] = [
Prisma.sql`d."betreuteId" = ${betreuteId}`,
Prisma.sql`d."geloeschtAm" IS NULL`,
Prisma.sql`d.suchvektor @@ q.abfrage`,
]
if (optionen.art) bedingungen.push(Prisma.sql`d.art = ${optionen.art}::"DokumentArt"`)
if (optionen.richtung) {
bedingungen.push(Prisma.sql`d.richtung = ${optionen.richtung}::"DokumentRichtung"`)
}
if (optionen.posteingangStatus) {
bedingungen.push(
Prisma.sql`d."posteingangStatus" = ${optionen.posteingangStatus}::"PosteingangStatus"`,
)
}
if (optionen.von) {
bedingungen.push(Prisma.sql`coalesce(d."eingangAm", d."schreibenVom") >= ${optionen.von}`)
}
if (optionen.bis) {
bedingungen.push(Prisma.sql`coalesce(d."eingangAm", d."schreibenVom") <= ${optionen.bis}`)
}
if (optionen.tagId) {
bedingungen.push(
Prisma.sql`EXISTS (
SELECT 1 FROM "_DokumentTags" v
WHERE v."A" = d.id AND v."B" = ${optionen.tagId}
)`,
)
}
const zeilen = await db.$queryRaw<
(Omit<Suchtreffer, 'rang'> & { rang: number })[]
>(Prisma.sql`
SELECT
d.id,
d.titel,
d.art,
d.richtung,
d.absender,
d.aktenzeichen,
d.dateiname,
d."eingangAm",
d."schreibenVom",
d."posteingangStatus",
ts_headline(
'german',
coalesce(nullif(d."ocrText", ''), concat_ws(' ', d.titel, d.absender, d.aktenzeichen)),
q.abfrage,
'StartSel=<mark>, StopSel=</mark>, MaxWords=32, MinWords=12, ShortWord=3, MaxFragments=2, FragmentDelimiter=" … "'
) AS ausschnitt,
ts_rank_cd(d.suchvektor, q.abfrage) AS rang
FROM dokument d,
websearch_to_tsquery('german', ${begriff}) AS q(abfrage)
WHERE ${Prisma.join(bedingungen, ' AND ')}
ORDER BY rang DESC, coalesce(d."eingangAm", d."schreibenVom", d."erstelltAm"::date) DESC
LIMIT ${grenze}
`)
return zeilen.map((zeile) => ({ ...zeile, rang: Number(zeile.rang) }))
}
/** Zaehlt die Treffer, ohne sie zu laden - fuer "127 Treffer". */
export async function trefferZaehlen(betreuteId: string, anfrage: string): Promise<number> {
const begriff = suchanfrageAufbereiten(anfrage)
if (!begriff) return 0
const ergebnis = await db.$queryRaw<{ anzahl: bigint }[]>(Prisma.sql`
SELECT count(*) AS anzahl
FROM dokument d, websearch_to_tsquery('german', ${begriff}) AS q(abfrage)
WHERE d."betreuteId" = ${betreuteId}
AND d."geloeschtAm" IS NULL
AND d.suchvektor @@ q.abfrage
`)
return Number(ergebnis[0]?.anzahl ?? 0)
}
/**
* Vorschlaege fuer das Absenderfeld: unscharf ueber den Trigram-Index, so
* dass "Bezirk Oberfr" auch "Bezirk Oberfranken" findet.
*/
export async function absenderVorschlagen(
betreuteId: string,
teil: string,
grenze = 8,
): Promise<string[]> {
const sauber = (teil ?? '').trim().slice(0, 80)
if (sauber.length < 2) return []
const zeilen = await db.$queryRaw<{ absender: string }[]>(Prisma.sql`
SELECT DISTINCT d.absender
FROM dokument d
WHERE d."betreuteId" = ${betreuteId}
AND d."geloeschtAm" IS NULL
AND d.absender IS NOT NULL
AND d.absender % ${sauber}
ORDER BY d.absender
LIMIT ${Math.min(Math.max(grenze, 1), 25)}
`)
return zeilen.map((z) => z.absender)
}
/**
* Alle Absender, die in dieser Akte schon einmal vorkamen - Futter fuer
* die Erkennung beim naechsten Upload.
*/
export async function bekannteAbsender(betreuteId: string, grenze = 250): Promise<string[]> {
const [ausDokumenten, ausKontakten] = await Promise.all([
db.dokument.findMany({
where: { betreuteId, absender: { not: null }, ...nichtGeloescht },
select: { absender: true },
distinct: ['absender'],
take: grenze,
}),
db.kontakt.findMany({
where: { betreuteId, ...nichtGeloescht },
select: { name: true, organisation: true },
take: grenze,
}),
])
const namen = new Set<string>()
for (const eintrag of ausDokumenten) {
if (eintrag.absender) namen.add(eintrag.absender.trim())
}
for (const kontakt of ausKontakten) {
if (kontakt.organisation) namen.add(kontakt.organisation.trim())
if (kontakt.name) namen.add(kontakt.name.trim())
}
// Lange Namen zuerst: "Bezirk Oberfranken, Sozialverwaltung" ist ein
// besserer Treffer als das blosse "Bezirk Oberfranken".
return [...namen].filter((n) => n.length >= 3).sort((a, b) => b.length - a.length)
}
// -----------------------------------------------------------------------------
// Liste ohne Suchbegriff
// -----------------------------------------------------------------------------
export type Listenfilter = {
suche?: string | null
art?: DokumentArt | null
richtung?: DokumentRichtung | null
posteingangStatus?: PosteingangStatus | null
von?: Date | null
bis?: Date | null
tagId?: string | null
seite?: number
proSeite?: number
}
export function filterWhere(betreuteId: string, filter: Listenfilter): Prisma.DokumentWhereInput {
const where: Prisma.DokumentWhereInput = { betreuteId, ...nichtGeloescht }
if (filter.art) where.art = filter.art
if (filter.richtung) where.richtung = filter.richtung
if (filter.posteingangStatus) where.posteingangStatus = filter.posteingangStatus
if (filter.tagId) where.tags = { some: { id: filter.tagId } }
if (filter.von || filter.bis) {
const spanne: Prisma.DateTimeFilter = {}
if (filter.von) spanne.gte = filter.von
if (filter.bis) spanne.lte = filter.bis
where.OR = [{ eingangAm: spanne }, { eingangAm: null, schreibenVom: spanne }]
}
return where
}
export type Listeneintrag = {
id: string
titel: string
art: DokumentArt
richtung: DokumentRichtung
absender: string | null
aktenzeichen: string | null
dateiname: string | null
mimeTyp: string | null
groesseBytes: number | null
seitenzahl: number | null
eingangAm: Date | null
schreibenVom: Date | null
posteingangStatus: PosteingangStatus
ablageortPapier: string | null
ocrStatus: string | null
erstelltAm: Date
tags: { id: string; name: string; farbe: string }[]
anzahlVerknuepfungen: number
ausschnitt?: string
}
/**
* Die Dokumentenliste mit Filtern - und, wenn ein Suchbegriff gesetzt ist,
* in der Rangfolge der Volltextsuche.
*/
export async function dokumenteListe(
betreuteId: string,
filter: Listenfilter = {},
): Promise<{ eintraege: Listeneintrag[]; gesamt: number }> {
const proSeite = Math.min(Math.max(filter.proSeite ?? 25, 5), 100)
const seite = Math.max(filter.seite ?? 1, 1)
const begriff = suchanfrageAufbereiten(filter.suche)
if (begriff) {
const treffer = await dokumenteSuchen(betreuteId, begriff, {
grenze: 200,
art: filter.art,
richtung: filter.richtung,
posteingangStatus: filter.posteingangStatus,
von: filter.von,
bis: filter.bis,
tagId: filter.tagId,
})
const seitenTreffer = treffer.slice((seite - 1) * proSeite, seite * proSeite)
if (seitenTreffer.length === 0) return { eintraege: [], gesamt: treffer.length }
const datensaetze = await db.dokument.findMany({
where: { id: { in: seitenTreffer.map((t) => t.id) } },
include: {
tags: { select: { id: true, name: true, farbe: true } },
_count: { select: { verknuepfungen: true } },
},
})
const nachId = new Map(datensaetze.map((d) => [d.id, d]))
const eintraege = seitenTreffer
.map((t) => {
const datensatz = nachId.get(t.id)
if (!datensatz) return null
return { ...zuEintrag(datensatz), ausschnitt: t.ausschnitt }
})
.filter((e): e is Listeneintrag => e !== null)
return { eintraege, gesamt: treffer.length }
}
const where = filterWhere(betreuteId, filter)
const [datensaetze, gesamt] = await Promise.all([
db.dokument.findMany({
where,
include: {
tags: { select: { id: true, name: true, farbe: true } },
_count: { select: { verknuepfungen: true } },
},
orderBy: [{ eingangAm: 'desc' }, { erstelltAm: 'desc' }],
skip: (seite - 1) * proSeite,
take: proSeite,
}),
db.dokument.count({ where }),
])
return { eintraege: datensaetze.map(zuEintrag), gesamt }
}
type RohDatensatz = Prisma.DokumentGetPayload<{
include: {
tags: { select: { id: true; name: true; farbe: true } }
_count: { select: { verknuepfungen: true } }
}
}>
function zuEintrag(d: RohDatensatz): Listeneintrag {
return {
id: d.id,
titel: d.titel,
art: d.art,
richtung: d.richtung,
absender: d.absender,
aktenzeichen: d.aktenzeichen,
dateiname: d.dateiname,
mimeTyp: d.mimeTyp,
groesseBytes: d.groesseBytes,
seitenzahl: d.seitenzahl,
eingangAm: d.eingangAm,
schreibenVom: d.schreibenVom,
posteingangStatus: d.posteingangStatus,
ablageortPapier: d.ablageortPapier,
ocrStatus: d.ocrStatus,
erstelltAm: d.erstelltAm,
tags: d.tags,
anzahlVerknuepfungen: d._count.verknuepfungen,
}
}