feat(vorlagen): Versionierung, Unveraenderlichkeit und JSON-Austausch (#23)
This commit was merged in pull request #23.
This commit is contained in:
@@ -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),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user