feat(vorlagen): Editor, Liste und Versionsgeschichte (#24)
This commit was merged in pull request #24.
This commit is contained in:
@@ -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.` };
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user