600 lines
24 KiB
TypeScript
600 lines
24 KiB
TypeScript
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,
|
||
vorgesetzterId: 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 } : {}),
|
||
/*
|
||
* Steht im Verzeichnis kein Vorgesetzter, wird der **rohe** Wert
|
||
* geleert. Die gepflegte Zuordnung nur dann, wenn sie vom
|
||
* Verzeichnis kam — erkennbar daran, dass dort eine Kennung stand.
|
||
* Eine von Hand gesetzte Zuordnung bleibt stehen: das Verzeichnis
|
||
* fuehrt sie nicht, also hat es sie auch nicht zu loeschen (#020).
|
||
*/
|
||
...(vorgesetztenLeeren
|
||
? {
|
||
vorgesetzterExterneId: null,
|
||
...(vorhanden.vorgesetzterExterneId !== null ? { vorgesetzterId: 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 --------------------------------------
|
||
//
|
||
// Geschrieben werden beide Felder: `vorgesetzterExterneId` als roher Wert des
|
||
// Verzeichnisses, `vorgesetzterId` als die gepflegte Zuordnung, aus der
|
||
// jeder Leseweg schoepft (#020). Vorher gab es nur das erste, und eine von
|
||
// Hand angelegte Person konnte keinen Vorgesetzten haben.
|
||
for (const offen of offene) {
|
||
const kennung = kennungNachDn.get(dnSchluessel(offen.vorgesetzterDn)) ?? null;
|
||
if (kennung === offen.vorherigeKennung) continue;
|
||
const vorgesetzter =
|
||
kennung === null
|
||
? null
|
||
: await prisma.mitarbeiter.findUnique({
|
||
where: { quelleId_externeId: { quelleId, externeId: kennung } },
|
||
select: { id: true },
|
||
});
|
||
await prisma.mitarbeiter.updateMany({
|
||
where: { quelleId, externeId: offen.externeId },
|
||
data: { vorgesetzterExterneId: kennung, vorgesetzterId: vorgesetzter?.id ?? null },
|
||
});
|
||
// 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 }),
|
||
},
|
||
},
|
||
});
|
||
});
|
||
}
|