Merge branch 'phase/027-protokoll-ansicht'

This commit is contained in:
2026-10-12 16:48:27 +02:00
20 changed files with 2257 additions and 15 deletions
+156
View File
@@ -0,0 +1,156 @@
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<ProtokollSeite> {
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<string[]> {
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<Rohzeile[], void, undefined> {
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 };