import type { Prisma } from "@/generated/prisma/client"; import { prisma } from "@/lib/db"; import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant"; /** * Lesemodell des Protokolls (#023). * * Geschrieben wurde es seit #006, ansehen konnte es niemand — wer wissen wollte, * wer wann eine Rolle geaendert hat, brauchte `psql`. * * Die Mandantensicht wird hier selbst angewandt, obwohl die Seite `verwaltung` * verlangt und ein ADMIN heute ohnehin alle Kunden sieht. Das ist Absicht: ein * Lesemodell traegt seine Sicht selbst (CLAUDE.md), und wenn das Recht je * weiter gefasst wird, haelt die Grenze trotzdem. */ export type ProtokollZeile = { readonly id: string; readonly erstelltAm: Date; readonly aktion: string; readonly entitaet: string; readonly entitaetId: string; readonly laufId: string | null; readonly laufTitel: string | null; readonly kundeName: string | null; /** Leer bei einem Systemlauf – etwa dem Zeitplan des Verzeichnisabgleichs. */ readonly urheber: { readonly id: string; readonly name: string } | null; readonly vorher: unknown; readonly nachher: unknown; }; export type ProtokollSeite = { readonly zeilen: readonly ProtokollZeile[]; readonly gesamt: number; readonly seite: number; readonly seiten: number; }; export type ProtokollFilter = { readonly suche?: string; readonly urheberId?: string; readonly vonDatum?: string; readonly bisDatum?: string; readonly seite?: number; }; export const PRO_SEITE = 50; /** * Eintraege ohne `kundeId` und ohne Lauf, deren Entitaet wirklich global ist. * * Alles andere mit leerer `kundeId` ist **nicht zuordenbar** — etwa ein Eintrag * zu einem inzwischen geloeschten Geraet. Einem auf Kunden beschraenkten * Benutzer wird das nicht gezeigt: `vorher`/`nachher` koennen einen Hostnamen * enthalten, und bei einer Mandantengrenze wird im Zweifel zugemacht. */ const GLOBALE_ENTITAETEN = ["Einstellung", "Benutzer", "GeraetetypVorlage", "Vorlage", "VorlagenVersion"]; async function sichtBedingung(benutzer: HandelnderBenutzer): Promise { const sichtbar = await sichtbareKundenIds(benutzer); if (sichtbar === undefined) return []; return [ { OR: [ // Eintraege mit eigener Zuordnung. { kundeId: { in: [...sichtbar] } }, // Laufbezogene Eintraege: der Lauf ist die bessere Quelle, weil er die // Zuordnung nicht verlieren kann. { lauf: { kundeId: { in: [...sichtbar] } } }, // Wirklich globale Eintraege. { kundeId: null, laufId: null, entitaet: { in: GLOBALE_ENTITAETEN } }, ], }, ]; } function datumBedingung(filter: ProtokollFilter): Prisma.ProtokollWhereInput[] { const von = filter.vonDatum ? new Date(`${filter.vonDatum}T00:00:00`) : undefined; // Bis **einschliesslich** des gewaehlten Tages: ein Filter „bis 4.10." soll // den 4. enthalten, sonst sucht man den Eintrag von heute vergeblich. const bis = filter.bisDatum ? new Date(`${filter.bisDatum}T23:59:59.999`) : undefined; if (!von && !bis) return []; return [ { erstelltAm: { ...(von && !Number.isNaN(von.getTime()) ? { gte: von } : {}), ...(bis && !Number.isNaN(bis.getTime()) ? { lte: bis } : {}), }, }, ]; } /** * Eine Seite des Protokolls, neueste zuerst. * * Alle Teilbedingungen liegen in einem `AND`, wo keine die andere erreichen * kann — dieselbe Vorsicht wie in den anderen Lesemodellen: im Review von #009 * hatte ein Suchfilter mit eigenem `OR` die Kundenbedingung ueberschrieben. */ export async function protokollSeite( filter: ProtokollFilter, benutzer: HandelnderBenutzer, ): Promise { const suche = filter.suche?.trim() ?? ""; const bedingungen: Prisma.ProtokollWhereInput[] = [ ...(await sichtBedingung(benutzer)), ...datumBedingung(filter), ...(suche.length > 0 ? [ { OR: [ { aktion: { contains: suche, mode: "insensitive" as const } }, { entitaet: { contains: suche, mode: "insensitive" as const } }, { lauf: { titel: { contains: suche, mode: "insensitive" as const } } }, ], }, ] : []), ...(filter.urheberId ? [{ benutzerId: filter.urheberId }] : []), ]; const where: Prisma.ProtokollWhereInput = { AND: bedingungen }; const seite = Math.max(1, Math.trunc(filter.seite ?? 1)); const [gesamt, zeilen] = await Promise.all([ prisma.protokoll.count({ where }), prisma.protokoll.findMany({ where, orderBy: { erstelltAm: "desc" }, skip: (seite - 1) * PRO_SEITE, take: PRO_SEITE, select: { id: true, erstelltAm: true, aktion: true, entitaet: true, entitaetId: true, laufId: true, vorher: true, nachher: true, lauf: { select: { titel: true, kunde: { select: { name: true } } } }, kunde: { select: { name: true } }, benutzer: { select: { id: true, name: true, email: true } }, }, }), ]); return { zeilen: zeilen.map((zeile) => ({ id: zeile.id, erstelltAm: zeile.erstelltAm, aktion: zeile.aktion, entitaet: zeile.entitaet, entitaetId: zeile.entitaetId, laufId: zeile.laufId, laufTitel: zeile.lauf?.titel ?? null, kundeName: zeile.kunde?.name ?? zeile.lauf?.kunde.name ?? null, urheber: zeile.benutzer ? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email } : null, vorher: zeile.vorher, nachher: zeile.nachher, })), gesamt, seite, seiten: Math.max(1, Math.ceil(gesamt / PRO_SEITE)), }; } /** Die Urheber, die im Protokoll vorkommen – für den Filter. */ export async function protokollUrheber( benutzer: HandelnderBenutzer, ): Promise { const bedingungen = await sichtBedingung(benutzer); const zeilen = await prisma.protokoll.findMany({ where: { AND: [...bedingungen, { benutzerId: { not: null } }] }, distinct: ["benutzerId"], select: { benutzer: { select: { id: true, name: true, email: true } } }, }); return zeilen .flatMap((zeile) => zeile.benutzer === null ? [] : [{ id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }], ) .sort((links, rechts) => links.name.localeCompare(rechts.name, "de")); } /** * Die Einträge eines Laufs, für seine Abarbeitungsansicht. * * Eigene Funktion und nicht `protokollSeite` mit Filter: hier gilt die Sicht des * Laufs, den der Aufrufer schon geprüft hat, und es gibt keine Blätterung — * ein Lauf hat Dutzende Einträge, nicht Tausende. */ export async function protokollDesLaufs(laufId: string): Promise { const zeilen = await prisma.protokoll.findMany({ where: { laufId }, orderBy: { erstelltAm: "desc" }, select: { id: true, erstelltAm: true, aktion: true, entitaet: true, entitaetId: true, laufId: true, vorher: true, nachher: true, benutzer: { select: { id: true, name: true, email: true } }, }, }); return zeilen.map((zeile) => ({ id: zeile.id, erstelltAm: zeile.erstelltAm, aktion: zeile.aktion, entitaet: zeile.entitaet, entitaetId: zeile.entitaetId, laufId: zeile.laufId, laufTitel: null, kundeName: null, urheber: zeile.benutzer ? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email } : null, vorher: zeile.vorher, nachher: zeile.nachher, })); }