feat(verzeichnis): LDAP-Abgleich, Zeitplan, CSV-Import und Log (#30)
This commit was merged in pull request #30.
This commit is contained in:
@@ -2,13 +2,15 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { MandantFehler } from "@/server/mandant";
|
||||
import { MandantFehler, kundenEbeneSichtbar } from "@/server/mandant";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import {
|
||||
MitarbeiterFehler,
|
||||
mitarbeiterAktivSetzen,
|
||||
mitarbeiterAnlegen,
|
||||
mitarbeiterBearbeiten,
|
||||
mitarbeiterImportieren,
|
||||
type MitarbeiterImportAbweisung,
|
||||
} from "@/server/services/mitarbeiter";
|
||||
import type { HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
@@ -21,7 +23,12 @@ import type { HandelnderBenutzer } from "@/server/mandant";
|
||||
* Verzeichnisabgleich gelaufen ist.
|
||||
*/
|
||||
|
||||
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||
export type AktionsErgebnis = {
|
||||
readonly fehler?: string;
|
||||
readonly erfolg?: string;
|
||||
/** Nur beim CSV-Import: je abgewiesene Zeile eine Nummer und ein Grund (#016). */
|
||||
readonly abgewiesen?: readonly MitarbeiterImportAbweisung[];
|
||||
};
|
||||
|
||||
const RECHTE_MELDUNG =
|
||||
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
|
||||
@@ -153,3 +160,51 @@ export async function mitarbeiterAktivSetzenAktion(
|
||||
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Personen aus einer CSV-Datei einspielen (#016).
|
||||
*
|
||||
* Dasselbe Recht wie beim Anlegen von Hand: wer eine Person eintragen darf, darf
|
||||
* auch zwanzig auf einmal eintragen. Der Inhalt kommt als Text im Formular — die
|
||||
* Datei liest der Browser, genau wie beim Geraeteimport.
|
||||
*/
|
||||
export async function mitarbeiterImportierenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
|
||||
const eingabe = z
|
||||
.object({
|
||||
inhalt: z.string().min(1, "Bitte eine CSV-Datei auswählen oder den Inhalt einfügen."),
|
||||
kundeId: z.string().trim().optional(),
|
||||
})
|
||||
.safeParse({ inhalt: formular.get("inhalt"), kundeId: formular.get("kundeId") ?? undefined });
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
// Im Modus INTERN gibt es keine Kundenwahl; `kundeErmitteln` verwirft eine
|
||||
// mitgeschickte Kennung ohnehin, hier bleibt sie schon gar nicht liegen.
|
||||
const kundeId = (await kundenEbeneSichtbar()) ? eingabe.data.kundeId || null : null;
|
||||
|
||||
try {
|
||||
const ergebnis = await mitarbeiterImportieren(eingabe.data.inhalt, zugang.benutzer, kundeId);
|
||||
revalidatePath("/mitarbeiter");
|
||||
// Neue Abteilungsnamen aus der Datei sind sofort ein Vorschlag auf der
|
||||
// Abteilungsseite — ohne diese Zeile erst nach dem naechsten Neuladen.
|
||||
revalidatePath("/admin/abteilungen");
|
||||
|
||||
const teile = [`${ergebnis.angelegt} angelegt`];
|
||||
if (ergebnis.aktualisiert > 0) teile.push(`${ergebnis.aktualisiert} aktualisiert`);
|
||||
if (ergebnis.unveraendert > 0) teile.push(`${ergebnis.unveraendert} unverändert`);
|
||||
if (ergebnis.abgewiesen.length > 0) teile.push(`${ergebnis.abgewiesen.length} abgewiesen`);
|
||||
return {
|
||||
erfolg: `${ergebnis.gesamt} ${ergebnis.gesamt === 1 ? "Zeile" : "Zeilen"} gelesen: ${teile.join(", ")}.`,
|
||||
abgewiesen: ergebnis.abgewiesen,
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Datei konnte nicht eingelesen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
"use server";
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { satzEnde } from "@/lib/satzzeichen";
|
||||
import {
|
||||
INTERVALL_HOECHSTENS,
|
||||
INTERVALL_MINDESTENS,
|
||||
intervallMeldung,
|
||||
zertifikatWahl,
|
||||
zuordnungAusEingabe,
|
||||
ZUORDNUNGS_FELDER,
|
||||
} from "@/lib/verzeichnis";
|
||||
import { z } from "@/lib/validierung";
|
||||
import { MandantFehler, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
|
||||
import { abgleichAusfuehren, type AbgleichErgebnis } from "@/server/services/abgleich";
|
||||
import {
|
||||
VerzeichnisFehler,
|
||||
quelleAnlegen,
|
||||
quelleBearbeiten,
|
||||
quelleLoeschen,
|
||||
verbindungTesten,
|
||||
type GemappterEintrag,
|
||||
} from "@/server/services/verzeichnis";
|
||||
|
||||
/**
|
||||
* Server Actions des Verzeichnisses (#016).
|
||||
*
|
||||
* Duenn wie ueberall: Recht pruefen, Zod anwenden, Dienst rufen. Eine Regel
|
||||
* steht hier nicht — dass nur der Modus INTERN ein Verzeichnis hat, dass das
|
||||
* Bind-Passwort verschluesselt wird und dass eine Fehlermeldung der Bibliothek
|
||||
* gefiltert gehoert, entscheidet alles der Dienst.
|
||||
*
|
||||
* **Das Bind-Passwort kommt durch diese Datei und geht nie zurueck.** Keine der
|
||||
* Antworten hier traegt es, und keine Meldung gibt eine rohe Ausnahme der
|
||||
* LDAP-Bibliothek weiter: die kann es enthalten.
|
||||
*/
|
||||
|
||||
export type AktionsErgebnis = { readonly fehler?: string; readonly erfolg?: string };
|
||||
|
||||
const RECHTE_MELDUNG =
|
||||
"Deine Anmeldung gilt nicht mehr. Melde dich in einem zweiten Tab neu an und klicke dann hier " +
|
||||
"noch einmal — was du eingegeben hast, steht weiterhin auf dieser Seite.";
|
||||
|
||||
async function angemeldetOderMeldung(): Promise<
|
||||
{ readonly benutzer: HandelnderBenutzer } | { readonly fehler: string }
|
||||
> {
|
||||
try {
|
||||
const { benutzer } = await rechtErforderlich("verwaltung");
|
||||
return { benutzer: { id: benutzer.id, rolle: benutzer.rolle } };
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof RechteFehler) return { fehler: RECHTE_MELDUNG };
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Uebersetzt die Fehler der Dienste in eine Meldung, alles andere in eine
|
||||
* neutrale — **und zwar hier**, nicht in der Komponente.
|
||||
*
|
||||
* Eine unbekannte Ausnahme wird nicht weitergereicht: sie kann aus `ldapts`
|
||||
* stammen und den Bind-Versuch samt Zugangsdaten enthalten. Ins Protokoll des
|
||||
* Prozesses kommt nur Name und Meldung, nicht das Objekt.
|
||||
*/
|
||||
function alsErgebnis(fehler: unknown, neutral: string): AktionsErgebnis {
|
||||
if (fehler instanceof VerzeichnisFehler || fehler instanceof MandantFehler) {
|
||||
return { fehler: fehler.message };
|
||||
}
|
||||
console.error(`${neutral}:`, fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : neutral);
|
||||
return { fehler: neutral };
|
||||
}
|
||||
|
||||
function text(formular: FormData, name: string): string {
|
||||
const wert = formular.get(name);
|
||||
return typeof wert === "string" ? wert.trim() : "";
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Attributzuordnung aus dem Formular. Die Felder heissen `attr.<feld>`, damit
|
||||
* sie nicht mit den Kopfdaten kollidieren; geprueft wird in `src/lib/verzeichnis.ts`.
|
||||
*/
|
||||
function zuordnungAusFormular(formular: FormData) {
|
||||
return zuordnungAusEingabe(
|
||||
Object.fromEntries(ZUORDNUNGS_FELDER.map((feld) => [feld, text(formular, `attr.${feld}`)])),
|
||||
);
|
||||
}
|
||||
|
||||
const QuelleSchema = z.object({
|
||||
name: z.string().trim().min(1, "Bitte einen Namen angeben."),
|
||||
url: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, "Bitte die Adresse angeben, etwa ldaps://dc01.firma.local:636.")
|
||||
.refine(
|
||||
(wert) =>
|
||||
wert.startsWith("ldaps://") || wert.startsWith("ldap+starttls://") || wert.startsWith("ldap://"),
|
||||
"Die Adresse beginnt mit ldaps://, ldap+starttls:// oder ldap://.",
|
||||
),
|
||||
bindDn: z.string().trim().min(1, "Bitte den Benutzer für die Anmeldung angeben."),
|
||||
basisDn: z.string().trim().min(1, "Bitte angeben, ab welchem Knoten gesucht wird."),
|
||||
filter: z.string().trim().optional(),
|
||||
caZertifikatPem: z.string().trim().optional(),
|
||||
// Dieselben Grenzen wie im Dienst, aus derselben Quelle.
|
||||
abgleichIntervallMinuten: z.coerce
|
||||
.number()
|
||||
.int()
|
||||
.min(INTERVALL_MINDESTENS, intervallMeldung())
|
||||
.max(INTERVALL_HOECHSTENS, intervallMeldung()),
|
||||
aktiv: z.boolean(),
|
||||
});
|
||||
|
||||
export async function quelleAnlegenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
caZertifikatPem: text(formular, "caZertifikatPem") || undefined,
|
||||
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
|
||||
aktiv: formular.get("aktiv") === "1",
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
const bindPasswort = text(formular, "bindPasswort");
|
||||
if (!bindPasswort) return { fehler: "Ohne Passwort kommt keine Anmeldung am Verzeichnis zustande." };
|
||||
|
||||
const zuordnung = zuordnungAusFormular(formular);
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const quelle = await quelleAnlegen(
|
||||
{ ...eingabe.data, bindPasswort, attributZuordnung: zuordnung.zuordnung },
|
||||
zugang.benutzer,
|
||||
);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return { erfolg: `„${quelle.name}“ ist angelegt. Prüf als Nächstes die Verbindung.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Quelle konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function quelleBearbeitenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
const eingabe = QuelleSchema.safeParse({
|
||||
name: text(formular, "name"),
|
||||
url: text(formular, "url"),
|
||||
bindDn: text(formular, "bindDn"),
|
||||
basisDn: text(formular, "basisDn"),
|
||||
filter: text(formular, "filter") || undefined,
|
||||
// Das Zertifikat entscheidet sich weiter unten: es kennt drei Faelle, das
|
||||
// Schema nur zwei.
|
||||
caZertifikatPem: undefined,
|
||||
abgleichIntervallMinuten: text(formular, "abgleichIntervallMinuten") || "60",
|
||||
aktiv: formular.get("aktiv") === "1",
|
||||
});
|
||||
if (!eingabe.success) {
|
||||
return { fehler: eingabe.error.issues[0]?.message ?? "Die Eingabe ist unvollständig." };
|
||||
}
|
||||
|
||||
/*
|
||||
* Drei Faelle, und das Formular kann sie nicht auf ein Feld abbilden: ein neues
|
||||
* Zertifikat eintragen, das hinterlegte behalten (Feld leer) und das hinterlegte
|
||||
* entfernen (Kaestchen). Ohne das Kaestchen liess sich ein einmal hinterlegtes
|
||||
* Zertifikat nie wieder loswerden — es stand unsichtbar in der Datenbank und
|
||||
* entschied weiter, welcher CA vertraut wird (Reviewbefund zu #016).
|
||||
*/
|
||||
const zertifikat = zertifikatWahl(
|
||||
text(formular, "caZertifikatPem"),
|
||||
formular.get("caZertifikatEntfernen") === "1",
|
||||
);
|
||||
if ("fehler" in zertifikat) return zertifikat;
|
||||
const caZertifikatPem = zertifikat.wert;
|
||||
|
||||
/*
|
||||
* Ein leeres Passwortfeld heisst „unveraendert“ und nicht „leer“. Das Feld
|
||||
* steht beim Bearbeiten leer da — es gibt das gespeicherte nicht her, und ein
|
||||
* Formular, das es als Loeschung verstuende, naehme der Quelle bei jeder
|
||||
* Namensaenderung die Anmeldung.
|
||||
*/
|
||||
const bindPasswort = text(formular, "bindPasswort");
|
||||
|
||||
const zuordnung = zuordnungAusFormular(formular);
|
||||
if ("fehler" in zuordnung) return zuordnung;
|
||||
|
||||
try {
|
||||
const quelle = await quelleBearbeiten(
|
||||
id,
|
||||
{
|
||||
...eingabe.data,
|
||||
...(bindPasswort ? { bindPasswort } : {}),
|
||||
...(caZertifikatPem !== undefined ? { caZertifikatPem } : {}),
|
||||
attributZuordnung: zuordnung.zuordnung,
|
||||
},
|
||||
zugang.benutzer,
|
||||
);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return { erfolg: `„${quelle.name}“ ist gespeichert.` };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Änderung konnte nicht gespeichert werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function quelleLoeschenAktion(
|
||||
_vorher: AktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AktionsErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
try {
|
||||
await quelleLoeschen(id, zugang.benutzer);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
return { erfolg: "Die Quelle ist entfernt." };
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Quelle konnte nicht entfernt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
export type TestErgebnis = AktionsErgebnis & {
|
||||
readonly treffer?: readonly GemappterEintrag[];
|
||||
readonly gesamtGelesen?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* „Verbindung testen“ — liest die ersten Treffer und **schreibt nichts**.
|
||||
*
|
||||
* Der Wert liegt nicht im „geht/geht nicht“, sondern in der Tabelle darunter:
|
||||
* erst an den gemappten Feldern sieht ein Administrator, ob die Zuordnung
|
||||
* stimmt. Ein „Verbindung erfolgreich“ allein saehe genauso aus, wenn `sam` und
|
||||
* `upn` vertauscht waeren.
|
||||
*/
|
||||
export async function verbindungTestenAktion(
|
||||
_vorher: TestErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<TestErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
try {
|
||||
const ergebnis = await verbindungTesten(id, zugang.benutzer);
|
||||
return {
|
||||
erfolg:
|
||||
ergebnis.treffer.length === 0
|
||||
? "Die Verbindung steht, aber der Filter findet niemanden."
|
||||
: ergebnis.treffer.length === 0
|
||||
? /*
|
||||
* Gelesen, aber nichts zuordenbar: das ist fast immer ein falsch
|
||||
* gesetztes Kennungsattribut und **nicht** ein zu enger Filter. Die
|
||||
* beiden Faelle denselben Satz sagen zu lassen hiesse, den
|
||||
* Administrator an der falschen Stelle suchen zu schicken.
|
||||
*/
|
||||
`Die Verbindung steht und der Filter trifft ${ergebnis.gesamtGelesen} Einträge — ` +
|
||||
`zuordnen ließ sich keiner davon. Grund des ersten: ` +
|
||||
`${satzEnde(ergebnis.uebersprungen[0]?.grund)} Prüf die Attributzuordnung, ` +
|
||||
"besonders das Kennungsattribut."
|
||||
: ergebnis.uebersprungen.length > 0
|
||||
? `Die Verbindung steht. ${ergebnis.gesamtGelesen} Einträge gelesen, ` +
|
||||
`${ergebnis.uebersprungen.length} übersprungen — Grund des ersten: ` +
|
||||
satzEnde(ergebnis.uebersprungen[0]?.grund)
|
||||
: `Die Verbindung steht. ${ergebnis.gesamtGelesen} Einträge gelesen.`,
|
||||
treffer: ergebnis.treffer,
|
||||
gesamtGelesen: ergebnis.gesamtGelesen,
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Die Verbindung kam nicht zustande.");
|
||||
}
|
||||
}
|
||||
|
||||
export type AbgleichAktionsErgebnis = AktionsErgebnis & { readonly ergebnis?: AbgleichErgebnis };
|
||||
|
||||
export async function abgleichJetztAktion(
|
||||
_vorher: AbgleichAktionsErgebnis,
|
||||
formular: FormData,
|
||||
): Promise<AbgleichAktionsErgebnis> {
|
||||
const zugang = await angemeldetOderMeldung();
|
||||
if ("fehler" in zugang) return zugang;
|
||||
const id = text(formular, "id");
|
||||
if (!id) return { fehler: "Es fehlt die Quelle." };
|
||||
|
||||
try {
|
||||
const ergebnis = await abgleichAusfuehren(id, zugang.benutzer);
|
||||
revalidatePath("/admin/verzeichnis");
|
||||
revalidatePath("/mitarbeiter");
|
||||
return ergebnis.status === "FEHLER"
|
||||
? { fehler: ergebnis.fehler ?? "Der Abgleich ist gescheitert.", ergebnis }
|
||||
: {
|
||||
erfolg:
|
||||
`${ergebnis.angelegt} angelegt, ${ergebnis.aktualisiert} aktualisiert, ` +
|
||||
`${ergebnis.deaktiviert} deaktiviert.` +
|
||||
// Die Uebernahme steht nur da, wenn es eine gab: eine Zeile „0
|
||||
// übernommen“ bei jedem Lauf erklaert nichts und verdeckt den Rest.
|
||||
(ergebnis.uebernommen > 0
|
||||
? ` ${ergebnis.uebernommen} von Hand angelegte Person${ergebnis.uebernommen === 1 ? "" : "en"} übernommen.`
|
||||
: "") +
|
||||
// Der Hinweis haengt an derselben Zeile, weil ein zweiter Absatz in
|
||||
// gruener Schrift wie eine zweite Erfolgsmeldung liest.
|
||||
(ergebnis.hinweis ? ` ${ergebnis.hinweis}` : ""),
|
||||
ergebnis,
|
||||
};
|
||||
} catch (fehler) {
|
||||
return alsErgebnis(fehler, "Der Abgleich ist gescheitert.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Die gemeinsame Fehlerklasse des Verzeichnisses (#016).
|
||||
*
|
||||
* Sie steht **unterhalb** der Dienste, weil `src/server/ldap/*` von ihnen
|
||||
* benutzt wird und nicht umgekehrt: eine Fehlerklasse in `services/verzeichnis`
|
||||
* koennte `verbindung.ts` nicht erweitern, ohne die Schichtung umzudrehen.
|
||||
*
|
||||
* Alles, was daran haengt, kommt in der Oberflaeche als **lesbare Meldung** an;
|
||||
* die Server Actions unterscheiden genau daran (`instanceof`), ob sie den Text
|
||||
* zeigen duerfen oder einen neutralen Satz nehmen muessen. Was nicht davon
|
||||
* erbt, gilt als unerwartet — und unerwartete Ausnahmen koennen aus einer
|
||||
* fremden Bibliothek stammen und Zugangsdaten enthalten.
|
||||
*/
|
||||
export class VerzeichnisFehler extends Error {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
super(nachricht, optionen);
|
||||
this.name = "VerzeichnisFehler";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import {
|
||||
type AttributZuordnung,
|
||||
type RoherEintrag,
|
||||
type Uebersprungen,
|
||||
angeforderteAttribute,
|
||||
eintragZuordnen,
|
||||
} from "@/server/ldap/zuordnung";
|
||||
import { type LdapZugang, mitVerbindung } from "@/server/ldap/verbindung";
|
||||
|
||||
/**
|
||||
* Das Verzeichnis lesen (#016).
|
||||
*
|
||||
* Gelesen wird mit `searchPaginated` und nicht mit `search({ paged: … })`. Der
|
||||
* Unterschied ist nicht kosmetisch: `search` sammelt alle Seiten und gibt sie am
|
||||
* Ende als ein Array zurueck — bei einem Verzeichnis mit einigen Tausend Konten
|
||||
* steht damit der gesamte Bestand im Speicher, bevor die erste Zeile geschrieben
|
||||
* ist. `searchPaginated` liefert Seite fuer Seite, und der Abgleich schreibt
|
||||
* mit. Die Seitengroesse geht trotzdem durch `paged.pageSize`; das ist der Weg,
|
||||
* auf dem ldapts 9 sie erwartet (`Client.searchPaginated` liest sie von dort,
|
||||
* sonst nimmt es 100).
|
||||
*/
|
||||
|
||||
/** Die Seitengroesse aus dem Auftrag. */
|
||||
export const SEITENGROESSE = 1000;
|
||||
|
||||
export type LeseVorgabe = {
|
||||
readonly basisDn: string;
|
||||
readonly filter: string;
|
||||
readonly zuordnung: AttributZuordnung;
|
||||
};
|
||||
|
||||
export type LeseBilanz = {
|
||||
/** Wie viele Eintraege der Server geliefert hat — samt der uebersprungenen. */
|
||||
readonly gesamtGelesen: number;
|
||||
readonly uebersprungen: readonly Uebersprungen[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Laeuft ueber die Treffer und gibt jeden einzeln weiter.
|
||||
*
|
||||
* `grenze` zaehlt die **zugeordneten** Eintraege, nicht die gelesenen: „die
|
||||
* ersten zehn Treffer zeigen“ meint zehn Zeilen in der Tabelle. Ist die Grenze
|
||||
* erreicht, wird die Suche abgebrochen und keine weitere Seite geholt;
|
||||
* `gesamtGelesen` kann dann um die uebersprungenen Eintraege hoeher liegen.
|
||||
*/
|
||||
export async function eintraegeStroemen(
|
||||
zugang: LdapZugang,
|
||||
vorgabe: LeseVorgabe,
|
||||
verarbeiten: (eintrag: RoherEintrag) => Promise<void> | void,
|
||||
grenze?: number,
|
||||
): Promise<LeseBilanz> {
|
||||
let gesamtGelesen = 0;
|
||||
let zugeordnet = 0;
|
||||
const uebersprungen: Uebersprungen[] = [];
|
||||
|
||||
await mitVerbindung(zugang, async (client) => {
|
||||
const seiten = client.searchPaginated(vorgabe.basisDn, {
|
||||
scope: "sub",
|
||||
filter: vorgabe.filter,
|
||||
// Nur die zugeordneten Attribute. Gruppenmitgliedschaften und was sonst am
|
||||
// Konto haengt werden nicht einmal angefragt.
|
||||
attributes: angeforderteAttribute(vorgabe.zuordnung),
|
||||
// objectGUID ist binaer. Ohne diese Zeile macht ldapts daraus eine
|
||||
// Zeichenkette, und 16 rohe Bytes als UTF-8 zu lesen zerstoert sie.
|
||||
explicitBufferAttributes: [vorgabe.zuordnung.externeId],
|
||||
paged: { pageSize: SEITENGROESSE },
|
||||
});
|
||||
|
||||
for await (const seite of seiten) {
|
||||
for (const roh of seite.searchEntries) {
|
||||
gesamtGelesen += 1;
|
||||
const ergebnis = eintragZuordnen(roh, vorgabe.zuordnung);
|
||||
if ("uebersprungen" in ergebnis) {
|
||||
uebersprungen.push(ergebnis.uebersprungen);
|
||||
continue;
|
||||
}
|
||||
await verarbeiten(ergebnis.eintrag);
|
||||
zugeordnet += 1;
|
||||
// Das Verlassen der Schleife schliesst den Generator; die naechste Seite
|
||||
// wird nicht mehr geholt.
|
||||
if (grenze !== undefined && zugeordnet >= grenze) return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { gesamtGelesen, uebersprungen };
|
||||
}
|
||||
|
||||
/**
|
||||
* Dieselbe Suche, aber alles im Speicher — fuer „Verbindung testen“ mit einer
|
||||
* kleinen Grenze. Fuer den Abgleich ist `eintraegeStroemen` der richtige Weg.
|
||||
*/
|
||||
export async function eintraegeLesen(
|
||||
zugang: LdapZugang,
|
||||
vorgabe: LeseVorgabe,
|
||||
grenze: number,
|
||||
): Promise<LeseBilanz & { readonly eintraege: readonly RoherEintrag[] }> {
|
||||
const eintraege: RoherEintrag[] = [];
|
||||
const bilanz = await eintraegeStroemen(zugang, vorgabe, (eintrag) => void eintraege.push(eintrag), grenze);
|
||||
return { ...bilanz, eintraege };
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
import type { ConnectionOptions } from "node:tls";
|
||||
import { Client } from "ldapts";
|
||||
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
||||
|
||||
/**
|
||||
* Die Verbindung zum Verzeichnis (#016).
|
||||
*
|
||||
* Hier stehen drei Dinge, die auseinandergehalten werden muessen: welche
|
||||
* Adressen erlaubt sind, wie die TLS-Optionen aussehen, und wie eine Meldung der
|
||||
* Bibliothek aussieht, nachdem das Bind-Passwort aus ihr entfernt wurde. Die
|
||||
* ersten beiden sind reine Funktionen und einzeln geprueft; nur `mitVerbindung`
|
||||
* spricht wirklich mit dem Netz.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Ein Fehler beim Zugriff auf das Verzeichnis.
|
||||
*
|
||||
* Erbt von `VerzeichnisFehler`, damit die Meldung in der Oberflaeche ankommt.
|
||||
* Vorher war es ein blosser `Error`, und die Server Action fiel fuer **jeden**
|
||||
* LDAP-Fehler auf den neutralen Satz „Die Verbindung kam nicht zustande“ zurueck
|
||||
* — falsches Passwort, falscher Basis-DN und ein toter Server sahen identisch
|
||||
* aus, obwohl die Meldung hier eigens dafuer gesaeubert wird (Reviewbefund zu
|
||||
* #016).
|
||||
*/
|
||||
export class VerbindungsFehler extends VerzeichnisFehler {
|
||||
constructor(nachricht: string, optionen?: ErrorOptions) {
|
||||
super(nachricht, optionen);
|
||||
this.name = "VerbindungsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die drei Adressformen.
|
||||
*
|
||||
* `ldaps` ist der Regelfall und stand deshalb zuerst in der Liste. `ldap+starttls`
|
||||
* ist unsere Schreibweise fuer „auf 389 verbinden und die Verbindung danach mit
|
||||
* StartTLS hochziehen“ — es gibt in der Datenbank kein zweites Feld dafuer, und
|
||||
* ein verstecktes Kaestchen waere schlechter als ein sichtbares Schema in der
|
||||
* Adresse. `ldap` ist Klartext: der Bind geht dann unverschluesselt ueber das
|
||||
* Netz, inklusive Passwort. Erlaubt bleibt es, weil es Verzeichnisse ohne TLS
|
||||
* gibt und weil der Testcontainer genau so laeuft.
|
||||
*/
|
||||
const SCHEMATA = ["ldaps://", "ldap+starttls://", "ldap://"] as const;
|
||||
|
||||
export type LdapZugang = {
|
||||
readonly url: string;
|
||||
readonly bindDn: string;
|
||||
readonly bindPasswort: string;
|
||||
readonly caZertifikatPem: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Prueft die Adresse und trennt das StartTLS-Schema ab.
|
||||
*
|
||||
* `ldapts` kennt nur `ldap://` und `ldaps://`; die Umschaltung auf StartTLS
|
||||
* geschieht bei uns nach dem Verbinden. Zurueck kommt deshalb die Adresse, die
|
||||
* die Bibliothek bekommt, und die Frage, ob danach `startTLS()` faellig ist.
|
||||
*/
|
||||
export function urlPruefen(roh: string): { url: string; startTls: boolean; verschluesselt: boolean } {
|
||||
const url = roh.trim();
|
||||
const schema = SCHEMATA.find((kandidat) => url.toLowerCase().startsWith(kandidat));
|
||||
if (!schema) {
|
||||
throw new VerbindungsFehler(
|
||||
`„${url}“ ist keine LDAP-Adresse. Erwartet wird ldaps://host:636, ` +
|
||||
"ldap+starttls://host:389 oder ldap://host:389.",
|
||||
);
|
||||
}
|
||||
const rest = url.slice(schema.length);
|
||||
if (!rest || rest.startsWith("/")) {
|
||||
throw new VerbindungsFehler(`In „${url}“ fehlt der Rechnername.`);
|
||||
}
|
||||
// Ein DN oder ein Pfad hinter dem Port wird von ldapts nicht ausgewertet. Ihn
|
||||
// stillschweigend zu schlucken hiesse, einen Basis-DN anzunehmen, der dann doch
|
||||
// aus dem eigenen Feld kaeme.
|
||||
if (rest.includes("/")) {
|
||||
throw new VerbindungsFehler(
|
||||
`„${url}“ enthält einen Pfad. Die Adresse besteht nur aus Schema, Rechner und Port; ` +
|
||||
"der Basis-DN steht in seinem eigenen Feld.",
|
||||
);
|
||||
}
|
||||
// Zugangsdaten in der Adresse wuerden das Passwort im Klartext in die
|
||||
// Datenbank schreiben — verschluesselt wird nur das Feld dafuer.
|
||||
if (rest.includes("@")) {
|
||||
throw new VerbindungsFehler(
|
||||
`„${url}“ enthält Zugangsdaten. Bind-DN und Passwort stehen in ihren eigenen Feldern.`,
|
||||
);
|
||||
}
|
||||
|
||||
const startTls = schema === "ldap+starttls://";
|
||||
return {
|
||||
url: startTls ? `ldap://${rest}` : url,
|
||||
startTls,
|
||||
verschluesselt: startTls || schema === "ldaps://",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Die TLS-Optionen.
|
||||
*
|
||||
* `minVersion: "TLSv1.2"` ist gesetzt, weil Node sonst auch TLS 1.0 und 1.1
|
||||
* annimmt, sobald ein alter Domaenencontroller sie anbietet. Ein eigenes
|
||||
* CA-Zertifikat **ersetzt** die mitgelieferten Wurzeln nicht zufaellig, sondern
|
||||
* absichtlich: wer ein internes Zertifikat hinterlegt, will genau dieser CA
|
||||
* trauen und keiner oeffentlichen. Die Pruefung des Hostnamens bleibt in jedem
|
||||
* Fall an — dafuer gibt es hier bewusst keinen Schalter.
|
||||
*/
|
||||
export function tlsOptionenBauen(caZertifikatPem: string | null | undefined): ConnectionOptions {
|
||||
const ca = caZertifikatPem?.trim();
|
||||
return {
|
||||
minVersion: "TLSv1.2",
|
||||
...(ca ? { ca: [ca] } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Wie viel von einer fremden Meldung wir uebernehmen. Der Rest waere Rauschen im Feld. */
|
||||
const MELDUNG_GRENZE = 400;
|
||||
|
||||
/**
|
||||
* Eine Meldung, die gespeichert und angezeigt werden darf.
|
||||
*
|
||||
* Rohe Fehler der Bibliothek koennen das Bind-Passwort enthalten — etwa wenn es
|
||||
* in einer Adresse steckt oder eine Ausnahme die Anfrage mitschreibt. In
|
||||
* `Verzeichnisquelle.letzterAbgleichFehler` landet deshalb nur, was hier
|
||||
* durchkommt: ohne die uebergebenen Geheimnisse, einzeilig und gekuerzt. Ein
|
||||
* Stapelverlauf gehoert ohnehin nicht in eine Oberflaeche.
|
||||
*
|
||||
* Die Geheimnisse werden vollstaendig ersetzt, nicht gekuerzt angezeigt: schon
|
||||
* die Laenge eines Passworts ist eine Auskunft, die niemand braucht.
|
||||
*/
|
||||
/**
|
||||
* Die drei Ergebniscodes, die im Alltag wirklich vorkommen.
|
||||
*
|
||||
* `ldapts` gibt sie als „Code: 0x31“ weiter — richtig, aber fuer den Menschen
|
||||
* vor dem Bildschirm wertlos: er sieht dieselbe Zeichenfolge bei falschem
|
||||
* Passwort wie bei fehlendem Leserecht. Der Rohtext bleibt trotzdem stehen,
|
||||
* denn die uebrigen fuenfzig Codes uebersetzt hier niemand (Reviewbefund zu
|
||||
* #016).
|
||||
*/
|
||||
const CODE_TEXTE: ReadonlyArray<readonly [RegExp, string]> = [
|
||||
[/0x31|invalid ?credentials/iu, "Anmeldung abgelehnt: Anmelde-DN oder Passwort stimmen nicht."],
|
||||
[
|
||||
/0x32|insufficient ?access/iu,
|
||||
"Das Dienstkonto darf hier nicht lesen — es fehlt die Leseberechtigung auf dem Basis-DN.",
|
||||
],
|
||||
[/0x20|no ?such ?object/iu, "Den Basis-DN gibt es auf diesem Server nicht."],
|
||||
];
|
||||
|
||||
export function fehlerMeldung(fehler: unknown, geheimnisse: readonly string[]): string {
|
||||
const roh = fehler instanceof Error ? fehler.message : String(fehler);
|
||||
let text = roh.replace(/\s+/gu, " ").trim();
|
||||
|
||||
// Die Erklaerung **vor** den Rohtext, nicht statt seiner: wer den Server
|
||||
// kennt, liest den Code, alle anderen den Satz.
|
||||
const treffer = CODE_TEXTE.find(([muster]) => muster.test(text));
|
||||
if (treffer) text = `${treffer[1]} (${text})`;
|
||||
for (const geheimnis of geheimnisse) {
|
||||
// Kurze Zeichenketten koennten in jeder Meldung stecken; das Geheimnis
|
||||
// dennoch zu ersetzen ist harmlos, es auszulassen waere es nicht.
|
||||
if (!geheimnis) continue;
|
||||
text = text.split(geheimnis).join("***");
|
||||
}
|
||||
if (!text) return "Unbekannter Fehler beim Zugriff auf das Verzeichnis.";
|
||||
return text.length > MELDUNG_GRENZE ? `${text.slice(0, MELDUNG_GRENZE)}…` : text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Baut die Verbindung, bindet, fuehrt `was` aus und trennt in jedem Fall wieder.
|
||||
*
|
||||
* `unbind` steht im `finally`, weil eine offene Verbindung den Zeitplan ueber
|
||||
* Stunden am Leben halten wuerde: der Abgleich laeuft im selben Prozess wie die
|
||||
* Anwendung. Fehler werden hier gesaeubert, damit kein Aufrufer es vergessen
|
||||
* kann — weiter aussen liegt nur noch die Stelle, die die Meldung speichert.
|
||||
*/
|
||||
export async function mitVerbindung<T>(zugang: LdapZugang, was: (client: Client) => Promise<T>): Promise<T> {
|
||||
const { url, startTls, verschluesselt } = urlPruefen(zugang.url);
|
||||
const tlsOptionen = tlsOptionenBauen(zugang.caZertifikatPem);
|
||||
const client = new Client({
|
||||
url,
|
||||
/*
|
||||
* Die Optionen kommen nur mit, wenn die Verbindung von Anfang an
|
||||
* verschluesselt ist — also bei `ldaps`.
|
||||
*
|
||||
* ldapts 9 entscheidet `secure` aus dem Schema **oder** daraus, dass
|
||||
* `tlsOptions` ueberhaupt ein gesetztes Feld hat (Client.ts: `this.secure =
|
||||
* isSecureProtocol || hasTlsOptions`). Sie immer mitzugeben liesse also auch
|
||||
* `ldap://` als TLS starten, und ein Server im Klartext legt dann sofort auf:
|
||||
* „Client network socket disconnected before secure TLS connection was
|
||||
* established“. Bei StartTLS bekommt `startTLS()` sie stattdessen.
|
||||
*/
|
||||
...(verschluesselt && !startTls ? { tlsOptions: tlsOptionen } : {}),
|
||||
// Ohne Zeitgrenzen haengt ein Abgleich an einem stummen Server, bis jemand
|
||||
// den Prozess neu startet — und die Sperre laeuft mit.
|
||||
connectTimeout: 10_000,
|
||||
timeout: 60_000,
|
||||
});
|
||||
|
||||
try {
|
||||
if (startTls) await client.startTLS(tlsOptionen);
|
||||
await client.bind(zugang.bindDn, zugang.bindPasswort);
|
||||
return await was(client);
|
||||
} catch (fehler) {
|
||||
// Ohne `cause`, und das ist der Punkt: Next schreibt bei einem Fehler die
|
||||
// ganze Kette ins Server-Log. Der rohe Fehler bliebe darin ungefiltert
|
||||
// stehen, und die gesaeuberte Meldung waere eine Zusage, die nur fuer die
|
||||
// Oberflaeche gilt.
|
||||
throw new VerbindungsFehler(fehlerMeldung(fehler, [zugang.bindPasswort]));
|
||||
} finally {
|
||||
// Auch das Trennen kann werfen (abgebrochene Verbindung). Das ist dann nicht
|
||||
// der Fehler, der den Aufrufer interessiert.
|
||||
await client.unbind().catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import type { Entry } from "ldapts";
|
||||
import { GuidFehler, guidAusBytes } from "@/lib/ldap-guid";
|
||||
import { AD_ZUORDNUNG, type AttributZuordnung, ZUORDNUNGS_FELDER, zuordnungLesen } from "@/lib/verzeichnis";
|
||||
|
||||
/*
|
||||
* Die Liste der Felder liegt in `src/lib/verzeichnis.ts`, weil das
|
||||
* Formular sie auch braucht. Sie wird hier weiter mitexportiert, damit die
|
||||
* LDAP-Module eine Anlaufstelle behalten und ein Aufrufer nicht wissen muss,
|
||||
* welcher Teil der Zuordnung framework-frei ist.
|
||||
*/
|
||||
export { AD_ZUORDNUNG, ZUORDNUNGS_FELDER, zuordnungLesen };
|
||||
export type { AttributZuordnung };
|
||||
|
||||
/**
|
||||
* Welches LDAP-Attribut wird welches Feld (#016).
|
||||
*
|
||||
* Die Zuordnung steht je Quelle in der Datenbank (`Verzeichnisquelle.attributZuordnung`,
|
||||
* ein Json-Feld), weil nicht jedes Verzeichnis ein Active Directory ist. Die
|
||||
* Vorgaben sind die AD-Namen; wer ein OpenLDAP anbindet, aendert einzelne Zeilen.
|
||||
*
|
||||
* Hier stehen deshalb nur reine Funktionen: aus einem `Entry` der Bibliothek
|
||||
* wird ein Datensatz in unseren Feldern. Kein Netz, keine Datenbank — dadurch
|
||||
* laesst sich die Zuordnung mit erfundenen Eintraegen pruefen, und der
|
||||
* Integrationstest muss nicht jeden Sonderfall des Mappings nachbauen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Der Standardfilter: Personenkonten, die **nicht** deaktiviert sind.
|
||||
*
|
||||
* `userAccountControl:1.2.840.113556.1.4.803:=2` ist die Bit-UND-Regel des AD;
|
||||
* Bit 2 (`ACCOUNTDISABLE`) gesetzt heisst deaktiviert. Das Ausschliessen hier ist
|
||||
* kein Komfort, sondern die halbe Aufgabe: ein deaktiviertes Konto verschwindet
|
||||
* damit aus dem Ergebnis, und was aus dem Ergebnis verschwindet, setzt der
|
||||
* Abgleich auf `aktiv = false`.
|
||||
*/
|
||||
export const STANDARD_FILTER =
|
||||
"(&(objectCategory=person)(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))";
|
||||
|
||||
/** Ein Eintrag in unseren Feldern, so wie ihn „Verbindung testen“ zeigt. */
|
||||
export type GemappterEintrag = {
|
||||
externeId: string;
|
||||
vorname: string;
|
||||
nachname: string;
|
||||
anzeigename: string;
|
||||
mail: string | null;
|
||||
upn: string | null;
|
||||
sam: string | null;
|
||||
dn: string;
|
||||
abteilung: string | null;
|
||||
titel: string | null;
|
||||
vorgesetzterExterneId: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Derselbe Eintrag, bevor der Vorgesetzte aufgeloest ist.
|
||||
*
|
||||
* `manager` ist im Verzeichnis ein **DN**, gespeichert wird aber die
|
||||
* `externeId` des Vorgesetzten — sonst zeigt `{{vorgesetzter_sam}}` ins Leere,
|
||||
* sobald jemand umgezogen ist und sein DN sich geaendert hat. Die Aufloesung
|
||||
* braucht die anderen Eintraege und kann deshalb nicht beim Lesen eines
|
||||
* einzelnen Eintrags geschehen.
|
||||
*/
|
||||
export type RoherEintrag = Omit<GemappterEintrag, "vorgesetzterExterneId"> & {
|
||||
vorgesetzterDn: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Attribute, die angefragt werden — genau die zugeordneten, nichts sonst.
|
||||
*
|
||||
* Ohne diese Liste liefert der Server alle Attribute, also auch
|
||||
* Gruppenmitgliedschaften und was sonst am Konto haengt. Gespeichert wird davon
|
||||
* nichts, aber gelesen eben doch: es stuende im Speicher, in einer
|
||||
* Fehlerausgabe, in einem Netzwerkmitschnitt. Wir fragen deshalb nur, was wir
|
||||
* brauchen.
|
||||
*/
|
||||
export function angeforderteAttribute(zuordnung: AttributZuordnung): string[] {
|
||||
// Set, weil zwei Felder dasselbe Attribut benutzen duerfen (etwa upn = mail).
|
||||
return [...new Set(Object.values(zuordnung))];
|
||||
}
|
||||
|
||||
/** Ein einzelner Attributwert als Text, oder `null`. */
|
||||
function textWert(eintrag: Entry, attribut: string): string | null {
|
||||
const wert = eintrag[attribut];
|
||||
if (wert === undefined) return null;
|
||||
// Mehrwertige Attribute kommen als Array. Der erste Wert gewinnt; ein Konto mit
|
||||
// zwei Vornamen ist ein Datenfehler im Verzeichnis, kein Fall fuer Rateraten.
|
||||
const erster = Array.isArray(wert) ? wert[0] : wert;
|
||||
if (erster === undefined) return null;
|
||||
const text = (typeof erster === "string" ? erster : erster.toString("utf8")).trim();
|
||||
return text ? text : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ist das lesbarer Text – oder sind es rohe Bytes, die nur so aussehen?
|
||||
*
|
||||
* Steuerzeichen sind das Unterscheidungsmerkmal. Ein verstuemmelter GUID-Puffer
|
||||
* enthaelt praktisch immer welche; eine Kennung wie `entryUUID` nie.
|
||||
*/
|
||||
function istLesbar(text: string): boolean {
|
||||
return text.length > 0 && !/[\u0000-\u001f\u007f]/u.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Die stabile Kennung als Text.
|
||||
*
|
||||
* `objectGUID` kommt dank `explicitBufferAttributes` als Puffer. Sind es genau 16
|
||||
* Bytes, ist es eine GUID und wird gemischt-endian umsortiert. Ist es etwas
|
||||
* anderes — wer `externeId` auf `entryUUID` eines OpenLDAP legt, bekommt 36
|
||||
* Textbytes —, wird der Puffer als UTF-8 gelesen, **aber nur wenn dabei lesbarer
|
||||
* Text herauskommt**.
|
||||
*
|
||||
* Diese Einschraenkung ist der Punkt: ein Puffer mit 7 Bytes ist eine
|
||||
* verstuemmelte GUID und keine Kennung. Wuerde er als Text durchgehen, staende
|
||||
* eine Zeichenfolge aus Steuerzeichen als `externeId` in der Datenbank — stabil
|
||||
* genug, um kein Duplikat zu erzeugen, und falsch genug, dass sie zu keinem Konto
|
||||
* im Verzeichnis passt. Lieber gar keine Kennung: dann wird der Eintrag
|
||||
* uebersprungen und das faellt auf.
|
||||
*/
|
||||
export function externeIdLesen(eintrag: Entry, attribut: string): string | undefined {
|
||||
const wert = eintrag[attribut];
|
||||
const erster = Array.isArray(wert) ? wert[0] : wert;
|
||||
if (erster === undefined) return undefined;
|
||||
if (typeof erster !== "string") {
|
||||
if (erster.length === 0) return undefined;
|
||||
if (erster.length === 16) return guidAusBytes(erster);
|
||||
const text = erster.toString("utf8").trim();
|
||||
return istLesbar(text) ? text : undefined;
|
||||
}
|
||||
const text = erster.trim();
|
||||
return istLesbar(text) ? text : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Warum ein Eintrag uebersprungen wurde. Nur `externeId` fehlt — alles andere
|
||||
* darf fehlen.
|
||||
*/
|
||||
export type Uebersprungen = { dn: string; grund: string };
|
||||
|
||||
/**
|
||||
* Ein `Entry` wird ein `RoherEintrag`, oder er wird uebersprungen.
|
||||
*
|
||||
* Uebersprungen wird genau ein Fall: ohne stabile Kennung gibt es nichts, woran
|
||||
* ein zweiter Abgleich den Eintrag wiedererkennen koennte — er wuerde bei jedem
|
||||
* Lauf erneut angelegt. Ein Abbruch waere die falsche Antwort: ein einzelnes
|
||||
* krummes Konto darf nicht den Abgleich aller anderen verhindern.
|
||||
*/
|
||||
export function eintragZuordnen(
|
||||
eintrag: Entry,
|
||||
zuordnung: AttributZuordnung,
|
||||
): { eintrag: RoherEintrag } | { uebersprungen: Uebersprungen } {
|
||||
const dn = eintrag.dn.trim();
|
||||
let externeId: string | undefined;
|
||||
try {
|
||||
externeId = externeIdLesen(eintrag, zuordnung.externeId);
|
||||
} catch (fehler) {
|
||||
if (!(fehler instanceof GuidFehler)) throw fehler;
|
||||
return { uebersprungen: { dn, grund: fehler.message } };
|
||||
}
|
||||
if (!externeId) {
|
||||
return {
|
||||
uebersprungen: {
|
||||
dn,
|
||||
grund: `Ohne ${zuordnung.externeId} gibt es keine dauerhafte Kennung.`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const vorname = textWert(eintrag, zuordnung.vorname);
|
||||
const nachname = textWert(eintrag, zuordnung.nachname);
|
||||
return {
|
||||
eintrag: {
|
||||
externeId,
|
||||
vorname: vorname ?? "",
|
||||
nachname: nachname ?? "",
|
||||
anzeigename: textWert(eintrag, zuordnung.anzeigename) ?? ersatzName(vorname, nachname, dn),
|
||||
mail: textWert(eintrag, zuordnung.mail),
|
||||
upn: textWert(eintrag, zuordnung.upn),
|
||||
sam: textWert(eintrag, zuordnung.sam),
|
||||
dn,
|
||||
abteilung: textWert(eintrag, zuordnung.abteilung),
|
||||
titel: textWert(eintrag, zuordnung.titel),
|
||||
vorgesetzterDn: textWert(eintrag, zuordnung.vorgesetzter),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Anzeigename muss stehen, das Feld ist nicht optional — und eine leere Zeile
|
||||
* in einer Auswahlliste waere fuer den Techniker unbrauchbar. Gibt es keinen,
|
||||
* wird er aus Nachname und Vorname gebaut; bleibt auch das leer, tritt der erste
|
||||
* Teil des DN ein.
|
||||
*/
|
||||
function ersatzName(vorname: string | null, nachname: string | null, dn: string): string {
|
||||
const gebaut = [nachname, vorname].filter((teil) => teil).join(", ");
|
||||
if (gebaut) return gebaut;
|
||||
const ersterTeil = dn.split(",")[0]?.split("=")[1]?.trim();
|
||||
return ersterTeil ?? dn;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vergleichbare Form eines DN.
|
||||
*
|
||||
* Verglichen wird als Text, klein geschrieben und ohne Leerzeichen um die
|
||||
* Kommas. Das deckt, was ein AD ausgibt. Es ist **keine** Normalisierung nach
|
||||
* RFC 4514: ein anders maskierter oder anders geschriebener DN bleibt
|
||||
* unaufgeloest, und der Vorgesetzte fehlt dann — er wird nicht falsch geraten.
|
||||
*/
|
||||
export function dnSchluessel(dn: string): string {
|
||||
return dn
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s*,\s*/gu, ",");
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt `vorgesetzterExterneId` aus den DNs.
|
||||
*
|
||||
* Aufgeloest wird nur gegen die Kennungen, die dieser Lauf gesehen hat. Steht der
|
||||
* Vorgesetzte ausserhalb des Suchbereichs oder ist er deaktiviert, bleibt das
|
||||
* Feld leer — lieber kein Vorgesetzter als ein falscher.
|
||||
*/
|
||||
export function vorgesetzteAufloesen(
|
||||
eintraege: readonly RoherEintrag[],
|
||||
zusaetzlicheKennungen?: ReadonlyMap<string, string>,
|
||||
): GemappterEintrag[] {
|
||||
const nachDn = new Map(zusaetzlicheKennungen ?? []);
|
||||
for (const eintrag of eintraege) {
|
||||
nachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
}
|
||||
return eintraege.map(({ vorgesetzterDn, ...rest }) => ({
|
||||
...rest,
|
||||
vorgesetzterExterneId: vorgesetzterDn ? (nachDn.get(dnSchluessel(vorgesetzterDn)) ?? null) : null,
|
||||
}));
|
||||
}
|
||||
@@ -80,6 +80,15 @@ export async function abteilungsListe(
|
||||
* Lauf leer, ohne dass diese Seite es je sagte. Genau so stand es hier, bis der
|
||||
* Review es gefunden hat; `abteilungsListe` darueber macht es seit jeher
|
||||
* richtig.
|
||||
*
|
||||
* Eine **archivierte** Abteilung gilt als gepflegt: der Name ist je Kunde
|
||||
* eindeutig, ein zweites Anlegen scheitert. Wer sie zurueck will, holt sie in der
|
||||
* Liste darunter wieder hervor.
|
||||
*
|
||||
* Kunden, die archiviert sind, kommen nicht vor (#016). Die Oberflaeche macht aus
|
||||
* jedem Eintrag hier einen Knopf, der die Abteilung anlegt — und `kundeErmitteln`
|
||||
* weist einen archivierten Kunden ab. Ein Vorschlag, der beim Anklicken nur eine
|
||||
* Fehlermeldung ergibt, ist ein Versprechen, das der Code nicht haelt.
|
||||
*/
|
||||
export async function ungepflegteAbteilungen(
|
||||
benutzer: HandelnderBenutzer,
|
||||
@@ -89,7 +98,9 @@ export async function ungepflegteAbteilungen(
|
||||
prisma.abteilung.findMany({ where: { AND: mandant }, select: { name: true, kundeId: true } }),
|
||||
prisma.mitarbeiter.groupBy({
|
||||
by: ["abteilung", "kundeId"],
|
||||
where: { AND: [{ aktiv: true }, { abteilung: { not: null } }, ...mandant] },
|
||||
where: {
|
||||
AND: [{ aktiv: true }, { abteilung: { not: null } }, { kunde: { archiviert: false } }, ...mandant],
|
||||
},
|
||||
_count: { _all: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { betriebsmodus, sichtbareKundenIds, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { type QuellenSicht, quellenSichtBauen } from "@/server/services/verzeichnis";
|
||||
|
||||
/**
|
||||
* Lesemodelle der Verzeichnisquellen (#016).
|
||||
*
|
||||
* Zwei Dinge wendet dieses Modul selbst an, damit kein Aufrufer sie vergessen
|
||||
* kann:
|
||||
*
|
||||
* 1. **Im Modus DIENSTLEISTUNG gibt es keine Quellen.** Nicht „keine
|
||||
* sichtbaren“, sondern keine: der Abgleich gilt nur fuer die eigene
|
||||
* Organisation. Eine Quelle, die aus einer frueheren Zeit im Modus INTERN
|
||||
* noch in der Tabelle steht, wird deshalb hier nicht gezeigt — genauso wenig,
|
||||
* wie sich der Abgleich dann starten liesse.
|
||||
* 2. **Das Bind-Passwort kommt nicht mit.** Die Zeile wird ueber
|
||||
* `quellenSichtBauen` in `QuellenSicht` uebersetzt, die kein Feld dafuer hat.
|
||||
* Ein `select` mit einer Feldliste waere die zweite Stelle, an der jemand ein
|
||||
* Feld vergessen oder zu viel aufnehmen kann.
|
||||
*/
|
||||
|
||||
export type { QuellenSicht };
|
||||
|
||||
export async function quellenListe(benutzer: HandelnderBenutzer): Promise<readonly QuellenSicht[]> {
|
||||
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return [];
|
||||
// Im Modus INTERN gibt es genau eine Organisation, und `sichtbareKundenIds`
|
||||
// liefert dort `undefined`. Die Bedingung steht trotzdem hier: sie ist die
|
||||
// Mandantensicht dieses Lesemodells, und sie wuerde fehlen, sobald sich die
|
||||
// Regel oben einmal aendert.
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
const zeilen = await prisma.verzeichnisquelle.findMany({
|
||||
where: sichtbar === undefined ? {} : { kundeId: { in: [...sichtbar] } },
|
||||
orderBy: [{ name: "asc" }],
|
||||
});
|
||||
return zeilen.map(quellenSichtBauen);
|
||||
}
|
||||
|
||||
export async function quelleHolen(
|
||||
id: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<QuellenSicht | undefined> {
|
||||
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return undefined;
|
||||
const zeile = await prisma.verzeichnisquelle.findUnique({ where: { id } });
|
||||
if (!zeile) return undefined;
|
||||
const sichtbar = await sichtbareKundenIds(benutzer);
|
||||
// Eine fremde Quelle ist „nicht vorhanden“ und nicht „verboten“: die Auskunft,
|
||||
// dass es sie gibt, ist selbst schon eine.
|
||||
if (sichtbar !== undefined && !sichtbar.includes(zeile.kundeId)) return undefined;
|
||||
return quellenSichtBauen(zeile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile des Abgleich-Logs.
|
||||
*
|
||||
* Die Zahlen stehen im `Protokoll` und nicht in einer eigenen Tabelle: jeder Lauf
|
||||
* schreibt dort ohnehin einen Eintrag, und ein zweiter Ort fuer dieselbe Auskunft
|
||||
* liefe auseinander. `durch` ist `null`, wenn der Zeitplan den Lauf angestossen
|
||||
* hat — dann gibt es keinen Menschen, der es getan hat.
|
||||
*/
|
||||
export type AbgleichLogZeile = {
|
||||
readonly id: string;
|
||||
readonly zeitpunkt: Date;
|
||||
readonly gescheitert: boolean;
|
||||
readonly gelesen: number;
|
||||
readonly angelegt: number;
|
||||
readonly uebernommen: number;
|
||||
readonly aktualisiert: number;
|
||||
readonly deaktiviert: number;
|
||||
readonly uebersprungen: number;
|
||||
readonly fehler: string | null;
|
||||
readonly durch: string | null;
|
||||
};
|
||||
|
||||
/** Wie viele Laeufe die Seite zeigt. Mehr liest niemand, und alle waeren unbegrenzt viele. */
|
||||
export const LOG_ZEILEN = 10;
|
||||
|
||||
/** Eine Zahl aus dem Json-Feld, oder 0 — aeltere Eintraege kennen neue Spalten nicht. */
|
||||
function zahl(nachher: unknown, name: string): number {
|
||||
if (typeof nachher !== "object" || nachher === null) return 0;
|
||||
const wert = (nachher as Record<string, unknown>)[name];
|
||||
return typeof wert === "number" && Number.isFinite(wert) ? wert : 0;
|
||||
}
|
||||
|
||||
function textOderNull(nachher: unknown, name: string): string | null {
|
||||
if (typeof nachher !== "object" || nachher === null) return null;
|
||||
const wert = (nachher as Record<string, unknown>)[name];
|
||||
return typeof wert === "string" && wert.trim() ? wert : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die letzten Abgleich-Laeufe einer Quelle.
|
||||
*
|
||||
* Geht ueber `quelleHolen` und damit ueber dieselbe Mandanten- und Modussicht:
|
||||
* eine fremde oder im Modus DIENSTLEISTUNG liegende Quelle hat kein Log, und die
|
||||
* Antwort ist leer und nicht verboten.
|
||||
*/
|
||||
export async function abgleichLog(
|
||||
quelleId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
grenze: number = LOG_ZEILEN,
|
||||
): Promise<readonly AbgleichLogZeile[]> {
|
||||
const quelle = await quelleHolen(quelleId, benutzer);
|
||||
if (!quelle) return [];
|
||||
|
||||
const zeilen = await prisma.protokoll.findMany({
|
||||
where: {
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: quelleId,
|
||||
aktion: { in: ["Verzeichnis abgeglichen", "Verzeichnisabgleich fehlgeschlagen"] },
|
||||
},
|
||||
orderBy: { erstelltAm: "desc" },
|
||||
take: Math.max(1, Math.trunc(grenze)),
|
||||
include: { benutzer: { select: { name: true, email: true } } },
|
||||
});
|
||||
|
||||
return zeilen.map((zeile) => ({
|
||||
id: zeile.id,
|
||||
zeitpunkt: zeile.erstelltAm,
|
||||
gescheitert: zeile.aktion === "Verzeichnisabgleich fehlgeschlagen",
|
||||
gelesen: zahl(zeile.nachher, "gelesen"),
|
||||
angelegt: zahl(zeile.nachher, "angelegt"),
|
||||
uebernommen: zahl(zeile.nachher, "uebernommen"),
|
||||
aktualisiert: zahl(zeile.nachher, "aktualisiert"),
|
||||
deaktiviert: zahl(zeile.nachher, "deaktiviert"),
|
||||
uebersprungen: zahl(zeile.nachher, "uebersprungen"),
|
||||
fehler: textOderNull(zeile.nachher, "fehler"),
|
||||
// Der Name, sonst die Mailadresse: ein Benutzer ohne Namen ist moeglich.
|
||||
durch: zeile.benutzer ? (zeile.benutzer.name ?? zeile.benutzer.email) : null,
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,574 @@
|
||||
import { hostname } from "node:os";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Mitarbeiter, Verzeichnisquelle } from "@/generated/prisma/client";
|
||||
import { Betriebsmodus, Herkunft } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { satzEnde } from "@/lib/satzzeichen";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { betriebsmodus, eigeneOrganisation, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { eintraegeStroemen } from "@/server/ldap/leser";
|
||||
import { fehlerMeldung } from "@/server/ldap/verbindung";
|
||||
import { dnSchluessel, type RoherEintrag, zuordnungLesen } from "@/server/ldap/zuordnung";
|
||||
import { VerzeichnisFehler, quelleZeileHolen, zugangAus } from "@/server/services/verzeichnis";
|
||||
|
||||
/**
|
||||
* Der Abgleich (#016).
|
||||
*
|
||||
* Die eine Regel, die alles andere traegt: **es wird nichts geloescht.** Wer aus
|
||||
* dem Suchergebnis verschwindet — gekuendigt, deaktiviert, in eine andere OU
|
||||
* gezogen —, wird `aktiv = false` und bleibt stehen. Ein archivierter Lauf muss
|
||||
* auch in zehn Jahren noch sagen koennen, fuer wen er lief; ein Loeschen waere
|
||||
* gegen den Zweck des Archivs, und der Fremdschluessel steht ohnehin auf
|
||||
* `Restrict`.
|
||||
*
|
||||
* Der Abgleich laeuft **nicht** in einer Transaktion. Das ist eine Entscheidung
|
||||
* mit Folgen, und sie steht hier, damit niemand etwas anderes annimmt: bricht der
|
||||
* Lauf in der Mitte ab, bleiben die bis dahin geschriebenen Personen so stehen,
|
||||
* wie sie geschrieben wurden. Deaktiviert wird dagegen **nur nach einem
|
||||
* vollstaendigen Durchlauf** — sonst haette ein Netzwerkfehler nach der halben
|
||||
* Liste die andere Haelfte der Firma abgeschaltet. Eine Transaktion ueber das
|
||||
* gesamte Verzeichnis haette die Tabelle solange gesperrt, wie das Lesen dauert.
|
||||
*/
|
||||
|
||||
export type AbgleichErgebnis = {
|
||||
readonly angelegt: number;
|
||||
/**
|
||||
* Personen, die es schon von Hand gab und die jetzt das Verzeichnis fuehrt.
|
||||
*
|
||||
* Eigene Spalte und nicht in `angelegt` oder `aktualisiert` mitgezaehlt: es ist
|
||||
* die einzige Zahl, die etwas ueber die Datenqualitaet sagt. Steht sie bei jedem
|
||||
* Lauf hoch, legt jemand Personen an, die das Verzeichnis kurz danach liefert.
|
||||
*/
|
||||
readonly uebernommen: number;
|
||||
readonly aktualisiert: number;
|
||||
readonly deaktiviert: number;
|
||||
readonly gelesen: number;
|
||||
readonly status: "ERFOLG" | "FEHLER";
|
||||
readonly fehler: string | null;
|
||||
/**
|
||||
* Etwas, das der Administrator wissen sollte, ohne dass der Lauf gescheitert
|
||||
* waere — etwa uebersprungene Eintraege. Steht **nicht** in
|
||||
* `letzterAbgleichFehler`: das Feld heisst, dass etwas schiefging.
|
||||
*/
|
||||
readonly hinweis: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Der zweite gleichzeitige Abgleich derselben Quelle wird **abgewiesen**, nicht
|
||||
* eingereiht: eine Warteschlange waere ein zweiter Lauf auf denselben Daten,
|
||||
* Minuten spaeter, ohne neuen Erkenntniswert. Eine eigene Klasse, damit die
|
||||
* Oberflaeche „laeuft schon“ von „ist schiefgegangen“ unterscheiden kann.
|
||||
*/
|
||||
export class AbgleichLaeuftBereitsFehler extends VerzeichnisFehler {
|
||||
constructor(nachricht: string) {
|
||||
super(nachricht);
|
||||
this.name = "AbgleichLaeuftBereitsFehler";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wie lange die Sperre hoechstens gilt.
|
||||
*
|
||||
* Sie muss ablaufen: stuerzt der Prozess mitten im Abgleich ab, bleibt die Zeile
|
||||
* stehen, und ohne Ablauf waere die Quelle fuer immer blockiert — ein
|
||||
* Datenbankeintrag, den niemand mehr findet und den ein Neustart nicht raeumt.
|
||||
* Zwei Stunden sind reichlich fuer ein grosses Verzeichnis und kurz genug, dass
|
||||
* ein Absturz am Morgen den Abgleich am Mittag nicht verhindert.
|
||||
*/
|
||||
const SPERRE_MINUTEN = 120;
|
||||
|
||||
function sperrSchluessel(quelleId: string): string {
|
||||
return `abgleich:${quelleId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wer die Sperre haelt: Rechner, Prozess und ein Zufallsanteil.
|
||||
*
|
||||
* Der Zufallsanteil ist nicht Zierde. Ohne ihn koennten zwei Abgleiche im
|
||||
* **selben** Prozess (ein Klick und der Zeitplan im selben Moment) einander die
|
||||
* Sperre wegnehmen und der zweite die des ersten beim Aufraeumen loeschen.
|
||||
*/
|
||||
function haltername(): string {
|
||||
return `${hostname()}/${process.pid}/${randomUUID().slice(0, 8)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fuehrt `was` unter der Sperre aus; `undefined`, wenn sie belegt ist.
|
||||
*
|
||||
* Das Belegen ist **eine** Anweisung, und das ist der Punkt: `SELECT` und danach
|
||||
* `INSERT` waeren zwei, und zwischen ihnen passt genau der zweite Abgleich, den
|
||||
* die Sperre verhindern soll. Das `WHERE` am `ON CONFLICT` uebernimmt eine
|
||||
* abgelaufene Sperre und laesst eine lebende in Ruhe, sodass die Zeile nie
|
||||
* dauerhaft blockiert.
|
||||
*/
|
||||
export async function mitAbgleichSperre<T>(
|
||||
schluessel: string,
|
||||
dauerMinuten: number,
|
||||
was: () => Promise<T>,
|
||||
): Promise<T | undefined> {
|
||||
const halter = haltername();
|
||||
const bekommen = await prisma.$executeRaw`
|
||||
INSERT INTO "AbgleichSperre" ("schluessel", "gehaltenVon", "gehaltenSeit", "laeuftAbAm")
|
||||
VALUES (${schluessel}, ${halter}, now(), now() + make_interval(mins => ${dauerMinuten}))
|
||||
ON CONFLICT ("schluessel") DO UPDATE
|
||||
SET "gehaltenVon" = EXCLUDED."gehaltenVon",
|
||||
"gehaltenSeit" = EXCLUDED."gehaltenSeit",
|
||||
"laeuftAbAm" = EXCLUDED."laeuftAbAm"
|
||||
WHERE "AbgleichSperre"."laeuftAbAm" <= now()
|
||||
`;
|
||||
if (bekommen === 0) return undefined;
|
||||
|
||||
try {
|
||||
return await was();
|
||||
} finally {
|
||||
// Nur die eigene Sperre freigeben. Ist sie zwischenzeitlich abgelaufen und
|
||||
// von einem anderen Lauf uebernommen worden, gehoert die Zeile diesem — sie
|
||||
// hier zu loeschen hiesse, ihm die Sperre unter den Fuessen wegzuziehen.
|
||||
await prisma.$executeRaw`
|
||||
DELETE FROM "AbgleichSperre" WHERE "schluessel" = ${schluessel} AND "gehaltenVon" = ${halter}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Quellen, deren Intervall abgelaufen ist – fuer den Zeitplan.
|
||||
*
|
||||
* Gerechnet wird in der Datenbank, weil das Intervall je Quelle in der Zeile
|
||||
* steht: `letzterAbgleichAm + Intervall <= jetzt`. Eine Quelle, die noch nie
|
||||
* gelaufen ist, ist sofort faellig. Sortiert wird nach dem aeltesten Abgleich,
|
||||
* damit bei vielen faelligen Quellen keine dauerhaft hinten steht.
|
||||
*
|
||||
* Im Modus DIENSTLEISTUNG ist die Liste leer: dort gibt es kein Verzeichnis.
|
||||
*/
|
||||
export async function faelligeQuellen(
|
||||
jetzt: Date = new Date(),
|
||||
): Promise<readonly { id: string; name: string }[]> {
|
||||
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) return [];
|
||||
/*
|
||||
* Nur die eigene Organisation. Eine Quelle, die an einem anderen Kunden
|
||||
* haengt, weist `quelleZeileHolen` beim Abgleich ohnehin ab — aber erst
|
||||
* **nachdem** der Zeitplan sie geholt hat. Ohne diese Bedingung stuende sie
|
||||
* jede Minute neu in der Liste, schriebe jede Minute „unerwartet gescheitert“
|
||||
* ins Protokoll des Prozesses und bliebe fuer immer faellig (Reviewbefund zu
|
||||
* #016). Anlegen laesst sich so eine Quelle ueber die Anwendung nicht; ueber
|
||||
* `psql` und eine Migration schon.
|
||||
*/
|
||||
const eigene = await eigeneOrganisation();
|
||||
return prisma.$queryRaw<{ id: string; name: string }[]>`
|
||||
SELECT "id", "name"
|
||||
FROM "Verzeichnisquelle"
|
||||
WHERE "aktiv" = true
|
||||
AND "kundeId" = ${eigene.id}
|
||||
AND ("letzterAbgleichAm" IS NULL
|
||||
OR "letzterAbgleichAm" + make_interval(mins => "abgleichIntervallMinuten") <= ${jetzt})
|
||||
ORDER BY "letzterAbgleichAm" ASC NULLS FIRST, "name" ASC
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die von Hand angelegte Person zu einem Verzeichniseintrag – oder `null`.
|
||||
*
|
||||
* Gesucht wird nur im eigenen Kunden und nur unter Zeilen **ohne** Quelle: eine
|
||||
* Person, die schon eine andere Quelle fuehrt, gehoert der und wird nicht
|
||||
* uebernommen. Verglichen wird ohne Rucksicht auf Gross- und Kleinschreibung,
|
||||
* weil `MHuber` und `mhuber` derselbe Anmeldename sind.
|
||||
*
|
||||
* `upn` zaehlt vor `sam`: der UPN traegt die Domaene und ist damit die genauere
|
||||
* Aussage. Ein blosser Namensvergleich waere hier falsch — zwei Menschen koennen
|
||||
* „Maria Huber“ heissen, aber nicht denselben Anmeldenamen haben.
|
||||
*/
|
||||
async function vonHandAngelegteFinden(kundeId: string, eintrag: RoherEintrag): Promise<Mitarbeiter | null> {
|
||||
const wege = [
|
||||
...(eintrag.upn ? [{ upn: { equals: eintrag.upn, mode: "insensitive" as const } }] : []),
|
||||
...(eintrag.sam ? [{ sam: { equals: eintrag.sam, mode: "insensitive" as const } }] : []),
|
||||
];
|
||||
for (const weg of wege) {
|
||||
const treffer = await prisma.mitarbeiter.findFirst({
|
||||
where: { kundeId, quelleId: null, externeId: null, ...weg },
|
||||
// Die aelteste zuerst: sie hat die Laeufe und Geraete an sich.
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
});
|
||||
if (treffer) return treffer;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Was wir zwischen den beiden Durchgaengen ueber eine Person behalten muessen. */
|
||||
type OffenerVorgesetzter = {
|
||||
readonly externeId: string;
|
||||
readonly vorgesetzterDn: string;
|
||||
readonly vorherigeKennung: string | null;
|
||||
readonly neuAngelegt: boolean;
|
||||
};
|
||||
|
||||
export async function abgleichAusfuehren(
|
||||
quelleId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<AbgleichErgebnis> {
|
||||
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
|
||||
throw new VerzeichnisFehler(
|
||||
"Der Verzeichnisabgleich gilt nur für die eigene Organisation und ist im Modus " +
|
||||
"Dienstleistung abgeschaltet.",
|
||||
);
|
||||
}
|
||||
if (!darf(benutzer.rolle, "verwaltung")) {
|
||||
throw new VerzeichnisFehler(
|
||||
`Einen Abgleich zu starten verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`,
|
||||
);
|
||||
}
|
||||
const quelle = await quelleZeileHolen(quelleId);
|
||||
|
||||
const ergebnis = await mitAbgleichSperre(sperrSchluessel(quelleId), SPERRE_MINUTEN, () =>
|
||||
lauf(quelle, benutzer),
|
||||
);
|
||||
if (ergebnis === undefined) {
|
||||
throw new AbgleichLaeuftBereitsFehler(
|
||||
`Für „${quelle.name}“ läuft gerade schon ein Abgleich. Bitte das Ergebnis abwarten.`,
|
||||
);
|
||||
}
|
||||
return ergebnis;
|
||||
}
|
||||
|
||||
/** Der eigentliche Lauf, immer unter der Sperre. */
|
||||
async function lauf(quelle: Verzeichnisquelle, benutzer: HandelnderBenutzer): Promise<AbgleichErgebnis> {
|
||||
const quelleId = quelle.id;
|
||||
// Ein Zeitpunkt fuer den ganzen Lauf. Daran haengt, wer als "gesehen" gilt;
|
||||
// mit einem Zeitstempel je Person waere die Grenze unscharf.
|
||||
const beginn = new Date();
|
||||
|
||||
let angelegt = 0;
|
||||
let uebernommen = 0;
|
||||
let gelesen = 0;
|
||||
/**
|
||||
* Wie viele Eintraege bis hierher wirklich durch die Hand gegangen sind.
|
||||
*
|
||||
* Bricht das Lesen mittendrin ab, gibt es keine Bilanz — und `gelesen` bliebe
|
||||
* null, waehrend `angelegt` schon bei 1500 stuende. Eine Zahl, die nicht
|
||||
* aufgeht, ist schlimmer als ein sichtbarer Fehler (Reviewbefund zu #016).
|
||||
*/
|
||||
let verarbeitet = 0;
|
||||
/**
|
||||
* Wer sich wirklich geaendert hat — als Menge und nicht als Zaehler.
|
||||
*
|
||||
* Eine Person, an der sich Nachname **und** Vorgesetzter geaendert haben, ist
|
||||
* eine aktualisierte Person und nicht zwei. Der zweite Durchgang traegt in
|
||||
* dieselbe Menge ein.
|
||||
*/
|
||||
const geaendert = new Set<string>();
|
||||
let bindPasswort: string | undefined;
|
||||
|
||||
try {
|
||||
const zugang = zugangAus(quelle);
|
||||
bindPasswort = zugang.bindPasswort;
|
||||
const kennungNachDn = new Map<string, string>();
|
||||
const offene: OffenerVorgesetzter[] = [];
|
||||
|
||||
const bilanz = await eintraegeStroemen(
|
||||
zugang,
|
||||
{
|
||||
basisDn: quelle.basisDn,
|
||||
filter: quelle.filter,
|
||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
||||
},
|
||||
async (eintrag) => {
|
||||
verarbeitet += 1;
|
||||
kennungNachDn.set(dnSchluessel(eintrag.dn), eintrag.externeId);
|
||||
let vorhanden = await prisma.mitarbeiter.findFirst({
|
||||
where: { quelleId, externeId: eintrag.externeId },
|
||||
});
|
||||
/*
|
||||
* Kennt die Quelle die Person noch nicht, kann sie trotzdem schon in der
|
||||
* Datenbank stehen: beim Onboarding legt jemand sie von Hand an, das
|
||||
* AD-Konto entsteht danach, und der naechste Abgleich findet sie.
|
||||
*
|
||||
* Ohne diese Uebernahme entstuenden zwei Maria Huber — eine mit Laeufen
|
||||
* und Geraeten, eine mit der Kennung. Die von Hand angelegte waere die mit
|
||||
* den Daten und die falsche in jeder Auswahlliste (Reviewbefund zu #016).
|
||||
*/
|
||||
let uebernommenJetzt = false;
|
||||
if (!vorhanden) {
|
||||
const vonHand = await vonHandAngelegteFinden(quelle.kundeId, eintrag);
|
||||
if (vonHand) {
|
||||
vorhanden = vonHand;
|
||||
uebernommenJetzt = true;
|
||||
uebernommen += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const felder = {
|
||||
vorname: eintrag.vorname,
|
||||
nachname: eintrag.nachname,
|
||||
anzeigename: eintrag.anzeigename,
|
||||
mail: eintrag.mail,
|
||||
upn: eintrag.upn,
|
||||
sam: eintrag.sam,
|
||||
dn: eintrag.dn,
|
||||
abteilung: eintrag.abteilung,
|
||||
titel: eintrag.titel,
|
||||
};
|
||||
|
||||
if (!vorhanden) {
|
||||
await prisma.mitarbeiter.create({
|
||||
data: {
|
||||
kundeId: quelle.kundeId,
|
||||
quelleId,
|
||||
externeId: eintrag.externeId,
|
||||
...felder,
|
||||
// Der Vorgesetzte kommt im zweiten Durchgang: sein DN laesst sich
|
||||
// erst aufloesen, wenn alle Kennungen bekannt sind.
|
||||
vorgesetzterExterneId: null,
|
||||
aktiv: true,
|
||||
herkunft: Herkunft.ABGLEICH,
|
||||
zuletztGesehenAm: beginn,
|
||||
},
|
||||
});
|
||||
angelegt += 1;
|
||||
if (eintrag.vorgesetzterDn) {
|
||||
offene.push({
|
||||
externeId: eintrag.externeId,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn,
|
||||
vorherigeKennung: null,
|
||||
neuAngelegt: true,
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Steht im Verzeichnis kein Vorgesetzter mehr, wird ein alter Wert
|
||||
// geleert. Steht einer, entscheidet der zweite Durchgang.
|
||||
const vorgesetztenLeeren = eintrag.vorgesetzterDn === null;
|
||||
if (
|
||||
// Eine Uebernahme zaehlt in ihrer eigenen Spalte und nicht zusaetzlich
|
||||
// als Aktualisierung; sonst gingen die Zahlen nicht mehr auf.
|
||||
!uebernommenJetzt &&
|
||||
(vorhanden.vorname !== felder.vorname ||
|
||||
vorhanden.nachname !== felder.nachname ||
|
||||
vorhanden.anzeigename !== felder.anzeigename ||
|
||||
vorhanden.mail !== felder.mail ||
|
||||
vorhanden.upn !== felder.upn ||
|
||||
vorhanden.sam !== felder.sam ||
|
||||
vorhanden.dn !== felder.dn ||
|
||||
vorhanden.abteilung !== felder.abteilung ||
|
||||
vorhanden.titel !== felder.titel ||
|
||||
!vorhanden.aktiv ||
|
||||
vorhanden.herkunft !== Herkunft.ABGLEICH ||
|
||||
(vorgesetztenLeeren && vorhanden.vorgesetzterExterneId !== null))
|
||||
) {
|
||||
geaendert.add(eintrag.externeId);
|
||||
}
|
||||
|
||||
await prisma.mitarbeiter.update({
|
||||
where: { id: vorhanden.id },
|
||||
data: {
|
||||
...felder,
|
||||
// Bei einer Uebernahme kommt die Herkunft dazu: ab jetzt fuehrt das
|
||||
// Verzeichnis diese Person, und der naechste Lauf findet sie ueber die
|
||||
// Kennung.
|
||||
...(uebernommenJetzt ? { quelleId, externeId: eintrag.externeId } : {}),
|
||||
...(vorgesetztenLeeren ? { vorgesetzterExterneId: null } : {}),
|
||||
// Wer wieder im Ergebnis steht, ist wieder aktiv — eine
|
||||
// wiedereingestellte Person behaelt so ihre Laeufe.
|
||||
aktiv: true,
|
||||
herkunft: Herkunft.ABGLEICH,
|
||||
zuletztGesehenAm: beginn,
|
||||
},
|
||||
});
|
||||
if (!vorgesetztenLeeren) {
|
||||
offene.push({
|
||||
externeId: eintrag.externeId,
|
||||
vorgesetzterDn: eintrag.vorgesetzterDn as string,
|
||||
vorherigeKennung: vorhanden.vorgesetzterExterneId,
|
||||
neuAngelegt: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
gelesen = bilanz.gesamtGelesen;
|
||||
|
||||
// --- Zweiter Durchgang: Vorgesetzte --------------------------------------
|
||||
for (const offen of offene) {
|
||||
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
|
||||
if (kennung === offen.vorherigeKennung) continue;
|
||||
await prisma.mitarbeiter.updateMany({
|
||||
where: { quelleId, externeId: offen.externeId },
|
||||
data: { vorgesetzterExterneId: kennung },
|
||||
});
|
||||
// Eine gerade angelegte Person ist angelegt, nicht zusaetzlich
|
||||
// aktualisiert.
|
||||
if (!offen.neuAngelegt) geaendert.add(offen.externeId);
|
||||
}
|
||||
|
||||
/*
|
||||
* --- Konnte ueberhaupt etwas zugeordnet werden? -------------------------
|
||||
*
|
||||
* Der Fall, den der Review zu #016 gefunden hat und der die ganze Firma
|
||||
* kostet: steht in der Attributzuordnung ein Kennungsattribut, das es am
|
||||
* Server nicht gibt — ein Tippfehler, oder ein OpenLDAP ohne `objectGUID` —,
|
||||
* dann wird **jeder** Eintrag uebersprungen. Der Durchlauf ist vollstaendig,
|
||||
* das Lesen hat geklappt, niemand wurde wiedergesehen: die Abschaltung
|
||||
* unten deaktivierte die gesamte Belegschaft und meldete `ERFOLG`. Danach
|
||||
* sucht niemand mehr nach dem Grund, weil alles gruen aussieht.
|
||||
*
|
||||
* Deshalb: nichts gelesen, was sich zuordnen liess, obwohl es Eintraege gab
|
||||
* **und** wir bisher aktive Personen aus dieser Quelle kennen ⇒ kein
|
||||
* Abschalten, sondern ein Fehler mit Namen.
|
||||
*/
|
||||
const zugeordnet = gelesen - bilanz.uebersprungen.length;
|
||||
if (zugeordnet === 0 && gelesen > 0) {
|
||||
const bisherAktiv = await prisma.mitarbeiter.count({
|
||||
where: { quelleId, herkunft: Herkunft.ABGLEICH, aktiv: true },
|
||||
});
|
||||
if (bisherAktiv > 0) {
|
||||
const ergebnis: AbgleichErgebnis = {
|
||||
angelegt: 0,
|
||||
uebernommen: 0,
|
||||
aktualisiert: 0,
|
||||
deaktiviert: 0,
|
||||
gelesen,
|
||||
status: "FEHLER",
|
||||
fehler:
|
||||
`Kein einziger der ${gelesen} gelesenen Einträge ließ sich zuordnen — ` +
|
||||
`Grund des ersten: ${satzEnde(bilanz.uebersprungen[0]?.grund)} ` +
|
||||
"Niemand wurde deaktiviert. Prüf die Attributzuordnung, besonders das Kennungsattribut.",
|
||||
hinweis: null,
|
||||
};
|
||||
await abschlussSchreiben(quelleId, benutzer, ergebnis, bilanz.uebersprungen.length);
|
||||
return ergebnis;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* --- Verschwundene abschalten -------------------------------------------
|
||||
*
|
||||
* Nur, was aus dem Abgleich stammt: eine von Hand angelegte Person steht
|
||||
* nicht im Verzeichnis und darf davon nicht abgeschaltet werden.
|
||||
*
|
||||
* **Uebersprungene Eintraege sind nicht verschwunden.** Sie standen im
|
||||
* Ergebnis, nur ohne brauchbare Kennung — wer sie mit abschaltete, naehme
|
||||
* einem anwesenden Menschen seinen Zugang. Erkannt werden sie am `dn`, den
|
||||
* der Leser mitliefert.
|
||||
*/
|
||||
const uebersprungeneDns = bilanz.uebersprungen
|
||||
.map((eintrag) => eintrag.dn)
|
||||
.filter((dn): dn is string => Boolean(dn));
|
||||
|
||||
const { count: deaktiviert } = await prisma.mitarbeiter.updateMany({
|
||||
where: {
|
||||
quelleId,
|
||||
herkunft: Herkunft.ABGLEICH,
|
||||
aktiv: true,
|
||||
OR: [{ zuletztGesehenAm: null }, { zuletztGesehenAm: { lt: beginn } }],
|
||||
...(uebersprungeneDns.length > 0 ? { NOT: { dn: { in: uebersprungeneDns } } } : {}),
|
||||
},
|
||||
data: { aktiv: false },
|
||||
});
|
||||
|
||||
const ergebnis: AbgleichErgebnis = {
|
||||
angelegt,
|
||||
uebernommen,
|
||||
aktualisiert: geaendert.size,
|
||||
deaktiviert,
|
||||
gelesen,
|
||||
status: "ERFOLG",
|
||||
fehler: null,
|
||||
/*
|
||||
* Kein Fehlschlag, aber auch keine Nebensache: uebersprungene Eintraege
|
||||
* stehen im Verzeichnis und fehlen hier. Sie kommen deshalb als **Hinweis**
|
||||
* zurueck und nicht als Fehler — `letzterAbgleichFehler` soll heissen, dass
|
||||
* etwas schiefging, und nicht „ein Konto war krumm“.
|
||||
*/
|
||||
hinweis:
|
||||
bilanz.uebersprungen.length === 0
|
||||
? null
|
||||
: `${bilanz.uebersprungen.length} von ${gelesen} Einträgen übersprungen — ` +
|
||||
`Grund des ersten: ${satzEnde(bilanz.uebersprungen[0]?.grund)}`,
|
||||
};
|
||||
await abschlussSchreiben(quelleId, benutzer, ergebnis, bilanz.uebersprungen.length);
|
||||
return ergebnis;
|
||||
} catch (fehler) {
|
||||
// Gesaeubert, weil die Meldung gespeichert und angezeigt wird. `mitVerbindung`
|
||||
// tut das fuer LDAP-Fehler schon; hier faengt es alles andere ab, damit kein
|
||||
// Weg daran vorbeifuehrt.
|
||||
const ergebnis: AbgleichErgebnis = {
|
||||
angelegt,
|
||||
uebernommen,
|
||||
aktualisiert: geaendert.size,
|
||||
// Bei einem Fehler wird niemand abgeschaltet. Siehe oben: eine halbe Liste
|
||||
// saehe aus wie eine halbe Firma, die gegangen ist.
|
||||
deaktiviert: 0,
|
||||
/*
|
||||
* `gelesen` kommt aus dem Zaehler, der **waehrend** des Stroemens
|
||||
* mitlaeuft, nicht aus der Bilanz am Ende: bricht das Lesen nach der
|
||||
* Haelfte ab, gibt es keine Bilanz, und „angelegt: 1500, gelesen: 0“
|
||||
* waere eine Zahl, die nicht aufgeht (Reviewbefund zu #016). Was der
|
||||
* Server insgesamt gehabt haette, weiss hier niemand — gezaehlt wird, was
|
||||
* wirklich durch die Hand ging.
|
||||
*/
|
||||
gelesen: gelesen > 0 ? gelesen : verarbeitet,
|
||||
status: "FEHLER",
|
||||
fehler: fehlerMeldung(fehler, bindPasswort === undefined ? [] : [bindPasswort]),
|
||||
hinweis: null,
|
||||
};
|
||||
await abschlussSchreiben(quelleId, benutzer, ergebnis, 0);
|
||||
return ergebnis;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Kennung fuer das Protokoll – oder `null` fuer „das System selbst“.
|
||||
*
|
||||
* Der Zeitplan hat keinen Benutzer und schickt eine leere Kennung.
|
||||
* `Protokoll.benutzerId` ist genau dafuer optional (CLAUDE.md,
|
||||
* „Erweiterbarkeit“: ein Systemlauf ist darstellbar). Eine leere Zeichenkette
|
||||
* durchzureichen waere kein Systemlauf, sondern ein Verstoss gegen den
|
||||
* Fremdschluessel — der Abgleich wuerde erst beim Schreiben des Protokolls
|
||||
* abbrechen, also nachdem er die Mitarbeiter schon geaendert hat.
|
||||
*/
|
||||
function urheber(benutzer: HandelnderBenutzer): string | null {
|
||||
const id = benutzer.id.trim();
|
||||
return id ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ergebnis an die Quelle und ins Protokoll.
|
||||
*
|
||||
* `letzterAbgleichAm` wird auch bei einem Fehler gesetzt: es beantwortet „wann
|
||||
* wurde es zuletzt versucht“, und daran haengt der Zeitplan. Stuende dort nur der
|
||||
* letzte Erfolg, liefe eine kaputte Quelle im Minutentakt gegen die Wand.
|
||||
*/
|
||||
async function abschlussSchreiben(
|
||||
quelleId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
ergebnis: AbgleichErgebnis,
|
||||
uebersprungen: number,
|
||||
): Promise<void> {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.verzeichnisquelle.update({
|
||||
where: { id: quelleId },
|
||||
data: {
|
||||
letzterAbgleichAm: new Date(),
|
||||
letzterAbgleichStatus: ergebnis.status,
|
||||
letzterAbgleichFehler: ergebnis.fehler,
|
||||
},
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: quelleId,
|
||||
aktion:
|
||||
ergebnis.status === "ERFOLG" ? "Verzeichnis abgeglichen" : "Verzeichnisabgleich fehlgeschlagen",
|
||||
benutzerId: urheber(benutzer),
|
||||
nachher: {
|
||||
angelegt: ergebnis.angelegt,
|
||||
uebernommen: ergebnis.uebernommen,
|
||||
aktualisiert: ergebnis.aktualisiert,
|
||||
deaktiviert: ergebnis.deaktiviert,
|
||||
gelesen: ergebnis.gelesen,
|
||||
uebersprungen,
|
||||
status: ergebnis.status,
|
||||
...(ergebnis.fehler === null ? {} : { fehler: ergebnis.fehler }),
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Mitarbeiter, Prisma } from "@/generated/prisma/client";
|
||||
import { Herkunft } from "@/generated/prisma/enums";
|
||||
import { CsvFehler, csvZerlegen, hatSpalte, wertAus, type CsvZeile } from "@/lib/csv";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { kundeErmitteln, kundeZugriffPruefen, type HandelnderBenutzer } from "@/server/mandant";
|
||||
|
||||
@@ -91,10 +92,23 @@ function anzeigenameBilden(eingabe: MitarbeiterEingabe, vorname: string, nachnam
|
||||
return gewuenscht ?? `${vorname} ${nachname}`.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Woher diese Zeile kam — nur fuers Protokoll (#016).
|
||||
*
|
||||
* Der CSV-Import legt ueber **denselben** Weg an wie das Formular; unterscheiden
|
||||
* soll sich nur der Eintrag im Protokoll. Ohne die Zeilennummer laesst sich ein
|
||||
* halb misslungener Import spaeter nicht mehr auf die Datei zurueckfuehren.
|
||||
*/
|
||||
export type ProtokollVermerk = {
|
||||
readonly aktion: string;
|
||||
readonly zeile?: number;
|
||||
};
|
||||
|
||||
/** Legt einen Mitarbeiter von Hand an — `herkunft = MANUELL`. */
|
||||
export async function mitarbeiterAnlegen(
|
||||
eingabe: MitarbeiterEingabe,
|
||||
benutzer: HandelnderBenutzer,
|
||||
vermerk?: ProtokollVermerk,
|
||||
): Promise<Mitarbeiter> {
|
||||
const vorname = pflicht(eingabe.vorname, "Der Vorname");
|
||||
const nachname = pflicht(eingabe.nachname, "Der Nachname");
|
||||
@@ -119,9 +133,13 @@ export async function mitarbeiterAnlegen(
|
||||
data: {
|
||||
entitaet: "Mitarbeiter",
|
||||
entitaetId: mitarbeiter.id,
|
||||
aktion: "Mitarbeiter angelegt",
|
||||
aktion: vermerk?.aktion ?? "Mitarbeiter angelegt",
|
||||
benutzerId: benutzer.id,
|
||||
nachher: { anzeigename: mitarbeiter.anzeigename, herkunft: mitarbeiter.herkunft },
|
||||
nachher: {
|
||||
anzeigename: mitarbeiter.anzeigename,
|
||||
herkunft: mitarbeiter.herkunft,
|
||||
...(vermerk?.zeile === undefined ? {} : { zeile: vermerk.zeile }),
|
||||
},
|
||||
},
|
||||
});
|
||||
return mitarbeiter;
|
||||
@@ -250,3 +268,336 @@ function auszug(zeile: Mitarbeiter, felder: readonly string[]): Prisma.InputJson
|
||||
}
|
||||
return ausschnitt;
|
||||
}
|
||||
|
||||
// ---- CSV-Import (#016) -----------------------------------------------------
|
||||
|
||||
/**
|
||||
* Obergrenze je Datei.
|
||||
*
|
||||
* Dieselbe Zahl und derselbe Grund wie beim Geraeteimport: ohne Grenze laeuft
|
||||
* eine versehentlich exportierte Riesentabelle minutenlang durch, und die
|
||||
* Oberflaeche nennt die Zahl, **bevor** jemand dagegen laeuft.
|
||||
*/
|
||||
export const MITARBEITER_IMPORT_HOECHSTZEILEN = 500;
|
||||
|
||||
/**
|
||||
* Die Spalten aus dem Auftrag – englisch, wie das Verzeichnis sie nennt – und
|
||||
* jeweils die deutsche Entsprechung.
|
||||
*
|
||||
* Gross- und Kleinschreibung, Leerraum, Unterstriche und Bindestriche sind
|
||||
* unerheblich, dafuer sorgt `spaltenName` in src/lib/csv.ts. Die Reihenfolge der
|
||||
* Schluessel ist die Reihenfolge, in der die Oberflaeche die Spalten nennt.
|
||||
*/
|
||||
export const MITARBEITER_IMPORT_SPALTEN = {
|
||||
vorname: ["givenName", "vorname"],
|
||||
nachname: ["sn", "surname", "nachname"],
|
||||
anzeigename: ["displayName", "anzeigename"],
|
||||
mail: ["mail", "email", "mailadresse"],
|
||||
upn: ["upn", "userPrincipalName"],
|
||||
sam: ["sam", "sAMAccountName"],
|
||||
abteilung: ["department", "abteilung"],
|
||||
titel: ["title", "titel"],
|
||||
} as const;
|
||||
|
||||
type ImportFeld = keyof typeof MITARBEITER_IMPORT_SPALTEN;
|
||||
|
||||
const IMPORT_FELDER = Object.keys(MITARBEITER_IMPORT_SPALTEN) as readonly ImportFeld[];
|
||||
|
||||
/**
|
||||
* Die Werte einer Zeile – und zwar **nur** die, die in der Datei gefuellt sind.
|
||||
*
|
||||
* Eine leere Zelle ist keine Loeschung: in einer Onboarding-Liste steht die
|
||||
* Abteilung, aber selten der Titel, und ein Import, der den vorhandenen Titel
|
||||
* dabei leert, richtet mehr Schaden an als er Nutzen bringt. Deshalb fehlt der
|
||||
* Schluessel hier ganz, statt `null` zu tragen.
|
||||
*/
|
||||
type ImportFelder = {
|
||||
readonly vorname: string;
|
||||
readonly nachname: string;
|
||||
readonly anzeigename?: string;
|
||||
readonly mail?: string;
|
||||
readonly upn?: string;
|
||||
readonly sam?: string;
|
||||
readonly abteilung?: string;
|
||||
readonly titel?: string;
|
||||
};
|
||||
|
||||
type Aenderung = { -readonly [K in keyof ImportFelder]?: string };
|
||||
|
||||
export type MitarbeiterImportAbweisung = {
|
||||
/** Zeilennummer in der Datei, wie sie ein Editor anzeigt. */
|
||||
readonly zeile: number;
|
||||
readonly grund: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Je Zeile genau ein Fach.
|
||||
*
|
||||
* `angelegt + aktualisiert + unveraendert + abgewiesen === gesamt` gilt immer,
|
||||
* und darauf prueft ein Test. Im Review von #011 hatte der Geraeteimport „3 von 3
|
||||
* angelegt“ gemeldet, obwohl Zeilen verschluckt worden waren — eine Zahl, die
|
||||
* nicht aufgeht, ist schlimmer als ein sichtbarer Fehler.
|
||||
*/
|
||||
export type MitarbeiterImportErgebnis = {
|
||||
/** Datenzeilen in der Datei, ohne Kopfzeile und ohne Leerzeilen. */
|
||||
readonly gesamt: number;
|
||||
readonly angelegt: number;
|
||||
readonly aktualisiert: number;
|
||||
/** Erkannt, aber es stand nichts Neues drin. */
|
||||
readonly unveraendert: number;
|
||||
readonly abgewiesen: readonly MitarbeiterImportAbweisung[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Eine Zeile zu Feldern. Wirft bei einem Zeilenfehler.
|
||||
*
|
||||
* Fehlen `givenName` und `sn`, wird `displayName` am **letzten** Leerzeichen
|
||||
* geteilt. Das ist eine Annahme und keine Wahrheit — bei „Anna van der Berg“
|
||||
* landet „van der“ beim Vornamen. Deshalb bleibt `displayName` als Anzeigename
|
||||
* genau so stehen, wie er in der Datei steht: was ein Mensch zu lesen bekommt,
|
||||
* ist dann auf jeden Fall richtig, und die Teilung betrifft nur die beiden Felder,
|
||||
* die ein Techniker beim Nachpflegen ohnehin sieht.
|
||||
*/
|
||||
function zeileLesen(zeile: CsvZeile): { readonly felder: ImportFelder; readonly anzeigename: string } {
|
||||
const spalten = MITARBEITER_IMPORT_SPALTEN;
|
||||
const angezeigt = wertAus(zeile, ...spalten.anzeigename);
|
||||
let vorname = wertAus(zeile, ...spalten.vorname);
|
||||
let nachname = wertAus(zeile, ...spalten.nachname);
|
||||
|
||||
if (!vorname && !nachname) {
|
||||
const trennstelle = angezeigt.lastIndexOf(" ");
|
||||
if (trennstelle > 0) {
|
||||
vorname = angezeigt.slice(0, trennstelle).trim();
|
||||
nachname = angezeigt.slice(trennstelle + 1).trim();
|
||||
}
|
||||
}
|
||||
if (!vorname || !nachname) {
|
||||
throw new MitarbeiterFehler(
|
||||
"In dieser Zeile fehlt ein Name. Gebraucht werden „givenName“ und „sn“ – oder ein " +
|
||||
"„displayName“ aus mindestens zwei Teilen, der dann am letzten Leerzeichen geteilt wird.",
|
||||
);
|
||||
}
|
||||
|
||||
const weitere: Aenderung = {};
|
||||
for (const feld of IMPORT_FELDER) {
|
||||
if (feld === "vorname" || feld === "nachname") continue;
|
||||
const wert = wertAus(zeile, ...spalten[feld]);
|
||||
if (wert) weitere[feld] = wert;
|
||||
}
|
||||
|
||||
return {
|
||||
felder: { vorname, nachname, ...weitere },
|
||||
anzeigename: angezeigt || `${vorname} ${nachname}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Kennungen, unter denen diese Zeile wiedererkannt wird.
|
||||
*
|
||||
* `sam` und `upn` sind die Felder, die das Verzeichnis eindeutig haelt und die der
|
||||
* Techniker vor sich hat; `mail` steht bewusst **nicht** dabei — eine
|
||||
* Sammeladresse trifft mehrere Personen, und eine Adresse wechselt bei einer
|
||||
* Heirat, ohne dass es eine andere Person wird.
|
||||
*
|
||||
* Steht keine der beiden in der Zeile, bleibt der Anzeigename. Das ist der
|
||||
* schwaechere Schluessel, aber der einzige, der bleibt — und ohne ihn legte
|
||||
* dieselbe Onboarding-Liste beim zweiten Einlesen jede Person ein zweites Mal an.
|
||||
* Genau diese Sorte stiller Verdopplung war der Blocker in #011.
|
||||
*/
|
||||
function kennungen(felder: ImportFelder, anzeigename: string): readonly string[] {
|
||||
const liste: string[] = [];
|
||||
if (felder.sam) liste.push(`sam:${felder.sam.toLowerCase()}`);
|
||||
if (felder.upn) liste.push(`upn:${felder.upn.toLowerCase()}`);
|
||||
if (liste.length === 0) liste.push(`name:${anzeigename.toLowerCase()}`);
|
||||
return liste;
|
||||
}
|
||||
|
||||
/** Die Felder, die der Import vergleicht – mehr braucht der Abgleich mit der Zeile nicht. */
|
||||
const VORHANDEN_AUSWAHL = {
|
||||
id: true,
|
||||
anzeigename: true,
|
||||
herkunft: true,
|
||||
vorname: true,
|
||||
nachname: true,
|
||||
mail: true,
|
||||
upn: true,
|
||||
sam: true,
|
||||
abteilung: true,
|
||||
titel: true,
|
||||
} satisfies Prisma.MitarbeiterSelect;
|
||||
|
||||
type VorhandenePerson = {
|
||||
readonly id: string;
|
||||
readonly herkunft: Herkunft;
|
||||
} & { readonly [K in keyof ImportFelder]-?: string | null } & { readonly anzeigename: string };
|
||||
|
||||
/**
|
||||
* Sucht die Person, die zu dieser Zeile gehoert – innerhalb **desselben** Kunden.
|
||||
*
|
||||
* Passen zwei Personen, wird die Zeile abgewiesen statt eine davon zu nehmen: hier
|
||||
* zu raten heisst, die Stammdaten der falschen Person zu ueberschreiben.
|
||||
*/
|
||||
async function vorhandenePersonSuchen(
|
||||
kundeId: string,
|
||||
felder: ImportFelder,
|
||||
anzeigename: string,
|
||||
): Promise<VorhandenePerson | null> {
|
||||
const bedingungen: Prisma.MitarbeiterWhereInput[] = [];
|
||||
if (felder.sam) bedingungen.push({ sam: { equals: felder.sam, mode: "insensitive" } });
|
||||
if (felder.upn) bedingungen.push({ upn: { equals: felder.upn, mode: "insensitive" } });
|
||||
if (bedingungen.length === 0) {
|
||||
bedingungen.push({ anzeigename: { equals: anzeigename, mode: "insensitive" } });
|
||||
}
|
||||
|
||||
const treffer = await prisma.mitarbeiter.findMany({
|
||||
where: { kundeId, OR: bedingungen },
|
||||
select: VORHANDEN_AUSWAHL,
|
||||
orderBy: { erstelltAm: "asc" },
|
||||
take: 2,
|
||||
});
|
||||
if (treffer.length > 1) {
|
||||
throw new MitarbeiterFehler(
|
||||
`Diese Zeile passt auf „${treffer[0]?.anzeigename}“ und „${treffer[1]?.anzeigename}“. ` +
|
||||
"Welche der beiden gemeint ist, steht nicht in der Datei — bitte sAMAccountName oder UPN eintragen.",
|
||||
);
|
||||
}
|
||||
return treffer[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Spielt eine CSV-Datei mit Personen ein.
|
||||
*
|
||||
* **Je Zeile ein Ergebnis.** Eine fehlerhafte Zeile bricht den Import nicht ab;
|
||||
* sie wird mit Zeilennummer und Grund gemeldet, die uebrigen werden verarbeitet.
|
||||
* Deshalb schreibt jede Zeile in ihrer eigenen Transaktion — eine gemeinsame
|
||||
* haette am Ende alles zurueckgerollt, was vor dem Fehler gelungen war.
|
||||
*
|
||||
* Geschrieben wird ueber `mitarbeiterAnlegen` und `mitarbeiterBearbeiten`, nicht
|
||||
* an ihnen vorbei: die Namensregeln, die 20-Zeichen-Grenze des `sam` und die
|
||||
* Sperre der abgeglichenen Felder gelten damit hier genauso wie im Formular.
|
||||
*
|
||||
* Eine Person mit `herkunft = ABGLEICH` wird **nicht** angetastet und die Zeile
|
||||
* mit Begruendung gemeldet — der naechste Abgleich drehte die Aenderung ohnehin
|
||||
* zurueck, und stillschweigend zu ueberspringen hiesse, einen Import zu melden,
|
||||
* der nicht stattgefunden hat.
|
||||
*/
|
||||
export async function mitarbeiterImportieren(
|
||||
csvText: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
kundeIdAusEingabe?: string | null,
|
||||
): Promise<MitarbeiterImportErgebnis> {
|
||||
const kundeId = await kundeErmitteln(kundeIdAusEingabe, benutzer);
|
||||
|
||||
let tabelle;
|
||||
try {
|
||||
tabelle = csvZerlegen(csvText, { hoechstZeilen: MITARBEITER_IMPORT_HOECHSTZEILEN });
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof CsvFehler) throw new MitarbeiterFehler(fehler.message);
|
||||
throw fehler;
|
||||
}
|
||||
|
||||
const spalten = MITARBEITER_IMPORT_SPALTEN;
|
||||
// Ohne eine Namensspalte koennte keine einzige Zeile gelingen. Das als 500
|
||||
// gleichlautende Zeilenfehler zu melden waere richtig, aber nutzlos.
|
||||
if (!hatSpalte(tabelle, ...spalten.vorname, ...spalten.nachname, ...spalten.anzeigename)) {
|
||||
throw new MitarbeiterFehler(
|
||||
"In der Kopfzeile fehlt der Name — „givenName“ und „sn“ oder wenigstens „displayName“. " +
|
||||
`Gelesen werden diese Spalten: ${Object.values(spalten)
|
||||
.map((namen) => namen[0])
|
||||
.join(", ")}.`,
|
||||
);
|
||||
}
|
||||
|
||||
const abgewiesen: MitarbeiterImportAbweisung[] = [];
|
||||
let angelegt = 0;
|
||||
let aktualisiert = 0;
|
||||
let unveraendert = 0;
|
||||
|
||||
/** Kennung zu Zeilennummer – damit die Meldung sagen kann, *wo* es schon stand. */
|
||||
const gesehen = new Map<string, number>();
|
||||
|
||||
for (const zeile of tabelle.zeilen) {
|
||||
if (zeile.fehler !== undefined) {
|
||||
abgewiesen.push({ zeile: zeile.nummer, grund: zeile.fehler });
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const { felder, anzeigename } = zeileLesen(zeile);
|
||||
|
||||
const schluessel = kennungen(felder, anzeigename);
|
||||
const doppelt = schluessel.find((eintrag) => gesehen.has(eintrag));
|
||||
if (doppelt !== undefined) {
|
||||
abgewiesen.push({
|
||||
zeile: zeile.nummer,
|
||||
grund: `„${anzeigename}“ steht in dieser Datei schon in Zeile ${gesehen.get(doppelt)}.`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const vorhanden = await vorhandenePersonSuchen(kundeId, felder, anzeigename);
|
||||
|
||||
if (vorhanden === null) {
|
||||
await mitarbeiterAnlegen({ ...felder, kundeId }, benutzer, {
|
||||
aktion: "Mitarbeiter aus CSV angelegt",
|
||||
zeile: zeile.nummer,
|
||||
});
|
||||
angelegt += 1;
|
||||
for (const eintrag of schluessel) gesehen.set(eintrag, zeile.nummer);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (vorhanden.herkunft === Herkunft.ABGLEICH) {
|
||||
abgewiesen.push({
|
||||
zeile: zeile.nummer,
|
||||
grund:
|
||||
`„${vorhanden.anzeigename}“ kommt aus dem Verzeichnis. Von Hand eingelesene Werte würde ` +
|
||||
"der nächste Abgleich zurücksetzen — geändert wird diese Person im Verzeichnis.",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const aenderung: Aenderung = {};
|
||||
for (const feld of IMPORT_FELDER) {
|
||||
const wert = felder[feld];
|
||||
if (wert === undefined) continue;
|
||||
const alt = vorhanden[feld];
|
||||
/*
|
||||
* `sam` und `upn` sind die Kennungen, ueber die diese Zeile gefunden
|
||||
* wurde — und gefunden wird ohne Ruecksicht auf Gross- und
|
||||
* Kleinschreibung. Dann darf ein „AMUSTER“ in der Datei den vorhandenen
|
||||
* Wert auch nicht umschreiben: es ist dieselbe Kennung, und sonst
|
||||
* schriebe jeder Import aus einem Werkzeug, das anders schreibt, einen
|
||||
* Protokolleintrag ueber einen Unterschied, der keiner ist. Bei den
|
||||
* uebrigen Feldern zaehlt die Schreibweise: „vertrieb“ und „Vertrieb“
|
||||
* sind zwei Abteilungsnamen, und nur einer davon trifft eine `Abteilung`.
|
||||
*/
|
||||
const gleich =
|
||||
feld === "sam" || feld === "upn"
|
||||
? alt !== null && alt.toLowerCase() === wert.toLowerCase()
|
||||
: alt === wert;
|
||||
if (!gleich) aenderung[feld] = wert;
|
||||
}
|
||||
if (Object.keys(aenderung).length === 0) {
|
||||
unveraendert += 1;
|
||||
} else {
|
||||
// Der Protokolleintrag entsteht in `mitarbeiterBearbeiten` und nennt die
|
||||
// geaenderten Felder. Die Zeilennummer steht dort nicht — sie ist beim
|
||||
// Anlegen die einzige Spur zur Datei, beim Aendern sagt das Protokoll
|
||||
// ohnehin, was vorher und was nachher drin stand.
|
||||
await mitarbeiterBearbeiten(vorhanden.id, aenderung, benutzer);
|
||||
aktualisiert += 1;
|
||||
}
|
||||
for (const eintrag of schluessel) gesehen.set(eintrag, zeile.nummer);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof MitarbeiterFehler) {
|
||||
abgewiesen.push({ zeile: zeile.nummer, grund: fehler.message });
|
||||
continue;
|
||||
}
|
||||
// Alles Uebrige – ein Verbindungsabbruch etwa – ist kein Zeilenproblem und
|
||||
// wuerde in den folgenden Zeilen genauso auftreten.
|
||||
throw fehler;
|
||||
}
|
||||
}
|
||||
|
||||
return { gesamt: tabelle.zeilen.length, angelegt, aktualisiert, unveraendert, abgewiesen };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,442 @@
|
||||
import type { Prisma, Verzeichnisquelle } from "@/generated/prisma/client";
|
||||
import { Betriebsmodus } from "@/generated/prisma/enums";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { entschluesseln, verschluesseln } from "@/lib/geheimnisse";
|
||||
import { darf } from "@/server/auth/rollen";
|
||||
import { betriebsmodus, eigeneOrganisation, kundeErmitteln, type HandelnderBenutzer } from "@/server/mandant";
|
||||
import { VerzeichnisFehler } from "@/server/ldap/fehler";
|
||||
import { INTERVALL_HOECHSTENS, INTERVALL_MINDESTENS, intervallMeldung } from "@/lib/verzeichnis";
|
||||
import { eintraegeLesen } from "@/server/ldap/leser";
|
||||
import { type LdapZugang, urlPruefen } from "@/server/ldap/verbindung";
|
||||
import {
|
||||
AD_ZUORDNUNG,
|
||||
type AttributZuordnung,
|
||||
type GemappterEintrag,
|
||||
STANDARD_FILTER,
|
||||
type Uebersprungen,
|
||||
vorgesetzteAufloesen,
|
||||
zuordnungLesen,
|
||||
} from "@/server/ldap/zuordnung";
|
||||
|
||||
/**
|
||||
* Verzeichnisquellen verwalten (#016).
|
||||
*
|
||||
* Drei Grenzen liegen hier, und alle drei sind absichtlich in der Service-Schicht
|
||||
* und nicht nur in der Oberflaeche:
|
||||
*
|
||||
* 1. **Nur im Modus INTERN.** Das Verzeichnis ist das der eigenen Firma. Im Modus
|
||||
* DIENSTLEISTUNG gibt es keinen Abgleich — dort stehen die Mitarbeiter der
|
||||
* Kunden, und die kommen nicht aus unserem AD. Das Ausblenden in der
|
||||
* Oberflaeche allein waere eine Zusage, die eine Server Action von Hand
|
||||
* umgehen koennte.
|
||||
* 2. **Nur `verwaltung`.** An einer Quelle haengen Zugangsdaten zum
|
||||
* Domaenencontroller. Die Aktion prueft das Recht ebenfalls; hier steht es
|
||||
* noch einmal, damit auch der Zeitplan und ein Skript daran vorbeikommen.
|
||||
* 3. **Das Bind-Passwort verlaesst die Schicht nicht.** Es wird mit
|
||||
* `src/lib/geheimnisse.ts` verschluesselt gespeichert, `QuellenSicht` hat kein
|
||||
* Feld dafuer, und jede Meldung der LDAP-Bibliothek geht durch
|
||||
* `fehlerMeldung`, bevor sie gespeichert wird.
|
||||
*/
|
||||
|
||||
export type { AttributZuordnung, GemappterEintrag };
|
||||
export { AD_ZUORDNUNG, STANDARD_FILTER };
|
||||
|
||||
/*
|
||||
* Die Klasse selbst liegt in `src/server/ldap/fehler.ts` — unterhalb dieser
|
||||
* Schicht, damit auch `server/ldap/verbindung.ts` von ihr erben kann, ohne die
|
||||
* Schichtung umzudrehen. Hier steht sie weiter zur Verfuegung, weil jeder
|
||||
* Aufrufer sie von hier kennt.
|
||||
*/
|
||||
export { VerzeichnisFehler };
|
||||
|
||||
/** Ohne Passwort – diese Form geht an die Oberflaeche. */
|
||||
export type QuellenSicht = {
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly aktiv: boolean;
|
||||
readonly url: string;
|
||||
readonly bindDn: string;
|
||||
readonly basisDn: string;
|
||||
readonly filter: string;
|
||||
readonly attributZuordnung: AttributZuordnung;
|
||||
readonly caZertifikatVorhanden: boolean;
|
||||
readonly abgleichIntervallMinuten: number;
|
||||
readonly letzterAbgleichAm: Date | null;
|
||||
readonly letzterAbgleichStatus: string | null;
|
||||
readonly letzterAbgleichFehler: string | null;
|
||||
};
|
||||
|
||||
export type NeueQuelle = {
|
||||
name: string;
|
||||
url: string;
|
||||
bindDn: string;
|
||||
bindPasswort: string;
|
||||
basisDn: string;
|
||||
filter?: string;
|
||||
attributZuordnung?: Partial<AttributZuordnung>;
|
||||
caZertifikatPem?: string | null;
|
||||
abgleichIntervallMinuten?: number;
|
||||
aktiv?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Die Grenzen des Intervalls.
|
||||
*
|
||||
* Nach unten fuenf Minuten: ein Abgleich laeuft im selben Prozess wie die
|
||||
* Anwendung und liest das ganze Verzeichnis: alle dreissig Sekunden waere eine
|
||||
* Last, die niemand gewollt hat. Nach oben eine Woche, damit ein Tippfehler mit
|
||||
* einer Null zu viel nicht zu einer Quelle fuehrt, die nie wieder laeuft.
|
||||
*/
|
||||
|
||||
/** „Verbindung testen“ zeigt die ersten zehn Treffer. Mehr braucht keine Probe. */
|
||||
const PROBE_GRENZE = 10;
|
||||
const PROBE_GRENZE_HOECHSTENS = 50;
|
||||
|
||||
/**
|
||||
* Das Modul ist im Modus DIENSTLEISTUNG nicht bloss unsichtbar, sondern
|
||||
* abgeschaltet. Gilt fuer jeden Schreibweg, auch fuer den Zeitplan.
|
||||
*/
|
||||
async function nurIntern(): Promise<void> {
|
||||
if ((await betriebsmodus()) !== Betriebsmodus.INTERN) {
|
||||
throw new VerzeichnisFehler(
|
||||
"Der Verzeichnisabgleich gilt nur für die eigene Organisation und ist im Modus " +
|
||||
"Dienstleistung abgeschaltet.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function rechtPruefen(benutzer: HandelnderBenutzer): void {
|
||||
if (!darf(benutzer.rolle, "verwaltung")) {
|
||||
throw new VerzeichnisFehler(
|
||||
`Verzeichnisquellen zu pflegen verlangt das Recht "verwaltung"; die Rolle ${benutzer.rolle} hat es nicht.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function pflichtText(wert: string | undefined, feld: string): string {
|
||||
const text = wert?.trim();
|
||||
if (!text) throw new VerzeichnisFehler(`${feld} fehlt.`);
|
||||
return text;
|
||||
}
|
||||
|
||||
function intervallPruefen(minuten: number | undefined): number | undefined {
|
||||
if (minuten === undefined) return undefined;
|
||||
if (!Number.isInteger(minuten) || minuten < INTERVALL_MINDESTENS || minuten > INTERVALL_HOECHSTENS) {
|
||||
throw new VerzeichnisFehler(intervallMeldung());
|
||||
}
|
||||
return minuten;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ein Zertifikat, oder gar keines.
|
||||
*
|
||||
* Geprueft wird nur die Huelle — dass es ein PEM-Block ist. Ob das Zertifikat
|
||||
* passt, sagt erst der Verbindungstest, und das ist die richtige Stelle dafuer:
|
||||
* eine Pruefung hier koennte nur behaupten, was sie nicht weiss.
|
||||
*/
|
||||
function zertifikatPruefen(pem: string | null | undefined): string | null {
|
||||
const wert = pem?.trim();
|
||||
if (!wert) return null;
|
||||
if (!wert.includes("-----BEGIN CERTIFICATE-----") || !wert.includes("-----END CERTIFICATE-----")) {
|
||||
throw new VerzeichnisFehler(
|
||||
"Das CA-Zertifikat muss im PEM-Format vorliegen, also mit „-----BEGIN CERTIFICATE-----“ beginnen.",
|
||||
);
|
||||
}
|
||||
return wert;
|
||||
}
|
||||
|
||||
function alsSicht(zeile: Verzeichnisquelle): QuellenSicht {
|
||||
return {
|
||||
id: zeile.id,
|
||||
name: zeile.name,
|
||||
aktiv: zeile.aktiv,
|
||||
url: zeile.url,
|
||||
bindDn: zeile.bindDn,
|
||||
basisDn: zeile.basisDn,
|
||||
filter: zeile.filter,
|
||||
attributZuordnung: zuordnungLesen(zeile.attributZuordnung),
|
||||
// Der Inhalt bleibt hier: die Oberflaeche muss nur wissen, ob eines
|
||||
// hinterlegt ist, und braucht dafuer keinen Zertifikatstext.
|
||||
caZertifikatVorhanden: Boolean(zeile.caZertifikatPem?.trim()),
|
||||
abgleichIntervallMinuten: zeile.abgleichIntervallMinuten,
|
||||
letzterAbgleichAm: zeile.letzterAbgleichAm,
|
||||
letzterAbgleichStatus: zeile.letzterAbgleichStatus,
|
||||
letzterAbgleichFehler: zeile.letzterAbgleichFehler,
|
||||
};
|
||||
}
|
||||
|
||||
/** Auch fuer die Lesemodelle: die eine Stelle, die aus einer Zeile eine Sicht macht. */
|
||||
export { alsSicht as quellenSichtBauen };
|
||||
|
||||
export async function quelleAnlegen(
|
||||
eingabe: NeueQuelle,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<QuellenSicht> {
|
||||
await nurIntern();
|
||||
rechtPruefen(benutzer);
|
||||
const kundeId = await kundeErmitteln(null, benutzer);
|
||||
|
||||
const name = pflichtText(eingabe.name, "Der Name der Quelle");
|
||||
const url = pflichtText(eingabe.url, "Die Adresse des Verzeichnisses");
|
||||
urlPruefen(url);
|
||||
const bindDn = pflichtText(eingabe.bindDn, "Der Bind-DN");
|
||||
const bindPasswort = eingabe.bindPasswort?.trim();
|
||||
if (!bindPasswort) {
|
||||
throw new VerzeichnisFehler("Das Bind-Passwort fehlt. Ein anonymer Bind wird nicht unterstützt.");
|
||||
}
|
||||
const basisDn = pflichtText(eingabe.basisDn, "Der Basis-DN");
|
||||
const intervall = intervallPruefen(eingabe.abgleichIntervallMinuten);
|
||||
|
||||
const zeile = await prisma.$transaction(async (tx) => {
|
||||
const angelegt = await tx.verzeichnisquelle.create({
|
||||
data: {
|
||||
kundeId,
|
||||
name,
|
||||
// Gespeichert wird die getippte Adresse samt Schema. `ldap+starttls://`
|
||||
// ist Teil der Angabe und wird erst beim Verbinden abgetrennt — sonst
|
||||
// waere nach dem Speichern nicht mehr zu erkennen, dass StartTLS
|
||||
// gewuenscht war.
|
||||
url,
|
||||
bindDn,
|
||||
bindPasswortVerschluesselt: verschluesseln(bindPasswort),
|
||||
basisDn,
|
||||
filter: eingabe.filter?.trim() || STANDARD_FILTER,
|
||||
attributZuordnung: zuordnungLesen({ ...AD_ZUORDNUNG, ...eingabe.attributZuordnung }),
|
||||
caZertifikatPem: zertifikatPruefen(eingabe.caZertifikatPem),
|
||||
// Ohne Angabe bleibt der Standardwert des Schemas (60 Minuten) stehen.
|
||||
...(intervall === undefined ? {} : { abgleichIntervallMinuten: intervall }),
|
||||
aktiv: eingabe.aktiv ?? false,
|
||||
},
|
||||
});
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: angelegt.id,
|
||||
aktion: "Verzeichnisquelle angelegt",
|
||||
benutzerId: benutzer.id,
|
||||
// Kein Passwort, auch nicht verschluesselt: das Protokoll wird gelesen.
|
||||
nachher: { name: angelegt.name, url: angelegt.url, basisDn: angelegt.basisDn },
|
||||
},
|
||||
});
|
||||
return angelegt;
|
||||
});
|
||||
return alsSicht(zeile);
|
||||
}
|
||||
|
||||
export async function quelleBearbeiten(
|
||||
id: string,
|
||||
aenderung: Partial<NeueQuelle>,
|
||||
benutzer: HandelnderBenutzer,
|
||||
): Promise<QuellenSicht> {
|
||||
await nurIntern();
|
||||
rechtPruefen(benutzer);
|
||||
const vorher = await quelleZeileHolen(id);
|
||||
|
||||
const daten: Prisma.VerzeichnisquelleUpdateInput = {};
|
||||
const geaendert: string[] = [];
|
||||
|
||||
if (aenderung.name !== undefined) {
|
||||
const name = pflichtText(aenderung.name, "Der Name der Quelle");
|
||||
if (name !== vorher.name) {
|
||||
daten.name = name;
|
||||
geaendert.push("Name");
|
||||
}
|
||||
}
|
||||
if (aenderung.url !== undefined) {
|
||||
const url = pflichtText(aenderung.url, "Die Adresse des Verzeichnisses");
|
||||
urlPruefen(url);
|
||||
if (url !== vorher.url) {
|
||||
daten.url = url;
|
||||
geaendert.push("Adresse");
|
||||
}
|
||||
}
|
||||
if (aenderung.bindDn !== undefined) {
|
||||
const bindDn = pflichtText(aenderung.bindDn, "Der Bind-DN");
|
||||
if (bindDn !== vorher.bindDn) {
|
||||
daten.bindDn = bindDn;
|
||||
geaendert.push("Bind-DN");
|
||||
}
|
||||
}
|
||||
if (aenderung.bindPasswort !== undefined) {
|
||||
// Ein leeres Feld heisst „unveraendert“. Ein Formular, das das Passwort nicht
|
||||
// anzeigen kann (und das darf es nicht), schickt sonst bei jedem Speichern
|
||||
// eine leere Zeichenkette und loeschte das Geheimnis.
|
||||
const neu = aenderung.bindPasswort.trim();
|
||||
if (neu) {
|
||||
daten.bindPasswortVerschluesselt = verschluesseln(neu);
|
||||
geaendert.push("Bind-Passwort");
|
||||
}
|
||||
}
|
||||
if (aenderung.basisDn !== undefined) {
|
||||
const basisDn = pflichtText(aenderung.basisDn, "Der Basis-DN");
|
||||
if (basisDn !== vorher.basisDn) {
|
||||
daten.basisDn = basisDn;
|
||||
geaendert.push("Basis-DN");
|
||||
}
|
||||
}
|
||||
if (aenderung.filter !== undefined) {
|
||||
const filter = aenderung.filter.trim() || STANDARD_FILTER;
|
||||
if (filter !== vorher.filter) {
|
||||
daten.filter = filter;
|
||||
geaendert.push("Filter");
|
||||
}
|
||||
}
|
||||
if (aenderung.attributZuordnung !== undefined) {
|
||||
const zuordnung = zuordnungLesen({
|
||||
...zuordnungLesen(vorher.attributZuordnung),
|
||||
...aenderung.attributZuordnung,
|
||||
});
|
||||
if (JSON.stringify(zuordnung) !== JSON.stringify(zuordnungLesen(vorher.attributZuordnung))) {
|
||||
daten.attributZuordnung = zuordnung;
|
||||
geaendert.push("Attributzuordnung");
|
||||
}
|
||||
}
|
||||
if (aenderung.caZertifikatPem !== undefined) {
|
||||
const pem = zertifikatPruefen(aenderung.caZertifikatPem);
|
||||
if (pem !== vorher.caZertifikatPem) {
|
||||
daten.caZertifikatPem = pem;
|
||||
geaendert.push("CA-Zertifikat");
|
||||
}
|
||||
}
|
||||
if (aenderung.abgleichIntervallMinuten !== undefined) {
|
||||
const minuten = intervallPruefen(aenderung.abgleichIntervallMinuten);
|
||||
if (minuten !== undefined && minuten !== vorher.abgleichIntervallMinuten) {
|
||||
daten.abgleichIntervallMinuten = minuten;
|
||||
geaendert.push("Intervall");
|
||||
}
|
||||
}
|
||||
if (aenderung.aktiv !== undefined && aenderung.aktiv !== vorher.aktiv) {
|
||||
daten.aktiv = aenderung.aktiv;
|
||||
geaendert.push(aenderung.aktiv ? "aktiviert" : "abgeschaltet");
|
||||
}
|
||||
|
||||
if (geaendert.length === 0) return alsSicht(vorher);
|
||||
|
||||
const zeile = await prisma.$transaction(async (tx) => {
|
||||
const nachher = await tx.verzeichnisquelle.update({ where: { id }, data: daten });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: id,
|
||||
aktion: `${geaendert.join(", ")} geändert`,
|
||||
benutzerId: benutzer.id,
|
||||
vorher: { name: vorher.name, url: vorher.url, basisDn: vorher.basisDn, aktiv: vorher.aktiv },
|
||||
nachher: { name: nachher.name, url: nachher.url, basisDn: nachher.basisDn, aktiv: nachher.aktiv },
|
||||
},
|
||||
});
|
||||
return nachher;
|
||||
});
|
||||
return alsSicht(zeile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Loeschen, aber nur solange nichts daran haengt.
|
||||
*
|
||||
* Mitarbeiter werden nie geloescht, und ein archivierter Lauf muss auch in zehn
|
||||
* Jahren noch sagen koennen, woher die Person kam. Der Fremdschluessel steht
|
||||
* deshalb auf `Restrict`; hier steht der Satz, den ein Mensch lesen kann, samt
|
||||
* dem Weg heraus: die Quelle abschalten statt sie zu loeschen.
|
||||
*/
|
||||
export async function quelleLoeschen(id: string, benutzer: HandelnderBenutzer): Promise<void> {
|
||||
await nurIntern();
|
||||
rechtPruefen(benutzer);
|
||||
const quelle = await quelleZeileHolen(id);
|
||||
|
||||
const anzahl = await prisma.mitarbeiter.count({ where: { quelleId: id } });
|
||||
if (anzahl > 0) {
|
||||
throw new VerzeichnisFehler(
|
||||
`An „${quelle.name}“ hängen ${anzahl} Mitarbeiter. Die Quelle lässt sich abschalten, ` +
|
||||
"aber nicht löschen – sonst verlieren die Personen ihre Herkunft.",
|
||||
);
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.verzeichnisquelle.delete({ where: { id } });
|
||||
await tx.protokoll.create({
|
||||
data: {
|
||||
entitaet: "Verzeichnisquelle",
|
||||
entitaetId: id,
|
||||
aktion: "Verzeichnisquelle gelöscht",
|
||||
benutzerId: benutzer.id,
|
||||
vorher: { name: quelle.name, url: quelle.url, basisDn: quelle.basisDn },
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Liest hoechstens `grenze` Eintraege und **schreibt nichts**.
|
||||
*
|
||||
* Kein `Mitarbeiter`, kein `Protokoll`, kein `letzterAbgleich…` — das ist der
|
||||
* Zweck: man soll Zugangsdaten und Filter ausprobieren koennen, ohne damit
|
||||
* gleich Personen anzulegen. Die Rueckgabe zeigt die gemappten Felder, damit ein
|
||||
* falsch zugeordnetes Attribut sofort auffaellt.
|
||||
*
|
||||
* Der Vorgesetzte wird nur gegen die gelesenen Eintraege aufgeloest. In einer
|
||||
* Probe von zehn ist er meist nicht dabei und bleibt dann leer; das ist kein
|
||||
* Fehler der Zuordnung.
|
||||
*/
|
||||
export async function verbindungTesten(
|
||||
quelleId: string,
|
||||
benutzer: HandelnderBenutzer,
|
||||
grenze = PROBE_GRENZE,
|
||||
): Promise<{
|
||||
readonly treffer: readonly GemappterEintrag[];
|
||||
readonly gesamtGelesen: number;
|
||||
/*
|
||||
* Die uebersprungenen Eintraege gehen mit nach draussen, weil „keine Treffer“
|
||||
* zwei ganz verschiedene Ursachen hat: der Filter passt auf niemanden, oder er
|
||||
* passt und die Zuordnung scheitert an jedem Eintrag. Ohne diese Liste sagt der
|
||||
* Test beide Male denselben Satz — und ein falsch gesetztes Kennungsattribut
|
||||
* saehe aus wie ein zu enger Filter (Reviewbefund zu #016).
|
||||
*/
|
||||
readonly uebersprungen: readonly Uebersprungen[];
|
||||
}> {
|
||||
await nurIntern();
|
||||
rechtPruefen(benutzer);
|
||||
const quelle = await quelleZeileHolen(quelleId);
|
||||
const wieViele = Math.min(Math.max(Math.trunc(grenze), 1), PROBE_GRENZE_HOECHSTENS);
|
||||
|
||||
const { eintraege, gesamtGelesen, uebersprungen } = await eintraegeLesen(
|
||||
zugangAus(quelle),
|
||||
{
|
||||
basisDn: quelle.basisDn,
|
||||
filter: quelle.filter,
|
||||
zuordnung: zuordnungLesen(quelle.attributZuordnung),
|
||||
},
|
||||
wieViele,
|
||||
);
|
||||
return { treffer: vorgesetzteAufloesen(eintraege), gesamtGelesen, uebersprungen };
|
||||
}
|
||||
|
||||
/**
|
||||
* Die Zeile samt verschluesseltem Passwort – nur fuer diese Schicht und den
|
||||
* Abgleich. Wer nach draussen antwortet, nimmt `alsSicht`.
|
||||
*
|
||||
* Geprueft wird hier auch die Zugehoerigkeit: die Quelle muss der eigenen
|
||||
* Organisation gehoeren. `quelleAnlegen` kann keine andere anlegen — es laeuft nur
|
||||
* im Modus INTERN —, aber Migrationen, `psql` und Skripte schreiben an dieser
|
||||
* Schicht vorbei. „Gilt nur fuer die eigene Organisation“ ist sonst eine Zusage,
|
||||
* die nur fuer den Weg durch das Formular gilt.
|
||||
*/
|
||||
export async function quelleZeileHolen(id: string): Promise<Verzeichnisquelle> {
|
||||
const zeile = await prisma.verzeichnisquelle.findUnique({ where: { id } });
|
||||
if (!zeile) throw new VerzeichnisFehler("Diese Verzeichnisquelle gibt es nicht (mehr).");
|
||||
const eigene = await eigeneOrganisation();
|
||||
if (zeile.kundeId !== eigene.id) {
|
||||
throw new VerzeichnisFehler(
|
||||
`„${zeile.name}“ gehört nicht zur eigenen Organisation. Der Verzeichnisabgleich gilt nur für sie.`,
|
||||
);
|
||||
}
|
||||
return zeile;
|
||||
}
|
||||
|
||||
/** Entschluesselt das Bind-Passwort. Das Ergebnis bleibt in der Aufrufkette. */
|
||||
export function zugangAus(quelle: Verzeichnisquelle): LdapZugang {
|
||||
return {
|
||||
url: quelle.url,
|
||||
bindDn: quelle.bindDn,
|
||||
bindPasswort: entschluesseln(quelle.bindPasswortVerschluesselt),
|
||||
caZertifikatPem: quelle.caZertifikatPem,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import cron, { type ScheduledTask } from "node-cron";
|
||||
import { AbgleichLaeuftBereitsFehler, abgleichAusfuehren, faelligeQuellen } from "@/server/services/abgleich";
|
||||
|
||||
/**
|
||||
* Der Zeitplan des Verzeichnis-Abgleichs (#016).
|
||||
*
|
||||
* Er laeuft **im selben Prozess** wie die Anwendung — kein zweiter Dienst, kein
|
||||
* Systemd-Timer, kein Cron im Container. Das ist eine Entscheidung fuer den
|
||||
* Betrieb: beide Betriebswege dieses Projekts (Docker-Compose und die native
|
||||
* Installation im LXC) starten genau einen Prozess, und eine zweite Einheit
|
||||
* waere eine zweite Stelle, an der jemand `APP_SECRET` und `DATABASE_URL`
|
||||
* hinterlegen muss.
|
||||
*
|
||||
* Drei Dinge halten das zusammen:
|
||||
*
|
||||
* 1. **Die Uhr tickt jede Minute, faellig ist trotzdem nur, was faellig ist.**
|
||||
* Das Intervall steht je Quelle in der Datenbank (`abgleichIntervallMinuten`,
|
||||
* Standard 60). Ein Zeitplan, der die Intervalle selbst verwaltete, muesste
|
||||
* bei jeder Aenderung neu aufgesetzt werden — und vergaesse es irgendwann.
|
||||
* 2. **Die Sperre setzt der Dienst, nicht der Zeitplan.** `abgleichAusfuehren`
|
||||
* nimmt sie selbst — in der Datenbank und damit auch ueber zwei Instanzen
|
||||
* hinter einem Lastverteiler hinweg. Hier eine **zweite** Sperre zu setzen
|
||||
* waere schlimmer als keine: sie traege einen anderen Schluessel, liefe also
|
||||
* an der ersten vorbei, und ein gerade von Hand angestossener Abgleich saehe
|
||||
* fuer den Zeitplan wie ein unerwarteter Fehler aus.
|
||||
* 3. **Ein Fehler beendet den Zeitplan nicht.** Ein Verzeichnis, das gerade
|
||||
* nicht antwortet, ist der Normalfall im Betrieb — der naechste Durchgang
|
||||
* versucht es wieder, und was passiert ist, steht an der Quelle.
|
||||
*/
|
||||
|
||||
/** Jede Minute nachsehen; wer wirklich drankommt, entscheidet die Datenbank. */
|
||||
const TAKT = "* * * * *";
|
||||
|
||||
/** Der laufende Zeitplan – `undefined`, solange keiner laeuft. */
|
||||
let plan: ScheduledTask | undefined;
|
||||
|
||||
/**
|
||||
* Der Abgleich laeuft **ohne Benutzer**.
|
||||
*
|
||||
* `HandelnderBenutzer` traegt Kennung und Rolle; der Zeitplan hat beides nicht,
|
||||
* und ein erfundener Administrator waere eine Luege im Protokoll. `ADMIN` steht
|
||||
* hier fuer „das System selbst“, die Kennung bleibt leer — genau dafuer duerfen
|
||||
* `Lauf.erstelltVonId` und `Schrittergebnis.aktualisiertVonId` leer sein
|
||||
* (CLAUDE.md, „Erweiterbarkeit“).
|
||||
*/
|
||||
const SYSTEM = { id: "", rolle: "ADMIN" } as const;
|
||||
|
||||
/**
|
||||
* Sieht nach, welche Quelle faellig ist, und gleicht sie ab.
|
||||
*
|
||||
* Exportiert, damit ein Test den Durchgang aufrufen kann, ohne eine Minute zu
|
||||
* warten.
|
||||
*/
|
||||
export async function abgleichDurchgang(): Promise<void> {
|
||||
const quellen = await faelligeQuellen();
|
||||
for (const quelle of quellen) {
|
||||
try {
|
||||
await abgleichAusfuehren(quelle.id, SYSTEM);
|
||||
} catch (fehler) {
|
||||
if (fehler instanceof AbgleichLaeuftBereitsFehler) {
|
||||
/*
|
||||
* Eine andere Instanz oder ein Administrator von Hand ist schon dabei.
|
||||
* Kein Fehler und keine Zeile im Protokoll: im Normalbetrieb mit zwei
|
||||
* Instanzen waere das jede Minute eine.
|
||||
*/
|
||||
continue;
|
||||
}
|
||||
/*
|
||||
* Bis hierher sollte nichts durchkommen — `abgleichAusfuehren` schreibt
|
||||
* seinen Fehler an die Quelle und gibt ihn als Ergebnis zurueck. Was
|
||||
* trotzdem hier ankommt, ist unerwartet und gehoert ins Protokoll des
|
||||
* Prozesses; der Zeitplan laeuft weiter, sonst stuende nach einer
|
||||
* Stoerung der Abgleich aller Quellen still.
|
||||
*/
|
||||
console.error(
|
||||
`Abgleich der Quelle ${quelle.name} ist unerwartet gescheitert:`,
|
||||
fehler instanceof Error ? `${fehler.name}: ${fehler.message}` : fehler,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Startet den Zeitplan. Ein zweiter Aufruf tut nichts.
|
||||
*
|
||||
* Aufgerufen wird er aus `src/instrumentation.ts`, also einmal je
|
||||
* Server-Prozess. Im Entwicklungsmodus laedt Next Module mehrfach — deshalb die
|
||||
* Wache.
|
||||
*/
|
||||
export function zeitplanStarten(): void {
|
||||
if (plan) return;
|
||||
plan = cron.schedule(TAKT, () => void abgleichDurchgang(), {
|
||||
// Zwei Durchgaenge duerfen sich nicht ueberholen, wenn einer laenger als
|
||||
// eine Minute braucht. Die Sperre in der Datenbank ist das zweite Netz.
|
||||
noOverlap: true,
|
||||
name: "verzeichnis-abgleich",
|
||||
});
|
||||
}
|
||||
|
||||
/** Haelt den Zeitplan an – fuer Tests und einen geordneten Abbruch. */
|
||||
export function zeitplanAnhalten(): void {
|
||||
void plan?.destroy();
|
||||
plan = undefined;
|
||||
}
|
||||
|
||||
/** Laeuft gerade ein Zeitplan? Nur fuer Tests und die Diagnose. */
|
||||
export function zeitplanLaeuft(): boolean {
|
||||
return plan !== undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user