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"]); + }); +});