764 lines
27 KiB
TypeScript
764 lines
27 KiB
TypeScript
import type { Prisma } from "@/generated/prisma/client";
|
||
import { LaufStatus, SchrittTyp, SchrittZustand } from "@/generated/prisma/enums";
|
||
import { prisma } from "@/lib/db";
|
||
import { nurGeltende } from "@/lib/geltende-abschnitte";
|
||
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: {
|
||
// Beim Start festgeschriebene Abschnittsauswahl (#031).
|
||
geltendeAbschnitte: true,
|
||
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 geltende = nurGeltende(lauf.vorlagenVersion.abschnitte, lauf.geltendeAbschnitte);
|
||
const abschnitte = geltende.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,
|
||
/*
|
||
* **Dasselbe Passwort wie im Befehl.** Titel und Hilfetext bekommen das
|
||
* Passwort dieses Seitenaufbaus mit, nicht jeweils ein eigenes. Ohne das
|
||
* `passwort`-Argument erzeugt jede Aufloesung ein neues: im Befehl stuende
|
||
* A, im Hilfetext daneben B — das Konto bekaeme A, der Benutzer erfuehre
|
||
* B, und A waere danach weg. Genau davor warnt `platzhalter-werte.ts` in
|
||
* Regel 4 seiner eigenen Kopfdokumentation (Reviewbefund zu #017).
|
||
*/
|
||
titel: platzhalterErsetzen(schritt.titel, werte, { art: "anzeige" }, passwort).text,
|
||
hilfetext: schritt.hilfetext
|
||
? platzhalterErsetzen(schritt.hilfetext, werte, { art: "anzeige" }, passwort).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;
|
||
}[];
|
||
/**
|
||
* Abschnitte, die nur fuer bestimmte Abteilungen gelten (#031).
|
||
*
|
||
* Der Start-Dialog sagt damit **vorher**, was die gewaehlte Abteilung
|
||
* hinzunimmt. Ohne das waere die Zahl der Schritte eine Ueberraschung, und wer
|
||
* sich bei der Abteilung vertippt, merkt es erst in der Abarbeitung.
|
||
*/
|
||
readonly abteilungsAbschnitte: readonly {
|
||
readonly titel: string;
|
||
readonly abteilungen: readonly string[];
|
||
}[];
|
||
};
|
||
|
||
/**
|
||
* 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,
|
||
},
|
||
},
|
||
abschnitte: {
|
||
where: { NOT: { nurFuerAbteilungen: { isEmpty: true } } },
|
||
orderBy: { position: "asc" },
|
||
select: { titel: true, nurFuerAbteilungen: 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,
|
||
abteilungsAbschnitte: version.abschnitte.map((abschnitt) => ({
|
||
titel: abschnitt.titel,
|
||
abteilungen: abschnitt.nurFuerAbteilungen,
|
||
})),
|
||
});
|
||
}
|
||
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,
|
||
};
|
||
}
|