feat(schnellstart): Kacheln je Geraetetyp in der Oberflaeche pflegen (#44)
This commit was merged in pull request #44.
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { SchnellstartFehler, schnellstartSetzen } from "@/server/services/schnellstart";
|
||||
|
||||
/** Server Action für den Schnellstart (#022). Recht prüfen, Zod, Service rufen. */
|
||||
|
||||
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||
|
||||
const Schema = z.object({
|
||||
geraeteTyp: z.enum(GeraeteTyp),
|
||||
/**
|
||||
* Die ganze Liste in Anzeigereihenfolge, als ein Feld mit Zeilenumbrüchen.
|
||||
*
|
||||
* Eine leere Liste ist eine Angabe: „für diesen Typ gibt es keinen
|
||||
* Schnellstart“. Deshalb kein `min(1)`.
|
||||
*/
|
||||
vorlageIds: z.string(),
|
||||
});
|
||||
|
||||
export async function schnellstartSpeichernAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("verwaltung");
|
||||
|
||||
const eingabe = Schema.safeParse({
|
||||
geraeteTyp: formular.get("geraeteTyp"),
|
||||
vorlageIds: formular.get("vorlageIds") ?? "",
|
||||
});
|
||||
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||||
|
||||
const ids = eingabe.data.vorlageIds
|
||||
.split("\n")
|
||||
.map((zeile) => zeile.trim())
|
||||
.filter((zeile) => zeile.length > 0);
|
||||
|
||||
await schnellstartSetzen(eingabe.data.geraeteTyp, ids, benutzer.id);
|
||||
revalidatePath("/admin/schnellstart");
|
||||
// Die Geräteseiten zeigen die Kacheln – ohne das bliebe dort der alte Stand.
|
||||
revalidatePath("/geraete", "layout");
|
||||
return { erfolg: "Gespeichert." };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof SchnellstartFehler) return { fehler: fehler.message };
|
||||
if (fehler instanceof RechteFehler) {
|
||||
return { fehler: "Dafür fehlt dir die Berechtigung." };
|
||||
}
|
||||
console.error(
|
||||
"Schnellstart speichern fehlgeschlagen:",
|
||||
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : "unbekannter Fehler",
|
||||
);
|
||||
return { fehler: "Der Schnellstart konnte nicht gespeichert werden." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { GeraeteTyp } from "@/generated/prisma/enums";
|
||||
import { VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { GERAETE_TYP_LISTE } from "@/lib/geraete-text";
|
||||
|
||||
/**
|
||||
* Lesemodell fuer die Pflege des Schnellstarts (#022).
|
||||
*
|
||||
* `GeraetetypVorlage` ist global: die Zuordnung haengt am Geraetetyp, nicht am
|
||||
* Kunden. Deshalb gibt es hier **keine** Mandantensicht — die Seite verlangt
|
||||
* `verwaltung`, und wer die hat, sieht ohnehin die ganze Installation. Dass eine
|
||||
* kundengebundene Vorlage trotzdem nur bei ihrem Kunden erscheint, entscheidet
|
||||
* `vorlagenFuerGeraet` beim Lesen der Geraeteseite, nicht diese Pflege.
|
||||
*/
|
||||
|
||||
export type SchnellstartEintrag = {
|
||||
readonly vorlageId: string;
|
||||
readonly name: string;
|
||||
readonly kategorie: string | null;
|
||||
/** Gesetzt, wenn die Vorlage nur fuer einen Kunden gilt. */
|
||||
readonly kundeName: string | null;
|
||||
};
|
||||
|
||||
export type SchnellstartTyp = {
|
||||
readonly typ: GeraeteTyp;
|
||||
readonly zugewiesen: readonly SchnellstartEintrag[];
|
||||
};
|
||||
|
||||
export type SchnellstartPflege = {
|
||||
readonly typen: readonly SchnellstartTyp[];
|
||||
/** Alles, was sich zuweisen laesst – veroeffentlicht und nicht archiviert. */
|
||||
readonly auswahl: readonly SchnellstartEintrag[];
|
||||
};
|
||||
|
||||
export async function schnellstartPflege(): Promise<SchnellstartPflege> {
|
||||
const [vorlagen, vorgaben] = await Promise.all([
|
||||
prisma.vorlage.findMany({
|
||||
where: {
|
||||
archiviert: false,
|
||||
// Ohne veroeffentlichte Version laesst sich kein Lauf starten; sie
|
||||
// anzubieten waere eine Kachel, die nichts tut.
|
||||
versionen: { some: { status: VersionStatus.VEROEFFENTLICHT } },
|
||||
},
|
||||
orderBy: [{ kategorie: "asc" }, { name: "asc" }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
kategorie: true,
|
||||
kunde: { select: { name: true } },
|
||||
},
|
||||
}),
|
||||
prisma.geraetetypVorlage.findMany({
|
||||
// `vorlageId` als zweites Kriterium, damit die Reihenfolge **eindeutig**
|
||||
// ist. Zwei Zeilen mit derselben `position` gibt es wirklich — der Seed
|
||||
// zaehlt je Vorgabe von 0, und Altdaten tragen beides auf 0. Ohne diesen
|
||||
// Tiebreak gab Postgres sie hier anders zurueck als im Dienst, und eine
|
||||
// echte Umsortierung galt dort als "unveraendert" (#022, gefunden beim
|
||||
// Durchklicken des gebauten Stands).
|
||||
orderBy: [{ geraeteTyp: "asc" }, { position: "asc" }, { vorlageId: "asc" }],
|
||||
select: { geraeteTyp: true, vorlageId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const nachId = new Map<string, SchnellstartEintrag>(
|
||||
vorlagen.map((vorlage) => [
|
||||
vorlage.id,
|
||||
{
|
||||
vorlageId: vorlage.id,
|
||||
name: vorlage.name,
|
||||
kategorie: vorlage.kategorie,
|
||||
kundeName: vorlage.kunde?.name ?? null,
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
typen: GERAETE_TYP_LISTE.map((typ) => ({
|
||||
typ,
|
||||
zugewiesen: vorgaben
|
||||
.filter((vorgabe) => vorgabe.geraeteTyp === typ)
|
||||
// Eine archivierte oder unveroeffentlichte Vorlage bleibt in der Tabelle
|
||||
// stehen und verschwindet trotzdem aus der Liste — dieselbe Regel, die
|
||||
// `vorlagenFuerGeraet` auf der Geraeteseite anwendet. Niemand muss
|
||||
// aufraeumen, damit der Schnellstart stimmt.
|
||||
.map((vorgabe) => nachId.get(vorgabe.vorlageId))
|
||||
.filter((eintrag): eintrag is SchnellstartEintrag => eintrag !== undefined),
|
||||
})),
|
||||
auswahl: [...nachId.values()],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { GeraeteTyp, VersionStatus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
|
||||
/**
|
||||
* Pflege des Schnellstarts je Geraetetyp (#022).
|
||||
*
|
||||
* Welche Vorlagen auf der Geraeteseite als Kachel erscheinen, stand bisher nur
|
||||
* im Seed. Wer eine eigene Vorlage dort haben wollte, kam nicht heran; wer eine
|
||||
* mitgelieferte loswerden wollte, auch nicht.
|
||||
*
|
||||
* Die Zuordnung ist global und traegt keine `kundeId` — sie haengt am Typ. Dass
|
||||
* eine kundengebundene Vorlage nur bei ihrem Kunden erscheint, entscheidet
|
||||
* `vorlagenFuerGeraet` beim Lesen.
|
||||
*/
|
||||
|
||||
export class SchnellstartFehler extends Error {
|
||||
constructor(meldung: string) {
|
||||
super(meldung);
|
||||
this.name = "SchnellstartFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Liste eines Typs auf genau diese Vorlagen, in dieser Reihenfolge.
|
||||
*
|
||||
* Ersetzen und nicht ergaenzen: die Oberflaeche schickt die ganze Liste, und
|
||||
* `position` kommt aus ihrer Reihenfolge. Zwei Wahrheiten ueber die Reihenfolge
|
||||
* gaebe es sonst — eine im Formular und eine in der Spalte.
|
||||
*/
|
||||
export async function schnellstartSetzen(
|
||||
geraeteTyp: GeraeteTyp,
|
||||
vorlageIds: readonly string[],
|
||||
durchBenutzerId: string,
|
||||
): Promise<void> {
|
||||
const doppelt = vorlageIds.filter((id, stelle) => vorlageIds.indexOf(id) !== stelle);
|
||||
if (doppelt.length > 0) {
|
||||
throw new SchnellstartFehler("Eine Vorlage kann je Gerätetyp nur einmal im Schnellstart stehen.");
|
||||
}
|
||||
|
||||
if (vorlageIds.length > 0) {
|
||||
const brauchbar = await prisma.vorlage.findMany({
|
||||
where: {
|
||||
id: { in: [...vorlageIds] },
|
||||
archiviert: false,
|
||||
versionen: { some: { status: VersionStatus.VEROEFFENTLICHT } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (brauchbar.length !== vorlageIds.length) {
|
||||
throw new SchnellstartFehler(
|
||||
"Mindestens eine der gewählten Vorlagen gibt es nicht mehr, ist archiviert oder hat keine " +
|
||||
"veröffentlichte Version. Seite neu laden und noch einmal versuchen.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const vorher = await prisma.geraetetypVorlage.findMany({
|
||||
where: { geraeteTyp },
|
||||
// Derselbe Tiebreak wie im Lesemodell, und aus demselben Grund: bei
|
||||
// gleicher `position` ist die Reihenfolge sonst nicht eindeutig, und der
|
||||
// Vergleich unten haelt eine echte Umsortierung fuer einen Leerlauf.
|
||||
orderBy: [{ position: "asc" }, { vorlageId: "asc" }],
|
||||
select: { vorlageId: true, position: true, vorlage: { select: { name: true } } },
|
||||
});
|
||||
const vorherIds = vorher.map((eintrag) => eintrag.vorlageId);
|
||||
/*
|
||||
* Eine Liste mit gleichen Positionen gilt nicht als unveraendert, auch wenn
|
||||
* dieselben Vorlagen in derselben Reihenfolge kommen: Altdaten tragen mehrere
|
||||
* Zeilen auf `position` 0, und einmal Speichern soll sie geradeziehen.
|
||||
*
|
||||
* Das ist Aufraeumen, nicht die Behebung des Fehlers aus #022 — der lag am
|
||||
* fehlenden Tiebreak in beiden Abfragen. Ohne ihn gab Postgres die Zeilen hier
|
||||
* anders zurueck als im Lesemodell, und eine echte Umsortierung galt als
|
||||
* Leerlauf.
|
||||
*/
|
||||
const positionenSauber = vorher.every((eintrag, stelle) => eintrag.position === stelle);
|
||||
const unveraendert =
|
||||
positionenSauber &&
|
||||
vorherIds.length === vorlageIds.length &&
|
||||
vorherIds.every((id, stelle) => id === vorlageIds[stelle]);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
// Immer schreiben, auch bei unveraenderter Liste: die Marke ist der Punkt,
|
||||
// an dem der Seed aufhoert, eigene Vorgaben nachzulegen. Wer die Seite
|
||||
// einmal gespeichert hat, pflegt den Schnellstart selbst (#022).
|
||||
await tx.einstellung.update({
|
||||
where: { id: "global" },
|
||||
data: { schnellstartGepflegt: true },
|
||||
});
|
||||
|
||||
if (unveraendert) return;
|
||||
|
||||
await tx.geraetetypVorlage.deleteMany({ where: { geraeteTyp } });
|
||||
if (vorlageIds.length > 0) {
|
||||
await tx.geraetetypVorlage.createMany({
|
||||
data: vorlageIds.map((vorlageId, position) => ({ geraeteTyp, vorlageId, position })),
|
||||
});
|
||||
}
|
||||
|
||||
const namen = await tx.vorlage.findMany({
|
||||
where: { id: { in: [...vorlageIds] } },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const nachId = new Map(namen.map((eintrag) => [eintrag.id, eintrag.name]));
|
||||
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "GeraetetypVorlage",
|
||||
entitaetId: geraeteTyp,
|
||||
aktion: `Schnellstart für ${geraeteTyp} geändert`,
|
||||
benutzerId: durchBenutzerId,
|
||||
// Namen und nicht Kennungen: ein Protokolleintrag muss auch dann noch
|
||||
// lesbar sein, wenn die Vorlage spaeter anders heisst.
|
||||
vorher: { vorlagen: vorher.map((eintrag) => eintrag.vorlage.name) },
|
||||
nachher: { vorlagen: vorlageIds.map((id) => nachId.get(id) ?? id) },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user