import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { aktionenDerGruppe } from "@/lib/protokoll-aktionen"; import { zeitraum, type ProtokollFilter } from "@/lib/protokoll-filter"; /** * Lesemodell des Protokolls (#027). Jede Rolle darf das ganze Protokoll lesen (PLAN §4); * die Rechtepruefung machen Seite, Action und Route selbst. * * Geblaettert wird ueber die ID („älter als“), nicht ueber OFFSET: das Protokoll waechst * waehrend man liest, und OFFSET wuerde dann Zeilen doppelt zeigen oder ueberspringen – * ausserdem muesste die Datenbank bei OFFSET 10 000 erst 10 000 Zeilen lesen und verwerfen. */ export const SEITENGROESSE = 50; export type ProtokollZeile = { readonly id: string; readonly zeitpunkt: string; readonly akteurName: string; readonly aktion: string; readonly ergebnis: "ERFOLG" | "FEHLER" | "VERWEIGERT"; readonly hostName: string | null; readonly zielbenutzer: string | null; readonly fingerprint: string | null; readonly diff: string | null; /** Bereits beim Schreiben geschwaerzt (#005); oberste Ebene als Liste, Werte formatiert. */ readonly details: { readonly name: string; readonly wert: string }[] | null; readonly vorgaengerHash: string; readonly hash: string; }; const AUSWAHL = { id: true, zeitpunkt: true, akteurName: true, aktion: true, ergebnis: true, hostName: true, zielbenutzer: true, fingerprint: true, diff: true, details: true, vorgaengerHash: true, hash: true, } satisfies Prisma.ProtokollSelect; type Rohzeile = Prisma.ProtokollGetPayload<{ select: typeof AUSWAHL }>; export function protokollWhere(filter: ProtokollFilter, vor?: bigint): Prisma.ProtokollWhereInput { const und: Prisma.ProtokollWhereInput[] = []; const { ab, vor: bis } = zeitraum(filter); if (ab || bis) und.push({ zeitpunkt: { ...(ab ? { gte: ab } : {}), ...(bis ? { lt: bis } : {}) } }); if (filter.gruppe) und.push({ aktion: { in: aktionenDerGruppe(filter.gruppe) } }); if (filter.akteur) und.push({ akteurName: filter.akteur }); if (filter.host) und.push({ hostName: { contains: filter.host, mode: "insensitive" } }); if (filter.ergebnis) und.push({ ergebnis: filter.ergebnis }); if (filter.q) { const enthaelt = { contains: filter.q, mode: "insensitive" } as const; und.push({ OR: [{ fingerprint: enthaelt }, { hostName: enthaelt }, { zielbenutzer: enthaelt }] }); } if (vor !== undefined) und.push({ id: { lt: vor } }); return und.length ? { AND: und } : {}; } /** Texte bleiben Texte; Zahlen, Listen und Objekte als eingerueckte JSON. */ function detailsListe(details: Prisma.JsonValue): ProtokollZeile["details"] { if (details == null) return null; const alsText = (wert: unknown) => (typeof wert === "string" ? wert : JSON.stringify(wert, null, 2)); if (typeof details !== "object" || Array.isArray(details)) return [{ name: "Wert", wert: alsText(details) }]; const eintraege = Object.entries(details).map(([name, wert]) => ({ name, wert: alsText(wert) })); return eintraege.length ? eintraege : null; } function zeileAus(z: Rohzeile): ProtokollZeile { return { id: String(z.id), zeitpunkt: z.zeitpunkt.toISOString(), akteurName: z.akteurName, aktion: z.aktion, ergebnis: z.ergebnis, hostName: z.hostName, zielbenutzer: z.zielbenutzer, fingerprint: z.fingerprint, diff: z.diff, details: detailsListe(z.details), vorgaengerHash: z.vorgaengerHash, hash: z.hash, }; } export type ProtokollSeite = { readonly zeilen: ProtokollZeile[]; /** ID fuer „ältere laden“; fehlt, wenn es nichts Aelteres mehr gibt. */ readonly weiterVor: string | null; }; /** Eine Seite, neueste zuerst; `vor` ist die kleinste ID der vorigen Seite. */ export async function protokollSeite(filter: ProtokollFilter, vor?: bigint): Promise { const roh = await prisma.protokoll.findMany({ where: protokollWhere(filter, vor), orderBy: { id: "desc" }, take: SEITENGROESSE + 1, select: AUSWAHL, }); const mehr = roh.length > SEITENGROESSE; const zeilen = roh.slice(0, SEITENGROESSE).map(zeileAus); return { zeilen, weiterVor: mehr ? (zeilen.at(-1)?.id ?? null) : null }; } /** Kopfzeile der Seite: Gesamtzahl und Treffer des Filters. */ export async function protokollZaehlen( filter: ProtokollFilter, ): Promise<{ gesamt: number; treffer: number }> { const [gesamt, treffer] = await Promise.all([ prisma.protokoll.count(), prisma.protokoll.count({ where: protokollWhere(filter) }), ]); return { gesamt, treffer }; } /** Namen fuer den Akteur-Filter – haeufigste zuerst, begrenzt, damit die Liste handlich bleibt. */ export async function protokollAkteure(): Promise { const gruppen = await prisma.protokoll.groupBy({ by: ["akteurName"], _count: { _all: true }, orderBy: { _count: { akteurName: "desc" } }, take: 100, }); return gruppen.map((g) => g.akteurName).sort((a, b) => a.localeCompare(b, "de")); } /** * Alle Treffer in Stapeln, neueste zuerst – fuer den CSV-Export. Liest je Stapel nur * `stapel` Zeilen, damit auch ein grosses Protokoll nie ganz im Speicher liegt. */ export async function* protokollStapelweise( filter: ProtokollFilter, stapel = 500, ): AsyncGenerator { let vor: bigint | undefined; for (;;) { const zeilen = await prisma.protokoll.findMany({ where: protokollWhere(filter, vor), orderBy: { id: "desc" }, take: stapel, select: AUSWAHL, }); if (zeilen.length === 0) return; yield zeilen; if (zeilen.length < stapel) return; vor = zeilen.at(-1)!.id; } } export type { Rohzeile as ProtokollRohzeile };