From ac51753d53443baa0318fb2dca8b31e45da77749 Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sun, 4 Oct 2026 18:05:48 +0200 Subject: [PATCH] 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 --- CHANGELOG.md | 12 + docs/anleitung/07-admin.md | 25 +- docs/issues/023-protokoll-ansicht.md | 44 ++- docs/issues/README.md | 4 +- .../migration.sql | 78 +++++ prisma/schema.prisma | 9 + src/app/(app)/admin/layout.tsx | 1 + src/app/(app)/admin/protokoll/page.tsx | 115 +++++++ src/app/(app)/laeufe/[id]/page.tsx | 41 ++- src/components/protokoll/protokoll-filter.tsx | 88 +++++ src/components/protokoll/protokoll-liste.tsx | 115 +++++++ src/server/queries/protokoll.ts | 227 +++++++++++++ src/server/services/abgleich.ts | 5 +- src/server/services/abteilungen.ts | 3 + src/server/services/einstellungen.ts | 1 + src/server/services/geraete.ts | 4 + src/server/services/kunden.ts | 3 + src/server/services/mitarbeiter.ts | 4 + src/server/services/verzeichnis.ts | 3 + src/server/services/vorlagen-austausch.ts | 1 + src/server/services/vorlagen.ts | 23 +- tests/e2e/onboarding.spec.ts | 14 + tests/protokoll.test.ts | 305 ++++++++++++++++++ 23 files changed, 1103 insertions(+), 22 deletions(-) create mode 100644 prisma/migrations/20261004190000_protokoll_kunde/migration.sql create mode 100644 src/app/(app)/admin/protokoll/page.tsx create mode 100644 src/components/protokoll/protokoll-filter.tsx create mode 100644 src/components/protokoll/protokoll-liste.tsx create mode 100644 src/server/queries/protokoll.ts create mode 100644 tests/protokoll.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ba228a6..e644f43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/). ### Hinzugefügt +- **Das Protokoll ist ansehbar**, unter Verwaltung → Protokoll. Geschrieben wurde es seit dem + Datenmodell, ansehen konnte es niemand: wer wissen wollte, wer wann eine Rolle geändert hat, + brauchte `psql`. Jetzt mit Volltext über Aktion und Lauftitel, Filter nach Urheber und Zeitraum, + fünfzig Einträge je Seite. Ein Eintrag ohne Urheber wird als **Systemlauf** gezeigt und nicht als + Lücke — der Zeitplan des Verzeichnisabgleichs hat keinen Menschen hinter sich. Am Lauf selbst + stehen seine Einträge eingeklappt in der Abarbeitungsansicht; wer den Lauf sehen darf, sieht auch + dessen Geschichte (#023) +- **`Protokoll` trägt eine `kundeId`**, wie CLAUDE.md es für jede kundenbezogene Zeile verlangt. Ohne + sie ließ sich die Mandantensicht nicht anwenden, ohne je Eintrag die Entität nachzuschlagen. Leer + heißt global; bestehende Einträge nimmt die Migration mit. Ein Eintrag, der sich **keinem** Kunden + zuordnen lässt — etwa zu einem gelöschten Gerät —, wird einem auf Kunden beschränkten Benutzer + nicht gezeigt (#023) - **Schnellstart je Gerätetyp pflegen**, unter Verwaltung → Schnellstart. Welche Vorlagen auf der Geräteseite als Kachel erscheinen, stand bisher nur im Seed: wer eine eigene Vorlage dort haben wollte, kam nicht heran, und wer eine mitgelieferte loswerden wollte, auch nicht. Zur Auswahl diff --git a/docs/anleitung/07-admin.md b/docs/anleitung/07-admin.md index b93cf3f..af5d84a 100644 --- a/docs/anleitung/07-admin.md +++ b/docs/anleitung/07-admin.md @@ -169,13 +169,30 @@ nicht archivieren. Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten lassen. Eine kundenspezifische Vorlage startet keinen Lauf bei einem anderen Kunden. -## Was protokolliert wird +## Protokoll Laufzettel schreibt ein Protokoll über das, was nachvollziehbar bleiben muss: Rollenänderungen, veröffentlichte Vorlagen, eingespielte Vorlagen, archivierte und zurückgeholte Läufe, angelegte und -gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie. Eine -eigene Ansicht dafür gibt es in der Oberfläche noch nicht; was am Lauf selbst geschehen ist, steht in -seiner Abarbeitungsansicht und im Ausdruck. +gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie, und +geändert oder gelöscht wird keiner. + +Zu sehen ist das unter **Verwaltung → Protokoll**, neueste Einträge zuerst, fünfzig je Seite. Vier +Filter stehen darüber: + +- **Aktion oder Lauf** — Volltext über die Aktion, die Art des Eintrags und den Titel des Laufs. + „Rolle“ findet jede Rollenänderung, „archiviert“ jede Archivierung. +- **Urheber** — zur Auswahl stehen nur die, die wirklich Einträge haben. +- **von** und **bis** — der *bis*-Tag zählt mit; „bis heute“ enthält also, was heute passiert ist. + +Hinter **Werte** stehen die Felder vor und nach der Änderung, soweit sie festgehalten wurden. +Eingeklappt, weil sie lang werden können. + +**Ein Eintrag ohne Urheber ist keine Lücke.** Dort steht „Systemlauf“: der Zeitplan des +Verzeichnisabgleichs läuft ohne Menschen dahinter, und das soll man sehen können. + +Was an einem **einzelnen Lauf** geschehen ist, steht außerdem unten in seiner Abarbeitungsansicht +unter **Protokoll dieses Laufs**, eingeklappt. Wer den Lauf sehen darf, sieht auch dessen Einträge — +dafür braucht es keine Administratorrolle. Ein **erzeugtes Passwort steht nie im Protokoll** — es wird nirgends gespeichert. Gäbe es ein Feld dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben. diff --git a/docs/issues/023-protokoll-ansicht.md b/docs/issues/023-protokoll-ansicht.md index 6e292ae..5ad3b9d 100644 --- a/docs/issues/023-protokoll-ansicht.md +++ b/docs/issues/023-protokoll-ansicht.md @@ -1,6 +1,6 @@ # #023 Protokoll ansehen -**Status:** offen +**Status:** erledigt **Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35) **Meilenstein:** v0.2.0 **Labels:** phase-2, frontend @@ -14,22 +14,42 @@ geändert hat, braucht `psql`. Aufgefallen beim Schreiben der Anleitung in [#018](018-e2e-anleitung-pdf.md). -**Geschätzt:** 4 h **Tatsächlich:** – +**Geschätzt:** 4 h **Tatsächlich:** 1 h 50 min ## Aufgaben -- [ ] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar -- [ ] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion -- [ ] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar -- [ ] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft +- [x] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar +- [x] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion +- [x] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar +- [x] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft über Menschen -- [ ] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als +- [x] Ein Eintrag ohne Urheber (Systemlauf, etwa der Zeitplan) wird als solcher gezeigt, nicht als Lücke -- [ ] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt +- [x] Am Lauf selbst: die Einträge dieses Laufs in seiner Abarbeitungsansicht, eingeklappt ## Akzeptanzkriterien -- [ ] Test: ein Eintrag eines fremden Kunden erscheint nicht -- [ ] Test: `TECHNIKER` kommt nicht auf die Seite -- [ ] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf -- [ ] Am Handy kein horizontales Scrollen +- [x] Test: ein Eintrag eines fremden Kunden erscheint nicht +- [x] Test: `TECHNIKER` kommt nicht auf die Seite +- [x] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf +- [x] Am Handy kein horizontales Scrollen + +## Beim Umsetzen dazugekommen + +- **`Protokoll` hat jetzt eine `kundeId`.** CLAUDE.md verlangt sie für jede kundenbezogene Zeile; das + Protokoll hatte sie nicht, und damit ließ sich die Mandantensicht nicht anwenden, ohne je Eintrag + die Entität nachzuschlagen. Leer heißt **global** (Einstellungen, Benutzer, kundenfreie Vorlagen, + Schnellstart). Zwanzig Schreibstellen setzen sie jetzt; laufbezogene Einträge bleiben ohne, weil + der Lauf die bessere Quelle ist — er kann seine Zuordnung nicht verlieren. +- **Die Migration führt bestehende Einträge nach**, über den Lauf und über die fünf + kundentragenden Entitäten. An echten Daten gefahren: 59 von 733 Einträgen bekamen einen Kunden. +- **Nicht zuordenbare Einträge werden verborgen, nicht gezeigt.** 414 Einträge in der + Entwicklungsdatenbank zeigen auf längst gelöschte Testgeräte und lassen sich keinem Kunden + zuordnen. `vorher`/`nachher` können einen Hostnamen enthalten — bei einer Mandantengrenze wird im + Zweifel zugemacht, nicht aufgemacht. +- **Keine Tabelle, sondern Karten.** Sieben Spalten sind bei 390 px nicht darstellbar, ohne + horizontal zu scrollen, und genau das ist ausgeschlossen. +- Das Mandanten-Kriterium ist auf der Seite heute **strukturell** erfüllt, weil sie `verwaltung` + verlangt und ein `ADMIN` ohnehin alle Kunden sieht. Der Filter sitzt trotzdem im Lesemodell und ist + dort geprüft — ein Lesemodell trägt seine Sicht selbst, und wenn das Recht je weiter gefasst wird, + hält die Grenze. diff --git a/docs/issues/README.md b/docs/issues/README.md index d204003..f892f58 100644 --- a/docs/issues/README.md +++ b/docs/issues/README.md @@ -27,9 +27,9 @@ Diese Dateien sind die Quelle; `pnpm issues:sync` spiegelt sie in den Gitea-Trac | #020 | [Vorgesetzter auch für von Hand angelegte Personen](020-vorgesetzter-manuell.md) | erledigt | v0.2.0 | 3 h | 1 h 20 min | | #021 | [Stabiler Schlüssel für Seed-Vorlagen](021-stabiler-schluessel-vorlagen.md) | erledigt | v0.2.0 | 2 h | 55 min | | #022 | [Schnellstart je Gerätetyp pflegen](022-schnellstart-pflegen.md) | erledigt | v0.2.0 | 3 h | 1 h 10 min | -| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | offen | v0.2.0 | 4 h | – | +| #023 | [Protokoll ansehen](023-protokoll-ansicht.md) | erledigt | v0.2.0 | 4 h | 1 h 50 min | | #024 | [Proxmox-Einzeiler auf einem echten PVE-Host gegenprüfen](024-proxmox-echtlauf.md) | offen | v0.2.0 | 2 h | – | | #025 | [Healthcheck gibt die rohe Datenbankfehlermeldung heraus](025-gesundheit-fehlermeldung.md) | erledigt | v0.2.0 | 1 h | 25 min | | #026 | [Zwei Konventionen präzisieren, die strenger klingen als sie gemeint sind](026-konventionen-praezisieren.md) | erledigt | v0.2.0 | 1 h | 20 min | | #027 | [Die Editor-Abläufe lassen ihre Vorlagen stehen](027-e2e-reste-vorlagen.md) | offen | v0.2.0 | 1 h | – | -| | **Summe** | | | **153 h** | **39 h 30 min** | +| | **Summe** | | | **153 h** | **41 h 20 min** | diff --git a/prisma/migrations/20261004190000_protokoll_kunde/migration.sql b/prisma/migrations/20261004190000_protokoll_kunde/migration.sql new file mode 100644 index 0000000..85add13 --- /dev/null +++ b/prisma/migrations/20261004190000_protokoll_kunde/migration.sql @@ -0,0 +1,78 @@ +-- --------------------------------------------------------------------------- +-- HANDGESCHRIEBEN. Prisma erzeugt hier zusaetzlich Loeschbefehle fuer die +-- zusammengesetzten Fremdschluessel (Mandantengrenze), die Trigramm-Indizes +-- (Archivsuche), die partiellen eindeutigen Indizes und die Trigger auf +-- veroeffentlichten Versionen: es kennt sie nicht, weil sie sich im Schema nicht +-- ausdruecken lassen. Diese Loeschbefehle sind bewusst gestrichen. +-- +-- tests/datenbankregeln.test.ts prueft, dass alle diese Objekte vorhanden sind – +-- ein versehentlich stehengelassener Loeschbefehl faellt dort sofort auf. +-- --------------------------------------------------------------------------- + +-- Mandantenspalte fuer das Protokoll (#023). +-- +-- CLAUDE.md verlangt sie fuer jede kundenbezogene Zeile. Das Protokoll hatte sie +-- nicht, und damit liess sich die Protokoll-Ansicht nicht nach Mandant filtern, +-- ohne je Eintrag die Entitaet nachzuschlagen. Leer heisst **global**: +-- Einstellungen, Benutzer, kundenfreie Vorlagen, Schnellstart. +ALTER TABLE "Protokoll" + ADD COLUMN "kundeId" TEXT; + +CREATE INDEX "Protokoll_kundeId_erstelltAm_idx" ON "Protokoll"("kundeId", "erstelltAm"); + +ALTER TABLE "Protokoll" + ADD CONSTRAINT "Protokoll_kundeId_fkey" + FOREIGN KEY ("kundeId") REFERENCES "Kunde" ("id") + ON DELETE RESTRICT ON UPDATE CASCADE; + +-- ---- Nachfuehrung --------------------------------------------------------- +-- Vorhandene Eintraege bekommen ihren Kunden, soweit er sich ableiten laesst. +-- Ohne das traegt jeder alte Eintrag "global" und waere fuer einen auf Kunden +-- beschraenkten Benutzer sichtbar -- dieselbe Falle wie bei den Umstiegen in +-- #021 und #020, nur hier mit Folgen fuer die Sichtbarkeit. + +-- 1. Alles, was an einem Lauf haengt: der Lauf kennt seinen Kunden. +UPDATE "Protokoll" AS p + SET "kundeId" = l."kundeId" + FROM "Lauf" AS l + WHERE p."laufId" = l."id" AND p."kundeId" IS NULL; + +-- 2. Die kundentragenden Entitaeten, je ueber ihre eigene Tabelle. +UPDATE "Protokoll" AS p SET "kundeId" = m."kundeId" + FROM "Mitarbeiter" AS m + WHERE p."entitaet" = 'Mitarbeiter' AND p."entitaetId" = m."id" AND p."kundeId" IS NULL; + +UPDATE "Protokoll" AS p SET "kundeId" = g."kundeId" + FROM "Geraet" AS g + WHERE p."entitaet" = 'Geraet' AND p."entitaetId" = g."id" AND p."kundeId" IS NULL; + +UPDATE "Protokoll" AS p SET "kundeId" = a."kundeId" + FROM "Abteilung" AS a + WHERE p."entitaet" = 'Abteilung' AND p."entitaetId" = a."id" AND p."kundeId" IS NULL; + +UPDATE "Protokoll" AS p SET "kundeId" = q."kundeId" + FROM "Verzeichnisquelle" AS q + WHERE p."entitaet" = 'Verzeichnisquelle' AND p."entitaetId" = q."id" AND p."kundeId" IS NULL; + +-- 3. Ein Eintrag **ueber** einen Kunden gehoert zu diesem Kunden. +UPDATE "Protokoll" AS p SET "kundeId" = k."id" + FROM "Kunde" AS k + WHERE p."entitaet" = 'Kunde' AND p."entitaetId" = k."id" AND p."kundeId" IS NULL; + +-- 4. Eine Vorlage traegt nur im Modus DIENSTLEISTUNG einen Kunden; ist sie +-- kundenfrei, bleibt der Eintrag global. Genau richtig: sie gilt fuer alle. +UPDATE "Protokoll" AS p SET "kundeId" = v."kundeId" + FROM "Vorlage" AS v + WHERE p."entitaet" = 'Vorlage' AND p."entitaetId" = v."id" AND p."kundeId" IS NULL; + +-- 5. Eine Version gehoert zum Kunden ihrer Vorlage. +UPDATE "Protokoll" AS p SET "kundeId" = v."kundeId" + FROM "VorlagenVersion" AS vv + JOIN "Vorlage" AS v ON v."id" = vv."vorlageId" + WHERE p."entitaet" = 'VorlagenVersion' AND p."entitaetId" = vv."id" AND p."kundeId" IS NULL; + +-- Alles Uebrige bleibt leer: Einstellung, Benutzer, GeraetetypVorlage und +-- kundenfreie Vorlagen sind wirklich global. Eintraege zu **geloeschten** Zeilen +-- bleiben ebenfalls leer und sind damit nicht zuordenbar — die Abfrage zeigt sie +-- einem auf Kunden beschraenkten Benutzer deshalb **nicht** (fail closed, siehe +-- src/server/queries/protokoll.ts). diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 0285d56..0bc0a6f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -135,6 +135,7 @@ model Kunde { geraete Geraet[] laeufe Lauf[] mitarbeiter Mitarbeiter[] + protokolle Protokoll[] verzeichnisquellen Verzeichnisquelle[] vorlagen Vorlage[] @@ -608,6 +609,12 @@ model Kommentar { model Protokoll { id String @id @default(cuid()) laufId String? + /// Der Kunde, um den es geht — **leer heisst global** (Einstellungen, + /// Benutzer, kundenfreie Vorlagen, Schnellstart). CLAUDE.md verlangt die + /// Spalte fuer jede kundenbezogene Zeile; ohne sie liess sich das Protokoll + /// nicht nach Mandant filtern, ohne je Eintrag die Entitaet nachzuschlagen + /// (#023). + kundeId String? entitaet String entitaetId String aktion String @@ -617,11 +624,13 @@ model Protokoll { erstelltAm DateTime @default(now()) lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict) + kunde Kunde? @relation(fields: [kundeId], references: [id], onDelete: Restrict) benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict) @@index([laufId, erstelltAm]) @@index([entitaet, entitaetId]) @@index([erstelltAm]) + @@index([kundeId, erstelltAm]) } /// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der diff --git a/src/app/(app)/admin/layout.tsx b/src/app/(app)/admin/layout.tsx index c530ba2..47f73cf 100644 --- a/src/app/(app)/admin/layout.tsx +++ b/src/app/(app)/admin/layout.tsx @@ -14,6 +14,7 @@ const REITER = [ { pfad: "/admin/benutzer", beschriftung: "Benutzer" }, { pfad: "/admin/abteilungen", beschriftung: "Abteilungen" }, { pfad: "/admin/schnellstart", beschriftung: "Schnellstart" }, + { pfad: "/admin/protokoll", beschriftung: "Protokoll" }, /* * Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD, * aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu diff --git a/src/app/(app)/admin/protokoll/page.tsx b/src/app/(app)/admin/protokoll/page.tsx new file mode 100644 index 0000000..67e2563 --- /dev/null +++ b/src/app/(app)/admin/protokoll/page.tsx @@ -0,0 +1,115 @@ +import Link from "next/link"; +import { ScrollText } from "lucide-react"; +import { ProtokollFilter, type ProtokollFilterfelder } from "@/components/protokoll/protokoll-filter"; +import { ProtokollListe } from "@/components/protokoll/protokoll-liste"; +import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { kundenEbeneSichtbar } from "@/server/mandant"; +import { PRO_SEITE, protokollSeite, protokollUrheber } from "@/server/queries/protokoll"; + +/** + * Das Protokoll ansehen (#023). + * + * Geschrieben wurde es seit #006 — Rollenänderungen, veröffentlichte Vorlagen, + * archivierte und zurückgeholte Läufe, der Moduswechsel. Ansehen konnte es + * niemand: wer wissen wollte, wer wann eine Rolle geändert hat, brauchte `psql`. + * + * `verwaltung` und nicht `lesen`: das Protokoll sagt, wer was getan hat, und ist + * damit selbst eine Auskunft über Menschen. + * + * **Die Seite prüft selbst.** Das Admin-Layout darüber ist das zweite Netz und + * nicht das erste (siehe CLAUDE.md). + */ + +export default async function ProtokollSeiteAnsicht({ + searchParams, +}: { + searchParams: Promise>; +}) { + const { benutzer } = await sitzungErforderlich("verwaltung", "/admin/protokoll"); + const parameter = await searchParams; + const einzeln = (name: string): string => + (Array.isArray(parameter[name]) ? parameter[name][0] : parameter[name])?.trim() ?? ""; + + const felder: ProtokollFilterfelder = { + suche: einzeln("suche"), + urheber: einzeln("urheber"), + von: einzeln("von"), + bis: einzeln("bis"), + }; + const gewuenschteSeite = Number.parseInt(einzeln("seite"), 10); + + const [ergebnis, urheber, kundenEbene] = await Promise.all([ + protokollSeite( + { + suche: felder.suche, + ...(felder.urheber ? { urheberId: felder.urheber } : {}), + ...(felder.von ? { vonDatum: felder.von } : {}), + ...(felder.bis ? { bisDatum: felder.bis } : {}), + seite: Number.isNaN(gewuenschteSeite) ? 1 : gewuenschteSeite, + }, + benutzer, + ), + protokollUrheber(benutzer), + kundenEbeneSichtbar(), + ]); + + /** Dieselben Filter mitnehmen, nur die Seite wechseln. */ + function blaetternZu(seite: number): string { + const suchteil = new URLSearchParams(); + if (felder.suche) suchteil.set("suche", felder.suche); + if (felder.urheber) suchteil.set("urheber", felder.urheber); + if (felder.von) suchteil.set("von", felder.von); + if (felder.bis) suchteil.set("bis", felder.bis); + suchteil.set("seite", String(seite)); + return `/admin/protokoll?${suchteil.toString()}`; + } + + const erster = (ergebnis.seite - 1) * PRO_SEITE + 1; + const letzter = Math.min(ergebnis.seite * PRO_SEITE, ergebnis.gesamt); + + return ( + <> +

+

+

+ Wer hat was wann getan. Festgehalten wird, was nachvollziehbar bleiben muss — Rollenänderungen, + veröffentlichte Vorlagen, archivierte und zurückgeholte Läufe, Verzeichnisquellen, der Moduswechsel. + Einträge werden nie geändert und nie gelöscht. +

+ + + +

+ {ergebnis.gesamt === 0 ? "Keine Einträge." : `${erster}–${letzter} von ${ergebnis.gesamt} Einträgen`} +

+ + + + {ergebnis.seiten > 1 ? ( + + ) : null} + + ); +} diff --git a/src/app/(app)/laeufe/[id]/page.tsx b/src/app/(app)/laeufe/[id]/page.tsx index a165049..4321681 100644 --- a/src/app/(app)/laeufe/[id]/page.tsx +++ b/src/app/(app)/laeufe/[id]/page.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; import { notFound } from "next/navigation"; -import { ArrowLeft, MessageSquare, Printer, Star } from "lucide-react"; +import { ArrowLeft, MessageSquare, Printer, ScrollText, Star } from "lucide-react"; import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums"; import { Markdown } from "@/components/markdown"; import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle"; @@ -19,6 +19,7 @@ import { SchrittWert } from "@/components/laeufe/schritt-wert"; import { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand"; import { darf } from "@/server/auth/rollen"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; +import { protokollDesLaufs } from "@/server/queries/protokoll"; import { kommentareDesLaufs, laufZumAbarbeiten, @@ -293,6 +294,9 @@ export default async function LaufSeite({ if (!daten) notFound(); const kommentare = await kommentareDesLaufs(id, benutzer); + // Die Sicht hat `laufHolen` oben schon angewandt; hier geht es nur noch um die + // Einträge dieses Laufs (#023). + const laufProtokoll = await protokollDesLaufs(id); /* * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in * jedem Seitenaufbau eine Abfrage, die niemand braucht. @@ -416,6 +420,41 @@ export default async function LaufSeite({ )} + + {/* + * Die Geschichte dieses Laufs, eingeklappt (#023). Sie steht hier und + * nicht nur unter Verwaltung → Protokoll, weil die Frage „wer hat das + * zurückgeholt“ am Lauf aufkommt und nicht in der Verwaltung. Eingeklappt, + * weil ein abgearbeiteter Lauf Dutzende Einträge hat und sie die Schritte + * sonst aus dem Bild schieben. + * + * Kein eigenes Recht: wer den Lauf sehen darf, darf auch sehen, was an ihm + * geschehen ist. Die Mandantensicht hat `laufHolen` oben schon angewandt. + */} + {laufProtokoll.length > 0 ? ( +
+
+ + +
    + {laufProtokoll.map((eintrag) => ( +
  1. + + {ZEITPUNKT.format(eintrag.erstelltAm)} + + {eintrag.aktion} + + {eintrag.urheber ? eintrag.urheber.name : "Systemlauf"} + +
  2. + ))} +
+
+
+ ) : null} ); } diff --git a/src/components/protokoll/protokoll-filter.tsx b/src/components/protokoll/protokoll-filter.tsx new file mode 100644 index 0000000..ad0012a --- /dev/null +++ b/src/components/protokoll/protokoll-filter.tsx @@ -0,0 +1,88 @@ +import Link from "next/link"; +import { Search } from "lucide-react"; +import { FELD } from "@/lib/formular"; + +/** + * Filter des Protokolls (#023). + * + * Ein GET-Formular, kein React-State: eine gesetzte Suche ist verlinkbar, der + * Zurueck-Knopf tut das Erwartete, und die Seite bleibt eine Server-Komponente. + * Dieselbe Entscheidung wie beim Archiv (#015). + */ + +export type ProtokollFilterfelder = { + readonly suche: string; + readonly urheber: string; + readonly von: string; + readonly bis: string; +}; + +export function ProtokollFilter({ + felder, + urheber, +}: { + felder: ProtokollFilterfelder; + urheber: readonly { readonly id: string; readonly name: string }[]; +}) { + const gesetzt = Boolean(felder.suche || felder.urheber || felder.von || felder.bis); + + return ( +
+ {/* Beim Filtern immer auf Seite 1: die alte Seitenzahl zeigte sonst ins + Leere, wenn die neue Auswahl kuerzer ist. */} +
+ + + + + + + + + + + {gesetzt ? ( + + zurücksetzen + + ) : null} +
+
+ ); +} diff --git a/src/components/protokoll/protokoll-liste.tsx b/src/components/protokoll/protokoll-liste.tsx new file mode 100644 index 0000000..b885f64 --- /dev/null +++ b/src/components/protokoll/protokoll-liste.tsx @@ -0,0 +1,115 @@ +import Link from "next/link"; +import { Bot } from "lucide-react"; +import type { ProtokollZeile } from "@/server/queries/protokoll"; + +/** + * Die Einträge des Protokolls (#023). + * + * Keine Tabelle, sondern eine Liste aus Karten. Eine Tabelle mit sieben Spalten + * ist bei 390 px nicht darstellbar, ohne horizontal zu scrollen — und genau das + * ist ausgeschlossen (CLAUDE.md). + * + * `vorher`/`nachher` stehen in einem `details`: sie sind Json und koennen lang + * werden. Zugeklappt bleibt die Liste lesbar, aufgeklappt steht alles da. + */ + +const ZEITPUNKT = new Intl.DateTimeFormat("de-DE", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", +}); + +/** Json lesbar, ohne Anführungszeichen-Wald für den häufigen Fall eines Objekts. */ +function alsText(wert: unknown): string { + if (wert === null || wert === undefined) return ""; + if (typeof wert === "object" && !Array.isArray(wert)) { + const paare = Object.entries(wert as Record).map( + ([schluessel, inhalt]) => + `${schluessel}: ${typeof inhalt === "object" ? JSON.stringify(inhalt) : String(inhalt)}`, + ); + return paare.join(" · "); + } + return JSON.stringify(wert); +} + +export function ProtokollListe({ + zeilen, + kundenEbene, +}: { + zeilen: readonly ProtokollZeile[]; + kundenEbene: boolean; +}) { + if (zeilen.length === 0) { + return ( +

+ Kein Eintrag passt zu diesen Filtern. +

+ ); + } + + return ( +
    + {zeilen.map((zeile) => { + const vorher = alsText(zeile.vorher); + const nachher = alsText(zeile.nachher); + return ( +
  • +
    + + {ZEITPUNKT.format(zeile.erstelltAm)} + + {zeile.aktion} +
    + +
    + {zeile.urheber ? ( + {zeile.urheber.name} + ) : ( + /* Ein Systemlauf ist keine Lücke – der Zeitplan des + Verzeichnisabgleichs hat keinen Menschen hinter sich (#023). */ + + + )} + {zeile.entitaet} + {zeile.laufId && zeile.laufTitel ? ( + + {zeile.laufTitel} + + ) : null} + {kundenEbene && zeile.kundeName ? · {zeile.kundeName} : null} +
    + + {vorher || nachher ? ( +
    + + Werte + +
    + {vorher ? ( +
    +
    vorher
    +
    {vorher}
    +
    + ) : null} + {nachher ? ( +
    +
    nachher
    +
    {nachher}
    +
    + ) : null} +
    +
    + ) : null} +
  • + ); + })} +
+ ); +} diff --git a/src/server/queries/protokoll.ts b/src/server/queries/protokoll.ts new file mode 100644 index 0000000..b06a4d6 --- /dev/null +++ b/src/server/queries/protokoll.ts @@ -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 { + 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 { + 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 { + 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 { + 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, + })); +} diff --git a/src/server/services/abgleich.ts b/src/server/services/abgleich.ts index b3c4a2b..8d569c0 100644 --- a/src/server/services/abgleich.ts +++ b/src/server/services/abgleich.ts @@ -568,7 +568,7 @@ async function abschlussSchreiben( uebersprungen: number, ): Promise { await prisma.$transaction(async (tx) => { - await tx.verzeichnisquelle.update({ + const quelle = await tx.verzeichnisquelle.update({ where: { id: quelleId }, data: { letzterAbgleichAm: new Date(), @@ -578,6 +578,9 @@ async function abschlussSchreiben( }); await tx.protokoll.create({ data: { + // Aus der Aktualisierung, nicht aus einer zweiten Abfrage: der Kunde + // steht in derselben Zeile, die gerade geschrieben wurde (#023). + kundeId: quelle.kundeId, entitaet: "Verzeichnisquelle", entitaetId: quelleId, aktion: diff --git a/src/server/services/abteilungen.ts b/src/server/services/abteilungen.ts index 5a057d1..417a49e 100644 --- a/src/server/services/abteilungen.ts +++ b/src/server/services/abteilungen.ts @@ -100,6 +100,7 @@ export async function abteilungAnlegen( }); await tx.protokoll.create({ data: { + kundeId: abteilung.kundeId, entitaet: "Abteilung", entitaetId: abteilung.id, aktion: "Abteilung angelegt", @@ -169,6 +170,7 @@ export async function abteilungBearbeiten( const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten }); await tx.protokoll.create({ data: { + kundeId: vorher.kundeId, entitaet: "Abteilung", entitaetId: abteilungId, aktion: `${geaendert.join(" und ")} geändert`, @@ -202,6 +204,7 @@ export async function abteilungArchivieren( const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } }); await tx.protokoll.create({ data: { + kundeId: vorher.kundeId, entitaet: "Abteilung", entitaetId: abteilungId, aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben", diff --git a/src/server/services/einstellungen.ts b/src/server/services/einstellungen.ts index 40b3333..99824f7 100644 --- a/src/server/services/einstellungen.ts +++ b/src/server/services/einstellungen.ts @@ -81,6 +81,7 @@ export async function einstellungenSpeichern( await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } }); await prisma.protokoll.create({ data: { + kundeId: vorher.eigeneOrganisation.id, entitaet: "Kunde", entitaetId: vorher.eigeneOrganisation.id, aktion: "Name der eigenen Organisation geändert", diff --git a/src/server/services/geraete.ts b/src/server/services/geraete.ts index 41e6ca4..6cbc29d 100644 --- a/src/server/services/geraete.ts +++ b/src/server/services/geraete.ts @@ -197,6 +197,7 @@ async function geraetSchreiben(auftrag: SchreibAuftrag): Promise { }); await tx.protokoll.create({ data: { + kundeId: geraet.kundeId, entitaet: "Geraet", entitaetId: geraet.id, aktion: auftrag.aktion, @@ -314,6 +315,7 @@ export async function geraetBearbeiten( const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten }); await tx.protokoll.create({ data: { + kundeId: vorher.kundeId, entitaet: "Geraet", entitaetId: geraetId, aktion: `${geaendert.join(" und ")} geändert`, @@ -371,6 +373,7 @@ export async function geraetArchivieren( await tx.geraet.update({ where: { id: geraetId }, data: { archiviert } }); await tx.protokoll.create({ data: { + kundeId: geraet.kundeId, entitaet: "Geraet", entitaetId: geraetId, aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben", @@ -418,6 +421,7 @@ export async function geraetLoeschen(geraetId: string, benutzer: HandelnderBenut await tx.geraet.delete({ where: { id: geraetId } }); await tx.protokoll.create({ data: { + kundeId: geraet.kundeId, entitaet: "Geraet", entitaetId: geraetId, aktion: "Gerät gelöscht", diff --git a/src/server/services/kunden.ts b/src/server/services/kunden.ts index 07a9424..7ee41cc 100644 --- a/src/server/services/kunden.ts +++ b/src/server/services/kunden.ts @@ -114,6 +114,7 @@ export async function kundeAnlegen(eingabe: NeuerKunde, durchBenutzerId: string) await prisma.protokoll.create({ data: { + kundeId: kunde.id, entitaet: "Kunde", entitaetId: kunde.id, aktion: "Kunde angelegt", @@ -181,6 +182,7 @@ export async function kundeBearbeiten( await prisma.protokoll.create({ data: { + kundeId: id, entitaet: "Kunde", entitaetId: id, aktion: `${felder.map((feld) => FELD_TEXT[feld]).join(" und ")} geändert`, @@ -215,6 +217,7 @@ export async function kundeArchivieren( await prisma.kunde.update({ where: { id }, data: { archiviert } }); await prisma.protokoll.create({ data: { + kundeId: id, entitaet: "Kunde", entitaetId: id, aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben", diff --git a/src/server/services/mitarbeiter.ts b/src/server/services/mitarbeiter.ts index 678ec00..b04d753 100644 --- a/src/server/services/mitarbeiter.ts +++ b/src/server/services/mitarbeiter.ts @@ -131,6 +131,7 @@ export async function mitarbeiterAnlegen( }); await tx.protokoll.create({ data: { + kundeId: mitarbeiter.kundeId, entitaet: "Mitarbeiter", entitaetId: mitarbeiter.id, aktion: vermerk?.aktion ?? "Mitarbeiter angelegt", @@ -200,6 +201,7 @@ export async function mitarbeiterBearbeiten( const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten }); await tx.protokoll.create({ data: { + kundeId: vorher.kundeId, entitaet: "Mitarbeiter", entitaetId: mitarbeiterId, aktion: `${geaendert.join(" und ")} geändert`, @@ -260,6 +262,7 @@ export async function vorgesetztenSetzen( }); await tx.protokoll.create({ data: { + kundeId: vorher.kundeId, entitaet: "Mitarbeiter", entitaetId: mitarbeiterId, aktion: gewuenscht === null ? "Vorgesetzter entfernt" : `Vorgesetzter auf ${name} gesetzt`, @@ -321,6 +324,7 @@ export async function mitarbeiterAktivSetzen( const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } }); await tx.protokoll.create({ data: { + kundeId: vorher.kundeId, entitaet: "Mitarbeiter", entitaetId: mitarbeiterId, aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert", diff --git a/src/server/services/verzeichnis.ts b/src/server/services/verzeichnis.ts index 608a0ca..348c75a 100644 --- a/src/server/services/verzeichnis.ts +++ b/src/server/services/verzeichnis.ts @@ -210,6 +210,7 @@ export async function quelleAnlegen( }); await tx.protokoll.create({ data: { + kundeId: angelegt.kundeId, entitaet: "Verzeichnisquelle", entitaetId: angelegt.id, aktion: "Verzeichnisquelle angelegt", @@ -316,6 +317,7 @@ export async function quelleBearbeiten( const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten }); await tx.protokoll.create({ data: { + kundeId: vorher.kundeId, entitaet: "Verzeichnisquelle", entitaetId: id, aktion: `${geaendert.join(", ")} geändert`, @@ -354,6 +356,7 @@ export async function quelleLoeschen(id: string, benutzer: HandelnderBenutzer): await tx.verzeichnisquelle.delete({ where: { id } }); await tx.protokoll.create({ data: { + kundeId: quelle.kundeId, entitaet: "Verzeichnisquelle", entitaetId: id, aktion: "Verzeichnisquelle gelöscht", diff --git a/src/server/services/vorlagen-austausch.ts b/src/server/services/vorlagen-austausch.ts index c5897d1..6eb9456 100644 --- a/src/server/services/vorlagen-austausch.ts +++ b/src/server/services/vorlagen-austausch.ts @@ -177,6 +177,7 @@ export async function vorlageImportieren( await tx.protokoll.create({ data: { + kundeId: vorlage.kundeId, entitaet: "Vorlage", entitaetId: vorlage.id, aktion: "Vorlage importiert", diff --git a/src/server/services/vorlagen.ts b/src/server/services/vorlagen.ts index 5ff7ebd..0236b1d 100644 --- a/src/server/services/vorlagen.ts +++ b/src/server/services/vorlagen.ts @@ -61,6 +61,7 @@ export async function vorlageAnlegen( }); await tx.protokoll.create({ data: { + kundeId: vorlage.kundeId, entitaet: "Vorlage", entitaetId: vorlage.id, aktion: "Vorlage angelegt", @@ -147,6 +148,7 @@ export async function vorlageBearbeiten( const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten }); await tx.protokoll.create({ data: { + kundeId: nachher.kundeId, entitaet: "Vorlage", entitaetId: vorlageId, aktion: `${geaendert.join(" und ")} geändert`, @@ -263,6 +265,12 @@ async function entwurfKopieren( durchBenutzerId: string, ): Promise { return prisma.$transaction(async (tx) => { + // Der Kunde fuer den Protokolleintrag; ein indizierter Lesevorgang auf dem + // Primaerschluessel (#023). + const vorlage = await tx.vorlage.findUniqueOrThrow({ + where: { id: vorlageId }, + select: { kundeId: true }, + }); const entwurf = await tx.vorlagenVersion.create({ data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF }, }); @@ -304,6 +312,7 @@ async function entwurfKopieren( await tx.protokoll.create({ data: { + kundeId: vorlage.kundeId, entitaet: "VorlagenVersion", entitaetId: entwurf.id, aktion: "Entwurf aus veröffentlichter Version erstellt", @@ -485,7 +494,7 @@ export async function versionVeroeffentlichen( const kopf = await tx.vorlagenVersion.findUniqueOrThrow({ where: { id: versionId }, select: { - vorlage: { select: { archiviert: true, name: true } }, + vorlage: { select: { archiviert: true, name: true, kundeId: true } }, _count: { select: { abschnitte: true } }, }, }); @@ -518,6 +527,7 @@ export async function versionVeroeffentlichen( await tx.protokoll.create({ data: { + kundeId: kopf.vorlage.kundeId, entitaet: "VorlagenVersion", entitaetId: versionId, aktion: "Version veröffentlicht", @@ -545,7 +555,13 @@ export async function versionVeroeffentlichen( export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise { const version = await prisma.vorlagenVersion.findUnique({ where: { id: versionId }, - select: { id: true, status: true, version: true, vorlageId: true }, + select: { + id: true, + status: true, + version: true, + vorlageId: true, + vorlage: { select: { kundeId: true } }, + }, }); if (!version) throw new VorlagenFehler("Diese Version gibt es nicht."); versionMussEntwurfSein(version); @@ -563,6 +579,7 @@ export async function entwurfVerwerfen(versionId: string, durchBenutzerId: strin await tx.vorlagenVersion.delete({ where: { id: versionId } }); await tx.protokoll.create({ data: { + kundeId: version.vorlage.kundeId, entitaet: "VorlagenVersion", entitaetId: versionId, aktion: "Entwurf verworfen", @@ -661,6 +678,7 @@ export async function vorlageDuplizieren( await tx.protokoll.create({ data: { + kundeId: kopie.kundeId, entitaet: "Vorlage", entitaetId: kopie.id, aktion: "Vorlage dupliziert", @@ -693,6 +711,7 @@ export async function vorlageArchivieren( await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } }); await tx.protokoll.create({ data: { + kundeId: vorlage.kundeId, entitaet: "Vorlage", entitaetId: vorlageId, aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben", diff --git a/tests/e2e/onboarding.spec.ts b/tests/e2e/onboarding.spec.ts index e4b28dc..73addad 100644 --- a/tests/e2e/onboarding.spec.ts +++ b/tests/e2e/onboarding.spec.ts @@ -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, " "); diff --git a/tests/protokoll.test.ts b/tests/protokoll.test.ts new file mode 100644 index 0000000..ca4ac94 --- /dev/null +++ b/tests/protokoll.test.ts @@ -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"]); + }); +}); -- 2.54.0