feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #24.
This commit is contained in:
2026-09-25 08:23:22 +00:00
parent d8d5444e9f
commit e3d80e5982
65 changed files with 5454 additions and 103 deletions
+49 -7
View File
@@ -2,9 +2,9 @@
import { revalidatePath } from "next/cache";
import { z } from "@/lib/validierung";
import { StrukturSchema } from "@/lib/vorlagen-format";
import { StrukturSchema, stelleBenennen } from "@/lib/vorlagen-format";
import { kundeZugriffPruefen, kundenEbeneSichtbar } from "@/server/mandant";
import { rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import {
VorlagenFehler,
entwurfHolenOderErzeugen,
@@ -36,10 +36,40 @@ function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
if (fehler instanceof VorlagenFehler || fehler instanceof AustauschFehler) {
return { fehler: fehler.message };
}
if (fehler instanceof RechteFehler) {
return { fehler: RECHTE_MELDUNG };
}
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
return { fehler: neutral };
}
const RECHTE_MELDUNG =
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
/**
* Die Rechtepruefung so, dass ein abgelaufenes Cookie kein Datenverlust ist.
*
* `rechtErforderlich` wirft. Wird der Wurf nicht gefangen, endet er in Nexts
* Fehlergrenze — die Seite wird ausgehaengt, und im Editor ist damit alles weg,
* was noch nicht gespeichert war. Nach zwoelf Stunden laeuft die Sitzung von
* selbst ab; das trifft genau die Person, die lange an einer Vorlage sitzt.
*
* Deshalb: fangen, und dem Benutzer sagen, was zu tun ist. Der Editor behaelt
* seinen Zustand, weil die Aktion ganz normal ein Ergebnis zurueckgibt.
*/
async function angemeldetOderMeldung(): Promise<
{ readonly benutzerId: string } | { readonly fehler: string }
> {
try {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
return { benutzerId: benutzer.id };
} catch (fehler) {
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
throw fehler;
}
}
const AnlegenSchema = z.object({
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
beschreibung: z.string().trim().optional(),
@@ -127,7 +157,8 @@ export async function entwurfSpeichernAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis & { readonly stand?: string }> {
await rechtErforderlich("vorlagenBearbeiten");
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const roh = formular.get("struktur");
let geparst: unknown;
@@ -145,7 +176,7 @@ export async function entwurfSpeichernAktion(
if (!eingabe.success) {
const erster = eingabe.error.issues[0];
return {
fehler: erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
fehler: erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
};
}
@@ -197,6 +228,16 @@ export async function vorlageBearbeitenAktion(
await kundeZugriffPruefen(kundeId, benutzer);
}
/*
* Ein fehlendes Feld heisst „nicht angefasst“, ein leeres heisst „fuer alle“.
*
* Im Modus Intern zeigt das Formular gar keine Kundenwahl. Ohne diese
* Unterscheidung haette jedes Speichern der Kopfdaten die Kundenzuordnung
* stillschweigend geloescht — und nach einem Wechsel zurueck in den Modus
* Dienstleistung waere die Vorlage ploetzlich fuer alle sichtbar gewesen.
*/
const kundeAngegeben = formular.has("kundeId");
try {
await vorlageBearbeiten(
eingabe.data.vorlageId,
@@ -208,7 +249,7 @@ export async function vorlageBearbeitenAktion(
.split(",")
.map((wort) => wort.trim())
.filter(Boolean),
kundeId,
...(kundeAngegeben ? { kundeId } : {}),
},
benutzer.id,
);
@@ -223,7 +264,8 @@ export async function veroeffentlichenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const { benutzer } = await rechtErforderlich("vorlagenBearbeiten");
const zugang = await angemeldetOderMeldung();
if ("fehler" in zugang) return zugang;
const eingabe = z
.object({ versionId: z.string().min(1), aenderungsnotiz: z.string().trim().optional() })
.safeParse({
@@ -236,7 +278,7 @@ export async function veroeffentlichenAktion(
const version = await versionVeroeffentlichen(
eingabe.data.versionId,
eingabe.data.aenderungsnotiz ?? null,
benutzer.id,
zugang.benutzerId,
);
revalidatePath("/vorlagen");
return { erfolg: `Version ${version.version} ist veröffentlicht.` };
+82 -35
View File
@@ -1,3 +1,4 @@
import type { Prisma } from "@/generated/prisma/client";
import { VersionStatus } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterDerStruktur, type Struktur } from "@/lib/vorlagen-format";
@@ -19,6 +20,10 @@ export type VorlagenZeile = {
readonly schlagworte: readonly string[];
readonly archiviert: boolean;
readonly kundeId: string | null;
/** Name des Kunden, wenn die Vorlage kundenspezifisch ist – fuer die Anzeige. */
readonly kundeName: string | null;
/** Kennung des vorhandenen Entwurfs, damit die Liste direkt dorthin verlinkt. */
readonly entwurfVersionId: string | null;
/** Nummer der zuletzt veroeffentlichten Version, falls es eine gibt. */
readonly veroeffentlichteVersion: number | null;
readonly veroeffentlichtAm: Date | null;
@@ -51,6 +56,55 @@ async function kundenBedingung(benutzer: HandelnderBenutzer) {
return { OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] };
}
/**
* Auswahl und Abbildung stehen getrennt, damit Liste und Einzelabruf garantiert
* dieselben Felder liefern – zwei Modelle fuer dieselbe Zeile laufen mit der
* Zeit auseinander.
*/
const ZEILEN_AUSWAHL = {
id: true,
name: true,
beschreibung: true,
kategorie: true,
schlagworte: true,
archiviert: true,
kundeId: true,
kunde: { select: { name: true } },
versionen: {
select: {
id: true,
version: true,
status: true,
veroeffentlichtAm: true,
_count: { select: { laeufe: true } },
},
},
} satisfies Prisma.VorlageSelect;
type VorlagenRohzeile = Prisma.VorlageGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
function zeileBauen(zeile: VorlagenRohzeile): VorlagenZeile {
const neueste = zeile.versionen
.filter((version) => version.status === VersionStatus.VEROEFFENTLICHT)
.sort((a, b) => b.version - a.version)[0];
const entwurf = zeile.versionen.find((version) => version.status === VersionStatus.ENTWURF);
return {
id: zeile.id,
name: zeile.name,
beschreibung: zeile.beschreibung,
kategorie: zeile.kategorie,
schlagworte: zeile.schlagworte,
archiviert: zeile.archiviert,
kundeId: zeile.kundeId,
kundeName: zeile.kunde?.name ?? null,
entwurfVersionId: entwurf?.id ?? null,
veroeffentlichteVersion: neueste?.version ?? null,
veroeffentlichtAm: neueste?.veroeffentlichtAm ?? null,
hatEntwurf: entwurf !== undefined,
anzahlLaeufe: zeile.versionen.reduce((summe, version) => summe + version._count.laeufe, 0),
};
}
export async function vorlagenListe(
benutzer: HandelnderBenutzer,
filter: VorlagenFilter = {},
@@ -75,44 +129,29 @@ export async function vorlagenListe(
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 } },
},
},
},
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
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),
};
/**
* Eine einzelne Vorlage mit ihren Kopfdaten.
*
* Bewusst dasselbe Lesemodell wie die Liste: die Vorlagenseite zeigt dieselben
* Angaben, und zwei Modelle fuer dieselbe Zeile laufen mit der Zeit auseinander.
* `undefined` heisst „gibt es nicht oder darfst du nicht sehen“ — die
* Unterscheidung waere schon die Auskunft, die wir nicht geben wollen.
*/
export async function vorlageHolen(
vorlageId: string,
benutzer: HandelnderBenutzer,
): Promise<VorlagenZeile | undefined> {
const mandant = await kundenBedingung(benutzer);
const zeile = await prisma.vorlage.findFirst({
where: { id: vorlageId, ...(mandant ? mandant : {}) },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
/** Die Kategorien und Schlagworte, die tatsaechlich vorkommen – fuer die Filter. */
@@ -188,6 +227,12 @@ export type VersionMitAufbau = {
readonly vorlageName: string;
readonly version: number;
readonly status: VersionStatus;
/**
* Stand beim Laden. Der Editor schickt ihn beim Speichern zurueck; weicht er
* ab, hat jemand anderes zwischenzeitlich gespeichert (#009).
*/
readonly stand: Date;
readonly aenderungsnotiz: string | null;
readonly struktur: Struktur;
/** Platzhalter, die in Titeln, Hilfetexten und Befehlen vorkommen. */
readonly platzhalter: readonly string[];
@@ -238,6 +283,8 @@ export async function versionMitAufbau(
vorlageName: version.vorlage.name,
version: version.version,
status: version.status,
stand: version.geaendertAm,
aenderungsnotiz: version.aenderungsnotiz,
struktur,
platzhalter: platzhalterDerStruktur(struktur),
};
+2 -2
View File
@@ -1,7 +1,7 @@
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";
import { stelleBenennen, StrukturSchema, type Struktur, type StrukturEingabe } from "@/lib/vorlagen-format";
/**
* Vorlagen und ihre Versionierung (#009).
@@ -376,7 +376,7 @@ export async function entwurfSpeichern(
if (!gelesen.success) {
const erster = gelesen.error.issues[0];
throw new VorlagenFehler(
erster ? `${erster.path.join(".")}: ${erster.message}` : "Der Aufbau ist unvollständig.",
erster ? `${stelleBenennen(erster.path)}${erster.message}` : "Der Aufbau ist unvollständig.",
);
}
const struktur = gelesen.data;