feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #45.
This commit is contained in:
2026-10-04 16:06:26 +00:00
parent f5936327a0
commit e1f244e4e8
23 changed files with 1103 additions and 22 deletions
+227
View File
@@ -0,0 +1,227 @@
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<Prisma.ProtokollWhereInput[]> {
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<ProtokollSeite> {
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<readonly { readonly id: string; readonly name: string }[]> {
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<readonly ProtokollZeile[]> {
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,
}));
}