Files
Laufzettel/src/server/queries/protokoll.ts
T
florian.wilde e1f244e4e8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
2026-10-04 16:06:26 +00:00

228 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
}));
}