feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

This commit was merged in pull request #23.
This commit is contained in:
2026-09-25 06:10:01 +00:00
parent ecc3c579df
commit d8d5444e9f
41 changed files with 4006 additions and 104 deletions
+244
View File
@@ -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),
};
}