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
+1
View File
@@ -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
+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 { 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>
);
}
+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,
): 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:
+3
View File
@@ -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",
+1
View File
@@ -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",
+4
View File
@@ -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",
+3
View File
@@ -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",
+4
View File
@@ -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",
+3
View File
@@ -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",
+21 -2
View File
@@ -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",