feat(platzhalter): Aufloesung, Namensregeln und Shell-Maskierung (#26)
This commit was merged in pull request #26.
This commit is contained in:
@@ -0,0 +1,367 @@
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import {
|
||||
namenEindeutigBilden,
|
||||
NamensregelnSchema,
|
||||
type Namensregeln,
|
||||
type Namenswerte,
|
||||
type Zielformat,
|
||||
} from "@/lib/namensregeln";
|
||||
import { PASSWORT_HINWEIS } from "@/lib/passwort";
|
||||
import { platzhalterErsetzen, type ErsetzungsErgebnis } from "@/lib/platzhalter-werte";
|
||||
import {
|
||||
platzhalterAusserhalbAnfuehrungszeichen,
|
||||
ShellFehler,
|
||||
skriptBauen,
|
||||
wertMaskieren,
|
||||
type Shell,
|
||||
type SkriptSchritt,
|
||||
} from "@/lib/shell";
|
||||
import { STANDARD_SHELL } from "@/lib/editor-aufbau";
|
||||
import { betriebsmodus, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { platzhalterWerteDesLaufs, type LaufKontext, type PlatzhalterWerte } from "./platzhalter";
|
||||
|
||||
/**
|
||||
* Fertige Befehle und Namensvorschlaege (#012).
|
||||
*
|
||||
* Hier laufen die drei Teile zusammen: die Werte eines Laufs
|
||||
* (`services/platzhalter.ts`), das Ersetzen (`lib/platzhalter-werte.ts`) und die
|
||||
* Maskierung je Shell (`lib/shell.ts`). Das Ergebnis ist der Text, den ein
|
||||
* Techniker kopiert — und alles, was ihn verfaelscht, wird hier verfaelscht.
|
||||
*
|
||||
* Die Reihenfolge ist nicht beliebig: **erst ersetzen, dann maskieren** waere
|
||||
* falsch. Maskiert wird der **eingesetzte Wert**, nicht der fertige Befehl —
|
||||
* sonst maskierte man die Anfuehrungszeichen des Befehls gleich mit.
|
||||
* `platzhalterErsetzen` bekommt die Maskierung deshalb als Funktion und wendet
|
||||
* sie auf jeden Wert einzeln an.
|
||||
*/
|
||||
|
||||
export type BefehlsSchritt = {
|
||||
readonly titel: string;
|
||||
readonly shell: Shell;
|
||||
readonly befehl: string;
|
||||
};
|
||||
|
||||
/** Ein Schritt mit eingesetzten Werten, bereit zum Kopieren. */
|
||||
export type FertigerBefehl = {
|
||||
readonly titel: string;
|
||||
readonly shell: Shell;
|
||||
readonly befehl: string;
|
||||
/** Platzhalter, die niemand kennt – sie stehen im Befehl noch als `{{name}}`. */
|
||||
readonly unbekannt: readonly string[];
|
||||
/** Kam `{{generiertes_passwort}}` vor? Dann gehoert der Hinweis daneben. */
|
||||
readonly mitPasswort: boolean;
|
||||
/**
|
||||
* Der Satz, der neben einem Passwort stehen muss. Steht hier und nicht in der
|
||||
* Oberflaeche, damit jede Anzeige denselben bekommt — ein Passwort ohne diesen
|
||||
* Hinweis ist ein verlorenes Passwort.
|
||||
*/
|
||||
readonly hinweis?: string;
|
||||
/**
|
||||
* Gesetzt, wenn sich ein Wert fuer diese Shell **nicht sicher** maskieren
|
||||
* laesst. Dann steht in `befehl` der Befehl mit stehen gebliebenen
|
||||
* Platzhaltern, und die Oberflaeche bietet kein Kopieren an.
|
||||
*/
|
||||
readonly fehler?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Setzt die Werte in einen einzelnen Befehl ein.
|
||||
*
|
||||
* 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 {
|
||||
const titel = platzhalterErsetzen(schritt.titel, werte).text;
|
||||
|
||||
/*
|
||||
* Erst die Annahme pruefen, auf der die ganze Maskierung ruht: steht der
|
||||
* Platzhalter nicht in Anfuehrungszeichen, kann keine Maskierung daraus noch
|
||||
* **ein** Argument machen. Ein Leerzeichen im Wert zerlegt ihn, ein `;`
|
||||
* beendet den Befehl.
|
||||
*/
|
||||
const nackt = platzhalterAusserhalbAnfuehrungszeichen(schritt.befehl, schritt.shell);
|
||||
if (nackt.length > 0) {
|
||||
const zeichen = schritt.shell === "bash" ? "einfache" : "doppelte";
|
||||
return {
|
||||
titel,
|
||||
shell: schritt.shell,
|
||||
befehl: schritt.befehl,
|
||||
unbekannt: [],
|
||||
mitPasswort: false,
|
||||
fehler:
|
||||
`${nackt.map((name) => `„{{${name}}}“`).join(", ")} steht nicht in Anführungszeichen. ` +
|
||||
`Ein eingesetzter Wert lässt sich dann nicht sicher maskieren — bitte ${zeichen} ` +
|
||||
"Anführungszeichen um den Platzhalter setzen.",
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const befehl = platzhalterErsetzen(schritt.befehl, werte, {
|
||||
art: "befehl",
|
||||
maskieren: (wert) => wertMaskieren(wert, schritt.shell),
|
||||
});
|
||||
return {
|
||||
titel,
|
||||
shell: schritt.shell,
|
||||
befehl: befehl.text,
|
||||
unbekannt: befehl.unbekannt,
|
||||
mitPasswort: befehl.mitPasswort,
|
||||
...(befehl.mitPasswort ? { hinweis: PASSWORT_HINWEIS } : {}),
|
||||
};
|
||||
} catch (fehler) {
|
||||
/*
|
||||
* `wertMaskieren` **wirft** fuer `cmd`, wenn ein Wert ein
|
||||
* Anfuehrungszeichen oder einen Umbruch enthaelt: dort laesst sich das nicht
|
||||
* sicher maskieren, und ein `"` beendet das Argument — alles danach liest
|
||||
* cmd.exe als Befehl.
|
||||
*
|
||||
* Ein ungefangener Wurf risse hier die ganze Abarbeitungsansicht ab. Statt
|
||||
* dessen bleibt der Befehl mit seinen Platzhaltern stehen und traegt die
|
||||
* Begruendung: der Techniker sieht, was gemeint war, und dass er es von Hand
|
||||
* einsetzen muss. Einen halb maskierten Befehl zum Kopieren anzubieten waere
|
||||
* die gefaehrlichste aller Antworten.
|
||||
*/
|
||||
if (fehler instanceof ShellFehler) {
|
||||
return {
|
||||
titel,
|
||||
shell: schritt.shell,
|
||||
befehl: schritt.befehl,
|
||||
unbekannt: [],
|
||||
mitPasswort: false,
|
||||
fehler: fehler.message,
|
||||
};
|
||||
}
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
/**
|
||||
* Ein Skript aus **bereits gefuellten** Schritten.
|
||||
*
|
||||
* Entscheidend, sobald ein `{{generiertes_passwort}}` im Spiel ist: es entsteht
|
||||
* bei jedem Fuellen neu. Wer dieselben Schritte zweimal fuellt — einmal fuer die
|
||||
* Anzeige, einmal fuer das Skript —, zeigt dem Techniker Passwort X und gibt ihm
|
||||
* Passwort Y zum Kopieren. Er legt das Konto mit Y an und nennt dem Benutzer X,
|
||||
* und weil nichts gespeichert wird, ist Y danach unwiederbringlich.
|
||||
*/
|
||||
export function skriptAusFertigen(fertige: readonly FertigerBefehl[]): {
|
||||
readonly skript: string;
|
||||
readonly mitPasswort: boolean;
|
||||
readonly unbekannt: readonly string[];
|
||||
readonly nichtMaskierbar: readonly string[];
|
||||
} {
|
||||
/*
|
||||
* Ein Schritt, der sich nicht sicher maskieren laesst, faellt aus dem Skript
|
||||
* heraus — mit seinen Platzhaltern drin waere es ein Skript, das etwas anderes
|
||||
* tut, als es liest. Gemeldet wird er trotzdem, sonst fehlte er stillschweigend.
|
||||
*/
|
||||
const alsSkript: SkriptSchritt[] = fertige
|
||||
.filter((schritt) => schritt.fehler === undefined)
|
||||
.map((schritt) => ({ titel: schritt.titel, shell: schritt.shell, befehl: schritt.befehl }));
|
||||
|
||||
const unbekannt = new Set<string>();
|
||||
for (const schritt of fertige) for (const name of schritt.unbekannt) unbekannt.add(name);
|
||||
|
||||
return {
|
||||
skript: skriptBauen(alsSkript),
|
||||
mitPasswort: fertige.some((schritt) => schritt.mitPasswort),
|
||||
unbekannt: [...unbekannt],
|
||||
nichtMaskierbar: fertige
|
||||
.filter((schritt) => schritt.fehler !== undefined)
|
||||
.map((schritt) => schritt.titel),
|
||||
};
|
||||
}
|
||||
|
||||
/** Fuellt die Schritte und baut daraus das Skript – in **einem** Durchgang. */
|
||||
export function skriptFuerAbschnitt(
|
||||
schritte: readonly BefehlsSchritt[],
|
||||
werte: PlatzhalterWerte,
|
||||
): {
|
||||
readonly skript: string;
|
||||
readonly mitPasswort: boolean;
|
||||
readonly unbekannt: readonly string[];
|
||||
/** Schritte, deren Werte sich nicht sicher maskieren liessen. */
|
||||
readonly nichtMaskierbar: readonly string[];
|
||||
} {
|
||||
return skriptAusFertigen(schritte.map((schritt) => befehlFuellen(schritt, werte)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Befehle eines Laufs, abschnittsweise.
|
||||
*
|
||||
* Liest die Version, an der der Lauf haengt — nicht die neueste. Ein Lauf zeigt
|
||||
* fuer immer die Schritte, mit denen er gestartet wurde (#009).
|
||||
*/
|
||||
export async function befehleDesLaufs(kontext: LaufKontext): Promise<
|
||||
readonly {
|
||||
readonly abschnitt: string;
|
||||
readonly schritte: readonly FertigerBefehl[];
|
||||
readonly skript: string;
|
||||
/** Platzhalter, die niemand kennt – sie stehen noch als `{{name}}` im Befehl. */
|
||||
readonly unbekannt: readonly string[];
|
||||
/** Schritte, die nicht ins Skript konnten, weil ihre Werte nicht maskierbar sind. */
|
||||
readonly nichtMaskierbar: readonly string[];
|
||||
}[]
|
||||
> {
|
||||
const lauf = await prisma.lauf.findUnique({
|
||||
where: { id: kontext.laufId },
|
||||
select: {
|
||||
vorlagenVersion: {
|
||||
select: {
|
||||
abschnitte: {
|
||||
orderBy: { position: "asc" },
|
||||
select: {
|
||||
titel: true,
|
||||
schritte: {
|
||||
where: { typ: "BEFEHL" },
|
||||
orderBy: { position: "asc" },
|
||||
select: { titel: true, konfig: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!lauf) return [];
|
||||
|
||||
const werte = await platzhalterWerteDesLaufs(kontext);
|
||||
|
||||
return lauf.vorlagenVersion.abschnitte
|
||||
.map((abschnitt) => {
|
||||
const schritte: BefehlsSchritt[] = abschnitt.schritte.map((schritt) => ({
|
||||
titel: schritt.titel,
|
||||
shell: shellAus(schritt.konfig),
|
||||
befehl: befehlAus(schritt.konfig),
|
||||
}));
|
||||
// **Einmal** fuellen und daraus beides bauen: ein zweiter Durchgang
|
||||
// erzeugte ein zweites Passwort (siehe skriptAusFertigen).
|
||||
const gefuellt = schritte.map((schritt) => befehlFuellen(schritt, werte));
|
||||
const skript = skriptAusFertigen(gefuellt);
|
||||
return {
|
||||
abschnitt: abschnitt.titel,
|
||||
schritte: gefuellt,
|
||||
skript: skript.skript,
|
||||
unbekannt: skript.unbekannt,
|
||||
nichtMaskierbar: skript.nichtMaskierbar,
|
||||
};
|
||||
})
|
||||
.filter((abschnitt) => abschnitt.schritte.length > 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Shell und Befehl aus dem `konfig`-Json.
|
||||
*
|
||||
* Das Feld ist ein Json und kann alles enthalten — eine aeltere Fassung, ein
|
||||
* Import, ein Eingriff per psql. Was nicht passt, wird zum Standard bzw. leer;
|
||||
* ein leerer Befehl faellt aus dem Skript heraus, statt es zu sprengen.
|
||||
*/
|
||||
function shellAus(konfig: unknown): Shell {
|
||||
const wert = lesen(konfig, "shell");
|
||||
return wert === "powershell" || wert === "bash" || wert === "cmd" ? wert : STANDARD_SHELL;
|
||||
}
|
||||
|
||||
function befehlAus(konfig: unknown): string {
|
||||
return lesen(konfig, "befehl") ?? "";
|
||||
}
|
||||
|
||||
function lesen(konfig: unknown, schluessel: string): string | undefined {
|
||||
if (typeof konfig !== "object" || konfig === null || Array.isArray(konfig)) return undefined;
|
||||
const wert = (konfig as Record<string, unknown>)[schluessel];
|
||||
return typeof wert === "string" ? wert : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die geltenden Namensregeln.
|
||||
*
|
||||
* Global aus `Einstellung`, im Modus DIENSTLEISTUNG je Kunde ueberlagert: eine
|
||||
* gesetzte Kundenregel gewinnt, eine fehlende faellt auf die globale zurueck.
|
||||
* **Feld fuer Feld**, nicht als Ganzes — ein Kunde, der nur eine eigene
|
||||
* Mail-Domain hat, soll nicht auch seine Hostname-Regel neu schreiben muessen.
|
||||
*/
|
||||
export async function namensregelnFuer(kundeId: string | null): Promise<Namensregeln> {
|
||||
const [einstellung, kunde] = await Promise.all([
|
||||
prisma.einstellung.findUnique({ where: { id: "global" }, select: { namensregeln: true } }),
|
||||
kundeId
|
||||
? prisma.kunde.findUnique({ where: { id: kundeId }, select: { namensregeln: true } })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const global = regelnLesen(einstellung?.namensregeln);
|
||||
if ((await betriebsmodus()) !== Betriebsmodus.DIENSTLEISTUNG) return global;
|
||||
|
||||
const eigene = regelnLesen(kunde?.namensregeln);
|
||||
return {
|
||||
...global,
|
||||
...Object.fromEntries(Object.entries(eigene).filter(([, wert]) => (wert ?? "").trim() !== "")),
|
||||
};
|
||||
}
|
||||
|
||||
/** Ein unbrauchbarer Json-Inhalt darf keinen Namensvorschlag sprengen. */
|
||||
function regelnLesen(roh: unknown): Namensregeln {
|
||||
const gelesen = NamensregelnSchema.safeParse(roh);
|
||||
return gelesen.success ? gelesen.data : {};
|
||||
}
|
||||
|
||||
export type NamensVorschlaege = Partial<Record<Zielformat, string>>;
|
||||
|
||||
/**
|
||||
* Vorschlaege fuer `upn`, `sam`, `mail` und `hostname`.
|
||||
*
|
||||
* Eindeutigkeit wird gegen die Datenbank geprueft: `sam` und `upn` gegen die
|
||||
* Mitarbeiter desselben Kunden, `hostname` gegen dessen Geraete. Verbindlich ist
|
||||
* sie trotzdem erst beim Schreiben — zwischen Frage und Anlegen kann ein
|
||||
* zweiter Lauf denselben Namen belegen. Ein Vorschlag ist ein Vorschlag.
|
||||
*/
|
||||
export async function namenVorschlagen(
|
||||
kundeIdAusEingabe: string | null,
|
||||
werte: Namenswerte,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<NamensVorschlaege> {
|
||||
/*
|
||||
* Ueber `kundeErmitteln`, nicht ueber die mitgeschickte Kennung: im Modus
|
||||
* INTERN wird sie verworfen, im Modus DIENSTLEISTUNG gegen die sichtbaren
|
||||
* Kunden geprueft (CLAUDE.md, Regel „Mandant“). Ohne das waere `istVergeben`
|
||||
* ein Orakel ueber Kundengrenzen hinweg — „gibt es bei euch einen mhuber?“.
|
||||
*/
|
||||
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
|
||||
const regeln = await namensregelnFuer(kundeId);
|
||||
const vorschlaege: NamensVorschlaege = {};
|
||||
|
||||
for (const zielformat of ["upn", "sam", "mail", "hostname"] as const) {
|
||||
const regel = regeln[zielformat];
|
||||
if (!regel) continue;
|
||||
const name = await namenEindeutigBilden(zielformat, regel, werte, (kandidat) =>
|
||||
istVergeben(zielformat, kandidat, kundeId),
|
||||
);
|
||||
if (name) vorschlaege[zielformat] = name;
|
||||
}
|
||||
return vorschlaege;
|
||||
}
|
||||
|
||||
async function istVergeben(zielformat: Zielformat, kandidat: string, kundeId: string): Promise<boolean> {
|
||||
if (zielformat === "hostname") {
|
||||
const geraet = await prisma.geraet.findFirst({
|
||||
where: { kundeId, hostname: { equals: kandidat, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
});
|
||||
return geraet !== null;
|
||||
}
|
||||
|
||||
const feld = zielformat === "mail" ? "mail" : zielformat;
|
||||
const person = await prisma.mitarbeiter.findFirst({
|
||||
where: { kundeId, [feld]: { equals: kandidat, mode: "insensitive" } },
|
||||
select: { id: true },
|
||||
});
|
||||
return person !== null;
|
||||
}
|
||||
|
||||
export type { ErsetzungsErgebnis };
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from "@/lib/validierung";
|
||||
import type { Prisma } from "@/generated/prisma/client";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { NamensregelnSchema, type Namensregeln } from "@/lib/namensregeln";
|
||||
import { betriebsmodus, eigeneOrganisation } from "@/server/mandant";
|
||||
import { kuerzelPruefen } from "@/server/services/kunden";
|
||||
|
||||
@@ -21,18 +21,13 @@ export class EinstellungsFehler extends Error {
|
||||
}
|
||||
|
||||
/**
|
||||
* Vorlagen fuer abgeleitete Werte. Die Platzhalter darin (`{vorname}`,
|
||||
* `{nachname}`, `{v}`, `{nnn}`, `{kundenkuerzel}`) loest #012 auf; hier werden
|
||||
* sie nur gespeichert und bearbeitet.
|
||||
* Schema und Typ der Namensregeln liegen seit #012 in `src/lib/namensregeln.ts`,
|
||||
* zusammen mit der Aufloesung der Platzhalter darin (`{vorname}`, `{nachname}`,
|
||||
* `{v}`, `{nnn}`, `{kundenkuerzel}`). Hier werden sie nur gespeichert und
|
||||
* bearbeitet — weitergereicht werden sie, damit die Server Action und das
|
||||
* Formular ihren Import nicht aendern muessen.
|
||||
*/
|
||||
export const NamensregelnSchema = z.object({
|
||||
upn: z.string().trim().max(200).optional(),
|
||||
sam: z.string().trim().max(200).optional(),
|
||||
mail: z.string().trim().max(200).optional(),
|
||||
hostname: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
export type Namensregeln = z.infer<typeof NamensregelnSchema>;
|
||||
export { NamensregelnSchema, type Namensregeln };
|
||||
|
||||
export type Einstellungen = {
|
||||
readonly modus: Betriebsmodus;
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { istAlsVariableErlaubt } from "@/lib/platzhalter";
|
||||
import { betriebsmodus } from "@/server/mandant";
|
||||
|
||||
/**
|
||||
* Die Werte hinter den Platzhaltern (#012).
|
||||
*
|
||||
* Diese Datei traegt zusammen, was ein Lauf ueber sich weiss: Geraet,
|
||||
* Mitarbeiter, Abteilung, Kunde, Vorlagenvariablen. Das **Ersetzen** selbst
|
||||
* steht framework-frei in `src/lib/platzhalter-werte.ts`, die Liste der
|
||||
* eingebauten Namen in `src/lib/platzhalter.ts`. Drei Dateien, drei Aufgaben —
|
||||
* und nur diese eine kennt die Datenbank.
|
||||
*
|
||||
* Eine Regel zieht sich durch: **ein fehlender Wert ist leer, kein Fehler.** Ein
|
||||
* Lauf ohne Mitarbeiter ist gueltig (Server, Firewall, Patchday), und ein
|
||||
* Befehl mit einem leeren `-Department` faellt beim Lesen auf. Abzubrechen
|
||||
* hiesse, dem Techniker die Arbeit zu verweigern, weil ein Feld fehlt, das er
|
||||
* vielleicht gar nicht braucht.
|
||||
*/
|
||||
|
||||
export type PlatzhalterWerte = Readonly<Record<string, string>>;
|
||||
|
||||
/**
|
||||
* Der Eltern-Container eines `dn`.
|
||||
*
|
||||
* Aus `CN=Maria Huber,OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` wird
|
||||
* `OU=Buchhaltung,OU=Benutzer,DC=firma,DC=local` — alles nach dem ersten Komma.
|
||||
* Das ist der Ort, an dem das Konto liegt, und genau den braucht ein
|
||||
* `New-ADUser -Path`.
|
||||
*
|
||||
* Ein Komma **innerhalb** eines Namens ist im DN maskiert (`CN=Huber\, Maria`),
|
||||
* deshalb wird ein Komma hinter einem Backslash nicht als Trenner gelesen.
|
||||
*/
|
||||
export function ouAusDn(dn: string): string {
|
||||
/*
|
||||
* Gezaehlt statt geraten: `(?<!\\),` uebersieht, dass der Backslash selbst
|
||||
* maskiert sein kann. In `CN=X\\,OU=Y` ist `\\` ein literaler Backslash im
|
||||
* Wert (RFC 4514) und das Komma **ist** der Trenner — die Vorausschau hielt
|
||||
* es faelschlich fuer maskiert, lieferte nichts, und der Aufrufer fiel still
|
||||
* auf die Ziel-OU der Abteilung zurueck. Das Konto landete in der falschen OU.
|
||||
*
|
||||
* Ein Komma ist maskiert, wenn davor **ungerade** viele Backslashes stehen.
|
||||
*/
|
||||
for (let pos = 0; pos < dn.length; pos += 1) {
|
||||
if (dn.charAt(pos) !== ",") continue;
|
||||
let schraegstriche = 0;
|
||||
while (dn.charAt(pos - 1 - schraegstriche) === "\\") schraegstriche += 1;
|
||||
if (schraegstriche % 2 === 0) return dn.slice(pos + 1).trim();
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function alsText(wert: string | null | undefined): string {
|
||||
return wert?.trim() ?? "";
|
||||
}
|
||||
|
||||
/** Nur gesetzte Werte aufnehmen – ein leerer Eintrag und ein fehlender sind dasselbe. */
|
||||
function setzen(ziel: Record<string, string>, schluessel: string, wert: string): void {
|
||||
if (wert !== "") ziel[schluessel] = wert;
|
||||
}
|
||||
|
||||
export type LaufKontext = {
|
||||
readonly laufId: string;
|
||||
/** Wer den Lauf gerade abarbeitet – speist `{{techniker}}`. */
|
||||
readonly technikerName?: string | null;
|
||||
/** Fuer `{{datum}}`; hereingereicht, damit Tests nicht an der Uhr haengen. */
|
||||
readonly jetzt?: Date;
|
||||
};
|
||||
|
||||
const DATUM = new Intl.DateTimeFormat("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
|
||||
|
||||
/**
|
||||
* Traegt alle Werte eines Laufs zusammen.
|
||||
*
|
||||
* Die Reihenfolge ist Absicht: erst Geraet und Lauf, dann Mitarbeiter, dann
|
||||
* Abteilung, zuletzt die Vorlagenvariablen. Wer weiter hinten steht, gewinnt —
|
||||
* eine Variable, die der Redakteur `hostname` nennt, ueberschreibt also den
|
||||
* Hostnamen des Geraets. Das ist richtig so: sie ist die ausdrueckliche Angabe
|
||||
* dieses Laufs, der Geraetewert nur die Voreinstellung.
|
||||
*
|
||||
* `{{ou}}` ist der einzige Wert mit zwei Quellen: der Eltern-Container des `dn`
|
||||
* gewinnt, weil er sagt, wo das Konto **wirklich** liegt; die Ziel-OU der
|
||||
* Abteilung ist die Absicht fuer ein Konto, das es noch nicht gibt.
|
||||
*/
|
||||
export async function platzhalterWerteDesLaufs(kontext: LaufKontext): Promise<PlatzhalterWerte> {
|
||||
const lauf = await prisma.lauf.findUnique({
|
||||
where: { id: kontext.laufId },
|
||||
select: {
|
||||
ticket: true,
|
||||
variablen: true,
|
||||
mitarbeiterName: true,
|
||||
kunde: { select: { name: true, kuerzel: true } },
|
||||
geraet: {
|
||||
select: { hostname: true, bezeichnung: true, seriennummer: true, inventarnummer: true },
|
||||
},
|
||||
mitarbeiter: {
|
||||
select: {
|
||||
anzeigename: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
upn: true,
|
||||
sam: true,
|
||||
mail: true,
|
||||
abteilung: true,
|
||||
titel: true,
|
||||
dn: true,
|
||||
quelleId: true,
|
||||
vorgesetzterExterneId: true,
|
||||
},
|
||||
},
|
||||
abteilung: { select: { name: true, zielOu: true, standardGruppen: true } },
|
||||
},
|
||||
});
|
||||
if (!lauf) return {};
|
||||
|
||||
const werte: Record<string, string> = {};
|
||||
const jetzt = kontext.jetzt ?? new Date();
|
||||
|
||||
// --- Geraet und Lauf ---
|
||||
setzen(werte, "hostname", alsText(lauf.geraet?.hostname ?? lauf.geraet?.bezeichnung));
|
||||
setzen(werte, "seriennummer", alsText(lauf.geraet?.seriennummer));
|
||||
setzen(werte, "inventarnummer", alsText(lauf.geraet?.inventarnummer));
|
||||
setzen(werte, "ticket", alsText(lauf.ticket));
|
||||
setzen(werte, "datum", DATUM.format(jetzt));
|
||||
setzen(werte, "techniker", alsText(kontext.technikerName));
|
||||
|
||||
/*
|
||||
* Kunde und Kuerzel gibt es nur im Modus Dienstleistung. Im Modus Intern ist
|
||||
* die eigene Organisation fuer den Benutzer unsichtbar (#008) — ihren Namen in
|
||||
* einen Befehl zu schreiben waere eine Auskunft ueber eine Ebene, die es dort
|
||||
* gar nicht gibt. Die Platzhalter bleiben deshalb leer, nicht unbekannt.
|
||||
*/
|
||||
if ((await betriebsmodus()) === Betriebsmodus.DIENSTLEISTUNG) {
|
||||
setzen(werte, "kunde", alsText(lauf.kunde.name));
|
||||
setzen(werte, "kundenkuerzel", alsText(lauf.kunde.kuerzel));
|
||||
}
|
||||
|
||||
// --- Mitarbeiter ---
|
||||
const person = lauf.mitarbeiter;
|
||||
if (person) {
|
||||
setzen(werte, "mitarbeiter", alsText(person.anzeigename));
|
||||
setzen(werte, "benutzer", alsText(person.anzeigename));
|
||||
setzen(werte, "vorname", alsText(person.vorname));
|
||||
setzen(werte, "nachname", alsText(person.nachname));
|
||||
setzen(werte, "upn", alsText(person.upn));
|
||||
setzen(werte, "sam", alsText(person.sam));
|
||||
setzen(werte, "mail", alsText(person.mail));
|
||||
setzen(werte, "abteilung", alsText(person.abteilung));
|
||||
setzen(werte, "titel", alsText(person.titel));
|
||||
setzen(werte, "dn", alsText(person.dn));
|
||||
if (person.dn) setzen(werte, "ou", ouAusDn(person.dn));
|
||||
|
||||
const vorgesetzter = await vorgesetztenLesen(person);
|
||||
if (vorgesetzter) {
|
||||
setzen(werte, "vorgesetzter", alsText(vorgesetzter.anzeigename));
|
||||
setzen(werte, "vorgesetzter_sam", alsText(vorgesetzter.sam));
|
||||
}
|
||||
} else {
|
||||
// Beim Onboarding steht die Person noch nicht im Verzeichnis; dann traegt
|
||||
// der Lauf ihren Namen als Freitext.
|
||||
setzen(werte, "mitarbeiter", alsText(lauf.mitarbeiterName));
|
||||
setzen(werte, "benutzer", alsText(lauf.mitarbeiterName));
|
||||
}
|
||||
|
||||
// --- Abteilung ---
|
||||
if (lauf.abteilung) {
|
||||
setzen(werte, "abteilung", alsText(lauf.abteilung.name));
|
||||
setzen(werte, "gruppen", lauf.abteilung.standardGruppen.join(", "));
|
||||
// Nur, wenn der Mitarbeiter keine bessere Auskunft hatte.
|
||||
if (werte["ou"] === undefined) setzen(werte, "ou", alsText(lauf.abteilung.zielOu));
|
||||
}
|
||||
|
||||
/*
|
||||
* --- Vorlagenvariablen ---
|
||||
*
|
||||
* Sie kommen zuletzt und gewinnen — aber nur, wo sie duerfen. Eine Variable
|
||||
* `techniker` oder `datum` liesse sonst einen Befehl etwas unterschreiben, das
|
||||
* nicht stimmt, und eine Variable `kunde` haette im Modus INTERN die
|
||||
* Kundenebene zurueck in den Befehl geholt, die es dort nicht gibt.
|
||||
* Gesperrt sind genau fuenf Namen (siehe `GESPERRTE_VARIABLENNAMEN`); alles
|
||||
* andere darf eine Vorlage belegen — beim Onboarding gibt es die Person im
|
||||
* Verzeichnis noch nicht, und `upn`, `sam` und `mail` kommen dann aus dem Lauf.
|
||||
*/
|
||||
for (const [schluessel, wert] of Object.entries(variablenLesen(lauf.variablen))) {
|
||||
if (!istAlsVariableErlaubt(schluessel)) continue;
|
||||
setzen(werte, schluessel, wert);
|
||||
}
|
||||
|
||||
return werte;
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Vorgesetzte, ueber `vorgesetzterExterneId` **derselben** Verzeichnisquelle.
|
||||
*
|
||||
* Die Kennung ist nur innerhalb einer Quelle eindeutig; ohne diese
|
||||
* Einschraenkung zoege ein zweites Verzeichnis fremde Treffer herbei.
|
||||
*/
|
||||
async function vorgesetztenLesen(person: {
|
||||
readonly quelleId: string | null;
|
||||
readonly vorgesetzterExterneId: string | null;
|
||||
}): Promise<{ anzeigename: string; sam: string | null } | null> {
|
||||
if (!person.vorgesetzterExterneId || !person.quelleId) return null;
|
||||
return prisma.mitarbeiter.findFirst({
|
||||
where: { quelleId: person.quelleId, externeId: person.vorgesetzterExterneId },
|
||||
select: { anzeigename: true, sam: true },
|
||||
});
|
||||
}
|
||||
|
||||
/** `Lauf.variablen` ist ein Json-Feld und kann alles enthalten. */
|
||||
function variablenLesen(roh: unknown): Record<string, string> {
|
||||
if (typeof roh !== "object" || roh === null || Array.isArray(roh)) return {};
|
||||
const werte: Record<string, string> = {};
|
||||
for (const [schluessel, wert] of Object.entries(roh as Record<string, unknown>)) {
|
||||
// Zahlen und Wahrheitswerte kommen aus den Variablentypen ZAHL und HAKEN;
|
||||
// alles andere waere ein verunglueckter Eintrag und bleibt draussen.
|
||||
if (typeof wert === "string") werte[schluessel] = wert;
|
||||
else if (typeof wert === "number" || typeof wert === "boolean") werte[schluessel] = String(wert);
|
||||
}
|
||||
return werte;
|
||||
}
|
||||
Reference in New Issue
Block a user