feat(laeufe): Service, Abarbeitung, Kommentare und Archivschutz
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user