Files
Laufzettel/src/server/services/abgleich.ts
T
florian.wilde e1f244e4e8
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s
feat(protokoll): Protokoll ansehen, mit Filtern und Mandantensicht (#45)
2026-10-04 16:06:26 +00:00

603 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { 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) => {
const quelle = await tx.verzeichnisquelle.update({
where: { id: quelleId },
data: {
letzterAbgleichAm: new Date(),
letzterAbgleichStatus: ergebnis.status,
letzterAbgleichFehler: ergebnis.fehler,
},
});
await tx.protokoll.create({
data: {
// Aus der Aktualisierung, nicht aus einer zweiten Abfrage: der Kunde
// steht in derselben Zeile, die gerade geschrieben wurde (#023).
kundeId: quelle.kundeId,
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 }),
},
},
});
});
}