Ein Lauf wird vorbereitet, abgearbeitet, abgeschlossen und archiviert. Der Archiv-Schreibschutz sitzt in genau einer Funktion und wird vor jeder Aenderung an Kopfdaten, Schrittergebnis und Kommentar gerufen; abgeschlossen bleibt aenderbar, archiviert nicht. Die Abarbeitungsansicht speichert jeden Schritt einzeln, frischt sich alle zehn Sekunden auf und pausiert im Hintergrund. Sie traegt in der Polling-Antwort keine Befehle und laedt sich auch nicht von selbst neu, solange ein generiertes Passwort auf der Seite steht: beides tauschte es dem Techniker zwischen Lesen und Einfuegen aus. Der unabhaengige Review fand zwei Blocker: ein Befehl konnte zwei verschiedene Passwoerter tragen (Konto bekommt A, Benutzer erfaehrt B), und „nicht zutreffend“ liess sich nicht zuruecknehmen, waehrend die Oberflaeche behauptete, nichts sei gewaehlt. Beides behoben und in Tests festgehalten. Closes #13 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
422 lines
15 KiB
TypeScript
422 lines
15 KiB
TypeScript
"use server";
|
||
|
||
import { revalidatePath } from "next/cache";
|
||
import { SchrittZustand } from "@/generated/prisma/enums";
|
||
import { z } from "@/lib/validierung";
|
||
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
|
||
import type { Recht } from "@/server/auth/rollen";
|
||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||
import {
|
||
LaufFehler,
|
||
laufAbschliessen,
|
||
laufAnlegen,
|
||
laufArchivieren,
|
||
laufMitAdKontoVerknuepfen,
|
||
laufZurBearbeitungOeffnen,
|
||
laufZurueckholen,
|
||
} from "@/server/services/laeufe";
|
||
import { namenVorschlagen, type NamensVorschlaege } from "@/server/services/befehle";
|
||
import { abschnittNichtZutreffend, schrittergebnisSetzen } from "@/server/services/schrittergebnisse";
|
||
import { kommentarHinzufuegen } from "@/server/services/kommentare";
|
||
|
||
/**
|
||
* Server Actions der Laeufe (#013).
|
||
*
|
||
* Duenn: Recht pruefen, Zod anwenden, Dienst rufen. Die fachlichen Regeln — das
|
||
* Archiv, die Notizpflicht, die Abschlussbedingung — stehen alle im Dienst, und
|
||
* zwar an genau einer Stelle. Eine Action, die eine davon nachbaut, waere eine
|
||
* zweite Wahrheit.
|
||
*/
|
||
|
||
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||
|
||
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.";
|
||
|
||
/**
|
||
* Faengt den Rechtefehler ab, statt ihn werfen zu lassen.
|
||
*
|
||
* Ein ungefangener Wurf landet in Nexts Fehlergrenze und haengt die Seite aus —
|
||
* mitten in der Abarbeitung waere das der Verlust dessen, was gerade im Feld
|
||
* stand (#010).
|
||
*/
|
||
async function angemeldetOderMeldung(
|
||
recht: Recht,
|
||
): Promise<{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }> {
|
||
try {
|
||
const { benutzer } = await rechtErforderlich(recht);
|
||
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
|
||
} catch (fehler) {
|
||
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
|
||
throw fehler;
|
||
}
|
||
}
|
||
|
||
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||
if (fehler instanceof LaufFehler || fehler instanceof MandantFehler) {
|
||
return { fehler: fehler.message };
|
||
}
|
||
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler);
|
||
return { fehler: neutral };
|
||
}
|
||
|
||
/** Nach jeder Aenderung: Liste und Lauf neu rendern. */
|
||
function neuRendern(laufId: string): void {
|
||
revalidatePath("/laeufe");
|
||
revalidatePath(`/laeufe/${laufId}`);
|
||
}
|
||
|
||
// ---- Anlegen und Kopfdaten ------------------------------------------------
|
||
|
||
const AnlegenSchema = z.object({
|
||
vorlagenVersionId: z.string().min(1, "Bitte eine Vorlage wählen."),
|
||
titel: z.string().trim().optional(),
|
||
mitarbeiterId: z.string().trim().optional(),
|
||
mitarbeiterName: z.string().trim().optional(),
|
||
geraetId: z.string().trim().optional(),
|
||
abteilungId: z.string().trim().optional(),
|
||
ticket: z.string().trim().optional(),
|
||
zugewiesenAnId: z.string().trim().optional(),
|
||
faelligAm: z.string().trim().optional(),
|
||
kundeId: z.string().trim().optional(),
|
||
/** Die Variablen kommen als JSON – sie haengen an der gewaehlten Version. */
|
||
variablen: z.string().trim().optional(),
|
||
});
|
||
|
||
function variablenLesen(roh: string | undefined): Record<string, string> {
|
||
if (!roh) return {};
|
||
try {
|
||
const geparst: unknown = JSON.parse(roh);
|
||
if (typeof geparst !== "object" || geparst === null || Array.isArray(geparst)) return {};
|
||
const werte: Record<string, string> = {};
|
||
for (const [schluessel, wert] of Object.entries(geparst as Record<string, unknown>)) {
|
||
if (typeof wert === "string") werte[schluessel] = wert;
|
||
else if (typeof wert === "number" || typeof wert === "boolean") werte[schluessel] = String(wert);
|
||
}
|
||
return werte;
|
||
} catch {
|
||
// Ein kaputtes JSON darf den Start nicht verhindern – die Variablen sind
|
||
// Beiwerk, der Lauf ist die Hauptsache.
|
||
return {};
|
||
}
|
||
}
|
||
|
||
function datumLesen(roh: string | undefined): Date | null {
|
||
if (!roh) return null;
|
||
const datum = new Date(roh);
|
||
return Number.isNaN(datum.getTime()) ? null : datum;
|
||
}
|
||
|
||
export async function laufAnlegenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis & { readonly laufId?: string }> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
|
||
const eingabe = AnlegenSchema.safeParse(Object.fromEntries(formular.entries()));
|
||
if (!eingabe.success) {
|
||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||
}
|
||
|
||
try {
|
||
const lauf = await laufAnlegen(
|
||
{
|
||
vorlagenVersionId: eingabe.data.vorlagenVersionId,
|
||
titel: eingabe.data.titel ?? null,
|
||
mitarbeiterId: eingabe.data.mitarbeiterId || null,
|
||
mitarbeiterName: eingabe.data.mitarbeiterName || null,
|
||
geraetId: eingabe.data.geraetId || null,
|
||
abteilungId: eingabe.data.abteilungId || null,
|
||
ticket: eingabe.data.ticket || null,
|
||
zugewiesenAnId: eingabe.data.zugewiesenAnId || null,
|
||
faelligAm: datumLesen(eingabe.data.faelligAm),
|
||
kundeId: eingabe.data.kundeId || null,
|
||
variablen: variablenLesen(eingabe.data.variablen),
|
||
},
|
||
zugang.benutzer,
|
||
);
|
||
revalidatePath("/laeufe");
|
||
return { erfolg: `„${lauf.titel}“ wurde angelegt.`, laufId: lauf.id };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Lauf konnte nicht angelegt werden.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Vorschlaege fuer `upn`, `sam`, `mail` und `hostname` zu einem Namen (#013).
|
||
*
|
||
* Beim Onboarding steht die Person noch nicht im Verzeichnis; ihre Anmeldenamen
|
||
* entstehen erst hier. Die Regeln und die Eindeutigkeitspruefung liegen im
|
||
* Dienst (#012) — diese Action reicht nur durch, damit der Start-Dialog fragen
|
||
* kann, ohne die Regeln nachzubauen.
|
||
*
|
||
* Der Name kommt als ein Feld, weil der Dialog ihn so erfragt: „Jörg
|
||
* Müller-Lüdenscheid“. Das erste Wort ist der Vorname, der Rest der Nachname;
|
||
* steht dort nur ein Wort, ist es der **Nachname** — `{nachname}` kommt in jeder
|
||
* sinnvollen Regel vor, `{vorname}` nicht. Eine Annahme, die fuer die Faelle
|
||
* stimmt, fuer die dieser Dialog gebaut ist, und die der Techniker am Vorschlag
|
||
* sofort sieht.
|
||
*/
|
||
export async function namensvorschlaegeAktion(
|
||
name: string,
|
||
kundeId: string | null,
|
||
): Promise<{ readonly vorschlaege?: NamensVorschlaege; readonly fehler?: string }> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
|
||
const teile = name.trim().split(/\s+/u).filter(Boolean);
|
||
if (teile.length === 0) return { vorschlaege: {} };
|
||
const vorname = teile.length > 1 ? teile[0] : null;
|
||
const nachname = teile.length > 1 ? teile.slice(1).join(" ") : teile[0];
|
||
|
||
try {
|
||
const vorschlaege = await namenVorschlagen(
|
||
kundeId,
|
||
{ vorname: vorname ?? null, nachname: nachname ?? null },
|
||
zugang.benutzer,
|
||
);
|
||
return { vorschlaege };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Die Vorschläge ließen sich nicht bilden.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Verknuepft einen Lauf nachtraeglich mit einem Konto aus dem Verzeichnis (#013).
|
||
*
|
||
* Der Onboarding-Fall: der Lauf beginnt mit einem Freitextnamen, weil es die
|
||
* Person im Verzeichnis noch nicht gibt. Sobald das Konto angelegt ist, gehoert
|
||
* der Lauf daran — sonst steht die Arbeit fuer immer neben der Person statt an
|
||
* ihr, und die Geraete- und Personenhistorie kennt sie nicht.
|
||
*/
|
||
export async function laufMitAdKontoVerknuepfenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
|
||
const eingabe = z
|
||
.object({
|
||
laufId: z.string().min(1),
|
||
mitarbeiterId: z.string().trim().optional(),
|
||
upn: z.string().trim().optional(),
|
||
})
|
||
.safeParse(Object.fromEntries(formular.entries()));
|
||
if (!eingabe.success) {
|
||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||
}
|
||
|
||
try {
|
||
const lauf = await laufMitAdKontoVerknuepfen(
|
||
eingabe.data.laufId,
|
||
{
|
||
mitarbeiterId: eingabe.data.mitarbeiterId || null,
|
||
upn: eingabe.data.upn || null,
|
||
},
|
||
zugang.benutzer,
|
||
);
|
||
neuRendern(lauf.id);
|
||
return { erfolg: "Der Lauf hängt jetzt an dieser Person." };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Die Verknüpfung konnte nicht gespeichert werden.");
|
||
}
|
||
}
|
||
|
||
// ---- Abarbeiten -----------------------------------------------------------
|
||
|
||
/**
|
||
* Oeffnet den Lauf zur Bearbeitung.
|
||
*
|
||
* Erst hier entstehen `gestartetAm` und `IN_ARBEIT` — nicht beim Ansehen. Ein
|
||
* `OFFEN`-Lauf ist ein zugewiesener Lauf, und wer ihn nur aufschlaegt, hat noch
|
||
* nicht angefangen.
|
||
*/
|
||
export async function laufOeffnenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
|
||
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
|
||
|
||
try {
|
||
await laufZurBearbeitungOeffnen(laufId.data, zugang.benutzer);
|
||
neuRendern(laufId.data);
|
||
return { erfolg: "Los geht’s." };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Lauf konnte nicht geöffnet werden.");
|
||
}
|
||
}
|
||
|
||
const ErgebnisSchema = z.object({
|
||
laufId: z.string().min(1),
|
||
vorlagenSchrittId: z.string().min(1),
|
||
zustand: z.enum(SchrittZustand),
|
||
wert: z.string().optional(),
|
||
notiz: z.string().optional(),
|
||
});
|
||
|
||
export async function schrittergebnisSetzenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
|
||
const eingabe = ErgebnisSchema.safeParse(Object.fromEntries(formular.entries()));
|
||
if (!eingabe.success) {
|
||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||
}
|
||
|
||
try {
|
||
await schrittergebnisSetzen(
|
||
{
|
||
laufId: eingabe.data.laufId,
|
||
vorlagenSchrittId: eingabe.data.vorlagenSchrittId,
|
||
zustand: eingabe.data.zustand,
|
||
// Fehlt das Feld, bleibt der Wert stehen; ein leeres Feld loescht ihn.
|
||
...(eingabe.data.wert === undefined ? {} : { wert: eingabe.data.wert || null }),
|
||
...(eingabe.data.notiz === undefined ? {} : { notiz: eingabe.data.notiz || null }),
|
||
},
|
||
zugang.benutzer,
|
||
);
|
||
neuRendern(eingabe.data.laufId);
|
||
return {};
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Schritt konnte nicht gespeichert werden.");
|
||
}
|
||
}
|
||
|
||
export async function abschnittNichtZutreffendAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
|
||
const eingabe = z
|
||
.object({ laufId: z.string().min(1), abschnittId: z.string().min(1), notiz: z.string() })
|
||
.safeParse(Object.fromEntries(formular.entries()));
|
||
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||
|
||
try {
|
||
const ergebnis = await abschnittNichtZutreffend(eingabe.data, zugang.benutzer);
|
||
neuRendern(eingabe.data.laufId);
|
||
const anzahl = ergebnis.uebersprungen.length;
|
||
// Deutsch, auch bei eins: „1 bereits erledigte Schritte“ liest sich wie ein
|
||
// Fehler und lenkt von der Meldung ab.
|
||
const uebersprungen =
|
||
anzahl === 0
|
||
? ""
|
||
: anzahl === 1
|
||
? " 1 bereits erledigter Schritt blieb, wie er war."
|
||
: ` ${anzahl} bereits erledigte Schritte blieben, wie sie waren.`;
|
||
return {
|
||
erfolg: `${ergebnis.gesetzt} ${
|
||
ergebnis.gesetzt === 1 ? "Schritt" : "Schritte"
|
||
} als nicht zutreffend vermerkt.${uebersprungen}`,
|
||
};
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Abschnitt konnte nicht gesetzt werden.");
|
||
}
|
||
}
|
||
|
||
export async function kommentarHinzufuegenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
|
||
const eingabe = z
|
||
.object({
|
||
laufId: z.string().min(1),
|
||
schrittergebnisId: z.string().trim().optional(),
|
||
rumpf: z.string(),
|
||
})
|
||
.safeParse(Object.fromEntries(formular.entries()));
|
||
if (!eingabe.success) return { fehler: "Die Eingabe ist unvollständig." };
|
||
|
||
try {
|
||
await kommentarHinzufuegen(
|
||
{
|
||
laufId: eingabe.data.laufId,
|
||
schrittergebnisId: eingabe.data.schrittergebnisId || null,
|
||
rumpf: eingabe.data.rumpf,
|
||
},
|
||
zugang.benutzer,
|
||
);
|
||
neuRendern(eingabe.data.laufId);
|
||
return { erfolg: "Kommentar gespeichert." };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Kommentar konnte nicht gespeichert werden.");
|
||
}
|
||
}
|
||
|
||
// ---- Abschliessen, Archivieren, Zurueckholen ------------------------------
|
||
|
||
export async function laufAbschliessenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
|
||
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
|
||
|
||
try {
|
||
await laufAbschliessen(laufId.data, zugang.benutzer);
|
||
neuRendern(laufId.data);
|
||
return { erfolg: "Lauf abgeschlossen." };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Lauf konnte nicht abgeschlossen werden.");
|
||
}
|
||
}
|
||
|
||
export async function laufArchivierenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("laeufeBearbeiten");
|
||
if ("fehler" in zugang) return zugang;
|
||
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
|
||
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
|
||
|
||
try {
|
||
await laufArchivieren(laufId.data, zugang.benutzer);
|
||
neuRendern(laufId.data);
|
||
return { erfolg: "Lauf archiviert. Ab jetzt ändert sich daran nichts mehr." };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Lauf konnte nicht archiviert werden.");
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Zurueckholen — nur `ADMIN`.
|
||
*
|
||
* Das Archiv ist die Zusage, dass ein abgeschlossener Lauf sich nicht mehr
|
||
* aendert. Diese Zusage darf nur eine Stelle aufheben; der Dienst prueft das
|
||
* Recht noch einmal selbst.
|
||
*/
|
||
export async function laufZurueckholenAktion(
|
||
_vorher: AktionsErgebnis,
|
||
formular: FormData,
|
||
): Promise<AktionsErgebnis> {
|
||
const zugang = await angemeldetOderMeldung("archivVerwalten");
|
||
if ("fehler" in zugang) return zugang;
|
||
const laufId = z.string().min(1).safeParse(formular.get("laufId"));
|
||
if (!laufId.success) return { fehler: "Es fehlt der Lauf." };
|
||
|
||
try {
|
||
await laufZurueckholen(laufId.data, zugang.benutzer);
|
||
neuRendern(laufId.data);
|
||
return { erfolg: "Lauf zurückgeholt — er steht wieder auf „abgeschlossen“." };
|
||
} catch (fehler) {
|
||
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgeholt werden.");
|
||
}
|
||
}
|