feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht
Geschrieben wurde es seit dem Datenmodell, ansehen konnte es niemand: wer wissen wollte, wer wann eine Rolle geaendert hat, brauchte psql. Neue Seite Verwaltung -> Protokoll: Volltext ueber Aktion und Lauftitel, Filter nach Urheber und Zeitraum, fuenfzig Eintraege je Seite, neueste zuerst. Ein Eintrag ohne Urheber steht als Systemlauf da und nicht als Luecke -- der Zeitplan des Verzeichnisabgleichs hat keinen Menschen hinter sich. Am Lauf selbst stehen seine Eintraege eingeklappt in der Abarbeitungsansicht. Kein eigenes Recht dafuer: wer den Lauf sehen darf, darf auch sehen, was an ihm geschehen ist. Dafuer traegt Protokoll jetzt eine kundeId, wie CLAUDE.md es fuer jede kundenbezogene Zeile verlangt. Ohne sie liess sich die Mandantensicht nicht anwenden, ohne je Eintrag die Entitaet nachzuschlagen. Leer heisst global; laufbezogene Eintraege bleiben ohne, weil der Lauf die bessere Quelle ist. Die Migration fuehrt bestehende Eintraege nach -- an echten Daten gefahren, 59 von 733 bekamen einen Kunden. Ein Eintrag, der sich keinem Kunden zuordnen laesst, wird einem beschraenkten Benutzer nicht gezeigt: vorher/nachher koennen einen Hostnamen enthalten, und bei einer Mandantengrenze wird im Zweifel zugemacht. Keine Tabelle, sondern Karten: sieben Spalten sind bei 390 px nicht darstellbar, ohne horizontal zu scrollen. Im gebauten Stand durchgeklickt: blaettern, filtern, Werte aufklappen, Abschnitt am Lauf, 390 px auf beiden Seiten ohne Scrollen. Closes #35
This commit is contained in:
@@ -313,6 +313,20 @@ test.describe("Onboarding", () => {
|
||||
protokoll: Number(spuren[0]?.protokoll),
|
||||
}).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 });
|
||||
|
||||
/*
|
||||
* ---- Auch nicht in der Protokoll-Ansicht -------------------------------
|
||||
*
|
||||
* Seit #023 ist das Protokoll sichtbar, und die Seite zeigt `vorher` und
|
||||
* `nachher` als Text. Die Abfrage oben belegt, dass dort nichts steht; dieser
|
||||
* Griff belegt, dass die **Ansicht** nichts hinzuerfindet — etwa aus einer
|
||||
* aufgelösten Vorschau.
|
||||
*/
|
||||
await page.goto("/admin/protokoll");
|
||||
const protokollSeite = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
|
||||
expect(protokollSeite).not.toContain(passwort);
|
||||
// Gegenprobe: die Seite ist überhaupt da und zeigt Einträge.
|
||||
await expect(page.getByRole("heading", { name: "Protokoll", level: 1 })).toBeVisible();
|
||||
|
||||
// ---- Auch nicht auf dem Ausdruck ---------------------------------------
|
||||
await page.goto(`/drucken/lauf/${laufId}`);
|
||||
const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, VersionStatus } from "@/generated/prisma/enums";
|
||||
import { RECHTE } from "@/server/auth/rollen";
|
||||
import { protokollDesLaufs, protokollSeite, protokollUrheber } from "@/server/queries/protokoll";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Das Protokoll ansehen (#023).
|
||||
*
|
||||
* Drei Fragen tragen das Ganze: sieht jemand nur seine Kunden, kommt ein
|
||||
* Techniker ueberhaupt heran, und steht irgendwo ein erzeugtes Passwort.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein auf Kunden beschraenkter Benutzer entsteht nur unter drei Bedingungen —
|
||||
* Modus DIENSTLEISTUNG, Rolle unter REDAKTEUR, und ein gesetztes
|
||||
* `OIDC_CUSTOMER_GROUP_PREFIX`. Ohne die dritte liefert `sichtbareKundenIds`
|
||||
* `undefined`, und jeder Filtertest waere ein Test ohne Gegenstand.
|
||||
*/
|
||||
const VORHER = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
|
||||
async function aufbau() {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||
await testPrisma.einstellung.create({
|
||||
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
const admin = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||
});
|
||||
const techniker = await testPrisma.benutzer.create({
|
||||
data: { email: "tech@firma.example", name: "Techniker", rolle: "TECHNIKER" },
|
||||
});
|
||||
// Nur an die eigene Organisation gebunden – „Fremd GmbH“ ist für ihn unsichtbar.
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: techniker.id, kundeId: eigene.id },
|
||||
});
|
||||
return {
|
||||
eigene,
|
||||
fremd,
|
||||
admin: { id: admin.id, rolle: admin.rolle },
|
||||
techniker: { id: techniker.id, rolle: techniker.rolle },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Recht auf das Protokoll", () => {
|
||||
it("liegt bei verwaltung, also nur bei ADMIN", () => {
|
||||
// Das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
|
||||
// über Menschen. TECHNIKER und LESER kommen nicht auf die Seite.
|
||||
expect(RECHTE.verwaltung).toEqual(["ADMIN"]);
|
||||
expect(RECHTE.verwaltung).not.toContain("TECHNIKER");
|
||||
expect(RECHTE.verwaltung).not.toContain("LESER");
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Protokoll ansehen", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (VORHER === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = VORHER;
|
||||
});
|
||||
|
||||
it("zeigt einem beschränkten Benutzer keinen Eintrag eines fremden Kunden", async () => {
|
||||
const { eigene, fremd, admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-eigen",
|
||||
aktion: "Gerät angelegt (eigen)",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
{
|
||||
kundeId: fremd.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-fremd",
|
||||
aktion: "Gerät angelegt (fremd)",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fuerTechniker = await protokollSeite({}, techniker);
|
||||
expect(fuerTechniker.zeilen.map((zeile) => zeile.aktion)).toEqual(["Gerät angelegt (eigen)"]);
|
||||
expect(fuerTechniker.gesamt).toBe(1);
|
||||
|
||||
// Der ADMIN sieht beide – er ist auf keinen Kunden beschränkt.
|
||||
const fuerAdmin = await protokollSeite({}, admin);
|
||||
expect(fuerAdmin.gesamt).toBe(2);
|
||||
});
|
||||
|
||||
it("zeigt globale Einträge auch einem beschränkten Benutzer", async () => {
|
||||
const { admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Einstellung",
|
||||
entitaetId: "global",
|
||||
aktion: "Betriebsmodus gewechselt",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
});
|
||||
|
||||
const ergebnis = await protokollSeite({}, techniker);
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.aktion)).toEqual(["Betriebsmodus gewechselt"]);
|
||||
});
|
||||
|
||||
it("verbirgt einen nicht zuordenbaren Eintrag vor einem beschränkten Benutzer", async () => {
|
||||
// Ein Eintrag zu einem inzwischen gelöschten Gerät: ohne `kundeId` und ohne
|
||||
// Lauf lässt er sich keinem Kunden zuordnen. `vorher`/`nachher` können einen
|
||||
// Hostnamen enthalten, deshalb wird im Zweifel zugemacht (#023).
|
||||
const { admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "laengst-geloescht",
|
||||
aktion: "Gerät gelöscht",
|
||||
benutzerId: admin.id,
|
||||
vorher: { hostname: "FREMD-PC-007" },
|
||||
},
|
||||
});
|
||||
|
||||
expect((await protokollSeite({}, techniker)).gesamt).toBe(0);
|
||||
expect((await protokollSeite({}, admin)).gesamt).toBe(1);
|
||||
});
|
||||
|
||||
it("ordnet einen laufbezogenen Eintrag über den Lauf zu, auch ohne eigene kundeId", async () => {
|
||||
const { fremd, admin, techniker } = await aufbau();
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Irgendeine" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "Lauf bei Fremd", kundeId: fremd.id },
|
||||
});
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
laufId: lauf.id,
|
||||
entitaet: "Lauf",
|
||||
aktion: "Lauf angelegt",
|
||||
entitaetId: lauf.id,
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect((await protokollSeite({}, techniker)).gesamt).toBe(0);
|
||||
const fuerAdmin = await protokollSeite({}, admin);
|
||||
expect(fuerAdmin.zeilen[0]?.laufTitel).toBe("Lauf bei Fremd");
|
||||
expect(fuerAdmin.zeilen[0]?.kundeName).toBe("Fremd GmbH");
|
||||
});
|
||||
|
||||
it("nennt einen Eintrag ohne Urheber als Systemlauf, nicht als Lücke", async () => {
|
||||
const { eigene, admin } = await aufbau();
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: "q-1",
|
||||
aktion: "Verzeichnis abgeglichen",
|
||||
benutzerId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const ergebnis = await protokollSeite({}, admin);
|
||||
expect(ergebnis.zeilen[0]?.urheber).toBeNull();
|
||||
});
|
||||
|
||||
it("filtert nach Urheber, Zeitraum und Volltext", async () => {
|
||||
const { eigene, admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: "b-1",
|
||||
aktion: "Rolle geändert",
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date("2026-09-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-1",
|
||||
aktion: "Gerät archiviert",
|
||||
benutzerId: techniker.id,
|
||||
erstelltAm: new Date("2026-10-04T10:00:00Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect((await protokollSeite({ suche: "rolle" }, admin)).gesamt).toBe(1);
|
||||
expect((await protokollSeite({ urheberId: techniker.id }, admin)).gesamt).toBe(1);
|
||||
expect((await protokollSeite({ vonDatum: "2026-10-01" }, admin)).gesamt).toBe(1);
|
||||
// Einschliesslich des bis-Tages – sonst fehlt der Eintrag von heute.
|
||||
expect((await protokollSeite({ bisDatum: "2026-10-04" }, admin)).gesamt).toBe(2);
|
||||
expect((await protokollSeite({ vonDatum: "2026-09-01", bisDatum: "2026-09-01" }, admin)).gesamt).toBe(1);
|
||||
});
|
||||
|
||||
it("blättert und zählt richtig", async () => {
|
||||
const { eigene, admin } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: Array.from({ length: 55 }, (_, nummer) => ({
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: `g-${nummer}`,
|
||||
aktion: `Eintrag ${String(nummer).padStart(2, "0")}`,
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date(Date.UTC(2026, 9, 1, 0, nummer)),
|
||||
})),
|
||||
});
|
||||
|
||||
const erste = await protokollSeite({}, admin);
|
||||
expect(erste.gesamt).toBe(55);
|
||||
expect(erste.seiten).toBe(2);
|
||||
expect(erste.zeilen).toHaveLength(50);
|
||||
// Neueste zuerst.
|
||||
expect(erste.zeilen[0]?.aktion).toBe("Eintrag 54");
|
||||
|
||||
const zweite = await protokollSeite({ seite: 2 }, admin);
|
||||
expect(zweite.zeilen).toHaveLength(5);
|
||||
expect(zweite.zeilen.at(-1)?.aktion).toBe("Eintrag 00");
|
||||
});
|
||||
|
||||
it("nennt als Urheber nur die, die auch im sichtbaren Teil vorkommen", async () => {
|
||||
const { eigene, fremd, admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-1",
|
||||
aktion: "eigen",
|
||||
benutzerId: techniker.id,
|
||||
},
|
||||
{
|
||||
kundeId: fremd.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-2",
|
||||
aktion: "fremd",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect((await protokollUrheber(techniker)).map((eintrag) => eintrag.name)).toEqual(["Techniker"]);
|
||||
expect((await protokollUrheber(admin)).map((eintrag) => eintrag.name).sort()).toEqual([
|
||||
"Admin",
|
||||
"Techniker",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gibt am Lauf nur dessen Einträge, neueste zuerst", async () => {
|
||||
const { eigene, admin } = await aufbau();
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Irgendeine" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "Der Lauf", kundeId: eigene.id },
|
||||
});
|
||||
const anderer = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "Ein anderer", kundeId: eigene.id },
|
||||
});
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
laufId: lauf.id,
|
||||
entitaet: "Lauf",
|
||||
entitaetId: lauf.id,
|
||||
aktion: "zuerst",
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date("2026-10-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
laufId: lauf.id,
|
||||
entitaet: "Lauf",
|
||||
entitaetId: lauf.id,
|
||||
aktion: "danach",
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date("2026-10-02T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
laufId: anderer.id,
|
||||
entitaet: "Lauf",
|
||||
entitaetId: anderer.id,
|
||||
aktion: "fremder Lauf",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eintraege = await protokollDesLaufs(lauf.id);
|
||||
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual(["danach", "zuerst"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user