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( schluessel: string, dauerMinuten: number, was: () => Promise, ): Promise { 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 { 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 { 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 { 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 { 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(); let bindPasswort: string | undefined; try { const zugang = zugangAus(quelle); bindPasswort = zugang.bindPasswort; const kennungNachDn = new Map(); 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 { 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 }), }, }, }); }); }