feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

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>
This commit was merged in pull request #27.
This commit is contained in:
2026-09-25 16:56:33 +00:00
co-authored by Claude Opus 5
parent eec5de404a
commit c78a6ac1af
44 changed files with 7440 additions and 61 deletions
+421
View File
@@ -0,0 +1,421 @@
"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.");
}
}
+729
View File
@@ -0,0 +1,729 @@
import type { Prisma } from "@/generated/prisma/client";
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import { platzhalterErsetzen } from "@/lib/platzhalter-werte";
import { platzhalterAusserhalbAnfuehrungszeichen, skriptBauen, wertMaskieren, type Shell } from "@/lib/shell";
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
import { PASSWORT_HINWEIS, passwortErzeugen } from "@/lib/passwort";
import { sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
import { platzhalterWerteDesLaufs } from "@/server/services/platzhalter";
/**
* Lesemodelle der Laeufe (#013).
*
* Wie ueberall wenden sie die Mandantensicht selbst an. Zwei Dinge sind hier
* besonders:
*
* 1. **Ein Lauf zeigt die Version, mit der er gestartet wurde** — nie die
* neueste. Das ist die Kernzusage aus #009, und sie faellt hier an, weil die
* Abarbeitungsansicht die Schritte ueber `Lauf.vorlagenVersionId` liest.
* 2. **Das Zustands-Lesemodell fuers Polling enthaelt keine Befehle.** Ein
* `{{generiertes_passwort}}` entsteht bei **jeder** Aufloesung neu; ein
* Abruf alle zehn Sekunden wuerde dem Techniker das Passwort unter den
* Haenden wegtauschen, waehrend er es gerade abtippt.
*/
async function kundenBedingung(benutzer: HandelnderBenutzer): Promise<{ kundeId: { in: string[] } }[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
return sichtbar === undefined ? [] : [{ kundeId: { in: [...sichtbar] } }];
}
export type LaufZeile = {
readonly id: string;
readonly titel: string;
readonly status: LaufStatus;
readonly vorlagenName: string;
readonly version: number;
readonly geraetName: string | null;
readonly mitarbeiterName: string | null;
/**
* Der Name steht nur als Freitext im Lauf – die Person gab es beim Start im
* Verzeichnis noch nicht. Die Abarbeitungsansicht bietet dann das Verknüpfen
* mit einem AD-Konto an.
*/
readonly nurFreitextName: boolean;
readonly ticket: string | null;
readonly faelligAm: Date | null;
readonly zugewiesenAn: string | null;
readonly kundeName: string;
readonly erstelltAm: Date;
readonly abgeschlossenAm: Date | null;
readonly anzahlSchritte: number;
readonly anzahlErledigt: number;
};
export type LaufFilter = {
readonly suche?: string;
readonly status?: LaufStatus;
/** Nur Laeufe, die dieser Person zugewiesen sind. */
readonly zugewiesenAnId?: string;
/** Standard: ohne archivierte – die stehen im Archiv (#015). */
readonly auchArchivierte?: boolean;
};
/** So viele Laeufe zeigt die Liste hoechstens; darueber hinaus wird gefiltert. */
export const LISTE_HOECHSTENS = 200;
const ZEILEN_AUSWAHL = {
id: true,
titel: true,
status: true,
ticket: true,
faelligAm: true,
erstelltAm: true,
abgeschlossenAm: true,
mitarbeiterName: true,
kunde: { select: { name: true } },
zugewiesenAn: { select: { name: true } },
mitarbeiter: { select: { anzeigename: true } },
geraet: { select: { hostname: true, bezeichnung: true, tagCode: true } },
vorlagenVersion: { select: { version: true, vorlage: { select: { name: true } } } },
ergebnisse: { select: { zustand: true } },
} satisfies Prisma.LaufSelect;
type Rohzeile = Prisma.LaufGetPayload<{ select: typeof ZEILEN_AUSWAHL }>;
/** Noch nichts geschehen: dann sagt auch der Zeitstempel der Zeile nichts. */
function istOffen(zustand: SchrittZustand): boolean {
return zustand === SchrittZustand.OFFEN;
}
/** Erledigt heisst hier: der Schritt ist vom Tisch – auch als „nicht zutreffend“. */
function istVomTisch(zustand: SchrittZustand): boolean {
return zustand === SchrittZustand.ERLEDIGT || zustand === SchrittZustand.NICHT_ZUTREFFEND;
}
function zeileBauen(zeile: Rohzeile): LaufZeile {
return {
id: zeile.id,
titel: zeile.titel,
status: zeile.status,
vorlagenName: zeile.vorlagenVersion.vorlage.name,
version: zeile.vorlagenVersion.version,
geraetName: zeile.geraet?.hostname ?? zeile.geraet?.bezeichnung ?? zeile.geraet?.tagCode ?? null,
// Freitext nur, wenn keine Person aus dem Verzeichnis dranhaengt – die
// Datenbank schliesst beides zugleich aus.
mitarbeiterName: zeile.mitarbeiter?.anzeigename ?? zeile.mitarbeiterName,
nurFreitextName: zeile.mitarbeiter === null && zeile.mitarbeiterName !== null,
ticket: zeile.ticket,
faelligAm: zeile.faelligAm,
zugewiesenAn: zeile.zugewiesenAn?.name ?? null,
kundeName: zeile.kunde.name,
erstelltAm: zeile.erstelltAm,
abgeschlossenAm: zeile.abgeschlossenAm,
anzahlSchritte: zeile.ergebnisse.length,
anzahlErledigt: zeile.ergebnisse.filter((ergebnis) => istVomTisch(ergebnis.zustand)).length,
};
}
export async function laufListe(
benutzer: HandelnderBenutzer,
filter: LaufFilter = {},
): Promise<readonly LaufZeile[]> {
const suche = filter.suche?.trim();
const bedingungen = [
...(await kundenBedingung(benutzer)),
filter.auchArchivierte ? undefined : { status: { not: LaufStatus.ARCHIVIERT } },
filter.status ? { status: filter.status } : undefined,
filter.zugewiesenAnId ? { zugewiesenAnId: filter.zugewiesenAnId } : undefined,
// Gesucht wird ueber das, was auf einem Zettel stuende: Titel, Ticket,
// Hostname und Name der Person.
suche
? {
OR: [
{ titel: { contains: suche, mode: "insensitive" as const } },
{ ticket: { contains: suche, mode: "insensitive" as const } },
{ mitarbeiterName: { contains: suche, mode: "insensitive" as const } },
{ geraet: { hostname: { contains: suche, mode: "insensitive" as const } } },
{ geraet: { tagCode: { contains: suche, mode: "insensitive" as const } } },
{ mitarbeiter: { anzeigename: { contains: suche, mode: "insensitive" as const } } },
],
}
: undefined,
].filter((bedingung) => bedingung !== undefined);
const zeilen = await prisma.lauf.findMany({
where: { AND: bedingungen },
orderBy: [{ faelligAm: { sort: "asc", nulls: "last" } }, { erstelltAm: "desc" }],
/*
* Gekappt, weil die Liste die **laufende Arbeit** zeigt: archivierte stehen
* hier ohnehin nicht, und wer 200 offene Laeufe hat, sucht oder filtert.
* Ohne Grenze baute ein Haus nach zwei Jahren eine Seite mit tausenden
* Karten — jede mit ihren Schrittergebnissen.
*/
take: LISTE_HOECHSTENS,
select: ZEILEN_AUSWAHL,
});
return zeilen.map(zeileBauen);
}
/** `undefined` heisst „gibt es nicht oder darfst du nicht sehen“. */
export async function laufHolen(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZeile | undefined> {
const zeile = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: ZEILEN_AUSWAHL,
});
return zeile ? zeileBauen(zeile) : undefined;
}
export type AbarbeitungsSchritt = {
readonly schrittId: string;
readonly ergebnisId: string | null;
readonly nummer: number;
readonly titel: string;
readonly hilfetext: string | null;
readonly typ: SchrittTyp;
readonly pflicht: boolean;
readonly dokuUrl: string | null;
readonly konfig: Record<string, unknown>;
readonly zustand: SchrittZustand;
readonly wert: unknown;
readonly notiz: string | null;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
readonly anzahlKommentare: number;
/** Nur bei `BEFEHL`: der fertige Befehl mit eingesetzten Werten. */
readonly befehl: FertigerSchrittBefehl | null;
};
export type FertigerSchrittBefehl = {
readonly shell: Shell;
readonly text: string;
/** Platzhalter, die niemand kennt – sie stehen noch als `{{name}}` darin. */
readonly unbekannt: readonly string[];
readonly mitPasswort: boolean;
readonly hinweis: string | null;
/** Gesetzt, wenn sich der Befehl nicht sicher fuellen liess. */
readonly fehler: string | null;
};
export type AbarbeitungsAbschnitt = {
/** Die Kennung des `VorlagenAbschnitt`s – fuer „Abschnitt trifft nicht zu“. */
readonly id: string;
readonly titel: string;
readonly nummer: string;
readonly schritte: readonly AbarbeitungsSchritt[];
/** Alle Befehle des Abschnitts als ein Skript – leer, wenn es keine gibt. */
readonly skript: string;
};
export type LaufZumAbarbeiten = {
readonly lauf: LaufZeile;
readonly abschnitte: readonly AbarbeitungsAbschnitt[];
readonly anzahlPflicht: number;
readonly anzahlPflichtOffen: number;
};
/**
* Der ganze Lauf zum Abarbeiten: Abschnitte, Schritte, Ergebnisse und die
* fertigen Befehle.
*
* Gelesen wird ueber `Lauf.vorlagenVersionId` — die Version, mit der der Lauf
* gestartet wurde. Ein spaeteres Veroeffentlichen aendert hier nichts (#009).
*/
export async function laufZumAbarbeiten(
laufId: string,
benutzer: HandelnderBenutzer,
technikerName?: string | null,
): Promise<LaufZumAbarbeiten | undefined> {
const kopf = await laufHolen(laufId, benutzer);
if (!kopf) return undefined;
const lauf = await prisma.lauf.findUniqueOrThrow({
where: { id: laufId },
select: {
vorlagenVersion: {
select: {
abschnitte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
schritte: {
orderBy: { position: "asc" },
select: {
id: true,
titel: true,
hilfetext: true,
typ: true,
pflicht: true,
dokuUrl: true,
konfig: true,
},
},
},
},
},
},
ergebnisse: {
select: {
id: true,
vorlagenSchrittId: true,
zustand: true,
wert: true,
notiz: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
_count: { select: { kommentare: true } },
},
},
},
});
const werte = await platzhalterWerteDesLaufs({
laufId,
...(technikerName === undefined ? {} : { technikerName }),
});
const ergebnisse = new Map(lauf.ergebnisse.map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]));
/*
* **Ein** Passwort fuer diesen Seitenaufbau (Reviewbefund zu #013). Vorher
* bekam jedes Vorkommen ein eigenes: der Befehl, der das Konto anlegt, und die
* Zeile, die das Passwort mitteilt, trugen verschiedene — das Konto bekam A,
* der Benutzer erfuhr B, und weil nichts gespeichert wird, war A danach weg.
* Gespeichert wird es auch jetzt nirgends; beim naechsten Aufbau ist es ein
* anderes, und genau darum darf die Seite sich nicht von selbst neu bauen,
* solange eines darauf steht (siehe `LaufAktualisierung`).
*/
const passwort = passwortErzeugen();
let nummer = 0;
let anzahlPflicht = 0;
let anzahlPflichtOffen = 0;
const abschnitte = lauf.vorlagenVersion.abschnitte.map((abschnitt, index) => {
const befehle: { titel: string; shell: Shell; befehl: string }[] = [];
const schritte = abschnitt.schritte.map((schritt) => {
nummer += 1;
const ergebnis = ergebnisse.get(schritt.id);
const zustand = ergebnis?.zustand ?? SchrittZustand.OFFEN;
if (schritt.pflicht) {
anzahlPflicht += 1;
if (!istVomTisch(zustand)) anzahlPflichtOffen += 1;
}
const konfig = (schritt.konfig ?? {}) as Record<string, unknown>;
const befehl = schritt.typ === SchrittTyp.BEFEHL ? befehlFuellen(konfig, werte, passwort) : null;
if (befehl && befehl.fehler === null) {
befehle.push({ titel: schritt.titel, shell: befehl.shell, befehl: befehl.text });
}
return {
schrittId: schritt.id,
ergebnisId: ergebnis?.id ?? null,
nummer,
titel: platzhalterErsetzen(schritt.titel, werte).text,
hilfetext: schritt.hilfetext ? platzhalterErsetzen(schritt.hilfetext, werte).text : null,
typ: schritt.typ,
pflicht: schritt.pflicht,
dokuUrl: schritt.dokuUrl,
konfig,
zustand,
wert: ergebnis?.wert ?? null,
notiz: ergebnis?.notiz ?? null,
/*
* „Wer wann“, sobald der Schritt nicht mehr offen ist — nicht erst, wenn
* ein **Mensch** ihn angefasst hat. Die Ergebniszeilen entstehen beim
* Anlegen des Laufs, `aktualisiertAm` traegt also auch an einem
* unberuehrten Schritt einen Zeitstempel; der sagt nichts, deshalb der
* Zustand als Bedingung.
*
* Am Zustand und nicht am Urheber, weil `aktualisiertVonId` leer bleiben
* **darf**: ein Schritt vom Typ `AKTION` (Phase 4) erledigt sich selbst,
* und ausgerechnet dann fiele das „wann“ weg (CLAUDE.md,
* „Erweiterbarkeit“; Reviewbefund zu #013).
*/
bearbeitetVon: ergebnis?.aktualisiertVon?.name ?? (istOffen(zustand) ? null : "System"),
bearbeitetAm: istOffen(zustand) ? null : (ergebnis?.aktualisiertAm ?? null),
anzahlKommentare: ergebnis?._count.kommentare ?? 0,
befehl,
};
});
return {
id: abschnitt.id,
titel: abschnitt.titel,
nummer: String(index + 1).padStart(2, "0"),
schritte,
skript: skriptBauenAus(befehle),
};
});
return { lauf: kopf, abschnitte, anzahlPflicht, anzahlPflichtOffen };
}
/**
* Fuellt einen einzelnen Befehl.
*
* Bewusst hier und nicht ueber `befehleDesLaufs`: das Lesemodell braucht den
* Befehl **am Schritt**, und es darf ihn nur **einmal** fuellen — bei jedem
* Fuellen entsteht ein neues `{{generiertes_passwort}}`. Zweimal gefuellt hiesse
* zwei verschiedene Passwoerter in einer Antwort (Reviewbefund aus #012).
*/
function befehlFuellen(
konfig: Record<string, unknown>,
werte: Readonly<Record<string, string>>,
/** Das Passwort dieses Seitenaufbaus – dasselbe fuer jeden Befehl der Seite. */
passwort: string,
): FertigerSchrittBefehl {
const shell = shellAus(konfig);
const roh = typeof konfig["befehl"] === "string" ? konfig["befehl"] : "";
const nackt = platzhalterAusserhalbAnfuehrungszeichen(roh, shell);
if (nackt.length > 0) {
const zeichen = shell === "bash" ? "einfache" : "doppelte";
return {
shell,
text: roh,
unbekannt: [],
mitPasswort: false,
hinweis: null,
fehler:
`${nackt.map((name) => `„{{${name}}}“`).join(", ")} steht nicht in Anführungszeichen. ` +
`Der Wert lässt sich dann nicht sicher einsetzen — bitte ${zeichen} Anführungszeichen ` +
"um den Platzhalter setzen.",
};
}
try {
const ergebnis = platzhalterErsetzen(
roh,
werte,
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, shell) },
passwort,
);
return {
shell,
text: ergebnis.text,
unbekannt: ergebnis.unbekannt,
mitPasswort: ergebnis.mitPasswort,
hinweis: ergebnis.mitPasswort ? PASSWORT_HINWEIS : null,
fehler: null,
};
} catch (fehler) {
// `wertMaskieren` wirft fuer cmd, wenn ein Wert nicht sicher darstellbar
// ist. Der Befehl bleibt mit Platzhaltern stehen: sichtbar, nicht kopierbar.
return {
shell,
text: roh,
unbekannt: [],
mitPasswort: false,
hinweis: null,
fehler: fehler instanceof Error ? fehler.message : "Der Befehl ließ sich nicht füllen.",
};
}
}
function shellAus(konfig: Record<string, unknown>): Shell {
const wert = konfig["shell"];
return wert === "powershell" || wert === "bash" || wert === "cmd" ? wert : STANDARD_SHELL;
}
/** Das Skript eines Abschnitts – aus den **bereits gefuellten** Befehlen. */
function skriptBauenAus(befehle: readonly { titel: string; shell: Shell; befehl: string }[]): string {
if (befehle.length === 0) return "";
// `skriptBauen` liegt in src/lib/shell.ts und erwartet genau diese Form.
return skriptBauen(befehle);
}
export type LaufZustand = {
readonly status: LaufStatus;
readonly anzahlPflichtOffen: number;
/**
* Zahl der Kommentare und Zeitpunkt der letzten Aenderung an den Kopfdaten.
*
* Beides gehoert dazu, weil „zwei Browser sehen die Arbeit des anderen“ sonst
* nur fuer Schrittzustaende gilt: ein Kommentar des Kollegen und ein
* geaenderter Titel blieben unsichtbar, bis jemand von sich aus neu laedt
* (Reviewbefund zu #013).
*/
readonly anzahlKommentare: number;
readonly geaendertAm: Date;
readonly schritte: readonly {
readonly schrittId: string;
readonly zustand: SchrittZustand;
readonly bearbeitetVon: string | null;
readonly bearbeitetAm: Date | null;
}[];
};
/**
* Der schmale Zustand fuer das Polling alle zehn Sekunden.
*
* **Ohne Befehle, und das ist der Punkt.** Ein `{{generiertes_passwort}}`
* entsteht bei jeder Aufloesung neu — wuerde das Polling die Befehle
* mitliefern, tauschte es dem Techniker das Passwort unter den Haenden weg,
* waehrend er es abtippt.
*
* Mitgeliefert wird dagegen alles, was eine Aenderung **anzeigt**: Status,
* offene Pflichtschritte, Schrittzustaende, die Zahl der Kommentare und
* `geaendertAm` des Laufs. Der Client vergleicht die Antwort als Ganzes mit der
* vorigen; was hier fehlt, bleibt im anderen Browser unsichtbar.
*/
export async function laufZustand(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<LaufZustand | undefined> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: {
status: true,
geaendertAm: true,
_count: { select: { kommentare: true } },
ergebnisse: {
select: {
vorlagenSchrittId: true,
zustand: true,
aktualisiertAm: true,
aktualisiertVon: { select: { name: true } },
vorlagenSchritt: { select: { pflicht: true } },
},
},
},
});
if (!lauf) return undefined;
return {
status: lauf.status,
anzahlKommentare: lauf._count.kommentare,
geaendertAm: lauf.geaendertAm,
anzahlPflichtOffen: lauf.ergebnisse.filter(
(ergebnis) => ergebnis.vorlagenSchritt.pflicht && !istVomTisch(ergebnis.zustand),
).length,
schritte: lauf.ergebnisse.map((ergebnis) => ({
schrittId: ergebnis.vorlagenSchrittId,
zustand: ergebnis.zustand,
// Siehe `laufZumAbarbeiten`: an einem offenen Schritt ist der Zeitstempel
// nur der Anlegezeitpunkt der Zeile und sagt nichts.
bearbeitetVon: ergebnis.aktualisiertVon?.name ?? (istOffen(ergebnis.zustand) ? null : "System"),
bearbeitetAm: istOffen(ergebnis.zustand) ? null : ergebnis.aktualisiertAm,
})),
};
}
export type KommentarZeile = {
readonly id: string;
readonly rumpf: string;
readonly benutzer: string;
readonly erstelltAm: Date;
/** Leer: der Kommentar haengt am Lauf und nicht an einem Schritt. */
readonly schrittergebnisId: string | null;
};
/**
* Die Kommentare eines Laufs, aelteste zuerst.
*
* Alle auf einmal statt je Schritt: ein Lauf hat selten mehr als eine Handvoll,
* und eine Abfrage je Schritt waeren bei 150 Schritten 150 Abfragen.
*/
export async function kommentareDesLaufs(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly KommentarZeile[]> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: { id: true },
});
if (!lauf) return [];
const zeilen = await prisma.kommentar.findMany({
where: { laufId },
orderBy: { erstelltAm: "asc" },
select: {
id: true,
rumpf: true,
erstelltAm: true,
schrittergebnisId: true,
benutzer: { select: { name: true } },
},
});
return zeilen.map((zeile) => ({
id: zeile.id,
rumpf: zeile.rumpf,
benutzer: zeile.benutzer.name,
erstelltAm: zeile.erstelltAm,
schrittergebnisId: zeile.schrittergebnisId,
}));
}
/**
* Die Personen, mit denen sich dieser Lauf verknuepfen laesst.
*
* Nur Personen **desselben Kunden**: der Dienst weist alles andere ohnehin ab,
* und eine Liste, die fremde Namen zeigt, verraet sie trotzdem. Gekappt bei 500,
* wie die Listen im Start-Dialog; wer die Person dort nicht findet, traegt den
* UPN ein.
*/
export async function personenZurVerknuepfung(
laufId: string,
benutzer: HandelnderBenutzer,
): Promise<readonly { id: string; anzeigename: string }[]> {
const lauf = await prisma.lauf.findFirst({
where: { AND: [{ id: laufId }, ...(await kundenBedingung(benutzer))] },
select: { kundeId: true },
});
if (!lauf) return [];
return prisma.mitarbeiter.findMany({
where: { kundeId: lauf.kundeId, aktiv: true },
orderBy: { anzeigename: "asc" },
take: 500,
select: { id: true, anzeigename: true },
});
}
export type StartbareVorlage = {
readonly versionId: string;
readonly vorlageId: string;
readonly name: string;
readonly version: number;
readonly kategorie: string | null;
readonly variablen: readonly {
readonly schluessel: string;
readonly beschriftung: string;
readonly typ: string;
readonly optionen: readonly string[];
readonly pflicht: boolean;
}[];
};
/**
* Die Vorlagen, an denen sich ein Lauf starten laesst.
*
* Genau die **zuletzt veroeffentlichte** Version je Vorlage — nicht alle. Wer
* einen Lauf startet, will die geltende Fassung; eine Liste mit allen Versionen
* waere eine Wahl, die niemand treffen will und bei der man sich vertun kann.
* Archivierte Vorlagen stehen nicht dabei.
*/
export async function startbareVorlagen(benutzer: HandelnderBenutzer): Promise<readonly StartbareVorlage[]> {
const sichtbar = await sichtbareKundenIds(benutzer);
const vorlagen = await prisma.vorlage.findMany({
where: {
AND: [
{ archiviert: false },
// Vorlagen ohne Kundenbezug gelten fuer alle (#009).
...(sichtbar === undefined ? [] : [{ OR: [{ kundeId: null }, { kundeId: { in: [...sichtbar] } }] }]),
],
},
orderBy: [{ kategorie: "asc" }, { name: "asc" }],
select: {
id: true,
name: true,
kategorie: true,
versionen: {
where: { status: "VEROEFFENTLICHT" },
orderBy: { version: "desc" },
take: 1,
select: {
id: true,
version: true,
variablen: {
orderBy: { position: "asc" },
select: {
schluessel: true,
beschriftung: true,
typ: true,
optionen: true,
pflicht: true,
},
},
},
},
},
});
const startbare: StartbareVorlage[] = [];
for (const vorlage of vorlagen) {
const version = vorlage.versionen[0];
// Eine Vorlage ohne veroeffentlichte Version laesst sich nicht starten – ein
// Entwurf ist noch niemandes Arbeitsanweisung.
if (!version) continue;
startbare.push({
versionId: version.id,
vorlageId: vorlage.id,
name: vorlage.name,
version: version.version,
kategorie: vorlage.kategorie,
variablen: version.variablen,
});
}
return startbare;
}
export type StartAuswahl = {
readonly personen: readonly { id: string; anzeigename: string }[];
readonly geraete: readonly { id: string; anzeige: string; besitzerId: string | null }[];
readonly abteilungen: readonly { id: string; name: string }[];
readonly bearbeiter: readonly { id: string; name: string }[];
};
/**
* Was im Start-Dialog zur Auswahl steht.
*
* Gekappt bei 500 je Liste — ein Auswahlfeld mit zehntausend Eintraegen ist
* keine Auswahl. **Der Dialog sagt das auch**, wenn eine Liste voll ist; die
* Suche ueber alle kommt mit der Geraeteseite (#014). Ohne den Hinweis fehlte
* das 501. Geraet stillschweigend, und niemand wuesste, wo es geblieben ist.
*/
export async function startAuswahl(benutzer: HandelnderBenutzer): Promise<StartAuswahl> {
const mandant = await kundenBedingung(benutzer);
const [personen, geraete, abteilungen, bearbeiter] = await Promise.all([
prisma.mitarbeiter.findMany({
where: { AND: [{ aktiv: true }, ...mandant] },
orderBy: { anzeigename: "asc" },
take: 500,
select: { id: true, anzeigename: true },
}),
prisma.geraet.findMany({
where: { AND: [{ archiviert: false }, ...mandant] },
orderBy: [{ hostname: "asc" }, { bezeichnung: "asc" }],
take: 500,
select: { id: true, hostname: true, bezeichnung: true, tagCode: true, mitarbeiterId: true },
}),
prisma.abteilung.findMany({
where: { AND: [{ archiviert: false }, ...mandant] },
orderBy: { name: "asc" },
take: 500,
select: { id: true, name: true },
}),
/*
* Zuweisen laesst sich nur an jemanden, der den Lauf auch oeffnen koennte
* (Reviewbefund zu #013). Vorher stand hier jeder Bearbeiter des Hauses:
* eine Technikerin von Kunde B konnte einen Lauf an den Techniker von Kunde
* A zuweisen, der ihn dann nicht einmal sah — und der Dienst lehnte es beim
* Anlegen mit einer Meldung ab, die niemand erwartet hatte.
*
* Wer an **keinen** Kunden gebunden ist, sieht alle und steht deshalb immer
* zur Auswahl; die Rollen `ADMIN` und `REDAKTEUR` sind ohnehin nicht
* gebunden (siehe `sichtbareKundenIds`).
*/
prisma.benutzer.findMany({
where: {
aktiv: true,
rolle: { in: ["ADMIN", "REDAKTEUR", "TECHNIKER"] },
...(mandant.length === 0
? {}
: { OR: [{ kundenbindungen: { none: {} } }, { kundenbindungen: { some: mandant[0] } }] }),
},
orderBy: { name: "asc" },
take: 500,
select: { id: true, name: true },
}),
]);
return {
personen,
geraete: geraete.map((geraet) => ({
id: geraet.id,
anzeige: `${geraet.hostname ?? geraet.bezeichnung ?? geraet.tagCode} · ${geraet.tagCode}`,
besitzerId: geraet.mitarbeiterId,
})),
abteilungen,
bearbeiter,
};
}
+31 -11
View File
@@ -7,7 +7,7 @@ import {
type Namenswerte,
type Zielformat,
} from "@/lib/namensregeln";
import { PASSWORT_HINWEIS } from "@/lib/passwort";
import { PASSWORT_HINWEIS, passwortErzeugen } from "@/lib/passwort";
import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
import {
platzhalterAusserhalbAnfuehrungszeichen,
@@ -71,7 +71,16 @@ export type FertigerBefehl = {
* Der Titel wird **ohne** Shell-Maskierung ersetzt: er wird gelesen, nicht
* ausgefuehrt. Ein maskierter Titel stuende mit Backticks auf dem Bildschirm.
*/
export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte): FertigerBefehl {
export function befehlFuellen(
schritt: BefehlsSchritt,
werte: PlatzhalterWerte,
/**
* Das Passwort dieses Durchgangs. Ohne Angabe bekommt jeder Befehl sein
* eigenes — richtig fuer einen einzelnen, falsch fuer einen Lauf, in dem ein
* Schritt das Konto anlegt und der naechste das Passwort uebergibt.
*/
passwort?: string,
): FertigerBefehl {
const titel = platzhalterErsetzen(schritt.titel, werte).text;
/*
@@ -97,10 +106,12 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
}
try {
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
art: "befehl",
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
});
const befehl = platzhalterErsetzen(
schritt.befehl,
werte,
{ art: "befehl", maskieren: (wert) => wertMaskieren(wert, schritt.shell) },
passwort,
);
return {
titel,
shell: schritt.shell,
@@ -139,9 +150,10 @@ export function befehlFuellen(schritt: BefehlsSchritt, werte: PlatzhalterWerte):
/**
* Alle Befehle eines Abschnitts als ein Skript zum Kopieren.
*
* **Die Passwoerter darin entstehen genau jetzt.** Wer das Skript zweimal
* kopiert, bekommt zweimal verschiedene — das ist die ehrliche Folge der Regel
* „bei jeder Anzeige neu“ und der Grund, warum der Hinweis daneben steht.
* **Das Passwort darin entsteht genau jetzt.** Wer die Seite neu laedt, bekommt
* ein anderes — das ist die ehrliche Folge der Regel „bei jeder Anzeige neu“ und
* der Grund, warum der Hinweis daneben steht. Innerhalb eines Aufbaus ist es
* ueberall dasselbe.
*/
/**
* Ein Skript aus **bereits gefuellten** Schritten.
@@ -184,6 +196,7 @@ export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
export function skriptFuerAbschnitt(
schritte: readonly BefehlsSchritt[],
werte: PlatzhalterWerte,
passwort?: string,
): {
readonly skript: string;
readonly mitPasswort: boolean;
@@ -191,7 +204,7 @@ export function skriptFuerAbschnitt(
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
readonly nichtMaskierbar: readonly string[];
} {
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte)));
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte, passwort)));
}
/**
@@ -234,6 +247,13 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
if (!lauf) return [];
const werte = await platzhalterWerteDesLaufs(kontext);
/*
* **Ein** Passwort fuer den ganzen Lauf, nicht eines je Befehl: der Schritt
* „Konto anlegen“ und der Schritt „Passwort uebergeben“ meinen dasselbe.
* Erzeugt wird es hier und nirgends gespeichert; beim naechsten Abruf ist es
* ein anderes.
*/
const passwort = passwortErzeugen();
return lauf.vorlagenVersion.abschnitte
.map((abschnitt) => {
@@ -244,7 +264,7 @@ export async function befehleDesLaufs(kontext: LaufKontext): Promise<
}));
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte));
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte, passwort));
const skript = skriptAusFertigen(gefuellt);
return {
abschnitt: abschnitt.titel,
+80
View File
@@ -0,0 +1,80 @@
import type { Kommentar } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import type { HandelnderBenutzer } from "@/server/mandant";
import { LaufFehler, laufZumSchreibenHolen } from "@/server/services/laeufe";
/**
* Kommentare an Laeufen und Schrittergebnissen (#013).
*
* Der Ort fuer das, was in kein Feld passt: „Kunde war nicht erreichbar“,
* „Lizenz kommt am Montag“, „Netzteil defekt, Ersatz bestellt“. Ein Kommentar
* traegt seinen Urheber und seinen Zeitpunkt selbst, und er wird nie geaendert —
* deshalb gibt es hier nur einen Weg hinein und keinen hinaus. Das ist auch der
* Grund, warum ein Kommentar **keinen** Protokolleintrag bekommt: er ist schon
* einer. Ein zweiter Eintrag, der dasselbe noch einmal sagt, wuerde das Protokoll
* nur laenger machen.
*
* Der Schreibschutz gilt trotzdem: an einem archivierten Lauf kommt kein
* Kommentar mehr dazu. Der Weg dorthin ist derselbe wie bei den
* Schrittergebnissen — `laufZumSchreibenHolen`, und darin
* `laufMussVeraenderbarSein`.
*/
/**
* Obergrenze je Kommentar.
*
* Ohne Grenze landet ein versehentlich eingefuegtes Ereignisprotokoll in der
* Datenbank und in jeder Druckansicht. Viertausend Zeichen sind ungefaehr zwei
* DIN-A4-Seiten — mehr als genug fuer eine Notiz, und die Meldung nennt die Zahl.
*/
export const KOMMENTAR_HOECHSTLAENGE = 4000;
export type NeuerKommentar = {
readonly laufId: string;
/** Leer: der Kommentar haengt am Lauf und nicht an einem einzelnen Schritt. */
readonly schrittergebnisId?: string | null;
readonly rumpf: string;
};
/**
* Schreibt einen Kommentar.
*
* Ein Kommentar **startet den Lauf nicht**: eine Rueckfrage zu notieren ist keine
* Abarbeitung, und ein zugewiesener Lauf, der von einer Notiz auf `IN_ARBEIT`
* springt, saehe in der Liste aus wie begonnene Arbeit.
*/
export async function kommentarHinzufuegen(
eingabe: NeuerKommentar,
benutzer: HandelnderBenutzer,
): Promise<Kommentar> {
const rumpf = eingabe.rumpf.trim();
if (!rumpf) throw new LaufFehler("Ein leerer Kommentar hilft niemandem.");
if (rumpf.length > KOMMENTAR_HOECHSTLAENGE) {
throw new LaufFehler(
`Der Kommentar ist ${rumpf.length} Zeichen lang; erlaubt sind ${KOMMENTAR_HOECHSTLAENGE}. ` +
"Längeres gehört in die Notiz am Schritt oder ins Ticket.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const schrittergebnisId = eingabe.schrittergebnisId?.trim() || null;
if (schrittergebnisId) {
// Die Kennung kommt von aussen. Ohne diese Pruefung hinge der Kommentar an
// einem Schritt eines fremden Laufs – und damit moeglicherweise eines
// fremden Kunden, dessen Schreibschutz nie geprueft wurde.
const ergebnis = await tx.schrittergebnis.findUnique({
where: { id: schrittergebnisId },
select: { laufId: true },
});
if (!ergebnis || ergebnis.laufId !== lauf.id) {
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
}
}
return tx.kommentar.create({
data: { laufId: lauf.id, schrittergebnisId, benutzerId: benutzer.id, rumpf },
});
});
}
File diff suppressed because it is too large Load Diff
+314
View File
@@ -0,0 +1,314 @@
import { Prisma, type Schrittergebnis } from "@/generated/prisma/client";
import { SchrittZustand } from "@/generated/prisma/enums";
import { prisma } from "@/lib/db";
import type { HandelnderBenutzer } from "@/server/mandant";
import {
LaufFehler,
abschlussPruefenNachAenderung,
laufStartenFallsOffen,
laufZumSchreibenHolen,
} from "@/server/services/laeufe";
/**
* Schrittergebnisse (#013).
*
* Ein Schritt wird **einzeln** gesetzt, nicht als Gesamtformular: der Techniker
* arbeitet einen Laufzettel von oben nach unten ab, oft am Handy, oft
* unterbrochen. Ein Formular, das am Ende alles auf einmal schickt, verliert
* genau dann die Arbeit, wenn es darauf ankommt.
*
* Drei Regeln stehen hier:
*
* 1. **`NICHT_ZUTREFFEND` und `FEHLGESCHLAGEN` verlangen eine Notiz.** Sonst
* stuende im Archiv ein uebersprungener Schritt ohne Begruendung — und genau
* das ist die Luecke, die ein Archiv wertlos macht. Die Bedingung
* `Schrittergebnis_begruendung_noetig` haelt dasselbe in der Datenbank fest.
* 2. **Jede Aenderung bekommt einen Protokolleintrag**, in derselben
* Transaktion wie die Aenderung selbst.
* 3. **Der Schreibschutz des Archivs laeuft durch `laufZumSchreibenHolen`** —
* die eine Stelle, die `laufMussVeraenderbarSein` ruft und die Laufzeile
* sperrt.
*/
/** Wie ein Zustand im Protokoll heisst. Die Oberflaeche hat ihre eigenen Texte. */
const ZUSTAND_AKTION: Readonly<Record<SchrittZustand, string>> = {
OFFEN: "Schritt zurückgesetzt",
ERLEDIGT: "Schritt erledigt",
NICHT_ZUTREFFEND: "Schritt als nicht zutreffend eingetragen",
FEHLGESCHLAGEN: "Schritt als fehlgeschlagen eingetragen",
};
export type SchrittergebnisEingabe = {
readonly laufId: string;
readonly vorlagenSchrittId: string;
readonly zustand: SchrittZustand;
/**
* Der erfasste Wert. Fehlt das Feld, bleibt der vorhandene Wert stehen;
* `null` loescht ihn. Ein Haken, der einen Text mitbringt, und ein Zustand, der
* ohne Wert wechselt, sind verschiedene Vorgaenge.
*/
readonly wert?: Prisma.InputJsonValue | null;
/** Fehlt das Feld, bleibt die vorhandene Notiz stehen; `null` loescht sie. */
readonly notiz?: string | null;
};
function text(wert: string | null | undefined): string | null {
return wert?.trim() || null;
}
/**
* Die Notizpflicht, an einer Stelle.
*
* Geprueft wird der **Zustand danach**: wer einen Schritt mit vorhandener Notiz
* auf `NICHT_ZUTREFFEND` setzt, muss sie nicht noch einmal eintippen; wer die
* Notiz im selben Zug leert, faellt hierauf.
*/
function notizPflichtPruefen(zustand: SchrittZustand, notiz: string | null): void {
if (notiz !== null) return;
if (zustand === SchrittZustand.NICHT_ZUTREFFEND) {
throw new LaufFehler(
"Ein Schritt, der nicht zutrifft, braucht eine Begründung — sonst steht im Archiv ein " +
"übersprungener Schritt, und niemand weiß mehr, warum.",
);
}
if (zustand === SchrittZustand.FEHLGESCHLAGEN) {
throw new LaufFehler(
"Ein fehlgeschlagener Schritt braucht eine Notiz: was ist passiert und was ist zu tun?",
);
}
}
/**
* Prueft, dass dieser Schritt zu diesem Lauf gehoert.
*
* Die Schrittkennung kommt von aussen. Ohne diese Pruefung liesse sich ein
* Schritt einer fremden Version — und damit eines fremden Kunden — an diesen Lauf
* haengen; in der Abarbeitungsansicht tauchte er nie auf, im Archiv aber schon.
*/
async function schrittDesLaufsHolen(
tx: Prisma.TransactionClient,
vorlagenSchrittId: string,
vorlagenVersionId: string,
): Promise<{ readonly id: string; readonly titel: string; readonly pflicht: boolean }> {
const schritt = await tx.vorlagenSchritt.findUnique({
where: { id: vorlagenSchrittId },
select: { id: true, titel: true, pflicht: true, abschnitt: { select: { versionId: true } } },
});
if (!schritt || schritt.abschnitt.versionId !== vorlagenVersionId) {
throw new LaufFehler("Dieser Schritt gehört nicht zu diesem Lauf.");
}
return schritt;
}
/**
* Setzt ein Schrittergebnis.
*
* Nebenwirkung mit Absicht: ein `OFFEN`er Lauf wechselt dabei auf `IN_ARBEIT`.
* Wer einen Schritt eintraegt, arbeitet den Lauf ab — ein Lauf mit
* Schrittergebnissen, aber ohne `gestartetAm`, waere eine Zeitleiste, die luegt.
*
* Aendert die Eingabe nichts, wird auch nichts geschrieben: ein Doppelklick soll
* keine zwei Protokolleintraege ergeben.
*/
export async function schrittergebnisSetzen(
eingabe: SchrittergebnisEingabe,
benutzer: HandelnderBenutzer,
): Promise<Schrittergebnis> {
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const schritt = await schrittDesLaufsHolen(tx, eingabe.vorlagenSchrittId, lauf.vorlagenVersionId);
const vorher = await tx.schrittergebnis.findUnique({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
});
const notiz = eingabe.notiz === undefined ? (vorher?.notiz ?? null) : text(eingabe.notiz);
notizPflichtPruefen(eingabe.zustand, notiz);
const wertGeaendert =
eingabe.wert !== undefined &&
JSON.stringify(eingabe.wert ?? null) !== JSON.stringify(vorher?.wert ?? null);
if (vorher && vorher.zustand === eingabe.zustand && vorher.notiz === notiz && !wertGeaendert) {
return vorher;
}
const wert: Prisma.InputJsonValue | typeof Prisma.DbNull | undefined =
eingabe.wert === undefined ? undefined : (eingabe.wert ?? Prisma.DbNull);
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
update: {
zustand: eingabe.zustand,
notiz,
...(wert === undefined ? {} : { wert }),
aktualisiertVonId: benutzer.id,
},
});
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: ZUSTAND_AKTION[eingabe.zustand],
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, wert: vorher.wert ?? null, notiz: vorher.notiz }
: Prisma.DbNull,
nachher: {
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: nachher.zustand,
wert: nachher.wert ?? null,
notiz: nachher.notiz,
},
},
});
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
return nachher;
});
}
export type AbschnittEingabe = {
readonly laufId: string;
readonly abschnittId: string;
/** Gilt fuer jeden Schritt des Abschnitts – einmal eingetippt, einmal gespeichert. */
readonly notiz: string;
};
export type AbschnittErgebnis = {
readonly gesetzt: number;
/** Schritte, die schon `ERLEDIGT` waren und deshalb geblieben sind. */
readonly uebersprungen: readonly string[];
};
/**
* Setzt einen ganzen Abschnitt auf `NICHT_ZUTREFFEND`.
*
* Der Fall aus dem Auftrag: im Onboarding gibt es zwei Konto-Abschnitte, und je
* nach Kunde trifft einer davon gar nicht zu. Achtzehn Haken einzeln mit
* derselben Begruendung zu versehen macht niemand — und wer es nicht macht, laesst
* sie offen.
*
* **Schon `ERLEDIGT`e Schritte bleiben `ERLEDIGT`.** Das ist die interessante
* Entscheidung, deshalb der Grund: „erledigt“ ist die Aussage, dass jemand etwas
* wirklich getan hat, mit Namen und Zeitstempel. „Nicht zutreffend“ ist die
* Aussage, dass es nichts zu tun gab. Die zweite ueber die erste zu schreiben
* waere eine Tatsache durch eine Behauptung zu ersetzen und wuerde das „wer wann“
* dieser Arbeit loeschen. Wer wirklich einen erledigten Schritt zurueckdrehen
* will, tut das einzeln und begruendet es dort. Die Zahl der uebersprungenen
* Schritte kommt zurueck, damit die Oberflaeche es sagen kann.
*
* `FEHLGESCHLAGEN` wird dagegen ueberschrieben: ein Schritt, der nicht zutrifft,
* kann nicht fehlgeschlagen sein — er haette gar nicht versucht werden muessen.
*/
export async function abschnittNichtZutreffend(
eingabe: AbschnittEingabe,
benutzer: HandelnderBenutzer,
): Promise<AbschnittErgebnis> {
const notiz = text(eingabe.notiz);
if (!notiz) {
throw new LaufFehler(
"Ein ganzer Abschnitt, der nicht zutrifft, braucht eine Begründung — sie steht danach an " +
"jedem Schritt des Abschnitts.",
);
}
return prisma.$transaction(async (tx) => {
const lauf = await laufZumSchreibenHolen(tx, eingabe.laufId, benutzer);
const abschnitt = await tx.vorlagenAbschnitt.findUnique({
where: { id: eingabe.abschnittId },
select: {
titel: true,
versionId: true,
schritte: { orderBy: { position: "asc" }, select: { id: true, titel: true, pflicht: true } },
},
});
if (!abschnitt || abschnitt.versionId !== lauf.vorlagenVersionId) {
throw new LaufFehler("Dieser Abschnitt gehört nicht zu diesem Lauf.");
}
const vorhandene = new Map(
(
await tx.schrittergebnis.findMany({
where: { laufId: lauf.id, vorlagenSchrittId: { in: abschnitt.schritte.map((s) => s.id) } },
})
).map((ergebnis) => [ergebnis.vorlagenSchrittId, ergebnis]),
);
const uebersprungen: string[] = [];
let gesetzt = 0;
for (const schritt of abschnitt.schritte) {
const vorher = vorhandene.get(schritt.id);
if (vorher?.zustand === SchrittZustand.ERLEDIGT) {
uebersprungen.push(schritt.titel);
continue;
}
if (vorher?.zustand === SchrittZustand.NICHT_ZUTREFFEND && vorher.notiz === notiz) {
// Schon so eingetragen, mit derselben Begruendung: nichts zu tun und kein
// zweiter Protokolleintrag.
continue;
}
const nachher = await tx.schrittergebnis.upsert({
where: { laufId_vorlagenSchrittId: { laufId: lauf.id, vorlagenSchrittId: schritt.id } },
create: {
laufId: lauf.id,
vorlagenSchrittId: schritt.id,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
update: {
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
aktualisiertVonId: benutzer.id,
},
});
gesetzt += 1;
// Je Schritt ein Eintrag: „jede Aenderung an einem Schrittergebnis steht im
// Protokoll“ gilt auch, wenn achtzehn davon aus einem Klick entstehen. Der
// Abschnitt steht dabei, damit die Herkunft erkennbar bleibt.
await tx.protokoll.create({
data: {
laufId: lauf.id,
entitaet: "Schrittergebnis",
entitaetId: nachher.id,
aktion: "Abschnitt als nicht zutreffend eingetragen",
benutzerId: benutzer.id,
vorher: vorher
? { zustand: vorher.zustand, notiz: vorher.notiz }
: { zustand: SchrittZustand.OFFEN, notiz: null },
nachher: {
abschnitt: abschnitt.titel,
schritt: schritt.titel,
pflicht: schritt.pflicht,
zustand: SchrittZustand.NICHT_ZUTREFFEND,
notiz,
},
},
});
}
if (gesetzt > 0) {
const status = await laufStartenFallsOffen(tx, lauf, benutzer.id);
await abschlussPruefenNachAenderung(tx, { id: lauf.id, status }, benutzer.id);
}
return { gesetzt, uebersprungen };
});
}