"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 { if (!roh) return {}; try { const geparst: unknown = JSON.parse(roh); if (typeof geparst !== "object" || geparst === null || Array.isArray(geparst)) return {}; const werte: Record = {}; for (const [schluessel, wert] of Object.entries(geparst as Record)) { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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 { 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."); } }