228 lines
7.5 KiB
TypeScript
228 lines
7.5 KiB
TypeScript
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,
|
||
}));
|
||
}
|