feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
This commit was merged in pull request #45.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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** |
|
||||
|
||||
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</div>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -568,7 +568,7 @@ async function abschlussSchreiben(
|
||||
uebersprungen: number,
|
||||
): Promise<void> {
|
||||
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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -197,6 +197,7 @@ async function geraetSchreiben(auftrag: SchreibAuftrag): Promise<Geraet> {
|
||||
});
|
||||
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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -177,6 +177,7 @@ export async function vorlageImportieren(
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
kundeId: vorlage.kundeId,
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlage.id,
|
||||
aktion: "Vorlage importiert",
|
||||
|
||||
@@ -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<VorlagenVersion> {
|
||||
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<void> {
|
||||
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",
|
||||
|
||||
@@ -313,6 +313,20 @@ test.describe("Onboarding", () => {
|
||||
protokoll: Number(spuren[0]?.protokoll),
|
||||
}).toEqual({ ergebnisse: 0, kommentare: 0, protokoll: 0 });
|
||||
|
||||
/*
|
||||
* ---- Auch nicht in der Protokoll-Ansicht -------------------------------
|
||||
*
|
||||
* Seit #023 ist das Protokoll sichtbar, und die Seite zeigt `vorher` und
|
||||
* `nachher` als Text. Die Abfrage oben belegt, dass dort nichts steht; dieser
|
||||
* Griff belegt, dass die **Ansicht** nichts hinzuerfindet — etwa aus einer
|
||||
* aufgelösten Vorschau.
|
||||
*/
|
||||
await page.goto("/admin/protokoll");
|
||||
const protokollSeite = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
|
||||
expect(protokollSeite).not.toContain(passwort);
|
||||
// Gegenprobe: die Seite ist überhaupt da und zeigt Einträge.
|
||||
await expect(page.getByRole("heading", { name: "Protokoll", level: 1 })).toBeVisible();
|
||||
|
||||
// ---- Auch nicht auf dem Ausdruck ---------------------------------------
|
||||
await page.goto(`/drucken/lauf/${laufId}`);
|
||||
const blatt = (await page.locator("body").innerText()).replace(/\s+/gu, " ");
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { Betriebsmodus, VersionStatus } from "@/generated/prisma/enums";
|
||||
import { RECHTE } from "@/server/auth/rollen";
|
||||
import { protokollDesLaufs, protokollSeite, protokollUrheber } from "@/server/queries/protokoll";
|
||||
import { datenbankLeeren, datenbankVerfuegbar, testPrisma } from "./helpers/db";
|
||||
|
||||
/**
|
||||
* Das Protokoll ansehen (#023).
|
||||
*
|
||||
* Drei Fragen tragen das Ganze: sieht jemand nur seine Kunden, kommt ein
|
||||
* Techniker ueberhaupt heran, und steht irgendwo ein erzeugtes Passwort.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein auf Kunden beschraenkter Benutzer entsteht nur unter drei Bedingungen —
|
||||
* Modus DIENSTLEISTUNG, Rolle unter REDAKTEUR, und ein gesetztes
|
||||
* `OIDC_CUSTOMER_GROUP_PREFIX`. Ohne die dritte liefert `sichtbareKundenIds`
|
||||
* `undefined`, und jeder Filtertest waere ein Test ohne Gegenstand.
|
||||
*/
|
||||
const VORHER = process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
|
||||
async function aufbau() {
|
||||
const eigene = await testPrisma.kunde.create({
|
||||
data: { name: "Eigene Organisation", kuerzel: "EIGEN", istEigeneOrganisation: true },
|
||||
});
|
||||
const fremd = await testPrisma.kunde.create({ data: { name: "Fremd GmbH", kuerzel: "FREMD" } });
|
||||
await testPrisma.einstellung.create({
|
||||
data: { id: "global", modus: Betriebsmodus.DIENSTLEISTUNG },
|
||||
});
|
||||
const admin = await testPrisma.benutzer.create({
|
||||
data: { email: "admin@firma.example", name: "Admin", rolle: "ADMIN" },
|
||||
});
|
||||
const techniker = await testPrisma.benutzer.create({
|
||||
data: { email: "tech@firma.example", name: "Techniker", rolle: "TECHNIKER" },
|
||||
});
|
||||
// Nur an die eigene Organisation gebunden – „Fremd GmbH“ ist für ihn unsichtbar.
|
||||
await testPrisma.benutzerKunde.create({
|
||||
data: { benutzerId: techniker.id, kundeId: eigene.id },
|
||||
});
|
||||
return {
|
||||
eigene,
|
||||
fremd,
|
||||
admin: { id: admin.id, rolle: admin.rolle },
|
||||
techniker: { id: techniker.id, rolle: techniker.rolle },
|
||||
};
|
||||
}
|
||||
|
||||
describe("Recht auf das Protokoll", () => {
|
||||
it("liegt bei verwaltung, also nur bei ADMIN", () => {
|
||||
// Das Protokoll sagt, wer was getan hat, und ist damit selbst eine Auskunft
|
||||
// über Menschen. TECHNIKER und LESER kommen nicht auf die Seite.
|
||||
expect(RECHTE.verwaltung).toEqual(["ADMIN"]);
|
||||
expect(RECHTE.verwaltung).not.toContain("TECHNIKER");
|
||||
expect(RECHTE.verwaltung).not.toContain("LESER");
|
||||
});
|
||||
});
|
||||
|
||||
describe.runIf(datenbankVerfuegbar)("Protokoll ansehen", () => {
|
||||
beforeEach(async () => {
|
||||
await datenbankLeeren();
|
||||
process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = "kunde-";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (VORHER === undefined) delete process.env["OIDC_CUSTOMER_GROUP_PREFIX"];
|
||||
else process.env["OIDC_CUSTOMER_GROUP_PREFIX"] = VORHER;
|
||||
});
|
||||
|
||||
it("zeigt einem beschränkten Benutzer keinen Eintrag eines fremden Kunden", async () => {
|
||||
const { eigene, fremd, admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-eigen",
|
||||
aktion: "Gerät angelegt (eigen)",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
{
|
||||
kundeId: fremd.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-fremd",
|
||||
aktion: "Gerät angelegt (fremd)",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const fuerTechniker = await protokollSeite({}, techniker);
|
||||
expect(fuerTechniker.zeilen.map((zeile) => zeile.aktion)).toEqual(["Gerät angelegt (eigen)"]);
|
||||
expect(fuerTechniker.gesamt).toBe(1);
|
||||
|
||||
// Der ADMIN sieht beide – er ist auf keinen Kunden beschränkt.
|
||||
const fuerAdmin = await protokollSeite({}, admin);
|
||||
expect(fuerAdmin.gesamt).toBe(2);
|
||||
});
|
||||
|
||||
it("zeigt globale Einträge auch einem beschränkten Benutzer", async () => {
|
||||
const { admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Einstellung",
|
||||
entitaetId: "global",
|
||||
aktion: "Betriebsmodus gewechselt",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
});
|
||||
|
||||
const ergebnis = await protokollSeite({}, techniker);
|
||||
expect(ergebnis.zeilen.map((zeile) => zeile.aktion)).toEqual(["Betriebsmodus gewechselt"]);
|
||||
});
|
||||
|
||||
it("verbirgt einen nicht zuordenbaren Eintrag vor einem beschränkten Benutzer", async () => {
|
||||
// Ein Eintrag zu einem inzwischen gelöschten Gerät: ohne `kundeId` und ohne
|
||||
// Lauf lässt er sich keinem Kunden zuordnen. `vorher`/`nachher` können einen
|
||||
// Hostnamen enthalten, deshalb wird im Zweifel zugemacht (#023).
|
||||
const { admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "laengst-geloescht",
|
||||
aktion: "Gerät gelöscht",
|
||||
benutzerId: admin.id,
|
||||
vorher: { hostname: "FREMD-PC-007" },
|
||||
},
|
||||
});
|
||||
|
||||
expect((await protokollSeite({}, techniker)).gesamt).toBe(0);
|
||||
expect((await protokollSeite({}, admin)).gesamt).toBe(1);
|
||||
});
|
||||
|
||||
it("ordnet einen laufbezogenen Eintrag über den Lauf zu, auch ohne eigene kundeId", async () => {
|
||||
const { fremd, admin, techniker } = await aufbau();
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Irgendeine" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
await testPrisma.vorlagenVersion.update({
|
||||
where: { id: version.id },
|
||||
data: { status: VersionStatus.VEROEFFENTLICHT, veroeffentlichtAm: new Date() },
|
||||
});
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "Lauf bei Fremd", kundeId: fremd.id },
|
||||
});
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
laufId: lauf.id,
|
||||
entitaet: "Lauf",
|
||||
aktion: "Lauf angelegt",
|
||||
entitaetId: lauf.id,
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect((await protokollSeite({}, techniker)).gesamt).toBe(0);
|
||||
const fuerAdmin = await protokollSeite({}, admin);
|
||||
expect(fuerAdmin.zeilen[0]?.laufTitel).toBe("Lauf bei Fremd");
|
||||
expect(fuerAdmin.zeilen[0]?.kundeName).toBe("Fremd GmbH");
|
||||
});
|
||||
|
||||
it("nennt einen Eintrag ohne Urheber als Systemlauf, nicht als Lücke", async () => {
|
||||
const { eigene, admin } = await aufbau();
|
||||
await testPrisma.protokoll.create({
|
||||
data: {
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: "q-1",
|
||||
aktion: "Verzeichnis abgeglichen",
|
||||
benutzerId: null,
|
||||
},
|
||||
});
|
||||
|
||||
const ergebnis = await protokollSeite({}, admin);
|
||||
expect(ergebnis.zeilen[0]?.urheber).toBeNull();
|
||||
});
|
||||
|
||||
it("filtert nach Urheber, Zeitraum und Volltext", async () => {
|
||||
const { eigene, admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Benutzer",
|
||||
entitaetId: "b-1",
|
||||
aktion: "Rolle geändert",
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date("2026-09-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-1",
|
||||
aktion: "Gerät archiviert",
|
||||
benutzerId: techniker.id,
|
||||
erstelltAm: new Date("2026-10-04T10:00:00Z"),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect((await protokollSeite({ suche: "rolle" }, admin)).gesamt).toBe(1);
|
||||
expect((await protokollSeite({ urheberId: techniker.id }, admin)).gesamt).toBe(1);
|
||||
expect((await protokollSeite({ vonDatum: "2026-10-01" }, admin)).gesamt).toBe(1);
|
||||
// Einschliesslich des bis-Tages – sonst fehlt der Eintrag von heute.
|
||||
expect((await protokollSeite({ bisDatum: "2026-10-04" }, admin)).gesamt).toBe(2);
|
||||
expect((await protokollSeite({ vonDatum: "2026-09-01", bisDatum: "2026-09-01" }, admin)).gesamt).toBe(1);
|
||||
});
|
||||
|
||||
it("blättert und zählt richtig", async () => {
|
||||
const { eigene, admin } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: Array.from({ length: 55 }, (_, nummer) => ({
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: `g-${nummer}`,
|
||||
aktion: `Eintrag ${String(nummer).padStart(2, "0")}`,
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date(Date.UTC(2026, 9, 1, 0, nummer)),
|
||||
})),
|
||||
});
|
||||
|
||||
const erste = await protokollSeite({}, admin);
|
||||
expect(erste.gesamt).toBe(55);
|
||||
expect(erste.seiten).toBe(2);
|
||||
expect(erste.zeilen).toHaveLength(50);
|
||||
// Neueste zuerst.
|
||||
expect(erste.zeilen[0]?.aktion).toBe("Eintrag 54");
|
||||
|
||||
const zweite = await protokollSeite({ seite: 2 }, admin);
|
||||
expect(zweite.zeilen).toHaveLength(5);
|
||||
expect(zweite.zeilen.at(-1)?.aktion).toBe("Eintrag 00");
|
||||
});
|
||||
|
||||
it("nennt als Urheber nur die, die auch im sichtbaren Teil vorkommen", async () => {
|
||||
const { eigene, fremd, admin, techniker } = await aufbau();
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
kundeId: eigene.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-1",
|
||||
aktion: "eigen",
|
||||
benutzerId: techniker.id,
|
||||
},
|
||||
{
|
||||
kundeId: fremd.id,
|
||||
entitaet: "Geraet",
|
||||
entitaetId: "g-2",
|
||||
aktion: "fremd",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect((await protokollUrheber(techniker)).map((eintrag) => eintrag.name)).toEqual(["Techniker"]);
|
||||
expect((await protokollUrheber(admin)).map((eintrag) => eintrag.name).sort()).toEqual([
|
||||
"Admin",
|
||||
"Techniker",
|
||||
]);
|
||||
});
|
||||
|
||||
it("gibt am Lauf nur dessen Einträge, neueste zuerst", async () => {
|
||||
const { eigene, admin } = await aufbau();
|
||||
const vorlage = await testPrisma.vorlage.create({ data: { name: "Irgendeine" } });
|
||||
const version = await testPrisma.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
const lauf = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "Der Lauf", kundeId: eigene.id },
|
||||
});
|
||||
const anderer = await testPrisma.lauf.create({
|
||||
data: { vorlagenVersionId: version.id, titel: "Ein anderer", kundeId: eigene.id },
|
||||
});
|
||||
await testPrisma.protokoll.createMany({
|
||||
data: [
|
||||
{
|
||||
laufId: lauf.id,
|
||||
entitaet: "Lauf",
|
||||
entitaetId: lauf.id,
|
||||
aktion: "zuerst",
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date("2026-10-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
laufId: lauf.id,
|
||||
entitaet: "Lauf",
|
||||
entitaetId: lauf.id,
|
||||
aktion: "danach",
|
||||
benutzerId: admin.id,
|
||||
erstelltAm: new Date("2026-10-02T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
laufId: anderer.id,
|
||||
entitaet: "Lauf",
|
||||
entitaetId: anderer.id,
|
||||
aktion: "fremder Lauf",
|
||||
benutzerId: admin.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const eintraege = await protokollDesLaufs(lauf.id);
|
||||
expect(eintraege.map((eintrag) => eintrag.aktion)).toEqual(["danach", "zuerst"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user