fix: Reviewbefunde aus der Durchsicht von Phase 2 (#036, #027)
Prüfung / pruefung (push) Canceled after 0s
Prüfung / e2e (push) Canceled after 0s

Acht Meilensteine lagen ohne fremden Blick auf main. Ein Review mit eigener
Datenbank, Gegenproben und vollem Suitenlauf hat sie gegen die
Akzeptanzkriterien und gegen CLAUDE.md geprueft.

Die Kernregeln halten: fuenf von fuenf Gegenproben gegen Versionierung,
Mandantengrenze, Abschlussregel und Selbstauskunft wurden rot. Alle Befunde
lagen am Rand — und die drei wertvollsten waren Gegenproben, die gruen blieben.

Schwer:

- Der Link in der Mail "Verzeichnis-Abgleich fehlgeschlagen" war ein 404
  (/verwaltung/verzeichnis statt /admin/verzeichnis). Die eine Meldung, die an
  alle Administratoren geht, im Moment eines Ausfalls. Ein Test prueft jetzt
  jeden Meldungspfad gegen die vorhandenen Routen.
- Der Export eines Entwurfs mit Baustein lieferte einen leeren Abschnitt unter
  dem Platzhalter "wird ersetzt" — der Zweck des Formats ist, eine Vorlage in
  eine zweite Installation zu bringen. Bausteine werden jetzt aufgeloest.

Mittel:

- Der Anhang-Handler hatte keinen Test; die ganze Rechtepruefung liess sich
  durch einen hartcodierten ADMIN ersetzen, Suite gruen. Es ist die einzige
  Stelle, die rohe Dateibytes ausliefert. tests/anhang-route.test.ts faehrt ihn
  jetzt echt, acht Pruefungen.
- /api/scim in der Proxy-Allowlist stand in keinem Test. OEFFENTLICH ist
  exportiert, der Test vergleicht beide Richtungen: eine Liste, die nur in einer
  Richtung geprueft wird, laeuft dem Code hinterher.
- Ein offener Ereignisstrom ueberlebte Abmelden, Deaktivieren und
  Sitzungswiderruf. Der Herzschlag prueft die Sitzung jetzt nach. Dass es kein
  Test zeigte, lag an einer Attrappe `sitzung: {}` — eine Attrappe ohne die Form
  der Wirklichkeit prueft etwas anderes als die Wirklichkeit.
- README kannte MITARBEITER nicht und behauptete, unbekannte Benutzer wuerden
  angelegt. Wer auf v0.2.0 hebt und nur das README liest, haette genau diese
  Leute ausgesperrt.
- sicherung.md nannte einen Anhaenge-Pfad, den es nirgends gibt — als
  Rueckfallwert in einem Skript mit rm -rf. Jetzt bricht es ab statt zu raten.
- Attributnamen wurden nur in der Server Action geprueft, waehrend der
  Graph-Leser sich im Kommentar darauf berief.

Dazu #027: die Ende-zu-Ende-Ablaeufe raeumen jetzt zentral auf, vor und nach
dem Lauf, und ein struktureller Test bewacht es. Beim ersten echten Lauf seit
dem 26. September waren sieben Ablaeufe rot, vier Ursachen, keine ein
Produktfehler — unbemerkt, weil pnpm test:e2e in keinem Tor steht. Das ist jetzt
#038 und keine Fussnote.

Ein Befund des Reviews war falsch (der Zustands-Handler habe keinen Test; er hat
acht direkte Aufrufe). Nachgeprueft, bevor etwas geaendert wurde.

1454 Vitest-Tests (vorher 1432), 113 E2E-Ablaeufe gruen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Florian Wilde
2026-10-11 01:32:09 +02:00
co-authored by Claude Opus 5
parent fcd67018b8
commit 80d97467da
37 changed files with 1752 additions and 59 deletions
+35 -6
View File
@@ -1,4 +1,5 @@
import { RechteFehler, rechtErforderlich } from "@/server/auth/sitzungspflicht";
import { RechteFehler, rechtErforderlich, sitzungNochGueltig } from "@/server/auth/sitzungspflicht";
import type { Sitzung } from "@/lib/sitzung";
import { laufZustand } from "@/server/queries/laeufe";
import { HERZSCHLAG, herzschlagMs, maxStroeme, sseEreignis, willStrom } from "@/lib/sse";
import { aufLaufHoeren, stroemeOffen } from "@/server/lauf-ereignisse";
@@ -40,10 +41,14 @@ export async function GET(
): Promise<Response> {
let benutzerId: string;
let rolle: Parameters<typeof laufZustand>[1]["rolle"];
// Die Sitzung wird festgehalten, weil der Strom sie spaeter noch braucht:
// `cookies()` gibt es in seinen Rueckrufen nicht mehr.
let sitzung: Sitzung;
try {
const { benutzer } = await rechtErforderlich("lesen");
benutzerId = benutzer.id;
rolle = benutzer.rolle;
const zugang = await rechtErforderlich("lesen");
benutzerId = zugang.benutzer.id;
rolle = zugang.benutzer.rolle;
sitzung = zugang.sitzung;
} catch (fehler) {
if (fehler instanceof RechteFehler) {
// 401 und nicht 403: der Client soll die Aktualisierung einstellen und
@@ -54,7 +59,7 @@ export async function GET(
}
const { id } = await params;
const benutzer = { id: benutzerId, rolle };
let benutzer = { id: benutzerId, rolle };
const zustand = await laufZustand(id, benutzer);
if (!zustand) {
// Nicht vorhanden und nicht sichtbar sind dieselbe Antwort – die
@@ -151,7 +156,31 @@ export async function GET(
return;
}
herzschlag = setInterval(() => schicken(HERZSCHLAG), herzschlagMs());
/*
* **Der Herzschlag prueft auch die Sitzung nach** (Reviewbefund zu #029).
* Vorher wurde das Recht genau einmal geprueft, beim Aufbau, und das Paar
* aus Kennung und Rolle fuer die Lebensdauer des Stroms festgehalten — ein
* deaktiviertes Konto bekam weiter live Daten, bis jemand die Lasche
* schloss. Die Pruefung kostet eine Abfrage je offenem Strom und
* Herzschlag (25 s); dieselbe Abfrage macht jede gewoehnliche Anfrage
* ohnehin.
*
* Eine **Herabstufung** beendet den Strom nicht, sondern engt ihn ein: die
* neue Rolle gilt ab dem naechsten Ereignis. Das ist dieselbe Regel wie
* „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
*/
herzschlag = setInterval(() => {
void (async () => {
const noch = await sitzungNochGueltig(sitzung, "lesen");
if (!noch) {
schicken(sseEreignis("ende", JSON.stringify({ grund: "nicht-angemeldet" })));
aufraeumen();
return;
}
if (noch.rolle !== benutzer.rolle) benutzer = { id: benutzerId, rolle: noch.rolle };
schicken(HERZSCHLAG);
})();
}, herzschlagMs());
anfrage.signal.addEventListener("abort", aufraeumen);
},
+39 -1
View File
@@ -151,11 +151,49 @@ export function zuordnungLesen(roh: unknown, art: "LDAP" | "GRAPH" = "LDAP"): At
const gelesen = { ...ZUORDNUNGS_VORGABE[art] };
for (const schluessel of ZUORDNUNGS_FELDER) {
const wert = felder[schluessel];
if (typeof wert === "string" && wert.trim()) gelesen[schluessel] = wert.trim();
if (typeof wert !== "string" || !wert.trim()) continue;
/*
* **Was kein Attributname ist, wird beim Lesen verworfen** und nicht
* weitergegeben. Hier zu werfen waere falsch: eine einzige kaputte Zeile in
* der Datenbank machte sonst die ganze Quellenliste unbenutzbar. Dass so
* etwas gar nicht erst hineinkommt, haelt `zuordnungPruefen` beim Schreiben.
*
* Der Grund fuer beides: aus diesen Namen entstehen eine LDAP-Attributliste
* und ein `$select` in der Graph-Adresse. `src/server/graph/leser.ts` berief
* sich im Kommentar auf diese Pruefung, waehrend sie nur in der Server Action
* stand — ein Name mit Komma und `$filter` kam durch den Dienst und landete
* in der Abfrage (Reviewbefund zu #029).
*/
if (!ATTRIBUT_MUSTER.test(wert.trim())) continue;
gelesen[schluessel] = wert.trim();
}
return gelesen;
}
/**
* Prueft eine Zuordnung, die **geschrieben** werden soll. Wirft nicht, sondern
* meldet — der Aufrufer entscheidet, ob daraus eine Fehlermeldung oder eine
* Ausnahme wird.
*
* Getrennt von `zuordnungAusEingabe`, weil die aus **Formularfeldern** liest und
* dabei leere Felder herausfallen laesst. Diese hier prueft eine fertige
* Zuordnung, egal woher sie kommt: Formular, Skript, Seed oder Import.
*/
export function zuordnungPruefen(zuordnung: Partial<Record<string, unknown>>): string | undefined {
for (const [schluessel, wert] of Object.entries(zuordnung)) {
if (wert === undefined || wert === null) continue;
if (typeof wert !== "string" || !ATTRIBUT_MUSTER.test(wert.trim())) {
const beschriftung =
(ZUORDNUNGS_BESCHRIFTUNG as Record<string, string | undefined>)[schluessel] ?? schluessel;
return (
`„${String(wert)}“ ist kein Attributname (${beschriftung}). ` +
"Erwartet werden Buchstaben, Ziffern und Bindestriche, beginnend mit einem Buchstaben."
);
}
}
return undefined;
}
/**
* Die Attributzuordnung aus den Formularfeldern, geprueft.
*
+11 -2
View File
@@ -18,8 +18,17 @@ import { SITZUNG_COOKIE } from "@/lib/sitzung";
* den Proxy zu haengen.
*/
/** Pfade, die ohne Anmeldung erreichbar sein muessen. */
const OEFFENTLICH: readonly string[] = [
/**
* Pfade, die ohne Anmeldung erreichbar sein muessen.
*
* **Exportiert, damit der Test die Liste nicht abschreiben muss.** Vorher zaehlte
* `tests/zugang.test.ts` die Pfade von Hand auf, und seit #029 fehlte dort
* `/api/scim` — eine Aufzaehlung laeuft dem Code hinterher (Reviewbefund zu
* #029). Jetzt prueft ein Test, dass **jeder** Eintrag hier auch im Test steht:
* ein neuer oeffentlicher Pfad ist eine Sicherheitsentscheidung und soll jemanden
* zwingen, sie aufzuschreiben.
*/
export const OEFFENTLICH: readonly string[] = [
"/anmelden",
// Der Anmeldevorgang selbst – ohne ihn koennte sich niemand anmelden.
"/api/auth",
+10 -1
View File
@@ -137,10 +137,19 @@ async function personZuUpn(
const gesucht = upn.trim();
if (!gesucht) return { treffer: "keiner" };
/*
* **Ohne `take`, damit die gemeldete Zahl die Zahl ist.** Vorher stand hier
* `take: 2` — fuer die Entscheidung genuegt das, aber `anzahl` ging damit in
* den Protokolleintrag „Anmeldung abgewiesen: UPN mehrdeutig“, und der sagte
* dann immer „2“, auch bei sieben Treffern (Reviewbefund zu #029). Wer das
* Protokoll liest, um den Fall aufzuloesen, bekam eine Zahl, die keine war.
*
* Die Abfrage laeuft gegen einen Index auf `upn` und liefert im Normalfall
* **einen** Treffer; mehrere sind ein Datenfehler, den jemand beheben muss.
*/
const passende = await prisma.mitarbeiter.findMany({
where: { upn: { equals: gesucht, mode: "insensitive" }, aktiv: true },
select: { id: true, kundeId: true },
take: 2,
});
const erster = passende[0];
+34
View File
@@ -5,6 +5,7 @@ import type { Benutzer } from "@/generated/prisma/client";
import { prisma } from "@/lib/db";
import { SITZUNG_COOKIE, sitzungLesen, type Sitzung } from "@/lib/sitzung";
import { darf, type Recht } from "@/server/auth/rollen";
import type { Rolle } from "@/generated/prisma/enums";
/**
* Wer ist angemeldet und was darf er (#007).
@@ -55,6 +56,39 @@ export type Angemeldet = {
readonly sitzung: Sitzung;
};
/**
* Gilt diese Sitzung **jetzt noch**, und darf sie `recht`?
*
* Fuer langlebige Verbindungen. `angemeldet()` liest das Cookie und laeuft
* deshalb nur innerhalb einer Anfrage; ein Ereignisstrom lebt Minuten bis
* Stunden **nach** dem Ende der Anfrage weiter, und in seinen Rueckrufen gibt es
* keinen `cookies()`-Kontext mehr. Die Sitzung selbst ist zu dem Zeitpunkt
* laengst geprueft — nachzusehen ist, was sich seither in der Datenbank
* geaendert haben kann: `aktiv`, die Rolle und `sitzungenUngueltigAb`.
*
* **Warum das noetig ist:** Ohne diese Pruefung bekam eine offen gelassene
* Lasche weiter live Daten, nachdem ein Administrator das Konto deaktiviert und
* die Sitzungen beendet hatte — bis jemand die Lasche schloss. `docs/architektur.md`
* verspricht „ein deaktivierter Benutzer wird auch mit gueltigem Cookie
* abgewiesen“, und fuer einen offenen Strom galt das nicht (Reviewbefund zu #029).
*
* Die Rolle wird **mit**geliefert, nicht nur geprueft: wer waehrend eines Stroms
* herabgestuft wird, soll von da an die Sicht seiner neuen Rolle bekommen — das
* ist dieselbe Regel wie „Rolle pro Anfrage aus der Datenbank“ (CLAUDE.md).
*/
export async function sitzungNochGueltig(
sitzung: Sitzung,
recht: Recht,
): Promise<{ readonly rolle: Rolle } | undefined> {
const benutzer = await prisma.benutzer.findUnique({
where: { id: sitzung.benutzerId },
select: { id: true, aktiv: true, rolle: true, sitzungenUngueltigAb: true },
});
if (!sitzungBewerten(sitzung, benutzer).gueltig || !benutzer) return undefined;
if (!darf(benutzer.rolle, recht)) return undefined;
return { rolle: benutzer.rolle };
}
/**
* Der angemeldete Benutzer oder `undefined`. Leitet nicht weiter – dafuer gibt es
* `sitzungErforderlich`.
+30 -1
View File
@@ -102,6 +102,35 @@ export function angefragteFelder(zuordnung: AttributZuordnung): string[] {
*/
export const GRAPH_FILTER = "accountEnabled eq true";
/**
* Die naechste Seitenadresse — **nur, wenn sie auf denselben Host zeigt**.
*
* Jede Seite wird mit `Authorization: Bearer <token>` geholt, und das Token gilt
* mandantenweit (`User.Read.All`). Der Antwort von Graph ueber TLS zu trauen ist
* vertretbar, aber `GRAPH_API_URL` ist absichtlich einstellbar — fuer nationale
* Clouds und fuer den Test-Doppelgaenger. Ein Tippfehler darin oder ein
* aufbrechender Firmenproxy dazwischen wuerde das Token sonst an eine fremde
* Adresse schicken (Reviewbefund zu #029).
*
* Die Pruefung kostet eine Zeile und schliesst den Fall. Ein `nextLink`, der
* woandershin zeigt, beendet den Abruf still: er ist kein Fehler des
* Verzeichnisses, und ein Abbruch mitten im Abgleich waere schlimmer als eine
* unvollstaendige Seite — der naechste Lauf holt sie.
*/
export function naechsteSeite(weiter: unknown, basis = graphBasis()): string | undefined {
if (typeof weiter !== "string" || !weiter.trim()) return undefined;
try {
if (new URL(weiter).origin !== new URL(basis).origin) {
console.error(`Graph: @odata.nextLink zeigt auf einen anderen Host als GRAPH_API_URL — Abruf beendet.`);
return undefined;
}
} catch {
// Keine brauchbare Adresse: dasselbe Ergebnis, nur ohne Erklaerung.
return undefined;
}
return weiter;
}
/**
* Die erste Adresse der Abfrage. Danach folgt der Abruf `@odata.nextLink`.
*
@@ -272,7 +301,7 @@ export async function graphEintraegeStroemen(
}
const weiter = seite["@odata.nextLink"];
adresse = typeof weiter === "string" && weiter ? weiter : undefined;
adresse = naechsteSeite(weiter);
}
return { gesamtGelesen, uebersprungen, seiten };
+39 -6
View File
@@ -39,10 +39,15 @@ export class MeldungsFehler extends Error {
/**
* Wie lange zwischen zwei Versuchen gewartet wird, in Minuten.
*
* Die Laenge der Liste ist zugleich die Zahl der Versuche: nach dem vierten
* Fehlschlag wird aufgegeben. Wachsend, weil die haeufigste Ursache ein
* Mailserver ist, der gerade neu startet — eine Minute spaeter geht es meist
* wieder, und nach zwei Stunden ist es ein Fall fuer einen Menschen.
* **Vier Wartezeiten ergeben fuenf Versuche**, nicht vier: gewartet wird erst
* *nach* einem Fehlschlag, der fuenfte Versuch findet also keine Wartezeit mehr
* vor und wird aufgegeben. Der Kommentar hier behauptete „nach dem vierten“,
* waehrend `naechsterVersuch` weiter unten das Richtige sagt — und das ist der
* Kommentar, den man zuerst liest (Reviewbefund zu #029).
*
* Wachsend, weil die haeufigste Ursache ein Mailserver ist, der gerade neu
* startet — eine Minute spaeter geht es meist wieder, und nach zwei Stunden ist
* es ein Fall fuer einen Menschen.
*/
export const WARTEZEITEN_MINUTEN = [1, 5, 30, 120] as const;
@@ -332,8 +337,26 @@ function appUrl(): string {
return (process.env["APP_URL"]?.trim() || "").replace(/\/+$/, "");
}
/**
* Die Pfade, auf die eine Meldung zeigen kann — **an einer Stelle**.
*
* Sie stehen hier als Konstanten, weil ein Pfad in einer Mail nicht nachgesehen
* wird: er wird geklickt. `/verwaltung/verzeichnis` stand hier und war ein 404 —
* `verwaltung` ist der Name des **Rechts**, die Seite liegt unter `/admin/`
* (Reviewbefund zu #029). Aufgefallen ist das niemandem, weil der Test nur
* prueft, dass der **uebergebene** Link im Rumpf steht.
*
* `tests/benachrichtigungen.test.ts` prueft jeden dieser Pfade jetzt gegen die
* Routen unter `src/app/` — ein Pfad, den es nicht gibt, faellt beim Testlauf
* auf und nicht im Postfach eines Administrators.
*/
export const MELDUNGS_PFADE = {
lauf: "/laeufe",
verzeichnis: "/admin/verzeichnis",
} as const;
function laufLink(laufId: string): string {
return `${appUrl()}/laeufe/${laufId}`;
return `${appUrl()}${MELDUNGS_PFADE.lauf}/${laufId}`;
}
/**
@@ -424,6 +447,16 @@ async function einreihen(
* `skipDuplicates`, weil der partielle eindeutige Index fuer „ueberfaellig“
* genau hier zuschlaegt: der Zeitplan sieht jede Minute nach, und ohne das
* waere jeder zweite Durchgang ein Fehler statt einer stillen Nichtaenderung.
*
* **Hier haelt nur die Datenbank, und das ist Absicht.** Eine zweite Pruefung
* im Dienst („gibt es die schon?“) waere ein Wettlauf: zwei Instanzen hinter
* einem Lastverteiler fragen beide und bekommen beide „nein“. Die Migration
* `20261005110000_benachrichtigungen` sagt im Kommentar das Gegenteil — sie
* verspricht eine Dienstpruefung „mit besserer Meldung“, die es nicht gibt und
* nicht geben soll (Reviewbefund zu #029). Eine angewandte Migration wird
* nicht nachtraeglich umgeschrieben, so wie eine veroeffentlichte
* `VorlagenVersion` nicht; die Richtigstellung steht deshalb hier, wo sie
* gelesen wird, und in `tests/datenbankregeln.test.ts`.
*/
const ergebnis = await prisma.zustellung.createMany({ data: zeilen, skipDuplicates: true });
return ergebnis.count;
@@ -498,7 +531,7 @@ export async function abgleichMeldungEinreihen(quelleId: string, grund: string):
const webhook = await webhookZugang();
if (admins.length === 0 && !webhook) return 0;
const link = `${appUrl()}/verwaltung/verzeichnis`;
const link = `${appUrl()}${MELDUNGS_PFADE.verzeichnis}`;
const { betreff, rumpf } = abgleichMeldung(quelle.name, grund, link);
return einreihen(
Meldeanlass.ABGLEICH_FEHLGESCHLAGEN,
+8 -1
View File
@@ -566,7 +566,14 @@ export async function laufAnlegen(eingabe: NeuerLauf, benutzer: HandelnderBenutz
vorlage: version.vorlagenName,
vorlagenVersionId: version.id,
status: LaufStatus.OFFEN,
anzahlSchritte: version.schrittIds.length,
/*
* Die Schritte **dieses Laufs**, nicht die der Version. Seit #031
* fallen abteilungsfremde Abschnitte heraus; `version.schrittIds.length`
* behauptete deshalb mehr Schritte, als der Lauf hat (Reviewbefund zu
* #029). Das Protokoll ist seit #023 ansehbar — eine Zahl darin, die
* nicht zur Liste darunter passt, kostet jemanden eine Suche.
*/
anzahlSchritte: anzulegen.length,
zugewiesenAnId,
},
},
+15
View File
@@ -14,6 +14,7 @@ import {
INTERVALL_HOECHSTENS,
INTERVALL_MINDESTENS,
intervallMeldung,
zuordnungPruefen,
} from "@/lib/verzeichnis";
import { eintraegeDerQuelleStroemen } from "@/server/verzeichnis/quellenleser";
import { urlPruefen } from "@/server/ldap/verbindung";
@@ -317,6 +318,18 @@ export async function quelleAnlegen(
*/
const felder = feldernJeArt(art, eingabe);
/*
* **Die Zuordnung wird hier geprueft, nicht nur in der Server Action.**
* Aus diesen Namen entsteht eine LDAP-Attributliste und ein `$select` in der
* Graph-Adresse; `src/server/graph/leser.ts` berief sich im Kommentar darauf,
* dass sie dem Muster entsprechen — waehrend die Pruefung eine Schicht
* darueber in `zuordnungAusFormular` stand. Ein Name mit Komma und `$filter`
* kam deshalb durch den Dienst und landete in der Abfrage (Reviewbefund zu
* #029). Ein Skript, der Seed und ein Import gehen nicht durch das Formular.
*/
const zuordnungsMangel = zuordnungPruefen(eingabe.attributZuordnung ?? {});
if (zuordnungsMangel) throw new VerzeichnisFehler(zuordnungsMangel);
const zeile = await prisma.$transaction(async (tx) => {
const angelegt = await tx.verzeichnisquelle.create({
data: {
@@ -487,6 +500,8 @@ export async function quelleBearbeiten(
}
}
if (aenderung.attributZuordnung !== undefined) {
const mangel = zuordnungPruefen(aenderung.attributZuordnung);
if (mangel) throw new VerzeichnisFehler(mangel);
const zuordnung = zuordnungLesen({
...zuordnungLesen(vorher.attributZuordnung),
...aenderung.attributZuordnung,
+56 -9
View File
@@ -42,7 +42,22 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
variablen: { orderBy: [{ position: "asc" }, { id: "asc" }] },
abschnitte: {
orderBy: [{ position: "asc" }, { id: "asc" }],
include: { schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] } },
include: {
schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] },
/*
* Der Baustein mit. Ein Baustein-Abschnitt im **Entwurf** hat keine
* eigenen Schritte — die entstehen erst beim Veroeffentlichen. Ohne
* diese Zeilen exportierte ein Entwurf einen leeren Abschnitt unter
* dem Platzhaltertitel „wird ersetzt“ (Reviewbefund zu #029).
*/
bausteinVersion: {
select: {
titel: true,
baustein: { select: { name: true } },
schritte: { orderBy: [{ position: "asc" }, { id: "asc" }] },
},
},
},
},
},
});
@@ -68,13 +83,37 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
optionen: [...variable.optionen],
pflicht: variable.pflicht,
})),
abschnitte: version.abschnitte.map((abschnitt) => ({
titel: abschnitt.titel,
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
// dasselbe. Der Baustein-Verweis wandert bewusst **nicht** mit (#030).
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
schritte: abschnitt.schritte.map(schrittAbbilden),
})),
abschnitte: version.abschnitte.map((abschnitt) => {
/*
* **Ein Baustein-Abschnitt wird aufgeloest, nicht abgeschrieben.**
*
* Der Verweis selbst wandert bewusst nicht mit (#030): in der anderen
* Installation gibt es den Baustein vielleicht nicht, und eine Vorlage,
* die auf etwas Fehlendes zeigt, ist kein Austauschformat. Die **Inhalte**
* muessen aber mitwandern — und bei einem Entwurf stehen sie noch nicht
* am Abschnitt, sondern nur am Baustein.
*
* Vorher exportierte ein Entwurf deshalb `{"titel":"wird ersetzt",
* "schritte":[]}`: ein Redakteur brachte eine unveroeffentlichte Vorlage
* in eine zweite Installation, und der geteilte Abschnitt kam dort als
* leerer Abschnitt mit dem Editor-Platzhalter als Namen an. Die Checkliste
* war unvollstaendig, und nichts sagte es (Reviewbefund zu #029).
*
* Bei einer **veroeffentlichten** Version aendert das nichts: dort sind
* die Schritte beim Veroeffentlichen schon in den Abschnitt geschrieben
* worden, und der Titel kommt ohnehin aus dem Baustein.
*/
const quelle = abschnitt.bausteinVersion;
const ausBaustein = quelle !== null && abschnitt.schritte.length === 0;
return {
titel: ausBaustein ? quelle.titel : abschnitt.titel,
// Wandert mit: ein Abteilungsname bedeutet in einer anderen Installation
// dasselbe.
nurFuerAbteilungen: [...abschnitt.nurFuerAbteilungen],
schritte: (ausBaustein ? quelle.schritte : abschnitt.schritte).map(schrittAbbilden),
};
}),
},
};
}
@@ -90,7 +129,15 @@ export async function vorlageExportieren(versionId: string): Promise<Austausch>
* nachsichtige Schema soll. Exportiert wird deshalb der rohe Inhalt; nur was gar
* kein Objekt ist, gilt als leer.
*/
function schrittAbbilden(schritt: VorlagenSchritt): AustauschSchritt {
function schrittAbbilden(
/*
* Nur die gelesenen Felder statt `VorlagenSchritt`: dieselbe Funktion bildet
* jetzt auch einen `BausteinSchritt` ab (Baustein-Abschnitte werden beim
* Export aufgeloest). Die beiden Modelle unterscheiden sich nur darin, woran
* ein Schritt haengt — und das steht ohnehin in keinem Export.
*/
schritt: Pick<VorlagenSchritt, "titel" | "hilfetext" | "typ" | "pflicht" | "konfig" | "dokuUrl">,
): AustauschSchritt {
const roh = schritt.konfig;
const konfig =
typeof roh === "object" && roh !== null && !Array.isArray(roh) ? (roh as AustauschSchritt["konfig"]) : {};