Files
Laufzettel/src/server/services/platzhalter.ts
T
Florian WildeandClaude Opus 5 5c81a62cfd
Prüfung / pruefung (push) Canceled after 0s
Prüfung / pruefung (pull_request) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
Prüfung / e2e (pull_request) Canceled after 0s
feat(seed): sieben Vorlagen, Beispielbestand und Demo-Kunden
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>
2026-09-26 05:26:04 +02:00

238 lines
9.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
}