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 { 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 { 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; 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 { 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; 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, werte: Readonly>, /** 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): 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 { 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 { 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 { 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 { 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 { 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, }; }