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 (…). */
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 {
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 & { 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=, StopSel=, 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 {
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 {
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 {
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()
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,
}
}