feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -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,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user