Geschrieben wurde es seit dem Datenmodell, ansehen konnte es niemand: wer wissen wollte, wer wann eine Rolle geaendert hat, brauchte psql. Neue Seite Verwaltung -> Protokoll: Volltext ueber Aktion und Lauftitel, Filter nach Urheber und Zeitraum, fuenfzig Eintraege je Seite, neueste zuerst. Ein Eintrag ohne Urheber steht als Systemlauf da und nicht als Luecke -- der Zeitplan des Verzeichnisabgleichs hat keinen Menschen hinter sich. Am Lauf selbst stehen seine Eintraege eingeklappt in der Abarbeitungsansicht. Kein eigenes Recht dafuer: wer den Lauf sehen darf, darf auch sehen, was an ihm geschehen ist. Dafuer traegt Protokoll jetzt eine kundeId, wie CLAUDE.md es fuer jede kundenbezogene Zeile verlangt. Ohne sie liess sich die Mandantensicht nicht anwenden, ohne je Eintrag die Entitaet nachzuschlagen. Leer heisst global; laufbezogene Eintraege bleiben ohne, weil der Lauf die bessere Quelle ist. Die Migration fuehrt bestehende Eintraege nach -- an echten Daten gefahren, 59 von 733 bekamen einen Kunden. Ein Eintrag, der sich keinem Kunden zuordnen laesst, wird einem beschraenkten Benutzer nicht gezeigt: vorher/nachher koennen einen Hostnamen enthalten, und bei einer Mandantengrenze wird im Zweifel zugemacht. Keine Tabelle, sondern Karten: sieben Spalten sind bei 390 px nicht darstellbar, ohne horizontal zu scrollen. Im gebauten Stand durchgeklickt: blaettern, filtern, Werte aufklappen, Abschnitt am Lauf, 390 px auf beiden Seiten ohne Scrollen. Closes #35
247 lines
9.3 KiB
TypeScript
247 lines
9.3 KiB
TypeScript
import type { ZodError } from "@/lib/validierung";
|
||
import type { Prisma, VorlagenSchritt } from "@/generated/prisma/client";
|
||
import { VersionStatus } from "@/generated/prisma/enums";
|
||
import { prisma } from "@/lib/db";
|
||
import { AustauschSchema, FORMAT_VERSION, type Austausch } from "@/lib/vorlagen-format";
|
||
|
||
/**
|
||
* JSON-Austausch fuer Vorlagen (#009).
|
||
*
|
||
* Export und Import sind die Bruecke zwischen zwei Installationen: eine Vorlage
|
||
* verlaesst die eine als Datei und kommt in der anderen als Entwurf an. Deshalb
|
||
* traegt das Dokument keine Kennungen, keine Zeitstempel und keinen Kundenbezug
|
||
* — Verweise, die in der Zielinstallation nichts bedeuten wuerden. Das Format
|
||
* selbst steht in `docs/vorlagen-format.md`, die Schemata in
|
||
* `src/lib/vorlagen-format.ts`.
|
||
*
|
||
* Rechte werden hier nicht geprueft, das tut die Server Action darueber.
|
||
*/
|
||
|
||
export class AustauschFehler extends Error {
|
||
constructor(nachricht: string) {
|
||
super(nachricht);
|
||
this.name = "AustauschFehler";
|
||
}
|
||
}
|
||
|
||
/** Ein Schritt so, wie er im Austauschdokument steht — ohne Kennung und Position. */
|
||
type AustauschSchritt = Austausch["struktur"]["abschnitte"][number]["schritte"][number];
|
||
|
||
/**
|
||
* Baut das Austauschdokument einer Version. Liest nur.
|
||
*
|
||
* Sortiert wird nach `position`, bei Gleichstand nach `id`: zwei Zeilen mit
|
||
* derselben Position duerfen die Reihenfolge nicht dem Zufall der Datenbank
|
||
* ueberlassen, sonst waere ein Export nicht wiederholbar.
|
||
*/
|
||
export async function vorlageExportieren(versionId: string): Promise<Austausch> {
|
||
const version = await prisma.vorlagenVersion.findUnique({
|
||
where: { id: versionId },
|
||
include: {
|
||
vorlage: true,
|
||
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
|
||
abschnitte: {
|
||
orderBy: [{ position: "asc" }, { id: "asc" }],
|
||
include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
|
||
},
|
||
},
|
||
});
|
||
if (!version) {
|
||
throw new AustauschFehler("Diese Vorlagen-Version gibt es nicht (mehr).");
|
||
}
|
||
|
||
const { vorlage } = version;
|
||
return {
|
||
format: FORMAT_VERSION,
|
||
name: vorlage.name,
|
||
// Leere Felder werden weggelassen statt als null geschrieben: eine von Hand
|
||
// geschriebene Datei sieht genauso aus wie eine exportierte.
|
||
...(vorlage.beschreibung ? { beschreibung: vorlage.beschreibung } : {}),
|
||
...(vorlage.kategorie ? { kategorie: vorlage.kategorie } : {}),
|
||
schlagworte: [...vorlage.schlagworte],
|
||
version: version.version,
|
||
struktur: {
|
||
variablen: version.variablen.map((variable) => ({
|
||
schluessel: variable.schluessel,
|
||
beschriftung: variable.beschriftung,
|
||
typ: variable.typ,
|
||
optionen: [...variable.optionen],
|
||
pflicht: variable.pflicht,
|
||
})),
|
||
abschnitte: version.abschnitte.map((abschnitt) => ({
|
||
titel: abschnitt.titel,
|
||
schritte: abschnitt.schritte.map(schrittAbbilden),
|
||
})),
|
||
},
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Bildet eine Schritt-Zeile auf das Austauschformat ab.
|
||
*
|
||
* `konfig` ist in der Datenbank ein Json-Feld und kann alles enthalten.
|
||
*
|
||
* Frueher lief es durch `SchrittKonfigSchema.safeParse` und wurde bei **jeder**
|
||
* Beanstandung komplett verworfen — ein `shell: "zsh"` aus einer spaeteren
|
||
* Fassung kostete lautlos auch den `befehl`. Genau das Gegenteil dessen, was das
|
||
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
|
||
* kein Objekt ist, gilt als leer.
|
||
*/
|
||
function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
|
||
const roh = schritt.konfig;
|
||
const konfig =
|
||
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};
|
||
return {
|
||
titel: schritt.titel,
|
||
...(schritt.hilfetext ? { hilfetext: schritt.hilfetext } : {}),
|
||
typ: schritt.typ,
|
||
pflicht: schritt.pflicht,
|
||
konfig,
|
||
...(schritt.dokuUrl ? { dokuUrl: schritt.dokuUrl } : {}),
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Legt aus einem Austauschdokument eine neue Vorlage mit einem Entwurf an.
|
||
* Nimmt unbekannte Eingaben entgegen und prueft sie mit AustauschSchema.
|
||
*
|
||
* Immer eine **neue** Vorlage, nie in eine bestehende hinein, und die erste
|
||
* Version ist `ENTWURF`: eine eingespielte Vorlage wird bewusst nicht sofort
|
||
* veroeffentlicht, der Redakteur sieht sie sich erst an. Ein Name, den es schon
|
||
* gibt, ist kein Fehler — Vorlagennamen sind nicht eindeutig, und ein
|
||
* angehaengtes „(2)“ waere eine Aenderung, um die niemand gebeten hat.
|
||
*/
|
||
export async function vorlageImportieren(
|
||
dokument: unknown,
|
||
durchBenutzerId: string,
|
||
): Promise<{ readonly vorlageId: string; readonly versionId: string }> {
|
||
const austausch = dokumentPruefen(dokument);
|
||
variablenschluesselPruefen(austausch.struktur.variablen);
|
||
|
||
// Eine Vorlage ist erst mit Variablen, Abschnitten und Schritten sinnvoll.
|
||
// Deshalb alles in einer Transaktion: ein Fehler auf halbem Weg darf keine
|
||
// halbe Vorlage hinterlassen, die jemand in der Liste findet.
|
||
return prisma.$transaction(async (tx) => {
|
||
const vorlage = await tx.vorlage.create({
|
||
data: {
|
||
name: austausch.name,
|
||
beschreibung: austausch.beschreibung ?? null,
|
||
kategorie: austausch.kategorie ?? null,
|
||
schlagworte: [...austausch.schlagworte],
|
||
},
|
||
});
|
||
|
||
const version = await tx.vorlagenVersion.create({
|
||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||
});
|
||
|
||
// `position` kommt aus der Reihenfolge der Listen und wird nicht
|
||
// mitgeschickt — sonst gaebe es zwei Wahrheiten.
|
||
if (austausch.struktur.variablen.length > 0) {
|
||
await tx.vorlagenVariable.createMany({
|
||
data: austausch.struktur.variablen.map((variable, position) => ({
|
||
versionId: version.id,
|
||
schluessel: variable.schluessel,
|
||
beschriftung: variable.beschriftung,
|
||
typ: variable.typ,
|
||
optionen: [...variable.optionen],
|
||
pflicht: variable.pflicht,
|
||
position,
|
||
})),
|
||
});
|
||
}
|
||
|
||
let schritteGesamt = 0;
|
||
for (const [position, abschnitt] of austausch.struktur.abschnitte.entries()) {
|
||
const zeile = await tx.vorlagenAbschnitt.create({
|
||
data: { versionId: version.id, titel: abschnitt.titel, position },
|
||
});
|
||
if (abschnitt.schritte.length === 0) continue;
|
||
await tx.vorlagenSchritt.createMany({
|
||
data: abschnitt.schritte.map((schritt, schrittPosition) => ({
|
||
abschnittId: zeile.id,
|
||
titel: schritt.titel,
|
||
hilfetext: schritt.hilfetext ?? null,
|
||
typ: schritt.typ,
|
||
pflicht: schritt.pflicht,
|
||
// Das nachsichtige Schema traegt eine Index-Signatur auf `unknown`,
|
||
// die Prisma nicht als Json-Wert erkennt. Der Inhalt kommt aus
|
||
// geparstem JSON, ist also einer.
|
||
konfig: schritt.konfig as Prisma.InputJsonObject,
|
||
dokuUrl: schritt.dokuUrl ?? null,
|
||
position: schrittPosition,
|
||
})),
|
||
});
|
||
schritteGesamt += abschnitt.schritte.length;
|
||
}
|
||
|
||
await tx.protokoll.create({
|
||
data: {
|
||
kundeId: vorlage.kundeId,
|
||
entitaet: "Vorlage",
|
||
entitaetId: vorlage.id,
|
||
aktion: "Vorlage importiert",
|
||
benutzerId: durchBenutzerId,
|
||
nachher: {
|
||
name: vorlage.name,
|
||
version: version.version,
|
||
abschnitte: austausch.struktur.abschnitte.length,
|
||
schritte: schritteGesamt,
|
||
variablen: austausch.struktur.variablen.length,
|
||
},
|
||
},
|
||
});
|
||
|
||
return { vorlageId: vorlage.id, versionId: version.id };
|
||
});
|
||
}
|
||
|
||
/** Prueft das Dokument gegen das Schema und uebersetzt Zod-Fehler in eine lesbare Meldung. */
|
||
function dokumentPruefen(dokument: unknown): Austausch {
|
||
// Eigene Meldung fuer den haeufigsten Fehlgriff – eine Datei, die gar kein
|
||
// JSON-Objekt enthaelt. Zod sagt dazu nur einen englischen Satz.
|
||
if (typeof dokument !== "object" || dokument === null || Array.isArray(dokument)) {
|
||
throw new AustauschFehler("Das Austauschdokument ist kein JSON-Objekt.");
|
||
}
|
||
const ergebnis = AustauschSchema.safeParse(dokument);
|
||
if (!ergebnis.success) {
|
||
throw new AustauschFehler(ersteBeanstandung(ergebnis.error));
|
||
}
|
||
return ergebnis.data;
|
||
}
|
||
|
||
/**
|
||
* Nennt die erste Beanstandung samt Pfad.
|
||
*
|
||
* Eine rohe Zod-Ausgabe hilft niemandem: wer eine Datei von Hand schreibt,
|
||
* braucht die Stelle („abschnitte.0.schritte.2.titel“) und einen Satz dazu, und
|
||
* zwar den ersten — eine Liste von zwanzig Folgefehlern liest niemand.
|
||
*/
|
||
function ersteBeanstandung(fehler: ZodError): string {
|
||
const problem = fehler.issues[0];
|
||
if (!problem) return "Das Austauschdokument passt nicht zum Format.";
|
||
const pfad = problem.path.map(String).join(".");
|
||
return pfad.length === 0 ? problem.message : `${pfad}: ${problem.message}`;
|
||
}
|
||
|
||
/**
|
||
* Weist doppelte Variablenschluessel ab.
|
||
*
|
||
* Die Datenbank haelt dasselbe ueber `@@unique([versionId, schluessel])` fest;
|
||
* diese Pruefung existiert fuer die Meldung, denn der Fehler des Index nennt
|
||
* einen Spaltennamen und keinen Schluessel. Ausserdem faellt er erst mitten in
|
||
* der Transaktion auf, also nach halber Arbeit.
|
||
*/
|
||
function variablenschluesselPruefen(variablen: readonly { readonly schluessel: string }[]): void {
|
||
const gesehen = new Set<string>();
|
||
for (const variable of variablen) {
|
||
if (gesehen.has(variable.schluessel)) {
|
||
throw new AustauschFehler(
|
||
`Den Variablenschlüssel „${variable.schluessel}“ gibt es zweimal. ` +
|
||
"Jeder Schlüssel darf in einer Vorlage nur einmal stehen.",
|
||
);
|
||
}
|
||
gesehen.add(variable.schluessel);
|
||
}
|
||
}
|