Files
Laufzettel/src/server/actions/laeufe.ts
T
Florian WildeandClaude Opus 5 d30bb6d235
Prüfung / pruefung (push) Waiting to run
Prüfung / e2e (push) Blocked by required conditions
feat(laeufe): Vier-Augen-Freigabe (#040)
Verlangt eine Vorlagen-Version eine Freigabe, laesst sich ein Lauf erst
abschliessen, wenn ein Zweiter ihn freigegeben hat. Das braucht zweierlei, und
beides zusammen: das neue Recht `freigeben` (ADMIN und REDAKTEUR, nicht
TECHNIKER) und dass man nicht an dem Lauf gearbeitet hat.

Als gearbeitet zaehlt: angelegt, einen Schritt gesetzt oder abgeschlossen. Eine
Zuweisung allein nicht — waere die Regel hier strenger, blockierte sie kleine
Teams ohne Gewinn.

Auch ein Administrator kann sich nicht selbst freigeben, und das ist der ganze
Sinn. Die Pruefung sitzt in `hatDaranGearbeitet` an einer Stelle, wie
`laufMussVeraenderbarSein`; die wichtigste Haelfte — der Ersteller — haelt
zusaetzlich ein CHECK in der Datenbank.

Zurueckweisen verlangt eine Begruendung, setzt den Lauf auf IN_ARBEIT und nimmt
eine erteilte Freigabe zurueck. Kein fuenfter LaufStatus: ein neuer Enum-Wert
haette jeden Codepfad betroffen, der heute vier Faelle kennt.

Sobald jemand abzuschliessen versucht und die Freigabe fehlt, bekommen die
Freigabeberechtigten eine Mail — ein anderer Empfaengerkreis als bei jeder
anderen Meldung, und ohne die, die gearbeitet haben. Die Aufforderung entsteht
ausserhalb der Transaktion: innerhalb wuerde sie mit dem Fehler zurueckgerollt,
und dann wartet ein Lauf auf etwas, von dem niemand weiss. Ein partieller
Unique-Index haelt sie bei mehreren Versuchen auf einmal.

Die Pflicht haengt an der Version, nicht an der Vorlage: eine veroeffentlichte
Version ist unveraenderlich, ein laufender Lauf behaelt also seine Regel. Beim
Bearbeiten und Duplizieren wandert sie mit — das war die unmittelbare Lehre aus
#039, wo die tiefe Kopie zwei Felder stillschweigend verworfen hatte.

1526 Vitest-Tests (vorher 1496), 115 E2E-Ablaeufe gruen. Klickprobe am gebauten
Stand mit elf Pruefungen; drei rote Punkte darin waren meine eigenen
Testfehler, einer davon lehrreich: ein reload() direkt nach einer Server Action
trifft den Router-Cache von Next und zeigt kurz den alten Stand.

Closes #40

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-10-24 01:14:04 +02:00

475 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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,
laufFreigeben,
laufZurueckweisen,
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 laufFreigebenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
/*
* **`freigeben` und nicht `laeufeBearbeiten`** (#040): das ist der ganze Sinn
* des eigenen Rechts. Ob der Freigebende ausserdem nicht an dem Lauf
* gearbeitet hat, entscheidet der Dienst — hier wird genau ein Recht geprueft
* (CLAUDE.md).
*/
const zugang = await angemeldetOderMeldung("freigeben");
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." };
const notiz = formular.get("notiz");
try {
await laufFreigeben(laufId.data, zugang.benutzer, typeof notiz === "string" ? notiz : undefined);
neuRendern(laufId.data);
return { erfolg: "Lauf freigegeben. Er lässt sich jetzt abschließen." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht freigegeben werden.");
}
}
export async function laufZurueckweisenAktion(
_vorher: AktionsErgebnis,
formular: FormData,
): Promise<AktionsErgebnis> {
const zugang = await angemeldetOderMeldung("freigeben");
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." };
const notiz = z
.string()
.trim()
.min(1, "Eine Zurückweisung braucht eine Begründung.")
.safeParse(formular.get("notiz"));
if (!notiz.success) {
return { fehler: notiz.error.issues[0]?.message ?? "Eine Zurückweisung braucht eine Begründung." };
}
try {
await laufZurueckweisen(laufId.data, zugang.benutzer, notiz.data);
neuRendern(laufId.data);
return { erfolg: "Lauf zurückgewiesen. Der Bearbeiter sieht deine Begründung." };
} catch (fehler) {
return alsErgebnis(fehler, "Der Lauf konnte nicht zurückgewiesen 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.");
}
}