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, } }