feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #45.
This commit is contained in:
2026-10-04 16:06:26 +00:00
parent f5936327a0
commit e1f244e4e8
23 changed files with 1103 additions and 22 deletions
+12
View File
@@ -26,6 +26,18 @@ und dieses Projekt folgt [Semantic Versioning](https://semver.org/lang/de/).
### Hinzugefügt ### 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 - **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 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 wollte, kam nicht heran, und wer eine mitgelieferte loswerden wollte, auch nicht. Zur Auswahl
+21 -4
View File
@@ -169,13 +169,30 @@ nicht archivieren.
Eine Vorlage lässt sich **einem** Kunden zuordnen („nur Musterfirma GmbH“) oder für alle gelten 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. 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, 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 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 gelöschte Verzeichnisquellen, den Moduswechsel. Ein Protokolleintrag verliert seinen Urheber nie, und
eigene Ansicht dafür gibt es in der Oberfläche noch nicht; was am Lauf selbst geschehen ist, steht in geändert oder gelöscht wird keiner.
seiner Abarbeitungsansicht und im Ausdruck.
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 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. dafür, wäre die Versuchung groß, es „nur fürs Protokoll“ mitzuschreiben.
+32 -12
View File
@@ -1,6 +1,6 @@
# #023 Protokoll ansehen # #023 Protokoll ansehen
**Status:** offen **Status:** erledigt
**Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35) **Im Tracker:** [#35](https://git.wilde-server.de/florian.wilde/Laufzettel/issues/35)
**Meilenstein:** v0.2.0 **Meilenstein:** v0.2.0
**Labels:** phase-2, frontend **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). 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 ## Aufgaben
- [ ] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar - [x] Admin → Protokoll: Liste mit Zeitpunkt, Urheber, Aktion, betroffener Zeile, umblätterbar
- [ ] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion - [x] Filter nach Zeitraum, Aktion und Urheber; Volltext über die Aktion
- [ ] Die Mandantensicht gilt auch hier: ein Eintrag zu einem fremden Kunden ist nicht sichtbar - [x] 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] Recht `verwaltung` — das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
über Menschen ü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 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 ## Akzeptanzkriterien
- [ ] Test: ein Eintrag eines fremden Kunden erscheint nicht - [x] Test: ein Eintrag eines fremden Kunden erscheint nicht
- [ ] Test: `TECHNIKER` kommt nicht auf die Seite - [x] Test: `TECHNIKER` kommt nicht auf die Seite
- [ ] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf - [x] Test: ein erzeugtes Passwort taucht in keinem Eintrag auf
- [ ] Am Handy kein horizontales Scrollen - [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.
+2 -2
View File
@@ -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 | | #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 | | #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 | | #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 | – | | #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 | | #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 | | #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 | – | | #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** |
@@ -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).
+9
View File
@@ -135,6 +135,7 @@ model Kunde {
geraete Geraet[] geraete Geraet[]
laeufe Lauf[] laeufe Lauf[]
mitarbeiter Mitarbeiter[] mitarbeiter Mitarbeiter[]
protokolle Protokoll[]
verzeichnisquellen Verzeichnisquelle[] verzeichnisquellen Verzeichnisquelle[]
vorlagen Vorlage[] vorlagen Vorlage[]
@@ -608,6 +609,12 @@ model Kommentar {
model Protokoll { model Protokoll {
id String @id @default(cuid()) id String @id @default(cuid())
laufId String? 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 entitaet String
entitaetId String entitaetId String
aktion String aktion String
@@ -617,11 +624,13 @@ model Protokoll {
erstelltAm DateTime @default(now()) erstelltAm DateTime @default(now())
lauf Lauf? @relation(fields: [laufId], references: [id], onDelete: Restrict) 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) benutzer Benutzer? @relation(fields: [benutzerId], references: [id], onDelete: Restrict)
@@index([laufId, erstelltAm]) @@index([laufId, erstelltAm])
@@index([entitaet, entitaetId]) @@index([entitaet, entitaetId])
@@index([erstelltAm]) @@index([erstelltAm])
@@index([kundeId, erstelltAm])
} }
/// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der /// Sperre fuer Aufgaben, die nur einmal gleichzeitig laufen duerfen – zuerst der
+1
View File
@@ -14,6 +14,7 @@ const REITER = [
{ pfad: "/admin/benutzer", beschriftung: "Benutzer" }, { pfad: "/admin/benutzer", beschriftung: "Benutzer" },
{ pfad: "/admin/abteilungen", beschriftung: "Abteilungen" }, { pfad: "/admin/abteilungen", beschriftung: "Abteilungen" },
{ pfad: "/admin/schnellstart", beschriftung: "Schnellstart" }, { pfad: "/admin/schnellstart", beschriftung: "Schnellstart" },
{ pfad: "/admin/protokoll", beschriftung: "Protokoll" },
/* /*
* Das Verzeichnis gibt es nur im Modus INTERN: dort hat das Haus **ein** AD, * 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 * aus dem seine Leute kommen. Im Modus DIENSTLEISTUNG waere es ein LDAP zu
+115
View File
@@ -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<Record<string, string | string[] | undefined>>;
}) {
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 (
<>
<h1 className="flex items-center gap-2 text-2xl font-bold">
<ScrollText className="size-6 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Protokoll
</h1>
<p className="mt-2 max-w-prose text-nebentext">
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.
</p>
<ProtokollFilter felder={felder} urheber={urheber} />
<p className="mt-4 text-sm text-nebentext">
{ergebnis.gesamt === 0 ? "Keine Einträge." : `${erster}–${letzter} von ${ergebnis.gesamt} Einträgen`}
</p>
<ProtokollListe zeilen={ergebnis.zeilen} kundenEbene={kundenEbene} />
{ergebnis.seiten > 1 ? (
<nav aria-label="Seiten" className="mt-6 flex flex-wrap items-center gap-3">
{ergebnis.seite > 1 ? (
<Link
href={blaetternZu(ergebnis.seite - 1)}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
zurück
</Link>
) : null}
<span className="text-sm text-nebentext">
Seite {ergebnis.seite} von {ergebnis.seiten}
</span>
{ergebnis.seite < ergebnis.seiten ? (
<Link
href={blaetternZu(ergebnis.seite + 1)}
className="flex min-h-11 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
weiter
</Link>
) : null}
</nav>
) : null}
</>
);
}
+40 -1
View File
@@ -1,6 +1,6 @@
import Link from "next/link"; import Link from "next/link";
import { notFound } from "next/navigation"; 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 { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { Markdown } from "@/components/markdown"; import { Markdown } from "@/components/markdown";
import { AbschnittHuelle } from "@/components/laeufe/abschnitt-huelle"; 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 { SchrittZustandWaehlen, ZustandKennzeichen } from "@/components/laeufe/schritt-zustand";
import { darf } from "@/server/auth/rollen"; import { darf } from "@/server/auth/rollen";
import { sitzungErforderlich } from "@/server/auth/sitzungspflicht"; import { sitzungErforderlich } from "@/server/auth/sitzungspflicht";
import { protokollDesLaufs } from "@/server/queries/protokoll";
import { import {
kommentareDesLaufs, kommentareDesLaufs,
laufZumAbarbeiten, laufZumAbarbeiten,
@@ -293,6 +294,9 @@ export default async function LaufSeite({
if (!daten) notFound(); if (!daten) notFound();
const kommentare = await kommentareDesLaufs(id, benutzer); 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 * Die Personenliste nur fuer den Onboarding-Fall holen — sonst stuende in
* jedem Seitenaufbau eine Abfrage, die niemand braucht. * jedem Seitenaufbau eine Abfrage, die niemand braucht.
@@ -416,6 +420,41 @@ export default async function LaufSeite({
</div> </div>
)} )}
</section> </section>
{/*
* 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 ? (
<section className="mt-4 rounded-karte border border-linie bg-flaeche p-4 md:p-5">
<details>
<summary className="flex min-h-11 cursor-pointer items-center gap-2 text-lg font-semibold md:min-h-0">
<ScrollText className="size-5 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
Protokoll dieses Laufs
<span className="text-sm font-normal text-nebentext">({laufProtokoll.length})</span>
</summary>
<ol className="mt-3 flex flex-col gap-2">
{laufProtokoll.map((eintrag) => (
<li key={eintrag.id} className="flex flex-wrap items-baseline gap-x-3 gap-y-1 text-sm">
<span className="mono shrink-0 text-xs text-nebentext">
{ZEITPUNKT.format(eintrag.erstelltAm)}
</span>
<span className="min-w-0 break-words">{eintrag.aktion}</span>
<span className="text-xs text-nebentext">
{eintrag.urheber ? eintrag.urheber.name : "Systemlauf"}
</span>
</li>
))}
</ol>
</details>
</section>
) : null}
</> </>
); );
} }
@@ -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 (
<form method="get" className="mt-6 flex flex-col gap-3">
{/* Beim Filtern immer auf Seite 1: die alte Seitenzahl zeigte sonst ins
Leere, wenn die neue Auswahl kuerzer ist. */}
<div className="flex flex-wrap items-end gap-3">
<label className="flex min-w-0 flex-1 basis-64 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Aktion oder Lauf</span>
<div className="flex items-center gap-2">
<Search className="size-4 shrink-0 text-nebentext" strokeWidth={1.75} aria-hidden="true" />
<input
type="search"
name="suche"
defaultValue={felder.suche}
placeholder="Rolle geändert, archiviert …"
className={`${FELD} w-full`}
/>
</div>
</label>
<label className="flex min-w-0 basis-56 flex-col gap-1.5 text-sm">
<span className="text-nebentext">Urheber</span>
<select name="urheber" defaultValue={felder.urheber} className={`${FELD} w-full`}>
<option value="">alle</option>
{urheber.map((eintrag) => (
<option key={eintrag.id} value={eintrag.id}>
{eintrag.name}
</option>
))}
</select>
</label>
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
<span className="text-nebentext">von</span>
<input type="date" name="von" defaultValue={felder.von} className={`${FELD} w-full`} />
</label>
<label className="flex min-w-0 basis-40 flex-col gap-1.5 text-sm">
<span className="text-nebentext">bis</span>
<input type="date" name="bis" defaultValue={felder.bis} className={`${FELD} w-full`} />
</label>
<button
type="submit"
className="min-h-11 shrink-0 rounded-pille bg-akzent px-4 text-sm font-semibold text-grund md:min-h-10"
>
Filtern
</button>
{gesetzt ? (
<Link
href="/admin/protokoll"
className="flex min-h-11 shrink-0 items-center rounded-pille border border-linie px-4 text-sm text-nebentext hover:bg-flaeche-hoch hover:text-text md:min-h-10"
>
zurücksetzen
</Link>
) : null}
</div>
</form>
);
}
@@ -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<string, unknown>).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 (
<p className="mt-6 rounded-karte border border-linie bg-flaeche p-5 text-sm text-nebentext">
Kein Eintrag passt zu diesen Filtern.
</p>
);
}
return (
<ul className="mt-4 flex flex-col gap-2">
{zeilen.map((zeile) => {
const vorher = alsText(zeile.vorher);
const nachher = alsText(zeile.nachher);
return (
<li key={zeile.id} className="rounded-karte border border-linie bg-flaeche p-4">
<div className="flex flex-wrap items-baseline gap-x-3 gap-y-1">
<span className="mono shrink-0 text-xs text-nebentext">
{ZEITPUNKT.format(zeile.erstelltAm)}
</span>
<span className="min-w-0 font-semibold break-words">{zeile.aktion}</span>
</div>
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-nebentext">
{zeile.urheber ? (
<span>{zeile.urheber.name}</span>
) : (
/* Ein Systemlauf ist keine Lücke – der Zeitplan des
Verzeichnisabgleichs hat keinen Menschen hinter sich (#023). */
<span className="inline-flex items-center gap-1.5">
<Bot className="size-4" strokeWidth={1.75} aria-hidden="true" />
Systemlauf
</span>
)}
<span className="rounded-pille bg-flaeche-hoch px-2.5 py-0.5 text-xs">{zeile.entitaet}</span>
{zeile.laufId && zeile.laufTitel ? (
<Link
href={`/laeufe/${zeile.laufId}`}
className="min-w-0 break-words text-akzent underline underline-offset-2"
>
{zeile.laufTitel}
</Link>
) : null}
{kundenEbene && zeile.kundeName ? <span>· {zeile.kundeName}</span> : null}
</div>
{vorher || nachher ? (
<details className="mt-2">
<summary className="inline-flex min-h-11 cursor-pointer items-center text-sm text-nebentext hover:text-text md:min-h-0">
Werte
</summary>
<dl className="mt-1 flex flex-col gap-1 text-sm">
{vorher ? (
<div className="flex flex-wrap gap-x-2">
<dt className="shrink-0 text-nebentext">vorher</dt>
<dd className="mono min-w-0 break-words">{vorher}</dd>
</div>
) : null}
{nachher ? (
<div className="flex flex-wrap gap-x-2">
<dt className="shrink-0 text-nebentext">nachher</dt>
<dd className="mono min-w-0 break-words">{nachher}</dd>
</div>
) : null}
</dl>
</details>
) : null}
</li>
);
})}
</ul>
);
}
+227
View File
@@ -0,0 +1,227 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
/**
* Lesemodell des Protokolls (#023).
*
* Geschrieben wurde es seit #006, ansehen konnte es niemand — wer wissen wollte,
* wer wann eine Rolle geaendert hat, brauchte `psql`.
*
* Die Mandantensicht wird hier selbst angewandt, obwohl die Seite `verwaltung`
* verlangt und ein ADMIN heute ohnehin alle Kunden sieht. Das ist Absicht: ein
* Lesemodell traegt seine Sicht selbst (CLAUDE.md), und wenn das Recht je
* weiter gefasst wird, haelt die Grenze trotzdem.
*/
export type ProtokollZeile = {
readonly id: string;
readonly erstelltAm: Date;
readonly aktion: string;
readonly entitaet: string;
readonly entitaetId: string;
readonly laufId: string | null;
readonly laufTitel: string | null;
readonly kundeName: string | null;
/** Leer bei einem Systemlauf – etwa dem Zeitplan des Verzeichnisabgleichs. */
readonly urheber: { readonly id: string; readonly name: string } | null;
readonly vorher: unknown;
readonly nachher: unknown;
};
export type ProtokollSeite = {
readonly zeilen: readonly ProtokollZeile[];
readonly gesamt: number;
readonly seite: number;
readonly seiten: number;
};
export type ProtokollFilter = {
readonly suche?: string;
readonly urheberId?: string;
readonly vonDatum?: string;
readonly bisDatum?: string;
readonly seite?: number;
};
export const PRO_SEITE = 50;
/**
* Eintraege ohne `kundeId` und ohne Lauf, deren Entitaet wirklich global ist.
*
* Alles andere mit leerer `kundeId` ist **nicht zuordenbar** — etwa ein Eintrag
* zu einem inzwischen geloeschten Geraet. Einem auf Kunden beschraenkten
* Benutzer wird das nicht gezeigt: `vorher`/`nachher` koennen einen Hostnamen
* enthalten, und bei einer Mandantengrenze wird im Zweifel zugemacht.
*/
const GLOBALE_ENTITAETEN = ["Einstellung", "Benutzer", "GeraetetypVorlage", "Vorlage", "VorlagenVersion"];
async function sichtBedingung(benutzer: HandelnderBenutzer): Promise<Prisma.ProtokollWhereInput[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
if (sichtbar === undefined) return [];
return [
{
OR: [
// Eintraege mit eigener Zuordnung.
{ kundeId: { in: [...sichtbar] } },
// Laufbezogene Eintraege: der Lauf ist die bessere Quelle, weil er die
// Zuordnung nicht verlieren kann.
{ lauf: { kundeId: { in: [...sichtbar] } } },
// Wirklich globale Eintraege.
{ kundeId: null, laufId: null, entitaet: { in: GLOBALE_ENTITAETEN } },
],
},
];
}
function datumBedingung(filter: ProtokollFilter): Prisma.ProtokollWhereInput[] {
const von = filter.vonDatum ? new Date(`${filter.vonDatum}T00:00:00`) : undefined;
// Bis **einschliesslich** des gewaehlten Tages: ein Filter „bis 4.10." soll
// den 4. enthalten, sonst sucht man den Eintrag von heute vergeblich.
const bis = filter.bisDatum ? new Date(`${filter.bisDatum}T23:59:59.999`) : undefined;
if (!von && !bis) return [];
return [
{
erstelltAm: {
...(von && !Number.isNaN(von.getTime()) ? { gte: von } : {}),
...(bis && !Number.isNaN(bis.getTime()) ? { lte: bis } : {}),
},
},
];
}
/**
* Eine Seite des Protokolls, neueste zuerst.
*
* Alle Teilbedingungen liegen in einem `AND`, wo keine die andere erreichen
* kann — dieselbe Vorsicht wie in den anderen Lesemodellen: im Review von #009
* hatte ein Suchfilter mit eigenem `OR` die Kundenbedingung ueberschrieben.
*/
export async function protokollSeite(
filter: ProtokollFilter,
benutzer: HandelnderBenutzer,
): Promise<ProtokollSeite> {
const suche = filter.suche?.trim() ?? "";
const bedingungen: Prisma.ProtokollWhereInput[] = [
...(await sichtBedingung(benutzer)),
...datumBedingung(filter),
...(suche.length > 0
? [
{
OR: [
{ aktion: { contains: suche, mode: "insensitive" as const } },
{ entitaet: { contains: suche, mode: "insensitive" as const } },
{ lauf: { titel: { contains: suche, mode: "insensitive" as const } } },
],
},
]
: []),
...(filter.urheberId ? [{ benutzerId: filter.urheberId }] : []),
];
const where: Prisma.ProtokollWhereInput = { AND: bedingungen };
const seite = Math.max(1, Math.trunc(filter.seite ?? 1));
const [gesamt, zeilen] = await Promise.all([
prisma.protokoll.count({ where }),
prisma.protokoll.findMany({
where,
orderBy: { erstelltAm: "desc" },
skip: (seite - 1) * PRO_SEITE,
take: PRO_SEITE,
select: {
id: true,
erstelltAm: true,
aktion: true,
entitaet: true,
entitaetId: true,
laufId: true,
vorher: true,
nachher: true,
lauf: { select: { titel: true, kunde: { select: { name: true } } } },
kunde: { select: { name: true } },
benutzer: { select: { id: true, name: true, email: true } },
},
}),
]);
return {
zeilen: zeilen.map((zeile) => ({
id: zeile.id,
erstelltAm: zeile.erstelltAm,
aktion: zeile.aktion,
entitaet: zeile.entitaet,
entitaetId: zeile.entitaetId,
laufId: zeile.laufId,
laufTitel: zeile.lauf?.titel ?? null,
kundeName: zeile.kunde?.name ?? zeile.lauf?.kunde.name ?? null,
urheber: zeile.benutzer
? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }
: null,
vorher: zeile.vorher,
nachher: zeile.nachher,
})),
gesamt,
seite,
seiten: Math.max(1, Math.ceil(gesamt / PRO_SEITE)),
};
}
/** Die Urheber, die im Protokoll vorkommen – für den Filter. */
export async function protokollUrheber(
benutzer: HandelnderBenutzer,
): Promise<readonly { readonly id: string; readonly name: string }[]> {
const bedingungen = await sichtBedingung(benutzer);
const zeilen = await prisma.protokoll.findMany({
where: { AND: [...bedingungen, { benutzerId: { not: null } }] },
distinct: ["benutzerId"],
select: { benutzer: { select: { id: true, name: true, email: true } } },
});
return zeilen
.flatMap((zeile) =>
zeile.benutzer === null
? []
: [{ id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }],
)
.sort((links, rechts) => links.name.localeCompare(rechts.name, "de"));
}
/**
* Die Einträge eines Laufs, für seine Abarbeitungsansicht.
*
* Eigene Funktion und nicht `protokollSeite` mit Filter: hier gilt die Sicht des
* Laufs, den der Aufrufer schon geprüft hat, und es gibt keine Blätterung —
* ein Lauf hat Dutzende Einträge, nicht Tausende.
*/
export async function protokollDesLaufs(laufId: string): Promise<readonly ProtokollZeile[]> {
const zeilen = await prisma.protokoll.findMany({
where: { laufId },
orderBy: { erstelltAm: "desc" },
select: {
id: true,
erstelltAm: true,
aktion: true,
entitaet: true,
entitaetId: true,
laufId: true,
vorher: true,
nachher: true,
benutzer: { select: { id: true, name: true, email: true } },
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
erstelltAm: zeile.erstelltAm,
aktion: zeile.aktion,
entitaet: zeile.entitaet,
entitaetId: zeile.entitaetId,
laufId: zeile.laufId,
laufTitel: null,
kundeName: null,
urheber: zeile.benutzer
? { id: zeile.benutzer.id, name: zeile.benutzer.name ?? zeile.benutzer.email }
: null,
vorher: zeile.vorher,
nachher: zeile.nachher,
}));
}
+4 -1
View File
@@ -568,7 +568,7 @@ async function abschlussSchreiben(
uebersprungen: number, uebersprungen: number,
): Promise<void> { ): Promise<void> {
await prisma.$transaction(async (tx) => { await prisma.$transaction(async (tx) => {
await tx.verzeichnisquelle.update({ const quelle = await tx.verzeichnisquelle.update({
where: { id: quelleId }, where: { id: quelleId },
data: { data: {
letzterAbgleichAm: new Date(), letzterAbgleichAm: new Date(),
@@ -578,6 +578,9 @@ async function abschlussSchreiben(
}); });
await tx.protokoll.create({ await tx.protokoll.create({
data: { 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", entitaet: "Verzeichnisquelle",
entitaetId: quelleId, entitaetId: quelleId,
aktion: aktion:
+3
View File
@@ -100,6 +100,7 @@ export async function abteilungAnlegen(
}); });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: abteilung.kundeId,
entitaet: "Abteilung", entitaet: "Abteilung",
entitaetId: abteilung.id, entitaetId: abteilung.id,
aktion: "Abteilung angelegt", aktion: "Abteilung angelegt",
@@ -169,6 +170,7 @@ export async function abteilungBearbeiten(
const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten }); const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: daten });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorher.kundeId,
entitaet: "Abteilung", entitaet: "Abteilung",
entitaetId: abteilungId, entitaetId: abteilungId,
aktion: `${geaendert.join(" und ")} geändert`, 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 } }); const nachher = await tx.abteilung.update({ where: { id: abteilungId }, data: { archiviert } });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorher.kundeId,
entitaet: "Abteilung", entitaet: "Abteilung",
entitaetId: abteilungId, entitaetId: abteilungId,
aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben", aktion: archiviert ? "Abteilung archiviert" : "Abteilung wieder freigegeben",
+1
View File
@@ -81,6 +81,7 @@ export async function einstellungenSpeichern(
await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } }); await prisma.kunde.update({ where: { id: vorher.eigeneOrganisation.id }, data: { name } });
await prisma.protokoll.create({ await prisma.protokoll.create({
data: { data: {
kundeId: vorher.eigeneOrganisation.id,
entitaet: "Kunde", entitaet: "Kunde",
entitaetId: vorher.eigeneOrganisation.id, entitaetId: vorher.eigeneOrganisation.id,
aktion: "Name der eigenen Organisation geändert", aktion: "Name der eigenen Organisation geändert",
+4
View File
@@ -197,6 +197,7 @@ async function geraetSchreiben(auftrag: SchreibAuftrag): Promise<Geraet> {
}); });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: geraet.kundeId,
entitaet: "Geraet", entitaet: "Geraet",
entitaetId: geraet.id, entitaetId: geraet.id,
aktion: auftrag.aktion, aktion: auftrag.aktion,
@@ -314,6 +315,7 @@ export async function geraetBearbeiten(
const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten }); const nachher = await tx.geraet.update({ where: { id: geraetId }, data: daten });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorher.kundeId,
entitaet: "Geraet", entitaet: "Geraet",
entitaetId: geraetId, entitaetId: geraetId,
aktion: `${geaendert.join(" und ")} geändert`, 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.geraet.update({ where: { id: geraetId }, data: { archiviert } });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: geraet.kundeId,
entitaet: "Geraet", entitaet: "Geraet",
entitaetId: geraetId, entitaetId: geraetId,
aktion: archiviert ? "Gerät archiviert" : "Gerät wieder freigegeben", 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.geraet.delete({ where: { id: geraetId } });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: geraet.kundeId,
entitaet: "Geraet", entitaet: "Geraet",
entitaetId: geraetId, entitaetId: geraetId,
aktion: "Gerät gelöscht", aktion: "Gerät gelöscht",
+3
View File
@@ -114,6 +114,7 @@ export async function kundeAnlegen(eingabe: NeuerKunde, durchBenutzerId: string)
await prisma.protokoll.create({ await prisma.protokoll.create({
data: { data: {
kundeId: kunde.id,
entitaet: "Kunde", entitaet: "Kunde",
entitaetId: kunde.id, entitaetId: kunde.id,
aktion: "Kunde angelegt", aktion: "Kunde angelegt",
@@ -181,6 +182,7 @@ export async function kundeBearbeiten(
await prisma.protokoll.create({ await prisma.protokoll.create({
data: { data: {
kundeId: id,
entitaet: "Kunde", entitaet: "Kunde",
entitaetId: id, entitaetId: id,
aktion: `${felder.map((feld) => FELD_TEXT[feld]).join(" und ")} geändert`, 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.kunde.update({ where: { id }, data: { archiviert } });
await prisma.protokoll.create({ await prisma.protokoll.create({
data: { data: {
kundeId: id,
entitaet: "Kunde", entitaet: "Kunde",
entitaetId: id, entitaetId: id,
aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben", aktion: archiviert ? "Kunde archiviert" : "Kunde wieder freigegeben",
+4
View File
@@ -131,6 +131,7 @@ export async function mitarbeiterAnlegen(
}); });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: mitarbeiter.kundeId,
entitaet: "Mitarbeiter", entitaet: "Mitarbeiter",
entitaetId: mitarbeiter.id, entitaetId: mitarbeiter.id,
aktion: vermerk?.aktion ?? "Mitarbeiter angelegt", aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
@@ -200,6 +201,7 @@ export async function mitarbeiterBearbeiten(
const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten }); const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: daten });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorher.kundeId,
entitaet: "Mitarbeiter", entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId, entitaetId: mitarbeiterId,
aktion: `${geaendert.join(" und ")} geändert`, aktion: `${geaendert.join(" und ")} geändert`,
@@ -260,6 +262,7 @@ export async function vorgesetztenSetzen(
}); });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorher.kundeId,
entitaet: "Mitarbeiter", entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId, entitaetId: mitarbeiterId,
aktion: gewuenscht === null ? "Vorgesetzter entfernt" : `Vorgesetzter auf ${name} gesetzt`, 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 } }); const nachher = await tx.mitarbeiter.update({ where: { id: mitarbeiterId }, data: { aktiv } });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorher.kundeId,
entitaet: "Mitarbeiter", entitaet: "Mitarbeiter",
entitaetId: mitarbeiterId, entitaetId: mitarbeiterId,
aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert", aktion: aktiv ? "Mitarbeiter wieder aktiviert" : "Mitarbeiter deaktiviert",
+3
View File
@@ -210,6 +210,7 @@ export async function quelleAnlegen(
}); });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: angelegt.kundeId,
entitaet: "Verzeichnisquelle", entitaet: "Verzeichnisquelle",
entitaetId: angelegt.id, entitaetId: angelegt.id,
aktion: "Verzeichnisquelle angelegt", aktion: "Verzeichnisquelle angelegt",
@@ -316,6 +317,7 @@ export async function quelleBearbeiten(
const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten }); const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorher.kundeId,
entitaet: "Verzeichnisquelle", entitaet: "Verzeichnisquelle",
entitaetId: id, entitaetId: id,
aktion: `${geaendert.join(", ")} geändert`, 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.verzeichnisquelle.delete({ where: { id } });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: quelle.kundeId,
entitaet: "Verzeichnisquelle", entitaet: "Verzeichnisquelle",
entitaetId: id, entitaetId: id,
aktion: "Verzeichnisquelle gelöscht", aktion: "Verzeichnisquelle gelöscht",
@@ -177,6 +177,7 @@ export async function vorlageImportieren(
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorlage.kundeId,
entitaet: "Vorlage", entitaet: "Vorlage",
entitaetId: vorlage.id, entitaetId: vorlage.id,
aktion: "Vorlage importiert", aktion: "Vorlage importiert",
+21 -2
View File
@@ -61,6 +61,7 @@ export async function vorlageAnlegen(
}); });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorlage.kundeId,
entitaet: "Vorlage", entitaet: "Vorlage",
entitaetId: vorlage.id, entitaetId: vorlage.id,
aktion: "Vorlage angelegt", aktion: "Vorlage angelegt",
@@ -147,6 +148,7 @@ export async function vorlageBearbeiten(
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten }); const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: nachher.kundeId,
entitaet: "Vorlage", entitaet: "Vorlage",
entitaetId: vorlageId, entitaetId: vorlageId,
aktion: `${geaendert.join(" und ")} geändert`, aktion: `${geaendert.join(" und ")} geändert`,
@@ -263,6 +265,12 @@ async function entwurfKopieren(
durchBenutzerId: string, durchBenutzerId: string,
): Promise<VorlagenVersion> { ): Promise<VorlagenVersion> {
return prisma.$transaction(async (tx) => { 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({ const entwurf = await tx.vorlagenVersion.create({
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF }, data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
}); });
@@ -304,6 +312,7 @@ async function entwurfKopieren(
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorlage.kundeId,
entitaet: "VorlagenVersion", entitaet: "VorlagenVersion",
entitaetId: entwurf.id, entitaetId: entwurf.id,
aktion: "Entwurf aus veröffentlichter Version erstellt", aktion: "Entwurf aus veröffentlichter Version erstellt",
@@ -485,7 +494,7 @@ export async function versionVeroeffentlichen(
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({ const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
where: { id: versionId }, where: { id: versionId },
select: { select: {
vorlage: { select: { archiviert: true, name: true } }, vorlage: { select: { archiviert: true, name: true, kundeId: true } },
_count: { select: { abschnitte: true } }, _count: { select: { abschnitte: true } },
}, },
}); });
@@ -518,6 +527,7 @@ export async function versionVeroeffentlichen(
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: kopf.vorlage.kundeId,
entitaet: "VorlagenVersion", entitaet: "VorlagenVersion",
entitaetId: versionId, entitaetId: versionId,
aktion: "Version veröffentlicht", aktion: "Version veröffentlicht",
@@ -545,7 +555,13 @@ export async function versionVeroeffentlichen(
export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> { export async function entwurfVerwerfen(versionId: string, durchBenutzerId: string): Promise<void> {
const version = await prisma.vorlagenVersion.findUnique({ const version = await prisma.vorlagenVersion.findUnique({
where: { id: versionId }, 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."); if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
versionMussEntwurfSein(version); versionMussEntwurfSein(version);
@@ -563,6 +579,7 @@ export async function entwurfVerwerfen(versionId: string, durchBenutzerId: strin
await tx.vorlagenVersion.delete({ where: { id: versionId } }); await tx.vorlagenVersion.delete({ where: { id: versionId } });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: version.vorlage.kundeId,
entitaet: "VorlagenVersion", entitaet: "VorlagenVersion",
entitaetId: versionId, entitaetId: versionId,
aktion: "Entwurf verworfen", aktion: "Entwurf verworfen",
@@ -661,6 +678,7 @@ export async function vorlageDuplizieren(
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: kopie.kundeId,
entitaet: "Vorlage", entitaet: "Vorlage",
entitaetId: kopie.id, entitaetId: kopie.id,
aktion: "Vorlage dupliziert", aktion: "Vorlage dupliziert",
@@ -693,6 +711,7 @@ export async function vorlageArchivieren(
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } }); await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
await tx.protokoll.create({ await tx.protokoll.create({
data: { data: {
kundeId: vorlage.kundeId,
entitaet: "Vorlage", entitaet: "Vorlage",
entitaetId: vorlageId, entitaetId: vorlageId,
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben", aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
+14
View File
@@ -313,6 +313,20 @@ test.describe("Onboarding", () => {
protokoll: Number(spuren[0]?.protokoll), protokoll: Number(spuren[0]?.protokoll),
}).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 }); }).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 --------------------------------------- // ---- Auch nicht auf dem Ausdruck ---------------------------------------
await page.goto(`/drucken/lauf/${laufId}`); await page.goto(`/drucken/lauf/${laufId}`);
const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " "); const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
+305
View File
@@ -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"]);
});
});