feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
This commit was merged in pull request #23.
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { z } from "zod";
|
||||
|
||||
/**
|
||||
* Zod mit deutschen Standardmeldungen (#009).
|
||||
*
|
||||
* Ohne diese Einstellung kommen bei fehlenden oder falsch typisierten Feldern
|
||||
* englische Saetze heraus („Invalid input: expected string“), und die landen
|
||||
* ueber die Server Actions unmittelbar in der Oberflaeche. Eigene `message`-Texte
|
||||
* helfen nur dort, wo man an sie gedacht hat — die Lokalisierung deckt auch alles
|
||||
* ab, woran niemand gedacht hat.
|
||||
*
|
||||
* Deshalb wird `z` **hier** eingestellt und von hier weitergereicht: ein Import
|
||||
* direkt aus "zod" umginge die Einstellung. Die ESLint-Regel
|
||||
* `no-restricted-imports` sorgt dafuer, dass das auffaellt.
|
||||
*/
|
||||
z.config(z.locales.de());
|
||||
|
||||
export { z };
|
||||
|
||||
// Was sonst noch aus Zod gebraucht wird, laeuft ebenfalls ueber diesen Pfad –
|
||||
// sonst muesste doch wieder jemand direkt aus "zod" importieren.
|
||||
export { ZodError } from "zod";
|
||||
export type { ZodIssue, ZodType } from "zod";
|
||||
@@ -0,0 +1,203 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
|
||||
/**
|
||||
* Aufbau einer Vorlagen-Version (#009).
|
||||
*
|
||||
* Ein Schema fuer drei Zwecke: der Editor schickt den Entwurf in dieser Form,
|
||||
* der JSON-Export schreibt sie, der Import liest sie. Gaebe es drei Schemata,
|
||||
* liefen sie frueher oder spaeter auseinander — und der Import waere der Ort, an
|
||||
* dem das erst auffiele.
|
||||
*
|
||||
* Framework-frei und ohne Datenbank, damit es sich ohne Next testen laesst.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein leeres Formularfeld kommt als `""` an, nicht als `undefined`.
|
||||
*
|
||||
* Ohne diese Vorstufe scheiterte ein nicht ausgefuelltes optionales Feld an
|
||||
* seiner eigenen Pruefung — beim Doku-Link etwa mit „keine gültige Adresse“,
|
||||
* obwohl gar keine angegeben werden sollte.
|
||||
*/
|
||||
function leerAlsUndefiniert<T extends z.ZodType>(schema: T) {
|
||||
return z.preprocess(
|
||||
(wert) => (typeof wert === "string" && wert.trim() === "" ? undefined : wert),
|
||||
schema.optional(),
|
||||
);
|
||||
}
|
||||
|
||||
/** Die Schritt-Typen des MVP. `AKTION` kommt in Phase 4 dazu. */
|
||||
export const SCHRITT_TYPEN = ["HAKEN", "TEXT", "ZAHL", "AUSWAHL", "DATUM", "BEFEHL"] as const;
|
||||
|
||||
export const VARIABLEN_TYPEN = ["TEXT", "ZAHL", "AUSWAHL", "DATUM"] as const;
|
||||
|
||||
export const SHELLS = ["powershell", "bash", "cmd"] as const;
|
||||
|
||||
/**
|
||||
* Obergrenzen fuer ein Austauschdokument.
|
||||
*
|
||||
* Ohne sie nimmt der Import beliebig grosse Listen an, laeuft in die
|
||||
* Transaktions-Zeitgrenze von Postgres und meldet einen Fehler, der nichts ueber
|
||||
* die Ursache sagt. Die Werte sind grosszuegig gewaehlt: die groesste Vorlage im
|
||||
* Seed hat sieben Abschnitte und rund dreissig Schritte.
|
||||
*/
|
||||
export const GRENZEN = {
|
||||
abschnitte: 200,
|
||||
schritteJeAbschnitt: 500,
|
||||
variablen: 200,
|
||||
text: 20_000,
|
||||
optionen: 200,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Typabhaengige Einzelheiten eines Schritts.
|
||||
*
|
||||
* Bewusst nachsichtig: unbekannte Schluessel bleiben erhalten, damit ein
|
||||
* Export aus einer spaeteren Fassung hier nicht stillschweigend Angaben
|
||||
* verliert. Was die Anwendung kennt, wird geprueft.
|
||||
*/
|
||||
export const SchrittKonfigSchema = z.looseObject({
|
||||
/** AUSWAHL: die Optionen in der angezeigten Reihenfolge. */
|
||||
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).optional(),
|
||||
/** ZAHL: Grenzen und Einheit. Ein Wert ausserhalb wird markiert, nicht blockiert. */
|
||||
min: z.number().optional(),
|
||||
max: z.number().optional(),
|
||||
einheit: z.string().trim().max(40).optional(),
|
||||
/** TEXT: mehrzeilig statt einzeilig. */
|
||||
mehrzeilig: z.boolean().optional(),
|
||||
/** BEFEHL: Shell, Mehrzeiligkeit und der Befehlstext mit Platzhaltern. */
|
||||
shell: z.enum(SHELLS).optional(),
|
||||
befehl: z.string().max(GRENZEN.text).optional(),
|
||||
});
|
||||
|
||||
export type SchrittKonfig = z.infer<typeof SchrittKonfigSchema>;
|
||||
|
||||
export const SchrittSchema = z.object({
|
||||
titel: z.string().trim().min(1, "Ein Schritt braucht einen Titel.").max(GRENZEN.text),
|
||||
/** Markdown, in der Abarbeitungsansicht ausklappbar. Kein rohes HTML. */
|
||||
hilfetext: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
|
||||
typ: z.enum(SCHRITT_TYPEN).default("HAKEN"),
|
||||
pflicht: z.boolean().default(false),
|
||||
konfig: SchrittKonfigSchema.default({}),
|
||||
/**
|
||||
* Nur http und https. `z.url()` allein liesse `javascript:` und `data:` durch —
|
||||
* und daraus wuerde in der Abarbeitungsansicht ein `<a href>`, also ein
|
||||
* gespeicherter Angriff, eingeschleust ueber eine importierte Datei.
|
||||
*/
|
||||
dokuUrl: leerAlsUndefiniert(
|
||||
z.url({
|
||||
protocol: /^https?$/,
|
||||
message: "Der Doku-Link muss eine http- oder https-Adresse sein.",
|
||||
}),
|
||||
),
|
||||
});
|
||||
|
||||
export type SchrittEingabe = z.input<typeof SchrittSchema>;
|
||||
|
||||
export const AbschnittSchema = z.object({
|
||||
titel: z.string().trim().min(1, "Ein Abschnitt braucht einen Titel.").max(GRENZEN.text),
|
||||
schritte: z
|
||||
.array(SchrittSchema)
|
||||
.max(
|
||||
GRENZEN.schritteJeAbschnitt,
|
||||
`Ein Abschnitt fasst höchstens ${GRENZEN.schritteJeAbschnitt} Schritte.`,
|
||||
)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export const VariableSchema = z.object({
|
||||
/**
|
||||
* Der Schluessel steht als `{{schluessel}}` in Titeln, Hilfetexten und
|
||||
* Befehlen. Deshalb nur Kleinbuchstaben, Ziffern und Unterstrich — alles
|
||||
* andere liesse sich im Platzhalter nicht sauber wiedererkennen.
|
||||
*/
|
||||
schluessel: z
|
||||
.string()
|
||||
.trim()
|
||||
.regex(
|
||||
/^[a-z][a-z0-9_]{0,39}$/,
|
||||
"Ein Variablenschlüssel beginnt mit einem Kleinbuchstaben und besteht aus Kleinbuchstaben, Ziffern und Unterstrich.",
|
||||
),
|
||||
beschriftung: z.string().trim().min(1, "Eine Variable braucht eine Beschriftung.").max(GRENZEN.text),
|
||||
typ: z.enum(VARIABLEN_TYPEN).default("TEXT"),
|
||||
optionen: z.array(z.string().trim().min(1).max(GRENZEN.text)).max(GRENZEN.optionen).default([]),
|
||||
pflicht: z.boolean().default(false),
|
||||
});
|
||||
|
||||
export type VariableEingabe = z.input<typeof VariableSchema>;
|
||||
|
||||
/**
|
||||
* Der gesamte Aufbau einer Version. Die Reihenfolge steckt in der Reihenfolge
|
||||
* der Listen; `position` wird daraus abgeleitet und nicht mitgeschickt — sonst
|
||||
* gaebe es zwei Wahrheiten, die auseinanderlaufen koennen.
|
||||
*/
|
||||
export const StrukturSchema = z.object({
|
||||
variablen: z
|
||||
.array(VariableSchema)
|
||||
.max(GRENZEN.variablen, `Eine Vorlage fasst höchstens ${GRENZEN.variablen} Variablen.`)
|
||||
.default([]),
|
||||
abschnitte: z
|
||||
.array(AbschnittSchema)
|
||||
.max(GRENZEN.abschnitte, `Eine Vorlage fasst höchstens ${GRENZEN.abschnitte} Abschnitte.`)
|
||||
.default([]),
|
||||
});
|
||||
|
||||
export type Struktur = z.infer<typeof StrukturSchema>;
|
||||
export type StrukturEingabe = z.input<typeof StrukturSchema>;
|
||||
|
||||
/** Fassung des Austauschformats. Wird beim Import geprueft. */
|
||||
export const FORMAT_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Was eine exportierte Datei enthaelt.
|
||||
*
|
||||
* Bewusst ohne Kennungen, Zeitstempel und Kundenbezug: eine exportierte Vorlage
|
||||
* soll sich in eine andere Laufzettel-Installation einspielen lassen, ohne
|
||||
* Verweise mitzubringen, die dort nichts bedeuten.
|
||||
*/
|
||||
export const AustauschSchema = z.object({
|
||||
format: z.literal(FORMAT_VERSION, {
|
||||
message: `Unbekanntes Format. Diese Fassung liest Format ${FORMAT_VERSION}.`,
|
||||
}),
|
||||
name: z.string().trim().min(1, "Die Vorlage braucht einen Namen.").max(200),
|
||||
beschreibung: leerAlsUndefiniert(z.string().trim().max(GRENZEN.text)),
|
||||
kategorie: leerAlsUndefiniert(z.string().trim().max(100)),
|
||||
schlagworte: z.array(z.string().trim().min(1).max(100)).max(50).default([]),
|
||||
/** Nur zur Information, welche Version der Quelle exportiert wurde. */
|
||||
version: z.number().int().positive().optional(),
|
||||
struktur: StrukturSchema,
|
||||
});
|
||||
|
||||
export type Austausch = z.infer<typeof AustauschSchema>;
|
||||
|
||||
/**
|
||||
* Alle Platzhalter `{{key}}` aus einem Text.
|
||||
*
|
||||
* Steht hier und nicht in #012, weil der Editor schon beim Bearbeiten warnen
|
||||
* soll, wenn ein Platzhalter zu keiner Variablen und zu keinem eingebauten Wert
|
||||
* gehoert. Das Ersetzen selbst kommt in #012.
|
||||
*/
|
||||
export function platzhalterFinden(text: string): readonly string[] {
|
||||
const gefunden = new Set<string>();
|
||||
for (const treffer of text.matchAll(/\{\{\s*([a-z][a-z0-9_]*)\s*\}\}/g)) {
|
||||
if (treffer[1]) gefunden.add(treffer[1]);
|
||||
}
|
||||
return [...gefunden];
|
||||
}
|
||||
|
||||
/** Alle Platzhalter, die irgendwo in einer Struktur vorkommen. */
|
||||
export function platzhalterDerStruktur(struktur: Struktur): readonly string[] {
|
||||
const gefunden = new Set<string>();
|
||||
const sammeln = (text: string | undefined) => {
|
||||
if (text) for (const name of platzhalterFinden(text)) gefunden.add(name);
|
||||
};
|
||||
for (const abschnitt of struktur.abschnitte) {
|
||||
sammeln(abschnitt.titel);
|
||||
for (const schritt of abschnitt.schritte) {
|
||||
sammeln(schritt.titel);
|
||||
sammeln(schritt.hilfetext);
|
||||
const befehl = schritt.konfig["befehl"];
|
||||
if (typeof befehl === "string") sammeln(befehl);
|
||||
}
|
||||
}
|
||||
return [...gefunden];
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { sicheresZiel, sitzungSetzen } from "@/server/auth/anmeldung";
|
||||
import { AnmeldeFehler, lokalAnmelden } from "@/server/auth/benutzer";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { Rolle } from "@/generated/prisma/enums";
|
||||
import {
|
||||
AnmeldeFehler,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import {
|
||||
EinstellungsFehler,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { KundenFehler, kundeAnlegen, kundeArchivieren, kundeBearbeiten } from "@/server/services/kunden";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
|
||||
|
||||
@@ -0,0 +1,347 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { StrukturSchema } from "@/lib/vorlagen-format";
|
||||
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
|
||||
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
VorlagenFehler,
|
||||
entwurfHolenOderErzeugen,
|
||||
entwurfSpeichern,
|
||||
entwurfVerwerfen,
|
||||
versionVeroeffentlichen,
|
||||
vorlageAnlegen,
|
||||
vorlageArchivieren,
|
||||
vorlageBearbeiten,
|
||||
vorlageDuplizieren,
|
||||
} from "@/server/services/vorlagen";
|
||||
import {
|
||||
AustauschFehler,
|
||||
vorlageExportieren,
|
||||
vorlageImportieren,
|
||||
} from "@/server/services/vorlagen-austausch";
|
||||
|
||||
/**
|
||||
* Server Actions der Vorlagen (#009).
|
||||
*
|
||||
* Jede beginnt mit `rechtErforderlich("vorlagenBearbeiten")` – das haben nur
|
||||
* ADMIN und REDAKTEUR. Techniker starten Laeufe, sie bauen keine Vorlagen.
|
||||
*/
|
||||
|
||||
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||
|
||||
/** Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine neutrale. */
|
||||
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||||
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
|
||||
return { fehler: neutral };
|
||||
}
|
||||
|
||||
const AnlegenSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
beschreibung: z.string().trim().optional(),
|
||||
kategorie: z.string().trim().optional(),
|
||||
schlagworte: z.string().trim().optional(),
|
||||
kundeId: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export async function vorlageAnlegenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const eingabe = AnlegenSchema.safeParse({
|
||||
name: formular.get("name"),
|
||||
beschreibung: formular.get("beschreibung") ?? undefined,
|
||||
kategorie: formular.get("kategorie") ?? undefined,
|
||||
schlagworte: formular.get("schlagworte") ?? undefined,
|
||||
kundeId: formular.get("kundeId") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
// Eine kundenspezifische Vorlage gibt es nur im Modus Dienstleistung – und nur
|
||||
// fuer einen Kunden, den dieser Benutzer sehen darf.
|
||||
const kundeId = eingabe.data.kundeId || null;
|
||||
if (kundeId) {
|
||||
if (!(await kundenEbeneSichtbar())) {
|
||||
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
|
||||
}
|
||||
await kundeZugriffPruefen(kundeId, benutzer);
|
||||
}
|
||||
|
||||
try {
|
||||
const { vorlage } = await vorlageAnlegen(
|
||||
{
|
||||
name: eingabe.data.name,
|
||||
...(eingabe.data.beschreibung ? { beschreibung: eingabe.data.beschreibung } : {}),
|
||||
...(eingabe.data.kategorie ? { kategorie: eingabe.data.kategorie } : {}),
|
||||
schlagworte: (eingabe.data.schlagworte ?? "")
|
||||
.split(",")
|
||||
.map((wort) => wort.trim())
|
||||
.filter(Boolean),
|
||||
kundeId,
|
||||
},
|
||||
benutzer.id,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: `„${vorlage.name}“ wurde angelegt.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function entwurfOeffnenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis & { readonly versionId?: string }> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const vorlageId = z.string().min(1).safeParse(formular.get("vorlageId"));
|
||||
if (!vorlageId.success) return { fehler: "Es fehlt die Vorlage." };
|
||||
|
||||
try {
|
||||
const entwurf = await entwurfHolenOderErzeugen(vorlageId.data, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { versionId: entwurf.id };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Der Entwurf konnte nicht geöffnet werden.");
|
||||
}
|
||||
}
|
||||
|
||||
const SpeichernSchema = z.object({
|
||||
versionId: z.string().min(1),
|
||||
struktur: StrukturSchema,
|
||||
/**
|
||||
* Stand, den der Editor beim Laden bekommen hat. Weicht er ab, hat jemand
|
||||
* anderes zwischenzeitlich gespeichert – und dessen Arbeit waere sonst weg.
|
||||
*/
|
||||
stand: z.coerce.date().optional(),
|
||||
});
|
||||
|
||||
export async function entwurfSpeichernAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis & { readonly stand?: string }> {
|
||||
await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const roh = formular.get("struktur");
|
||||
let geparst: unknown;
|
||||
try {
|
||||
geparst = typeof roh === "string" ? JSON.parse(roh) : undefined;
|
||||
} catch {
|
||||
return { fehler: "Der Aufbau kam nicht als gültiges JSON an." };
|
||||
}
|
||||
|
||||
const eingabe = SpeichernSchema.safeParse({
|
||||
versionId: formular.get("versionId"),
|
||||
struktur: geparst,
|
||||
stand: formular.get("stand") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
const erster = eingabe.error.issues[0];
|
||||
return {
|
||||
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const { stand } = await entwurfSpeichern(
|
||||
eingabe.data.versionId,
|
||||
eingabe.data.struktur,
|
||||
eingabe.data.stand,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Entwurf gespeichert.", stand: stand.toISOString() };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Der Entwurf konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
const BearbeitenSchema = z.object({
|
||||
vorlageId: z.string().min(1),
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
beschreibung: z.string().trim().optional(),
|
||||
kategorie: z.string().trim().optional(),
|
||||
schlagworte: z.string().trim().optional(),
|
||||
kundeId: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export async function vorlageBearbeitenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const eingabe = BearbeitenSchema.safeParse({
|
||||
vorlageId: formular.get("vorlageId"),
|
||||
name: formular.get("name"),
|
||||
beschreibung: formular.get("beschreibung") ?? undefined,
|
||||
kategorie: formular.get("kategorie") ?? undefined,
|
||||
schlagworte: formular.get("schlagworte") ?? undefined,
|
||||
kundeId: formular.get("kundeId") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const kundeId = eingabe.data.kundeId || null;
|
||||
if (kundeId) {
|
||||
if (!(await kundenEbeneSichtbar())) {
|
||||
return { fehler: "Kundenspezifische Vorlagen gibt es nur im Modus Dienstleistung." };
|
||||
}
|
||||
await kundeZugriffPruefen(kundeId, benutzer);
|
||||
}
|
||||
|
||||
try {
|
||||
await vorlageBearbeiten(
|
||||
eingabe.data.vorlageId,
|
||||
{
|
||||
name: eingabe.data.name,
|
||||
beschreibung: eingabe.data.beschreibung ?? null,
|
||||
kategorie: eingabe.data.kategorie ?? null,
|
||||
schlagworte: (eingabe.data.schlagworte ?? "")
|
||||
.split(",")
|
||||
.map((wort) => wort.trim())
|
||||
.filter(Boolean),
|
||||
kundeId,
|
||||
},
|
||||
benutzer.id,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Gespeichert." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function veroeffentlichenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const eingabe = z
|
||||
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
|
||||
.safeParse({
|
||||
versionId: formular.get("versionId"),
|
||||
aenderungsnotiz: formular.get("aenderungsnotiz") ?? undefined,
|
||||
});
|
||||
if (!eingabe.success) return { fehler: "Es fehlt die Version." };
|
||||
|
||||
try {
|
||||
const version = await versionVeroeffentlichen(
|
||||
eingabe.data.versionId,
|
||||
eingabe.data.aenderungsnotiz ?? null,
|
||||
benutzer.id,
|
||||
);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Version konnte nicht veröffentlicht werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function entwurfVerwerfenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const versionId = z.string().min(1).safeParse(formular.get("versionId"));
|
||||
if (!versionId.success) return { fehler: "Es fehlt die Version." };
|
||||
|
||||
try {
|
||||
await entwurfVerwerfen(versionId.data, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Entwurf verworfen." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Der Entwurf konnte nicht verworfen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function vorlageDuplizierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const eingabe = z
|
||||
.object({ vorlageId: z.string().min(1), name: z.string().trim().min(1, "Bitte einen Namen angeben.") })
|
||||
.safeParse({ vorlageId: formular.get("vorlageId"), name: formular.get("name") });
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
try {
|
||||
const kopie = await vorlageDuplizieren(eingabe.data.vorlageId, eingabe.data.name, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: `„${kopie.name}“ wurde als Entwurf angelegt.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht dupliziert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function vorlageArchivierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
const eingabe = z
|
||||
.object({ vorlageId: z.string().min(1), archiviert: z.enum(["true", "false"]) })
|
||||
.safeParse({ vorlageId: formular.get("vorlageId"), archiviert: formular.get("archiviert") });
|
||||
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||
|
||||
try {
|
||||
await vorlageArchivieren(eingabe.data.vorlageId, eingabe.data.archiviert === "true", benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return {};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
/** Gibt das Austauschdokument als JSON-Text zurueck – der Browser bietet es zum Sichern an. */
|
||||
export async function vorlageExportierenAktion(
|
||||
versionId: string,
|
||||
): Promise<{ readonly dateiname: string; readonly inhalt: string } | AktionsErgebnis> {
|
||||
await rechtErforderlich("vorlagenBearbeiten");
|
||||
try {
|
||||
const dokument = await vorlageExportieren(versionId);
|
||||
const sauber = dokument.name.replace(/[^\p{L}\p{N}]+/gu, "-").toLowerCase();
|
||||
return {
|
||||
dateiname: `vorlage-${sauber}-v${dokument.version ?? 1}.json`,
|
||||
inhalt: JSON.stringify(dokument, null, 2),
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht exportiert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function vorlageImportierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
|
||||
|
||||
const roh = formular.get("dokument");
|
||||
if (typeof roh !== "string" || !roh.trim()) {
|
||||
return { fehler: "Bitte eine Vorlagendatei auswählen oder den Inhalt einfügen." };
|
||||
}
|
||||
|
||||
let geparst: unknown;
|
||||
try {
|
||||
geparst = JSON.parse(roh);
|
||||
} catch {
|
||||
return { fehler: "Die Datei enthält kein gültiges JSON." };
|
||||
}
|
||||
|
||||
try {
|
||||
await vorlageImportieren(geparst, benutzer.id);
|
||||
revalidatePath("/vorlagen");
|
||||
return { erfolg: "Die Vorlage wurde als Entwurf importiert." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Vorlage konnte nicht importiert werden.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
|
||||
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Lesemodelle der Vorlagen (#009).
|
||||
*
|
||||
* Wie bei den Kunden wenden sie die Mandantensicht selbst an: eine Vorlage kann
|
||||
* kundenspezifisch sein, und wer den Kunden nicht sehen darf, soll auch seine
|
||||
* Vorlagen nicht sehen. Vorlagen ohne Kundenbezug gelten fuer alle.
|
||||
*/
|
||||
|
||||
export type VorlagenZeile = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly beschreibung: string | null;
|
||||
readonly kategorie: string | null;
|
||||
readonly schlagworte: readonly string[];
|
||||
readonly archiviert: boolean;
|
||||
readonly kundeId: string | null;
|
||||
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
|
||||
readonly veroeffentlichteVersion: number | null;
|
||||
readonly veroeffentlichtAm: Date | null;
|
||||
/** Wird gerade an einem Entwurf gearbeitet? */
|
||||
readonly hatEntwurf: boolean;
|
||||
readonly anzahlLaeufe: number;
|
||||
};
|
||||
|
||||
export type VorlagenFilter = {
|
||||
readonly suche?: string;
|
||||
readonly kategorie?: string;
|
||||
readonly schlagwort?: string;
|
||||
readonly auchArchivierte?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Mandantenbedingung als **eigener** Ausdruck, nicht als Objekt zum
|
||||
* Hineinspreizen.
|
||||
*
|
||||
* Vorher wurde `{ OR: [...] }` in dasselbe Literal gespreizt wie die Suche, die
|
||||
* ebenfalls ein `OR` mitbringt — der zweite Schluessel gewann, und die
|
||||
* Mandantengrenze war durch einen Buchstaben im Suchfeld umgangen. Alle
|
||||
* Teilbedingungen liegen deshalb in einem `AND`, wo keine die andere
|
||||
* ueberschreiben kann.
|
||||
*/
|
||||
async function kundenBedingung(benutzer: HandelnderBenutzer) {
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
if (sichtbar === undefined) return undefined;
|
||||
// Vorlagen ohne Kundenbezug gelten fuer alle.
|
||||
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
|
||||
}
|
||||
|
||||
export async function vorlagenListe(
|
||||
benutzer: HandelnderBenutzer,
|
||||
filter: VorlagenFilter = {},
|
||||
): Promise<readonly VorlagenZeile[]> {
|
||||
const suche = filter.suche?.trim();
|
||||
|
||||
const bedingungen = [
|
||||
await kundenBedingung(benutzer),
|
||||
filter.auchArchivierte ? undefined : { archiviert: false },
|
||||
filter.kategorie ? { kategorie: filter.kategorie } : undefined,
|
||||
filter.schlagwort ? { schlagworte: { has: filter.schlagwort } } : undefined,
|
||||
suche
|
||||
? {
|
||||
OR: [
|
||||
{ name: { contains: suche, mode: "insensitive" as const } },
|
||||
{ beschreibung: { contains: suche, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: undefined,
|
||||
].filter((bedingung) => bedingung !== undefined);
|
||||
|
||||
const zeilen = await prisma.vorlage.findMany({
|
||||
where: { AND: bedingungen },
|
||||
orderBy: [{ archiviert: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
beschreibung: true,
|
||||
kategorie: true,
|
||||
schlagworte: true,
|
||||
archiviert: true,
|
||||
kundeId: true,
|
||||
versionen: {
|
||||
select: {
|
||||
version: true,
|
||||
status: true,
|
||||
veroeffentlichtAm: true,
|
||||
_count: { select: { laeufe: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return zeilen.map((zeile) => {
|
||||
const veroeffentlichte = zeile.versionen
|
||||
.filter((v) => v.status === VersionStatus.VEROEFFENTLICHT)
|
||||
.sort((a, b) => b.version - a.version);
|
||||
const neueste = veroeffentlichte[0];
|
||||
return {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
beschreibung: zeile.beschreibung,
|
||||
kategorie: zeile.kategorie,
|
||||
schlagworte: zeile.schlagworte,
|
||||
archiviert: zeile.archiviert,
|
||||
kundeId: zeile.kundeId,
|
||||
veroeffentlichteVersion: neueste?.version ?? null,
|
||||
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
|
||||
hatEntwurf: zeile.versionen.some((v) => v.status === VersionStatus.ENTWURF),
|
||||
anzahlLaeufe: zeile.versionen.reduce((summe, v) => summe + v._count.laeufe, 0),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
|
||||
export async function vorlagenFilterwerte(
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<{ readonly kategorien: readonly string[]; readonly schlagworte: readonly string[] }> {
|
||||
const mandant = await kundenBedingung(benutzer);
|
||||
const zeilen = await prisma.vorlage.findMany({
|
||||
where: { AND: [{ archiviert: false }, ...(mandant ? [mandant] : [])] },
|
||||
select: { kategorie: true, schlagworte: true },
|
||||
});
|
||||
const kategorien = new Set<string>();
|
||||
const schlagworte = new Set<string>();
|
||||
for (const zeile of zeilen) {
|
||||
if (zeile.kategorie) kategorien.add(zeile.kategorie);
|
||||
for (const wort of zeile.schlagworte) schlagworte.add(wort);
|
||||
}
|
||||
return {
|
||||
kategorien: [...kategorien].sort((a, b) => a.localeCompare(b, "de")),
|
||||
schlagworte: [...schlagworte].sort((a, b) => a.localeCompare(b, "de")),
|
||||
};
|
||||
}
|
||||
|
||||
export type VersionsZeile = {
|
||||
readonly id: string;
|
||||
readonly version: number;
|
||||
readonly status: VersionStatus;
|
||||
readonly aenderungsnotiz: string | null;
|
||||
readonly veroeffentlichtAm: Date | null;
|
||||
readonly veroeffentlichtVon: string | null;
|
||||
readonly anzahlLaeufe: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Versionshistorie einer Vorlage, neueste zuerst.
|
||||
*
|
||||
* Wendet die Mandantensicht selbst an: wer die Vorlage nicht sehen darf, sieht
|
||||
* auch ihre Historie nicht. Eine leere Liste ist hier die richtige Antwort – wer
|
||||
* die Vorlage nicht kennt, soll nicht einmal erfahren, dass es sie gibt.
|
||||
*/
|
||||
export async function versionsHistorie(
|
||||
vorlageId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<readonly VersionsZeile[]> {
|
||||
const mandant = await kundenBedingung(benutzer);
|
||||
const versionen = await prisma.vorlagenVersion.findMany({
|
||||
where: { vorlageId, ...(mandant ? { vorlage: mandant } : {}) },
|
||||
orderBy: { version: "desc" },
|
||||
select: {
|
||||
id: true,
|
||||
version: true,
|
||||
status: true,
|
||||
aenderungsnotiz: true,
|
||||
veroeffentlichtAm: true,
|
||||
veroeffentlichtVon: { select: { name: true } },
|
||||
_count: { select: { laeufe: true } },
|
||||
},
|
||||
});
|
||||
return versionen.map((version) => ({
|
||||
id: version.id,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
aenderungsnotiz: version.aenderungsnotiz,
|
||||
veroeffentlichtAm: version.veroeffentlichtAm,
|
||||
veroeffentlichtVon: version.veroeffentlichtVon?.name ?? null,
|
||||
anzahlLaeufe: version._count.laeufe,
|
||||
}));
|
||||
}
|
||||
|
||||
export type VersionMitAufbau = {
|
||||
readonly id: string;
|
||||
readonly vorlageId: string;
|
||||
readonly vorlageName: string;
|
||||
readonly version: number;
|
||||
readonly status: VersionStatus;
|
||||
readonly struktur: Struktur;
|
||||
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
|
||||
readonly platzhalter: readonly string[];
|
||||
};
|
||||
|
||||
export async function versionMitAufbau(
|
||||
versionId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<VersionMitAufbau | undefined> {
|
||||
const mandant = await kundenBedingung(benutzer);
|
||||
const version = await prisma.vorlagenVersion.findFirst({
|
||||
where: { id: versionId, ...(mandant ? { vorlage: mandant } : {}) },
|
||||
include: {
|
||||
vorlage: { select: { id: true, name: true } },
|
||||
variablen: { orderBy: { position: "asc" } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!version) return undefined;
|
||||
|
||||
const struktur: 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((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
...(schritt.hilfetext === null ? {} : { hilfetext: schritt.hilfetext }),
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: (schritt.konfig ?? {}) as Record<string, unknown>,
|
||||
...(schritt.dokuUrl === null ? {} : { dokuUrl: schritt.dokuUrl }),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
|
||||
return {
|
||||
id: version.id,
|
||||
vorlageId: version.vorlage.id,
|
||||
vorlageName: version.vorlage.name,
|
||||
version: version.version,
|
||||
status: version.status,
|
||||
struktur,
|
||||
platzhalter: platzhalterDerStruktur(struktur),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { z } from "zod";
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
@@ -180,7 +180,7 @@ async function pruefungMit(
|
||||
moeglich: true,
|
||||
brauchtAngaben: true,
|
||||
folgen: [
|
||||
`Die bisherige eigene Organisation „${organisation.name}" wird zum ersten Kunden.`,
|
||||
`Die bisherige eigene Organisation „${organisation.name}“ wird zum ersten Kunden.`,
|
||||
"Alle Geräte, Personen, Abteilungen und Läufe bleiben daran hängen.",
|
||||
"Kunden werden zu einer sichtbaren Ebene: Kundenliste, Kundenwahl beim Start eines Laufs, Kundenkürzel auf den Etiketten.",
|
||||
],
|
||||
@@ -204,7 +204,7 @@ async function pruefungMit(
|
||||
moeglich: true,
|
||||
brauchtAngaben: false,
|
||||
folgen: [
|
||||
`„${einziger.name}" wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
|
||||
`„${einziger.name}“ wird zur eigenen Organisation und verschwindet aus der Oberfläche.`,
|
||||
"Alle Daten bleiben erhalten, nur die Kundenebene wird ausgeblendet.",
|
||||
"Archivierte Kunden bleiben archiviert und tauchen nicht wieder auf.",
|
||||
],
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
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: {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,727 @@
|
||||
import { Prisma, type Vorlage, type VorlagenVersion } from "@/generated/prisma/client";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
|
||||
|
||||
/**
|
||||
* Vorlagen und ihre Versionierung (#009).
|
||||
*
|
||||
* Die eine Regel, die alles traegt: **eine veroeffentlichte Version wird nie
|
||||
* wieder veraendert.** Ein Lauf verweist auf eine bestimmte Version und zeigt
|
||||
* deshalb auch in zehn Jahren noch genau die Schritte, die damals abgearbeitet
|
||||
* wurden. „Bearbeiten“ heisst deswegen nicht "aendern", sondern "tiefe Kopie als
|
||||
* neuer Entwurf".
|
||||
*
|
||||
* Je Vorlage gibt es hoechstens einen Entwurf. Das steht zusaetzlich als
|
||||
* partieller Eindeutigkeitsindex in der Datenbank (`VorlagenVersion_ein_entwurf`);
|
||||
* die Pruefung hier existiert fuer die bessere Meldung.
|
||||
*/
|
||||
|
||||
export class VorlagenFehler extends Error {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "VorlagenFehler";
|
||||
}
|
||||
}
|
||||
|
||||
export type NeueVorlage = {
|
||||
readonly name: string;
|
||||
readonly beschreibung?: string;
|
||||
readonly kategorie?: string;
|
||||
readonly schlagworte?: readonly string[];
|
||||
/** Nur im Modus DIENSTLEISTUNG: kundenspezifische Vorlage. Leer gilt fuer alle. */
|
||||
readonly kundeId?: string | null;
|
||||
};
|
||||
|
||||
function namePruefen(roh: string): string {
|
||||
const name = roh.trim();
|
||||
if (!name) throw new VorlagenFehler("Eine Vorlage braucht einen Namen.");
|
||||
return name;
|
||||
}
|
||||
|
||||
/** Legt eine Vorlage mit einem leeren Entwurf als Version 1 an. */
|
||||
export async function vorlageAnlegen(
|
||||
eingabe: NeueVorlage,
|
||||
durchBenutzerId: string,
|
||||
): Promise<{ readonly vorlage: Vorlage; readonly version: VorlagenVersion }> {
|
||||
const name = namePruefen(eingabe.name);
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const vorlage = await tx.vorlage.create({
|
||||
data: {
|
||||
name,
|
||||
beschreibung: eingabe.beschreibung?.trim() || null,
|
||||
kategorie: eingabe.kategorie?.trim() || null,
|
||||
schlagworte: [...(eingabe.schlagworte ?? [])],
|
||||
kundeId: eingabe.kundeId ?? null,
|
||||
},
|
||||
});
|
||||
const version = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId: vorlage.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlage.id,
|
||||
aktion: "Vorlage angelegt",
|
||||
benutzerId: durchBenutzerId,
|
||||
nachher: { name: vorlage.name, version: 1 },
|
||||
},
|
||||
});
|
||||
return { vorlage, version };
|
||||
});
|
||||
}
|
||||
|
||||
export type VorlagenAenderung = {
|
||||
readonly name?: string;
|
||||
readonly beschreibung?: string | null;
|
||||
readonly kategorie?: string | null;
|
||||
readonly schlagworte?: readonly string[];
|
||||
/** `null` macht aus einer kundenspezifischen Vorlage eine fuer alle. */
|
||||
readonly kundeId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Aendert die Kopfdaten einer Vorlage.
|
||||
*
|
||||
* Betrifft die Vorlage, nicht ihre Versionen: Name, Kategorie und Schlagworte
|
||||
* sind kein Teil des abgearbeiteten Inhalts und aendern deshalb nichts an der
|
||||
* Nachvollziehbarkeit eines Laufs.
|
||||
*/
|
||||
export async function vorlageBearbeiten(
|
||||
vorlageId: string,
|
||||
aenderung: VorlagenAenderung,
|
||||
durchBenutzerId: string,
|
||||
): Promise<Vorlage> {
|
||||
const vorher = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
|
||||
if (!vorher) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
|
||||
const daten: {
|
||||
name?: string;
|
||||
beschreibung?: string | null;
|
||||
kategorie?: string | null;
|
||||
schlagworte?: string[];
|
||||
kundeId?: string | null;
|
||||
} = {};
|
||||
const geaendert: string[] = [];
|
||||
|
||||
if (aenderung.name !== undefined) {
|
||||
const name = namePruefen(aenderung.name);
|
||||
if (name !== vorher.name) {
|
||||
daten.name = name;
|
||||
geaendert.push("Name");
|
||||
}
|
||||
}
|
||||
if (aenderung.beschreibung !== undefined) {
|
||||
const wert = aenderung.beschreibung?.trim() || null;
|
||||
if (wert !== vorher.beschreibung) {
|
||||
daten.beschreibung = wert;
|
||||
geaendert.push("Beschreibung");
|
||||
}
|
||||
}
|
||||
if (aenderung.kategorie !== undefined) {
|
||||
const wert = aenderung.kategorie?.trim() || null;
|
||||
if (wert !== vorher.kategorie) {
|
||||
daten.kategorie = wert;
|
||||
geaendert.push("Kategorie");
|
||||
}
|
||||
}
|
||||
if (aenderung.schlagworte !== undefined) {
|
||||
const wert = [...aenderung.schlagworte];
|
||||
if (JSON.stringify(wert) !== JSON.stringify(vorher.schlagworte)) {
|
||||
daten.schlagworte = wert;
|
||||
geaendert.push("Schlagworte");
|
||||
}
|
||||
}
|
||||
if (aenderung.kundeId !== undefined) {
|
||||
const wert = aenderung.kundeId || null;
|
||||
if (wert !== vorher.kundeId) {
|
||||
daten.kundeId = wert;
|
||||
geaendert.push("Kundenzuordnung");
|
||||
}
|
||||
}
|
||||
|
||||
if (geaendert.length === 0) return vorher;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.vorlage.update({ where: { id: vorlageId }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlageId,
|
||||
aktion: `${geaendert.join(" und ")} geändert`,
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: {
|
||||
name: vorher.name,
|
||||
kategorie: vorher.kategorie,
|
||||
schlagworte: vorher.schlagworte,
|
||||
kundeId: vorher.kundeId,
|
||||
},
|
||||
nachher: {
|
||||
name: nachher.name,
|
||||
kategorie: nachher.kategorie,
|
||||
schlagworte: nachher.schlagworte,
|
||||
kundeId: nachher.kundeId,
|
||||
},
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Version, an der gerade gearbeitet wird.
|
||||
*
|
||||
* Gibt es schon einen Entwurf, wird der zurueckgegeben — je Vorlage darf es nur
|
||||
* einen geben. Sonst entsteht eine **tiefe Kopie** der zuletzt veroeffentlichten
|
||||
* Version: Variablen, Abschnitte und Schritte samt `konfig` werden mitkopiert,
|
||||
* nicht verlinkt. Wuerden sie geteilt, aenderte eine Bearbeitung rueckwirkend
|
||||
* die veroeffentlichte Version und damit alle Laeufe, die daran haengen.
|
||||
*/
|
||||
export async function entwurfHolenOderErzeugen(
|
||||
vorlageId: string,
|
||||
durchBenutzerId: string,
|
||||
): Promise<VorlagenVersion> {
|
||||
const vorlage = await prisma.vorlage.findUnique({
|
||||
where: { id: vorlageId },
|
||||
select: { id: true, archiviert: true, name: true },
|
||||
});
|
||||
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
if (vorlage.archiviert) {
|
||||
throw new VorlagenFehler(`„${vorlage.name}“ ist archiviert. Erst wieder freigeben, dann bearbeiten.`);
|
||||
}
|
||||
|
||||
const vorhandener = await prisma.vorlagenVersion.findFirst({
|
||||
where: { vorlageId, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
if (vorhandener) return vorhandener;
|
||||
|
||||
const letzte = await prisma.vorlagenVersion.findFirst({
|
||||
where: { vorlageId, status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" },
|
||||
include: {
|
||||
variablen: { orderBy: { position: "asc" } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!letzte) {
|
||||
throw new VorlagenFehler("Diese Vorlage hat weder einen Entwurf noch eine veröffentlichte Version.");
|
||||
}
|
||||
|
||||
const hoechste = await prisma.vorlagenVersion.aggregate({
|
||||
where: { vorlageId },
|
||||
_max: { version: true },
|
||||
});
|
||||
const naechste = (hoechste._max.version ?? 0) + 1;
|
||||
|
||||
try {
|
||||
return await entwurfKopieren(vorlageId, naechste, letzte, durchBenutzerId);
|
||||
} catch (fehler) {
|
||||
// Zwei gleichzeitige Aufrufe: der zweite laeuft in den eindeutigen Index –
|
||||
// mal in den auf (vorlageId, version), mal in den auf "ein Entwurf". Beides
|
||||
// heisst dasselbe: jemand anderes war schneller, und der Entwurf liegt
|
||||
// bereits vor. Ihn zurueckzugeben ist richtiger als eine Fehlermeldung.
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2002") {
|
||||
const inzwischen = await prisma.vorlagenVersion.findFirst({
|
||||
where: { vorlageId, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
if (inzwischen) return inzwischen;
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
type VersionMitInhalt = {
|
||||
readonly version: number;
|
||||
readonly variablen: readonly {
|
||||
schluessel: string;
|
||||
beschriftung: string;
|
||||
typ: "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM";
|
||||
optionen: string[];
|
||||
pflicht: boolean;
|
||||
}[];
|
||||
readonly abschnitte: readonly {
|
||||
titel: string;
|
||||
schritte: readonly {
|
||||
titel: string;
|
||||
hilfetext: string | null;
|
||||
typ: "HAKEN" | "TEXT" | "ZAHL" | "AUSWAHL" | "DATUM" | "BEFEHL";
|
||||
pflicht: boolean;
|
||||
konfig: unknown;
|
||||
dokuUrl: string | null;
|
||||
}[];
|
||||
}[];
|
||||
};
|
||||
|
||||
async function entwurfKopieren(
|
||||
vorlageId: string,
|
||||
naechste: number,
|
||||
letzte: VersionMitInhalt,
|
||||
durchBenutzerId: string,
|
||||
): Promise<VorlagenVersion> {
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const entwurf = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId, version: naechste, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
if (letzte.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: letzte.variablen.map((variable, index) => ({
|
||||
versionId: entwurf.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: variable.optionen,
|
||||
pflicht: variable.pflicht,
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
|
||||
const kopie = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId: entwurf.id, titel: abschnitt.titel, position: index },
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
||||
abschnittId: kopie.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
// Json wird als Wert kopiert, nicht als Verweis geteilt.
|
||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||
dokuUrl: schritt.dokuUrl,
|
||||
position: schrittIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: entwurf.id,
|
||||
aktion: "Entwurf aus veröffentlichter Version erstellt",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { version: letzte.version },
|
||||
nachher: { version: naechste },
|
||||
},
|
||||
});
|
||||
return entwurf;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperrt die Versionszeile und gibt ihren Stand zurueck.
|
||||
*
|
||||
* Ohne die Sperre lief folgendes durch: Speichern liest den Status, findet
|
||||
* ENTWURF, und waehrend es schreibt, veroeffentlicht ein zweiter Aufruf die
|
||||
* Version. Danach trug eine veroeffentlichte Version Inhalt, der nach dem
|
||||
* Veroeffentlichen geschrieben wurde — genau die Regel, die das Archiv traegt,
|
||||
* durchbrochen an einem Doppelklick.
|
||||
*/
|
||||
async function versionSperren(
|
||||
tx: Prisma.TransactionClient,
|
||||
versionId: string,
|
||||
): Promise<{ status: VersionStatus; version: number; geaendertAm: Date }> {
|
||||
const zeilen = await tx.$queryRaw<{ status: VersionStatus; version: number; geaendertAm: Date }[]>`
|
||||
SELECT status, version, "geaendertAm" FROM "VorlagenVersion" WHERE id = ${versionId} FOR UPDATE
|
||||
`;
|
||||
const zeile = zeilen[0];
|
||||
if (!zeile) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||
return zeile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wirft, wenn an dieser Version nicht mehr geschrieben werden darf.
|
||||
*
|
||||
* Eine einzige Stelle, wie `laufMussVeraenderbarSein` beim Archiv: verstreute
|
||||
* Pruefungen werden irgendwann an einer Stelle vergessen.
|
||||
*/
|
||||
export function versionMussEntwurfSein(version: {
|
||||
readonly status: VersionStatus;
|
||||
readonly version: number;
|
||||
}): void {
|
||||
if (version.status !== VersionStatus.ENTWURF) {
|
||||
throw new VorlagenFehler(
|
||||
`Version ${version.version} ist veröffentlicht und damit unveränderlich. ` +
|
||||
"„Bearbeiten“ erzeugt daraus einen neuen Entwurf.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ersetzt den Aufbau eines Entwurfs vollstaendig.
|
||||
*
|
||||
* Bewusst als Ganzes statt Schritt fuer Schritt: der Editor haelt den ganzen
|
||||
* Baum im Speicher und sortiert per Drag & Drop, ein Teil-Update waere dort
|
||||
* staendig mit dem Sortieren im Streit. Alles in einer Transaktion — ein halb
|
||||
* gespeicherter Entwurf waere schlimmer als gar keiner.
|
||||
*
|
||||
* Kein Protokolleintrag: ein Entwurf wird beim Bauen dutzendfach gespeichert,
|
||||
* das Protokoll wuerde davon unlesbar. Festgehalten wird das Veroeffentlichen —
|
||||
* der Punkt, ab dem die Version unveraenderlich ist.
|
||||
*/
|
||||
export async function entwurfSpeichern(
|
||||
versionId: string,
|
||||
eingabe: StrukturEingabe,
|
||||
erwarteterStand?: Date,
|
||||
): Promise<{ readonly struktur: Struktur; readonly stand: Date }> {
|
||||
const gelesen = StrukturSchema.safeParse(eingabe);
|
||||
if (!gelesen.success) {
|
||||
const erster = gelesen.error.issues[0];
|
||||
throw new VorlagenFehler(
|
||||
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
|
||||
);
|
||||
}
|
||||
const struktur = gelesen.data;
|
||||
|
||||
const schluessel = struktur.variablen.map((variable) => variable.schluessel);
|
||||
const doppelt = schluessel.find((wert, index) => schluessel.indexOf(wert) !== index);
|
||||
if (doppelt) {
|
||||
throw new VorlagenFehler(`Den Variablenschlüssel „${doppelt}“ gibt es zweimal.`);
|
||||
}
|
||||
|
||||
const stand = await prisma.$transaction(async (tx) => {
|
||||
// Erst sperren, dann pruefen: sonst kann zwischen Pruefung und Schreiben
|
||||
// veroeffentlicht werden.
|
||||
const zeile = await versionSperren(tx, versionId);
|
||||
versionMussEntwurfSein(zeile);
|
||||
|
||||
if (erwarteterStand && zeile.geaendertAm.getTime() !== erwarteterStand.getTime()) {
|
||||
throw new VorlagenFehler(
|
||||
"Der Entwurf wurde zwischenzeitlich von jemand anderem gespeichert. " +
|
||||
"Bitte neu laden, sonst geht dessen Arbeit verloren.",
|
||||
);
|
||||
}
|
||||
|
||||
// Abschnitte raeumen ihre Schritte per Cascade mit ab. Haengt an einem
|
||||
// Schritt ein Schrittergebnis, verhindert der Fremdschluessel das Loeschen.
|
||||
// Das darf bei einem Entwurf nicht vorkommen – Laeufe haengen nur an
|
||||
// veroeffentlichten Versionen, siehe veroeffentlichteVersionPruefen (#013).
|
||||
// Sollte es doch passieren, wird der rohe Fremdschluesselfehler unten in eine
|
||||
// verstaendliche Meldung uebersetzt.
|
||||
try {
|
||||
await tx.vorlagenAbschnitt.deleteMany({ where: { versionId } });
|
||||
await tx.vorlagenVariable.deleteMany({ where: { versionId } });
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof Prisma.PrismaClientKnownRequestError && fehler.code === "P2003") {
|
||||
throw new VorlagenFehler(
|
||||
"An diesem Entwurf hängen bereits Schrittergebnisse. Er lässt sich nicht mehr umbauen.",
|
||||
);
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
if (struktur.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: struktur.variablen.map((variable, index) => ({
|
||||
versionId,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: [...variable.optionen],
|
||||
pflicht: variable.pflicht,
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
for (const [index, abschnitt] of struktur.abschnitte.entries()) {
|
||||
const angelegt = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId, titel: abschnitt.titel, position: index },
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
||||
abschnittId: angelegt.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext ?? null,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||
dokuUrl: schritt.dokuUrl ?? null,
|
||||
position: schrittIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Der Stand wandert weiter, damit ein zweiter Editor beim Speichern merkt,
|
||||
// dass hier inzwischen etwas passiert ist.
|
||||
const frisch = await tx.vorlagenVersion.update({
|
||||
where: { id: versionId },
|
||||
data: { geaendertAm: new Date() },
|
||||
select: { geaendertAm: true },
|
||||
});
|
||||
return frisch.geaendertAm;
|
||||
});
|
||||
|
||||
return { struktur, stand };
|
||||
}
|
||||
|
||||
/**
|
||||
* Veroeffentlicht einen Entwurf.
|
||||
*
|
||||
* Ab hier ist die Version unveraenderlich. Bestehende Laeufe bleiben davon
|
||||
* unberuehrt: sie verweisen auf die Version, mit der sie gestartet wurden.
|
||||
*/
|
||||
export async function versionVeroeffentlichen(
|
||||
versionId: string,
|
||||
aenderungsnotiz: string | null,
|
||||
durchBenutzerId: string,
|
||||
): Promise<VorlagenVersion> {
|
||||
const notiz = aenderungsnotiz?.trim() || null;
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const zeile = await versionSperren(tx, versionId);
|
||||
versionMussEntwurfSein(zeile);
|
||||
|
||||
const kopf = await tx.vorlagenVersion.findUniqueOrThrow({
|
||||
where: { id: versionId },
|
||||
select: {
|
||||
vorlage: { select: { archiviert: true, name: true } },
|
||||
_count: { select: { abschnitte: true } },
|
||||
},
|
||||
});
|
||||
if (kopf.vorlage.archiviert) {
|
||||
throw new VorlagenFehler(
|
||||
`„${kopf.vorlage.name}“ ist archiviert. Erst wieder freigeben, dann veröffentlichen.`,
|
||||
);
|
||||
}
|
||||
if (kopf._count.abschnitte === 0) {
|
||||
throw new VorlagenFehler(
|
||||
"Eine Vorlage ohne Abschnitte lässt sich nicht veröffentlichen — es gäbe nichts abzuarbeiten.",
|
||||
);
|
||||
}
|
||||
|
||||
// Bedingtes Update: trifft es keine Zeile, hat jemand anderes in derselben
|
||||
// Sekunde veroeffentlicht. Ohne diese Bedingung entstuenden zwei
|
||||
// Protokolleintraege fuer denselben Vorgang.
|
||||
const getroffen = await tx.vorlagenVersion.updateMany({
|
||||
where: { id: versionId, status: VersionStatus.ENTWURF },
|
||||
data: {
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
aenderungsnotiz: notiz,
|
||||
veroeffentlichtAm: new Date(),
|
||||
veroeffentlichtVonId: durchBenutzerId,
|
||||
},
|
||||
});
|
||||
if (getroffen.count === 0) {
|
||||
throw new VorlagenFehler("Diese Version wurde bereits veröffentlicht.");
|
||||
}
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: versionId,
|
||||
aktion: "Version veröffentlicht",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { status: VersionStatus.ENTWURF },
|
||||
nachher: {
|
||||
status: VersionStatus.VEROEFFENTLICHT,
|
||||
version: zeile.version,
|
||||
aenderungsnotiz: notiz,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return tx.vorlagenVersion.findUniqueOrThrow({ where: { id: versionId } });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verwirft einen Entwurf.
|
||||
*
|
||||
* Nur ein Entwurf laesst sich verwerfen, und nur wenn die Vorlage noch eine
|
||||
* veroeffentlichte Version hat — sonst bliebe eine Vorlage ohne jeden Inhalt
|
||||
* zurueck, die niemand mehr bearbeiten kann.
|
||||
*/
|
||||
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 },
|
||||
});
|
||||
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||
versionMussEntwurfSein(version);
|
||||
|
||||
const veroeffentlichte = await prisma.vorlagenVersion.count({
|
||||
where: { vorlageId: version.vorlageId, status: VersionStatus.VEROEFFENTLICHT },
|
||||
});
|
||||
if (veroeffentlichte === 0) {
|
||||
throw new VorlagenFehler(
|
||||
"Das ist die einzige Version dieser Vorlage. Statt sie zu verwerfen, archiviere die Vorlage.",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.vorlagenVersion.delete({ where: { id: versionId } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "VorlagenVersion",
|
||||
entitaetId: versionId,
|
||||
aktion: "Entwurf verworfen",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { version: version.version },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Dupliziert eine Vorlage samt ihrer zuletzt veroeffentlichten Version.
|
||||
*
|
||||
* Die Kopie beginnt als Entwurf: eine duplizierte Vorlage ist fast nie
|
||||
* unveraendert brauchbar, und sofort zu veroeffentlichen wuerde sie in der
|
||||
* Auswahl erscheinen lassen, bevor jemand sie angesehen hat.
|
||||
*/
|
||||
export async function vorlageDuplizieren(
|
||||
vorlageId: string,
|
||||
neuerName: string,
|
||||
durchBenutzerId: string,
|
||||
): Promise<Vorlage> {
|
||||
const name = namePruefen(neuerName);
|
||||
const quelle = await prisma.vorlage.findUnique({
|
||||
where: { id: vorlageId },
|
||||
include: {
|
||||
versionen: {
|
||||
where: { status: VersionStatus.VEROEFFENTLICHT },
|
||||
orderBy: { version: "desc" },
|
||||
take: 1,
|
||||
include: {
|
||||
variablen: { orderBy: { position: "asc" } },
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
include: { schritte: { orderBy: { position: "asc" } } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!quelle) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
|
||||
const letzte = quelle.versionen[0];
|
||||
if (!letzte) {
|
||||
throw new VorlagenFehler(
|
||||
"Diese Vorlage hat noch keine veröffentlichte Version, es gibt also nichts zu duplizieren.",
|
||||
);
|
||||
}
|
||||
|
||||
return prisma.$transaction(async (tx) => {
|
||||
const kopie = await tx.vorlage.create({
|
||||
data: {
|
||||
name,
|
||||
beschreibung: quelle.beschreibung,
|
||||
kategorie: quelle.kategorie,
|
||||
schlagworte: quelle.schlagworte,
|
||||
kundeId: quelle.kundeId,
|
||||
},
|
||||
});
|
||||
const version = await tx.vorlagenVersion.create({
|
||||
data: { vorlageId: kopie.id, version: 1, status: VersionStatus.ENTWURF },
|
||||
});
|
||||
|
||||
if (letzte.variablen.length > 0) {
|
||||
await tx.vorlagenVariable.createMany({
|
||||
data: letzte.variablen.map((variable, index) => ({
|
||||
versionId: version.id,
|
||||
schluessel: variable.schluessel,
|
||||
beschriftung: variable.beschriftung,
|
||||
typ: variable.typ,
|
||||
optionen: variable.optionen,
|
||||
pflicht: variable.pflicht,
|
||||
position: index,
|
||||
})),
|
||||
});
|
||||
}
|
||||
for (const [index, abschnitt] of letzte.abschnitte.entries()) {
|
||||
const abschnittKopie = await tx.vorlagenAbschnitt.create({
|
||||
data: { versionId: version.id, titel: abschnitt.titel, position: index },
|
||||
});
|
||||
if (abschnitt.schritte.length > 0) {
|
||||
await tx.vorlagenSchritt.createMany({
|
||||
data: abschnitt.schritte.map((schritt, schrittIndex) => ({
|
||||
abschnittId: abschnittKopie.id,
|
||||
titel: schritt.titel,
|
||||
hilfetext: schritt.hilfetext,
|
||||
typ: schritt.typ,
|
||||
pflicht: schritt.pflicht,
|
||||
konfig: schritt.konfig as Prisma.InputJsonValue,
|
||||
dokuUrl: schritt.dokuUrl,
|
||||
position: schrittIndex,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: kopie.id,
|
||||
aktion: "Vorlage dupliziert",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { ausVorlage: quelle.name, version: letzte.version },
|
||||
nachher: { name },
|
||||
},
|
||||
});
|
||||
return kopie;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Archivieren statt Loeschen.
|
||||
*
|
||||
* An einer Vorlage haengen Laeufe, und die muessen fuer immer nachvollziehbar
|
||||
* bleiben. Eine archivierte Vorlage erscheint nicht mehr in der Auswahl, ihre
|
||||
* Laeufe aber weiter im Archiv.
|
||||
*/
|
||||
export async function vorlageArchivieren(
|
||||
vorlageId: string,
|
||||
archiviert: boolean,
|
||||
durchBenutzerId: string,
|
||||
): Promise<void> {
|
||||
const vorlage = await prisma.vorlage.findUnique({ where: { id: vorlageId } });
|
||||
if (!vorlage) throw new VorlagenFehler("Diese Vorlage gibt es nicht.");
|
||||
if (vorlage.archiviert === archiviert) return;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.vorlage.update({ where: { id: vorlageId }, data: { archiviert } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Vorlage",
|
||||
entitaetId: vorlageId,
|
||||
aktion: archiviert ? "Vorlage archiviert" : "Vorlage wieder freigegeben",
|
||||
benutzerId: durchBenutzerId,
|
||||
vorher: { archiviert: vorlage.archiviert },
|
||||
nachher: { archiviert },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prueft, dass ein Lauf an dieser Version haengen darf.
|
||||
*
|
||||
* Steht hier, damit #013 die Regel „Laeufe verweisen immer auf eine
|
||||
* veroeffentlichte Version" nicht neu erfinden muss.
|
||||
*/
|
||||
export async function veroeffentlichteVersionPruefen(versionId: string): Promise<void> {
|
||||
const version = await prisma.vorlagenVersion.findUnique({
|
||||
where: { id: versionId },
|
||||
select: { status: true, version: true, vorlage: { select: { archiviert: true, name: true } } },
|
||||
});
|
||||
if (!version) throw new VorlagenFehler("Diese Version gibt es nicht.");
|
||||
if (version.status !== VersionStatus.VEROEFFENTLICHT) {
|
||||
throw new VorlagenFehler(
|
||||
"Ein Lauf braucht eine veröffentlichte Version. Version " + `${version.version} ist noch ein Entwurf.`,
|
||||
);
|
||||
}
|
||||
if (version.vorlage.archiviert) {
|
||||
throw new VorlagenFehler(`„${version.vorlage.name}“ ist archiviert und startet keine Läufe mehr.`);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user