Die sieben Ablaeufe des Hauses liegen nach dem Seed als veroeffentlichte Version 1 bereit: 36 Abschnitte, 155 Schritte, 28 Variablen, jeder Schritt mit Hilfetext. Sie stehen als JSON im Austauschformat unter prisma/seed-daten/vorlagen/ und werden beim Einrichten gegen dasselbe Schema geprueft wie ein Import. Der Seed richtet ein, er raeumt nicht auf: jede Zeile hat einen sprechenden Schluessel, und was es schon gibt, bleibt unberuehrt. Erst Entwurf, dann veroeffentlichen - die Trigger aus #010 lassen keinen anderen Weg zu. Im Modus DIENSTLEISTUNG haengt der Beispielbestand an der Musterfirma, LZ-7K3F9Q fuehrt also auf MUS-PC-001. Dazu zwei Zusagen eingeloest, die niemand deckte: {{ou_gesperrt}} kommt jetzt wirklich aus den Einstellungen, und Titel und Hilfetext eines Laufs bekommen dasselbe generierte Passwort wie der Befehl daneben - vorher stand im Befehl A und im Hilfetext B. Closes #17 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
238 lines
9.7 KiB
TypeScript
238 lines
9.7 KiB
TypeScript
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));
|
||
}
|
||
|
||
/*
|
||
* --- Aus den Einstellungen ---
|
||
*
|
||
* Die Ziel-OU fuer deaktivierte Konten steht global in `Einstellung` und wird
|
||
* im Offboarding gebraucht. Sie stand dort schon, wurde aber von keinem
|
||
* Platzhalter gelesen: `Move-ADObject -TargetPath "{{ou_gesperrt}}"` blieb leer,
|
||
* obwohl die Einstellungsseite „Wird beim Offboarding vorbelegt“ verspricht
|
||
* (Reviewbefund zu #017). Eine Variable gleichen Namens gewinnt weiter — sie
|
||
* steht weiter unten.
|
||
*/
|
||
const einstellung = await prisma.einstellung.findUnique({
|
||
where: { id: "global" },
|
||
select: { gesperrteOu: true },
|
||
});
|
||
setzen(werte, "ou_gesperrt", alsText(einstellung?.gesperrteOu));
|
||
|
||
/*
|
||
* --- 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;
|
||
}
|