# Integration und Kontrolle – Umsetzungsplan (Strang Z) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Die sieben Portal-Stränge (L1–L7) und die zwei WOMS-Stränge (W1, W2) werden zu je einem lauffähigen, geprüften Stand auf `feature/auftraege` zusammengeführt, durch einen Durchlauf-Test über alle neuen Endpunkte abgesichert, von fünf Prüfern kontrolliert und mit exakten Befehlen und Reihenfolge für den Livegang übergeben. Nichts wird gepusht. **Architecture:** Z1 (Portal) und Z2 (WOMS) arbeiten in den Basis-Worktrees `dev-auftraege` der beiden Repos und dürfen parallel laufen; beide hängen ihre Ergebnisse an ein gemeinsames Protokoll (`z-protokoll.md`). Z3 liest den fertigen Gesamt-Diff mit fünf Linsen (sonnet), lässt jeden Befund von einem zweiten Agenten (haiku) gegenprüfen und korrigiert nur bestätigte Critical/Important (sonnet, eine Datei-Gruppe je Agent); danach laufen betroffene Tests, Durchlauf und volle Suite erneut. Z4 schreibt die Übergabe und die Memory-Notiz. **Tech Stack:** git 2.55 (Worktrees, `merge --no-ff`, `merge-tree`), `node:test` mit den Nachbauten aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `mailSink`, `startTestApp`) und **echtem pdfkit**, Node 24 auf dem Host und Node 20 (Docker-Image `node:20`) für die Auftrags-Tests, WOMS: React 18/Vite 5 mit `npm test` (`tests/unit/*.test.mjs`) und dem Prüfwerkzeug des Redesigns (Playwright-Chromium, Ordner `$T/werkzeug`), `flock` und `systemd-run` für die Speichergrenzen. **Spec:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/specs/2026-10-06-auftraege-und-rechnungen-design.md` **Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege/docs/superpowers/plans/2026-10-06-auftraege-00-uebersicht.md` (verbindlich: Ausführungsmodell, Dateizuständigkeit, **„Nachträge nach der Planrunde“**, V1–V8, Review Focus, „Integration, Kontrolle, Übergabe“, Livegang). Die Nachträge entscheiden, wo ein Strang-Plan abweicht. Offene Widersprüche und Lücken stehen am Ende unter „Vertragsfragen“; was die Planrunde entschieden hat, ist dort als „entschieden (siehe Übersicht, Nachträge)“ markiert. **Reihenfolge:** Z1 und Z2 dürfen parallel laufen, sobald ihre Stränge gemeldet sind (Z1: L1–L7, Z2: W1 und W2). Z3 beginnt erst, wenn Z1 und Z2 grün sind. Z4 folgt auf Z3. **Abkürzungen** (jede Shell-Zeile steht für sich: die Variablen werden in jedem Befehlsblock neu gesetzt): | Name | Pfad | |---|---| | `P` | `/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege` (Portal, Branch `feature/auftraege`) | | `Wo` | `/home/webklar/apps/ticket.webklar.com/dev-auftraege` (WOMS, Branch `feature/auftraege`) | | `L` | `/tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad` (Scratchpad; Sperre `$L/auftraege-schwer.lock`) | | `R` | `/root/reports/auftraege-rechnungen-2026-10-06` (Berichte, Bilder, Übergabe) | | `T` | `/root/reports/ticketsystem-design-2026-10-04` (Prüfwerkzeug des Redesigns in `$T/werkzeug`, Sperre `$T/sperren/schwer.lock`) | --- ## Global Constraints Alle Punkte der Übersicht gelten (Sprache, Cent, Berliner Tage, Token, keine IP-Adressen, Schalter, Tests nur mit Nachbauten, Ressourcen, Git, Sicherheit der Umgebung, „Vorhandene Tests bleiben grün“), ebenso ihr Abschnitt „Nachträge nach der Planrunde“ (Zuständigkeiten, Sperren, K31 ohne Team-BCC, Link mit `#t=`, Merge-Reihenfolge); wo ein Strang-Plan davon abweicht, gilt die Übersicht. Für Strang Z kommt hinzu: - **Volle Suite nur hier und nur hinter der Sperre:** Portal `flock $L/auftraege-schwer.lock timeout 3600 nice -n 10 env NODE_OPTIONS=--max-old-space-size=1536 npm test`, WOMS `… NODE_OPTIONS=--max-old-space-size=1024 … npm test`. Vorher `free -m`: steht „available“ unter 1500 MB, warten. Die Skripte des Redesign-Werkzeugs (`build.sh`, `check.sh`, `shots.sh`, `run-browser.sh`) nehmen ihre eigene Sperre `$T/sperren/schwer.lock`. Reihenfolge immer erst `auftraege-schwer.lock`, dann deren Sperre (wie in W0). - **Kein Push**, nie `--force`, nie `git add -A` oder `git add .`: Dateien einzeln. `git remote -v`, `git config remote.origin.url` und Push-URLs enthalten ein Zugangstoken: nie ausgeben und nie in Berichte oder Protokolle kopieren. - **Code ändert Strang Z nur** (a) als kleinen Integrationsfix, der einen Widerspruch zwischen Strängen oder zu einem bestehenden Test auflöst, und (b) als Korrektur eines bestätigten Befunds in Z3. Alles andere wird gelesen und geprüft. Ein Fix über etwa 30 Zeilen oder mit Wirkung auf einen Vertrag (V1–V8) geht als Rückfrage an den Orchestrator, nicht als Commit. - **Der Vertrag entscheidet.** Schlägt der Durchlauf-Test an einer Stelle an, die V1–V8 oder die Spec festlegen, ist der Code falsch. Legt der Vertrag nichts fest, ist die Annahme des Tests falsch: Test anpassen, im Test den Kommentar `// Annahme (nicht im Vertrag): …` setzen und die Entscheidung im Protokoll vermerken. - **Modelle:** Prüfer und Korrektoren `sonnet`, Gegenprüfung `haiku`; kein `opus`, kein `fable` (Memory „feedback-billige-modelle“). Schlusszeile jedes Commits wie in der Übersicht (zweites `-m`): `Co-Authored-By: Claude Opus 5.5 `. - **Sicherheitsblock für jeden Agentenauftrag** (Memory „feedback-destruktive-skripte“, steht wörtlich in jedem Auftrag unten): nie `rm -r`, `rsync --delete`, `git clean`, `git reset --hard`, `git worktree remove --force`; kein Skript mit einem Zielordner aufrufen oder mit Pfaden wie `..` ausprobieren; Hilfsdateien nur unter `$L` oder `$R`, nie nach `/` schreiben; keine Zugangsdaten, `.env`-Inhalte oder Tokens ausgeben; keine Live-Daten lesen oder schreiben; `grep` ist hier eine Funktion, die Dateien still auslässt: `rg` oder `command grep -a` benutzen; Node-Läufe immer mit `--max-old-space-size` und `timeout`; nie `pkill -f`. - **Protokoll:** Jeder Z-Agent hängt Befehle, Ergebnisse (Zahlen, SHAs) und Entscheidungen an `$R/z-protokoll.md` an (nur anhängen, `>>`). Die Übergabe (Z4.1) entsteht aus diesem Protokoll. - **Ablage:** Berichte, Bilder und Befunde unter `$R/` (`bilder//`, `kontrolle/`, `pruefmaterial/`), flüchtige Läufe im Scratchpad `$L`. ## Review Focus Die fünf Fälle der Übersicht. Jeder hat einen Durchlauf-Test in `test/auftraegeAblauf.test.js` (Task Z1.3) und einen Prüfer in Z3, der den Code dazu liest; die Tests der Stränge (L2, L5, L3, L4) decken sie zusätzlich im Einzelnen ab. | # | Fall | Erwartung | Durchlauf-Test | Prüfer | |---|---|---|---|---| | 1 | Gleichzeitige Aufwandsrechnungen mit denselben Arbeitsblättern (Doppelklick, zwei Admins) | Die zweite scheitert mit 409 „Arbeitsblatt bereits abgerechnet“ und hinterlässt keinen Entwurf bei Stripe | D3 (Wettlauf über HTTP mit verschiedenem Vermerk und Zahlungsziel, damit der 30-Sekunden-Schutz gegen gleiche Inhalte nicht schon greift) | 1 (Sperren, auch verschachtelte: die Schlüssel sind entschieden, Prüfer 1 prüft die Einhaltung) | | 2 | Alte Token nach Erinnerung, neuer Fassung, Storno oder Widerruf | Der alte Link bestätigt nie; die Seite nennt den Grund (404 nach Erinnerung oder Ablauf, 410 ersetzt/storniert/widerrufen) | D6 (Erinnerung, Ablauf, ersetzt, storniert), D6b (Bestätigen gegen Storno), D8 (widerrufen), D13 (Zähler zählt auch Fehlversuche) | 2 | | 3 | Schlussrechnung neben stornierten oder noch nicht gesendeten Rechnungen | Stornos zählen nicht; ein offener Entwurf zur AB blockiert mit 409 | D4 (Entwurf blockiert, Minuszeile), D5 (stornierte Anzahlung zählt nicht) | 1 | | 4 | Unvollständige Kundendaten beim AB-Senden (keine Kundenart, keine Anschrift, keine E-Mail, Interessent) | Klare Meldung **vor** Nummernvergabe und PDF, keine Nummer verbraucht | D9, D10 (Ablage kaputt), D15 (Nummern lückenlos) | 2 und 5 | | 5 | Umlaute, €, Minuszeichen und lange Texte im PDF | Lesbar, kein Glyphenausfall, Seitenumbruch mit Kopf der Tabelle | D4 (echtes pdfkit mit ä ö ü ß € im Titel und in Positionen), D8 (Privatperson hat mehr Seiten); Text und Optik an den Beispiel-PDFs aus Z3.1 | 3 und 4 | ## Dateien | Datei | Aktion | Task | |---|---|---| | `$L/zustaendigkeit-pruefen.mjs` | neu (Scratchpad, nicht im Repo) | Z1.1 | | `$L/zustaendigkeit-woms.mjs` | neu (Scratchpad, nicht im Repo) | Z2.1 | | `test/auftraegeAblauf.test.js` (Portal) | neu | Z1.3 | | `test/kundenansichtGuard.test.js` (Portal) | ändern: zwei Einträge in `MOUNTS` | Z1.4 | | `test/financeSettings.test.js` (Portal) | ändern, falls `steuernummer` die festen `DEFAULTS` bricht | Z1.4 | | Portal: Integrationsfixes | nur wenn Z1.2 bis Z1.6 sie verlangen | Z1.2–Z1.6 | | `$T/werkzeug-auftraege/` (Worktree des Prüfwerkzeugs, Zweig `strang/auftraege`): `backend.mjs`, `endpunkte.md`, `fixtures/auftraege.mjs`, `views.mjs`, `gates.mjs` | ändern/neu | Z2.3 | | `scripts/setup-legal.mjs`, `tests/setup-legal.test.mjs` (WOMS) | ändern, bedingt | Z2.4 | | `$L/pruefmaterial.mjs`, `$L/gruppen.mjs`, `$L/befunde-zusammenfuehren.mjs`, `$L/befunde-auswerten.mjs`, `$L/gruppen-pruefen.mjs` | neu (Scratchpad, nicht im Repo) | Z3.1, Z3.3, Z3.4 | | Dateien der Gruppen G1 bis G6 (Tabelle in Z3.4) und je eine neue Testdatei `auftraegeKorrektur` | nur für bestätigte Befunde | Z3.4 | | `server/services/finance/stripeFinance.js`, `test/financeStripeWrite.test.js`, `scripts/stripe-kunden-fuss.mjs`, `test/stripeKundenFuss.test.js` (Portal) | ändern bzw. neu, nur auf Weisung | Z4.3 | | `$R/z-protokoll.md`, `$R/uebergabe.md`, `$R/kontrolle/*`, `$R/pruefmaterial/*`, `$R/bilder/*` | neu | Z1–Z4 | | `/root/.claude/projects/-root/memory/auftraege-rechnungen.md` (Absatz anhängen), `MEMORY.md` (vorhandene Indexzeile ergänzen) | ändern | Z4.2 | --- ## Z1 – Portal-Integration (Worktree `$P`) ### Task Z1.1: Bestandsaufnahme und Zuständigkeitsprüfung **Files:** - Create: `$L/zustaendigkeit-pruefen.mjs` (Scratchpad), `$R/z-protokoll.md` (anhängen) - Keine Änderung im Repo. **Interfaces:** - Consumes: Branches `auftraege/l1-projektkonto`, `auftraege/l2-rechnungen`, `auftraege/l3-ab-kern`, `auftraege/l4-ab-pdf`, `auftraege/l5-bestaetigung`, `auftraege/l6-kundenportal`, `auftraege/l7-rechtstexte` und `feature/auftraege` (Stand nach L0); die Tabelle „Dateizuständigkeit“ der Übersicht. - Produces: die Gewissheit, dass jede Lane nur eigene Dateien geändert hat und keine zwei Lanes dieselbe Datei; im Protokoll die Ausgangs-SHAs (`ORIGIN_MAIN`, `L0_TIP`, je Lane der Tip). - [ ] **Schritt 1: Lage und Speicher** ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege free -m | sed -n 2p cd $P && git status --short && git log --oneline -3 && git rev-parse --short origin/main feature/auftraege for n in 1 2 3 4 5 6 7; do d=$P-l$n; printf 'Lane %s: ' $n; git -C $d status --short | rg -v '^\?\? node_modules$' | head -3; git -C $d branch --show-current; done ``` Erwartet: `git status` im Basis-Worktree leer; je Lane nur der Branch-Name (ein unversionierter `node_modules`-Symlink wird ausgeblendet; ein `.superpowers/`-Ordner mit Strang-Protokollen ist in Ordnung, alles andere (geänderte oder neue Dateien) bedeutet: die Lane ist nicht fertig, Orchestrator fragen). - [ ] **Schritt 2: Branch-Tips prüfen und festhalten** ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege for b in l1-projektkonto l2-rechnungen l3-ab-kern l4-ab-pdf l5-bestaetigung l6-kundenportal l7-rechtstexte; do printf '%-30s' "auftraege/$b"; git -C $P rev-parse --short "auftraege/$b" 2>/dev/null || echo FEHLT git -C $P merge-base --is-ancestor feature/auftraege "auftraege/$b" && echo ' enthält feature/auftraege' || echo ' HINWEIS: feature/auftraege ist weiter (L0 nach dem Start der Lane geändert): Merge bringt es mit' done ``` Erwartet: sieben Tips, kein `FEHLT`. Die SHAs gehören ins Protokoll (Schritt 8). - [ ] **Schritt 3: Prüfskript anlegen** (`$L/zustaendigkeit-pruefen.mjs`, nur lesend: ruft `git diff` auf) ```js // Meldet Dateien, die eine Lane laut Dateizuständigkeit der Übersicht nicht ändern darf, und Überschneidungen zwischen Lanes. import { execFileSync } from 'node:child_process' const REPO = '/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege' const S = 'server/services/auftraege/' // Zuständigkeit laut Tabelle „Dateizuständigkeit“ und Abschnitt „Nachträge nach der Planrunde“ der Übersicht (die Nachträge gelten, wo ein Strang-Plan abweicht). // L0 liegt schon auf feature/auftraege und ist nicht Gegenstand dieser Prüfung; ihm gehören auch .gitignore (Eintrag node_modules ohne Schrägstrich) // und die Abhängigkeit fontkit in package.json: ändert eine Lane .gitignore, package.json oder package-lock.json, meldet das Skript eine FREMDE DATEI. const LANES = { 'auftraege/l1-projektkonto': [S + 'projektkonto.js', 'server/routes/admin/projektkonten.js', 'server/routes/team/arbeitsblaetter.js'], // L2: zusätzlich der erwartete Schlüsselsatz von mapInvoice in test/financeStripeRead.test.js 'auftraege/l2-rechnungen': [S + 'positionen.js', S + 'rechnungsbau.js', 'server/services/finance/stripeFinance.js', 'server/routes/admin/finanzen.js', 'test/support/fakeStripe.js', 'test/financeStripeRead.test.js'], 'auftraege/l3-ab-kern': [S + 'nummern.js', S + 'auftraege.js', 'server/routes/admin/auftraege.js'], 'auftraege/l4-ab-pdf': [S + 'pdf.js', /^server\/assets\/fonts\//], // L5: zusätzlich die Blöcke „projekt“ in K04 und T09 (Widerruf von Projektaufträgen) 'auftraege/l5-bestaetigung': [S + 'token.js', S + 'bestaetigung.js', S + 'mails.js', /^server\/mail-templates\/(K31|K32|T22|K04|T09) /, 'server/routes/auftrag.js', 'server/routes/auftraegeKunde.js', 'server/routes/abo.js', 'test/mailTemplates.test.js'], // L6: zusätzlich vier Module in public/ und je eine Zeile in test/customerInvoices.test.js und test/publicForms.test.js; // die Testhilfe test/auftraegeKundenportalDaten.js und das UI-Skript test/ui/auftraege-kundenportal.ui.mjs sind eigene neue Testdateien (NEUE_TESTS) 'auftraege/l6-kundenportal': [/^public\/auftrag\.(html|js|css)$/, /^public\/auftrag-(ansicht|dom|portal|widerruf)\.js$/, 'public/dashboard.html', 'public/app.js', 'public/widerrufen.html', 'server/services/customerInvoices.js', 'test/customerInvoices.test.js', 'test/publicForms.test.js'], // L7: zusätzlich die AGB-Quelle, der Normnachweis und die Stand-Zeile in test/legalTexts.test.js 'auftraege/l7-rechtstexte': [S + 'texte.js', S + 'konstanten.js', /^public\/rechtliches\//, 'docs/rechtliches/agb.md', 'docs/rechtliches/quellen/normen-auftraege-2026-10-06.txt', 'scripts/build-legal-html.mjs', 'scripts/build-legal-pdfs.sh', 'public/datenschutz.html', /^docs\/WEBklar-Datenschutzerklaerung\.pdf(\.sha256)?$/, 'test/legalTexts.test.js'], } // jede Lane darf eigene neue Testdateien, Testhilfen ohne .test.js (test/helpers/auftraege….js, test/auftraege…Hilfe.js) und Prüfskripte mit dem Namensraum auftraege… anlegen: sie gehören dem anlegenden Strang (Übersicht, Nachträge) const NEUE_TESTS = /^test\/(auftraege[A-Za-z0-9]*\.(test\.)?js|ui\/auftraege-[a-z-]+\.ui\.mjs|helpers\/auftraege[A-Za-z]*\.js)$/ const passt = (muster, datei) => (muster instanceof RegExp ? muster.test(datei) : muster === datei) const git = (...args) => execFileSync('git', ['-C', REPO, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }) let meldungen = 0 const geaendert = {} for (const [zweig, erlaubt] of Object.entries(LANES)) { geaendert[zweig] = new Set() for (const zeile of git('diff', '--no-renames', '--name-status', `feature/auftraege...${zweig}`).split('\n').filter(Boolean)) { const [art, datei] = zeile.split('\t') geaendert[zweig].add(datei) if (art === 'A' && NEUE_TESTS.test(datei)) continue if (erlaubt.some((muster) => passt(muster, datei))) continue console.log(`FREMDE DATEI ${zweig}: ${art} ${datei}`) meldungen += 1 } } const namen = Object.keys(geaendert) for (let i = 0; i < namen.length; i += 1) { for (let j = i + 1; j < namen.length; j += 1) { for (const datei of geaendert[namen[i]]) { if (!geaendert[namen[j]].has(datei)) continue console.log(`ÜBERSCHNEIDUNG ${namen[i]} und ${namen[j]}: ${datei}`) meldungen += 1 } } } // Sonderregeln: L7 darf in konstanten.js nur die Zeile AGB_FASSUNG ändern und in texte.js keine Exportnamen const konstanten = git('diff', '-U0', 'feature/auftraege...auftraege/l7-rechtstexte', '--', S + 'konstanten.js').split('\n').filter((z) => /^[+-][^+-]/.test(z)) if (konstanten.some((z) => !z.includes('AGB_FASSUNG'))) { console.log('L7 ändert konstanten.js außerhalb von AGB_FASSUNG:\n' + konstanten.join('\n')) meldungen += 1 } const exportnamen = (ref) => { try { return (git('show', `${ref}:${S}texte.js`).match(/^export (?:const|function|class) \w+/gm) || []).sort().join('|') } catch { return '' } } if (exportnamen('feature/auftraege') !== exportnamen('auftraege/l7-rechtstexte')) { console.log('L7 ändert die Exportnamen von texte.js') meldungen += 1 } console.log(meldungen ? `${meldungen} Meldung(en)` : 'Zuständigkeit eingehalten') process.exit(meldungen ? 1 : 0) ``` - [ ] **Schritt 4: Prüfung laufen lassen** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 120 node --max-old-space-size=256 /tmp/claude-0/-root/dc97e1da-8b3d-40c0-9943-f8c37f910877/scratchpad/zustaendigkeit-pruefen.mjs` Erwartet: `Zuständigkeit eingehalten`, Exit 0. Jede Meldung entscheidet der Z1-Agent so: `FREMDE DATEI` in `docs/` oder in einer reinen Testdatei ist ein Hinweis fürs Protokoll; eine fremde Datei unter `server/` oder `public/` oder eine `ÜBERSCHNEIDUNG` ist ein **Konfliktkandidat**: im Protokoll festhalten und in Z1.2 beim Merge dieser Lane besonders prüfen (Konfliktregel dort). Wer die Datei „darf“, steht in der Tabelle der Übersicht; im Zweifel Orchestrator fragen. - [ ] **Schritt 5: Hygiene der Lane-Branches** ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege for b in l1-projektkonto l2-rechnungen l3-ab-kern l4-ab-pdf l5-bestaetigung l6-kundenportal l7-rechtstexte; do printf '%-22s' "$b"; git -C $P ls-tree -r --name-only "auftraege/$b" | rg '^(node_modules|upload-data|\.env)|\.(key|pem)$' || echo 'ok (keine Fremdkörper)' done ``` Erwartet: siebenmal `ok …`. Ein Treffer heißt: Symlink `node_modules`, Zugangsdaten oder Upload-Daten wurden versehentlich committed: **STOPP**, Orchestrator melden (nicht mergen). - [ ] **Schritt 6: Mail-Kennungen und Setup-Skript** ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege git -C $P fetch origin git -C $P ls-tree --name-only origin/main:server/mail-templates | rg '^(K3[1-9]|T2[2-9]) ' || echo 'K31, K32, T22 sind auf origin/main frei' git -C $P diff --name-status feature/auftraege...auftraege/l5-bestaetigung -- server/mail-templates git -C $P show feature/auftraege:scripts/auftraege-setup.mjs | rg -n "projektRechnungen|auftraege" | head ``` Erwartet: erste Zeile „frei“, die Vorlagen-Liste zeigt genau drei `A`-Zeilen (`K31 …`, `K32 …`, `T22 …`) und genau zwei `M`-Zeilen (`K04 Eingang Widerruf.html` und `T09 Widerruf eingegangen.html`: nur der neue Block `projekt`, Übersicht V6), sonst keine geänderte alte Vorlage. Das Setup-Skript legt beide Collections an (`projektRechnungen` **und** `auftraege`; L1 liest bei ausgeschaltetem Schalter die `auftraege`-Collection nicht, die Rechnungsarten ohne AB brauchen aber `projektRechnungen` immer). Ist die Kennung `K31`, `K32` oder `T22` auf `origin/main` vergeben (andere Sitzungen bauen parallel), **STOPP**: Orchestrator fragt, wer umbenennt (nächste freie Nummer, Pins in `test/mailTemplates.test.js`). - [ ] **Schritt 7: Abschlussberichte der Stränge sammeln** Die Abschlussberichte von L1–L7 (Testzahlen, Abweichungen, Vertragsfragen) liegen beim Orchestrator. Die Zahlen und alle Abweichungen kommen in das Protokoll; Abweichungen, die einen Vertrag berühren, fragt der Z1-Agent vor dem Merge nach. - [ ] **Schritt 8: Protokoll anlegen** ```bash R=/root/reports/auftraege-rechnungen-2026-10-06 P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege { echo "# Protokoll der Z-Phase (Aufträge und Rechnungen)" echo echo "## Z1.1 Bestandsaufnahme $(date -u +%Y-%m-%dT%H:%MZ)" echo "- ORIGIN_MAIN=$(git -C $P rev-parse --short origin/main) L0_TIP=$(git -C $P rev-parse --short feature/auftraege)" for b in l1-projektkonto l2-rechnungen l3-ab-kern l4-ab-pdf l5-bestaetigung l6-kundenportal l7-rechtstexte; do echo "- auftraege/$b=$(git -C $P rev-parse --short auftraege/$b)"; done echo "- Zuständigkeitsprüfung: " } >> $R/z-protokoll.md ``` Kein Commit (nichts im Repo geändert). --- ### Task Z1.2: Lane-Merges in der Reihenfolge der Übersicht (L1, L2, L3, L4, L7, L5, L6) mit den Tests des Strangs **Files:** - Modify: Branch `feature/auftraege` (Merge-Commits), Dateien nur bei einem Integrationsfix. **Interfaces:** - Consumes: die sieben Lane-Branches (Z1.1), die Dateizuständigkeit der Übersicht. - Produces: `feature/auftraege` mit allen sieben Strängen, je ein Merge-Commit je Lane (`--no-ff`) in der Reihenfolge L1, L2, L3, L4, L7 (ohne seinen Datenschutz-Commit), L5, L6 (entschieden, Übersicht „Integration“), nach jedem Merge grüne Strang-Tests. **Konfliktregel.** Die Tabelle „Dateizuständigkeit“ der Übersicht entscheidet: - Konflikt in einer Datei, die der **einmündenden** Lane gehört: deren Fassung gilt (`git checkout --theirs -- `). - Konflikt in einer Datei, die L0 oder einer **bereits gemergten** Lane gehört (z. B. `package.json`, `server/config.js`, `server/index.js`, `test/support/*`, `konstanten.js`): die Fassung auf `feature/auftraege` gilt (`git checkout --ours -- `); die Änderung der einmündenden Lane wird im Protokoll als Verstoß gegen die Zuständigkeit festgehalten und von Hand nachgezogen, **wenn** sie fachlich nötig ist (kleiner Integrationsfix). - Echter **Logikkonflikt** (beide Seiten haben dieselbe Funktion oder denselben Absatz inhaltlich geändert, und beide Änderungen werden gebraucht): nicht raten. `git merge --abort`, Datei, beide Fassungen und die Frage an den Orchestrator melden. - Nach jeder Auflösung die Datei einzeln stagen (`git add -- `), nie `-A`; Merge mit `git commit --no-edit` abschließen. Bleibt etwas unklar: `git merge --abort` ist jederzeit erlaubt. - [ ] **Schritt 1: Ausgangslage** ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege cd $P && git status --short && git branch --show-current && free -m | sed -n 2p ``` Erwartet: leer, `feature/auftraege`, „available“ mindestens 1500 MB. - [ ] **Schritt 2: Verfahren je Lane** (hier am Beispiel L1; für jede weitere Lane Branch und Testliste austauschen) ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege cd $P git merge --no-ff -m "Merge auftraege/l1-projektkonto: Projektkonto (Strang L1)" -m "Co-Authored-By: Claude Opus 5.5 " auftraege/l1-projektkonto git status --short | rg '^(UU|AA|DU|UD|AU|UA) ' || echo "keine Konflikte" # neue und geänderte Testdateien dieses Merges (Nettowirkung der Lane auf feature/auftraege) FILES=$(git diff --name-only --diff-filter=AM HEAD^1 HEAD -- 'test/*.test.js' | tr '\n' ' '); echo "$FILES" free -m | sed -n 2p timeout 900 nice -n 10 node --max-old-space-size=1024 --test $FILES ``` Erwartet: Merge-Commit angelegt (`git log --oneline -1`), „keine Konflikte“, `# fail 0` und `# pass` ungleich 0. Dazu je Lane die **bestehenden** Tests, die ihre Dateien berühren (unten); ihre Ausgabe `# fail 0`. Schlagen Tests nach dem Merge an, die in der Lane grün waren, ist es ein Integrationseffekt (L0-Stand nach dem Lane-Start geändert, oder Wechselwirkung mit einer früher gemergten Lane): Ursache im Protokoll festhalten, kleinste Korrektur als eigener Commit `Integration: ` in der Datei des zuständigen Strangs (Konfliktregel), Tests wiederholen. Kein Weitermachen mit einer roten Lane. - [ ] **Schritt 3: Die sieben Merges in dieser Reihenfolge** (entschieden, Übersicht „Integration“: L1, L2, L3, L4, L7 ohne seinen letzten Commit, L5, L6) | Lane | Branch | Zusätzlich zu den neuen Testdateien (bestehend, einzeln laufen lassen) | |---|---|---| | 1. L1 Projektkonto | `auftraege/l1-projektkonto` | `test/financeStripeRead.test.js` | | 2. L2 Rechnungen | `auftraege/l2-rechnungen` | `test/financeStripeWrite.test.js test/financeStripeRead.test.js test/financeRoutesBank.test.js test/financeAssign.test.js test/financeOverview.test.js test/financeSettings.test.js test/customerInvoices.test.js` | | 3. L3 AB-Kern | `auftraege/l3-ab-kern` | `test/financeSettings.test.js` | | 4. L4 AB-PDF | `auftraege/l4-ab-pdf` | (keine) | | 5. L7 Rechtstexte | `auftraege/l7-rechtstexte~1` (ohne den letzten Commit) | `test/legalTexts.test.js test/legalDocs.test.js test/buildLegalHtml.test.js` und alle Testdateien, die `datenschutz.html` oder `agb` lesen: `command grep -a -l "datenschutz.html\|Datenschutzerklaerung.pdf\|AGB_FASSUNG" test/*.test.js` | | 6. L5 Bestätigung und Mails | `auftraege/l5-bestaetigung` | `test/mailTemplates.test.js test/publicForms.test.js test/aboRoutes.test.js test/kundenansichtGuard.test.js` | | 7. L6 Kundenportal | `auftraege/l6-kundenportal` | `test/customerInvoices.test.js test/publicForms.test.js test/kundenansicht.test.js` und alle Testdateien, die `public/app.js`, `public/dashboard.html` oder `public/widerrufen.html` lesen: `command grep -a -l "public/app.js\|dashboard.html\|widerrufen.html" test/*.test.js` | - [ ] **1. L1 gemergt, Strang-Tests grün** - [ ] **2. L2 gemergt, Strang-Tests grün** (danach `test/auftraegeProjektkonto*.test.js` erneut: L2 ändert `stripeFinance.js`, das L1 nur liest) - [ ] **3. L3 gemergt, Strang-Tests grün** - [ ] **4. L4 gemergt, Strang-Tests grün** (die PDF-Tests von L4 laufen jetzt gegen das echte pdfkit von L0; Beispiel-PDF in der Lane-Ausgabe gegenlesen: `# pass` gleich dem Lane-Bericht) - [ ] **5. L7 gemergt (ohne den Datenschutz-Commit, siehe unten), Strang-Tests grün** (die PDF-Prüfsummen `*.sha256` gehören zu den Dateien aus L7; `test/legalTexts.test.js` und `test/buildLegalHtml.test.js` prüfen sie). Danach **alle** bisherigen Auftrags-Tests auf einmal: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-auftraege && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraege*.test.js 2>&1 | tail -15`. L7 ersetzt die vorläufigen Wortlaute aus L0; pinnt ein Test von L0 einen Wortlaut, bricht er jetzt. Erwartet `# fail 0`; ein roter L0-Test mit festem Wortlaut wird angepasst (Integrationsfix, nur die Erwartung, nie der Wortlaut) - [ ] **6. L5 gemergt, Strang-Tests grün** (`test/mailTemplates.test.js` pinnt die Vorlagenliste: K31, K32, T22 müssen darin stehen, T22 in der Liste der Team-Fußzeilen; K04 und T09 tragen den Block `projekt`; die AGB-Datei aus L7 liegt schon im Stand, K31 hängt sie an) - [ ] **7. L6 gemergt, Strang-Tests grün** **Reihenfolge und Datenschutz-Commit (beides entschieden, Übersicht „Nachträge“ und „Integration“)** 1. **L7 steht vor L5.** K31 hängt `public/rechtliches/agb-.pdf` an; die Datei kommt mit L7. So finden die Tests von L5 sie im Stand, und der Durchlauf in Z1.3 scheitert nicht am Mailanhang. 2. **Der Datenschutz-Commit bleibt draußen.** Er ist der **letzte** Commit von `auftraege/l7-rechtstexte` und wird erst beim AB-Livegang nach der Freigabe gemergt (Übergabe, Abschnitt D): `public/datenschutz.html` und das PDF sind mit dem ersten Push von `main` sofort öffentlich, und der neue Abschnitt 9a beschreibt eine Verarbeitung, die bei `AUFTRAEGE_ENABLED=off` nicht läuft, in einem Wortlaut, den der Nutzer noch nicht freigegeben hat. Gemergt wird deshalb `auftraege/l7-rechtstexte~1`: ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege cd $P git log --oneline feature/auftraege..auftraege/l7-rechtstexte | head -12 DS=$(git log --format=%H -n1 --grep='^Datenschutz: Abschnitt 9a' auftraege/l7-rechtstexte); echo "Datenschutz-Commit: $(git rev-parse --short $DS) Spitze der Lane: $(git rev-parse --short auftraege/l7-rechtstexte)" test "$DS" = "$(git rev-parse auftraege/l7-rechtstexte)" && echo "der Datenschutz-Commit ist die Spitze: ~1 merge" || echo "WEITERE COMMITS DANACH: Orchestrator fragen" git merge --no-ff -m "Merge auftraege/l7-rechtstexte (ohne Datenschutz-Commit): Rechtstexte (Strang L7)" -m "Co-Authored-By: Claude Opus 5.5 " auftraege/l7-rechtstexte~1 ``` Der Datenschutz-Commit (Abschnitt 9a, Widerrufsabschnitt, Stand, PDF und die Pins dazu in `test/auftraegeRechtstexte.test.js` und `test/legalTexts.test.js`) bleibt auf `auftraege/l7-rechtstexte` und wird nach der Rechtsrunde mit `git merge --no-ff auftraege/l7-rechtstexte` nachgeholt (Übergabe, Abschnitt D; **die Branches der Lanes bleiben dafür erhalten**). Verweise der Seiten auf den Abschnitt 9a zeigen bis dahin auf die Seite ohne diesen Anker. Gibt es nach dem Datenschutz-Commit noch Commits der Lane, die Datenschutz-Dateien berühren, das dem Orchestrator melden statt zu raten. - [ ] **Schritt 4: Nach dem letzten Merge die gemeinsame Lage prüfen** ```bash P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege cd $P && git status --short && git log --oneline --first-parent origin/main..HEAD | head -30 node -e "const p=require('./package.json'),l=require('./package-lock.json');const v=l.packages['node_modules/pdfkit']?.version;const f=Object.keys(l.packages).filter((k)=>k.endsWith('node_modules/fontkit'));console.log('pdfkit',p.dependencies.pdfkit,'gelockt',v,'| fontkit',p.dependencies.fontkit,'gelockt',f.length);process.exit(p.dependencies.pdfkit&&v&&p.dependencies.fontkit&&f.length?0:1)" rg -n '^node_modules$' .gitignore ls server/assets/fonts public/rechtliches | rg -i 'figtree|agb-' ``` Erwartet: `git status` leer, sieben Merge-Commits plus die Strang-Commits in der First-Parent-Liste, `pdfkit` und `fontkit` stehen in `dependencies` (nicht `devDependencies`: der Produktionscontainer installiert mit `npm install --omit=dev`) und im Lock, `.gitignore` hat `node_modules` ohne Schrägstrich (L0, Übersicht „Nachträge“); die Figtree-Schnitte samt Lizenzdatei und `public/rechtliches/agb-.pdf` samt `.sha256` sind da (die AGB-Datei hängt in K31 an: fehlt sie, scheitert der Durchlauf in Z1.3 am Mailanhang). Danach noch einmal alle Auftrags-Tests: `timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraege*.test.js 2>&1 | tail -15`, erwartet `# fail 0`. - [ ] **Schritt 5: Protokoll** ```bash R=/root/reports/auftraege-rechnungen-2026-10-06; P=/home/webklar/apps/kundenbereich.webklar.com/dev-auftraege { echo; echo "## Z1.2 Merges $(date -u +%Y-%m-%dT%H:%MZ)"; echo "- feature/auftraege = $(git -C $P rev-parse --short HEAD) (7 Merges)"; echo "- Konflikte und Integrationsfixes: "; } >> $R/z-protokoll.md ``` Die Merge-Commits sind der Commit dieses Tasks; Integrationsfixes wurden einzeln committed. ### Task Z1.3: Durchlauf-Test `test/auftraegeAblauf.test.js` **Files:** - Create: `test/auftraegeAblauf.test.js` **Interfaces:** - Consumes (Verträge der Übersicht nach der Planrunde, alle über HTTP): V5 komplett (Projektkonten, Aufträge samt `POST …/auftraege/:id/projekt`, Rechnungen mit `art`, Vorschau, Team-Abzeichen, öffentliche Token-Routen, Kundenrouten, Widerruf `order: 'projekt'` mit optionaler `abNummer`, Einstellungen mit `steuernummer`), V6 (K31 mit Link `auftrag.html#t=`, ohne BCC, dazu die Teamkopie ohne Link; K32 mit `FRUEHBEGINN_TEXT`; T22 mit Präfix „[Aufträge]“), V2 `AGB_FASSUNG` und `SCHALTER_AUS_MELDUNG`, V4 `formatNummer`, `hashToken`; die Nachbauten aus `test/support/`; echtes pdfkit. - Produces: den Beleg, dass die sieben Stränge zusammen funktionieren. Szenarien D0 bis D17 und D6b (siehe Review-Focus-Tabelle für D3, D4, D5, D6, D6b, D8, D9, D10, D13); D17 (AB ohne Projekt, später zuordnen) steht vor D15 und D16, die am Ende alle Nummern und Token prüfen. - Annahmen, die V5 offen lässt, und wie der Test damit umgeht: Erfolgsstatus 200 **oder** 201 (`erfolg()`), Router-Module mit `export default`, Fehlerstatus bei zu früher Anzahlung und Überfakturierung 400 **oder** 409, unbekanntes oder nicht abrechenbares Arbeitsblatt 400, 404 **oder** 409, fremdes oder unbekanntes Projekt bei `POST …/auftraege/:id/projekt` 400, 404 **oder** 409. - [ ] **Schritt 1: Testdatei anlegen** (vollständig) ```js // Durchlauf über alle neuen Endpunkte von „Aufträge und Rechnungen“ (Strang Z, Task Z1.3). // Echter Server-Code (Router, Dienste, pdfkit); Nachbauten für Appwrite, Stripe und SMTP, nichts verlässt den Rechner. // Aufruf: cd && free -m && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/auftraegeAblauf.test.js // Jeder Test hat eigene Kunden und Projekte; die AB-Nummern laufen über alle Tests (D15 prüft sie am Ende). // Der Vertrag entscheidet (Übersicht V1–V8, Spec). Was der Vertrag offen lässt, steht als „Annahme“ im Kommentar. import { test, after, beforeEach } from 'node:test' import assert from 'node:assert/strict' import crypto from 'node:crypto' import fs from 'node:fs' import os from 'node:os' import path from 'node:path' import { inspect } from 'node:util' import { startAppwriteMock } from './support/appwriteMock.js' import { startFakeStripe } from './support/fakeStripe.js' import { useTestEnv } from './support/testEnv.js' import { schemaKeys } from './support/schema.js' import { mailSink } from './support/mailSink.js' import { AUFTRAEGE_COLLECTIONS, AUFTRAEGE_ZUSATZ_ATTRIBUTE } from '../server/services/auftraege/schema.js' // --- Umgebung ------------------------------------------------------------------------------------------------ // Ablage der AB-PDFs in einem Wegwerf-Ordner und Schalter an (Vorgabe wäre off): config.js liest beides beim ersten Import. const ABLAGE = fs.mkdtempSync(path.join(os.tmpdir(), 'auftraege-ablauf-')) process.env.AUFTRAEGE_UPLOAD_DIR = ABLAGE process.env.AUFTRAEGE_ENABLED = 'on' // Alles, was dieser Prozess ins Protokoll schreibt, wird gesammelt: D16 prüft, dass kein Token und keine Client-Adresse darin steht. const PROTOKOLL = [] for (const stufe of ['log', 'info', 'warn', 'error']) { const original = console[stufe].bind(console) console[stufe] = (...teile) => { PROTOKOLL.push(teile.map((teil) => (typeof teil === 'string' ? teil : inspect(teil, { depth: 5 }))).join(' ')) original(...teile) } } const felder = (liste) => liste.map((attribut) => attribut.key) const datumsfelder = (liste) => liste.filter((attribut) => attribut.type === 'datetime').map((attribut) => attribut.key) const aw = await startAppwriteMock({ schema: { auftraege: felder(AUFTRAEGE_COLLECTIONS.auftraege.attributes), projektRechnungen: felder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes), financeSettings: [...new Set([...schemaKeys('financeSettings'), ...felder(AUFTRAEGE_ZUSATZ_ATTRIBUTE.financeSettings)])], }, datetimes: { auftraege: datumsfelder(AUFTRAEGE_COLLECTIONS.auftraege.attributes), projektRechnungen: datumsfelder(AUFTRAEGE_COLLECTIONS.projektRechnungen.attributes), }, defaultLimit: 25, // wie Appwrite: ohne limit() kommen nur 25 Dokumente zurück (die Termine-Tests prüfen es ebenso) }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) const { config } = await import('../server/config.js') const { startTestApp, send, sessionCookie } = await import('./support/testApp.js') const { kundenansichtCookie } = await import('./support/fixtures.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { kundenansichtGuard } = await import('../server/middleware/kundenansichtGuard.js') const { invalidateFinanceCache, resetInvoiceCreateGuardForTests } = await import('../server/services/finance/stripeFinance.js') const { customerStripeInvoice } = await import('../server/services/customerInvoices.js') const { deleteDocument } = await import('../server/services/appwriteAdmin.js') const { addDays, berlinDate, formatDe } = await import('../server/services/finance/dates.js') const { withdrawalDeadline } = await import('../server/services/deadlines.js') const { AGB_FASSUNG, SCHALTER_AUS_MELDUNG } = await import('../server/services/auftraege/konstanten.js') const { formatNummer } = await import('../server/services/auftraege/nummern.js') const { hashToken } = await import('../server/services/auftraege/token.js') const { FRUEHBEGINN_TEXT } = await import('../server/services/auftraege/texte.js') const { default: adminProjektkontenRoutes } = await import('../server/routes/admin/projektkonten.js') const { default: adminAuftraegeRoutes } = await import('../server/routes/admin/auftraege.js') const { default: adminFinanzenRoutes } = await import('../server/routes/admin/finanzen.js') const { default: publicAuftragRoutes } = await import('../server/routes/auftrag.js') const { default: kundeAuftraegeRoutes } = await import('../server/routes/auftraegeKunde.js') const { default: aboRoutes, resetPublicRateLimit } = await import('../server/routes/abo.js') const { default: teamRoutes } = await import('../server/routes/team/index.js') // Eingebunden wie in server/index.js (V5 „Einbindung“): Wächter der Kundenansicht zuerst, spezifische Admin-Pfade vor dem Finanzen-Router const app = await startTestApp({ '/': kundenansichtGuard, '/api/admin/finanzen/projektkonten': adminProjektkontenRoutes, '/api/admin/finanzen/auftraege': adminAuftraegeRoutes, '/api/admin/finanzen': adminFinanzenRoutes, '/api/auftrag': publicAuftragRoutes, '/api/auftraege': kundeAuftraegeRoutes, '/api/abo': aboRoutes, '/api/team': teamRoutes, }) const mails = mailSink() setMailTransportForTests(mails) after(async () => { setMailTransportForTests(null) await Promise.all([app.close(), aw.close(), st.close()]) // nur der selbst angelegte Wegwerf-Ordner, nie ein Pfad von außen if (ABLAGE.startsWith(`${os.tmpdir()}${path.sep}auftraege-ablauf-`)) fs.rmSync(ABLAGE, { recursive: true, force: true }) }) beforeEach(() => { resetInvoiceCreateGuardForTests() // 30-Sekunden-Schutz gegen gleiche Rechnungen invalidateFinanceCache() // Stripe-Zwischenspeicher (60 s) resetPublicRateLimit() // Widerrufsseite: 10 Erklärungen je Stunde und Adresse }) // --- Hilfen -------------------------------------------------------------------------------------------------- const ADMIN = { Authorization: 'Bearer jwt-admin' } const STAFF = { Authorization: 'Bearer jwt-staff' } // Mitarbeiter ohne Admin-Label const KV = 'a'.repeat(32) // Kennung der Kundenansicht (Kopfzeile X-Kundenansicht) const UNBEKANNT = 'Dieser Link ist ungültig oder abgelaufen.' const HEUTE = berlinDate() const JAHR = Number(HEUTE.slice(0, 4)) const AB = () => config.collections.auftraege const PR = () => config.collections.projektRechnungen const ip = (n) => ({ 'x-forwarded-for': `203.0.113.${n}` }) // je Test eine eigene Client-Adresse (Ratenbegrenzung je Adresse) const sha256 = (daten) => crypto.createHash('sha256').update(daten).digest('hex') const pause = (ms) => new Promise((fertig) => setTimeout(fertig, ms)) const kundeCookie = (id) => sessionCookie({ customerId: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example` }) const admin = (methode, pfad, body, headers = {}) => send(methode, `/api/admin/finanzen${pfad}`, { body, headers: { ...ADMIN, ...headers } }) function erwarte(res, status, was, meldung) { assert.equal(res.status, status, `${was}: erwartet ${status}, bekam ${res.status} ${JSON.stringify(res.body)}`) if (meldung instanceof RegExp) assert.match(String(res.body?.error ?? ''), meldung, `${was}: Meldung`) else if (typeof meldung === 'string') assert.equal(res.body?.error, meldung, `${was}: Meldung`) return res.body } // Annahme (nicht im Vertrag): Erfolg heißt 200 oder 201 function erfolg(res, was) { assert.ok(res.status === 200 || res.status === 201, `${was}: erwartet 200 oder 201, bekam ${res.status} ${JSON.stringify(res.body)}`) return res.body } async function holePdf(pfad, { headers = {}, cookie } = {}) { const res = await fetch(app.url + pfad, { headers: { ...(cookie ? { cookie } : {}), ...headers } }) return { status: res.status, typ: res.headers.get('content-type') || '', bytes: Buffer.from(await res.arrayBuffer()) } } const istPdf = (bytes) => bytes.subarray(0, 5).toString('latin1') === '%PDF-' && bytes.length > 1500 && bytes.toString('latin1', Math.max(0, bytes.length - 64)).includes('%%EOF') const seiten = (bytes) => (bytes.toString('latin1').match(/\/Type \/Page(?![s\w])/g) || []).length const pdfDateien = () => (fs.existsSync(ABLAGE) ? fs.readdirSync(ABLAGE, { recursive: true }).filter((name) => String(name).endsWith('.pdf')) : []) const adressen = (feld) => (feld || []).map((eintrag) => eintrag.address) const findeMail = (vorher, muster) => mails.sent.slice(vorher).find((mail) => muster.test(mail.subject)) async function warteBis(pruefung, { ms = 5000, was = 'Bedingung' } = {}) { const ende = Date.now() + ms for (;;) { const wert = await pruefung() if (wert) return wert if (Date.now() > ende) assert.fail(`Zeitüberschreitung beim Warten auf: ${was}`) await pause(25) } } const GESEHEN = new Set() // alle Token aus Mails und Fehlversuchen: D16 sucht sie in Dokumenten und im Protokoll const TOKEN_IM_LINK = /\/auftrag\.html#t=([A-Za-z0-9_-]{43})(?![A-Za-z0-9_-])/ // V6: K31 verlinkt auftrag.html#t= (Fragment: landet in keinem Server- oder Proxy-Protokoll) function tokenAus(mail) { const treffer = String(mail.html).match(TOKEN_IM_LINK) || String(mail.text).match(TOKEN_IM_LINK) assert.ok(treffer, `kein Bestätigungslink in der Mail "${mail.subject}"`) GESEHEN.add(treffer[1]) return treffer[1] } // V6: K31 geht OHNE BCC an den Kunden (die Mail trägt den Bestätigungslink); das Team bekommt eine eigene Kopie ohne Link (Block teamkopie), // auch bei Erinnerungen. k31Mails trennt beide. const kundenAdresse = (kundenId) => `${kundenId}@kunde.example` function k31Mails(vorher, kundenId) { const alle = mails.sent.slice(vorher).filter((mail) => /Auftragsbestätigung AB-\d{4}-\d{4}/.test(mail.subject)) const anKunde = alle.filter((mail) => adressen(mail.to).includes(kundenAdresse(kundenId))) return { anKunde, teamkopien: alle.filter((mail) => !anKunde.includes(mail)) } } function pruefeTeamkopie(kundenMail, token, teamkopien, was) { assert.deepEqual(adressen(kundenMail.bcc), [], `${was}: K31 an den Kunden hat keine BCC-Kopie (V6)`) assert.ok(teamkopien.length >= 1, `${was}: das Team bekommt eine Kopie der K31`) const empfaenger = teamkopien.flatMap((mail) => adressen(mail.to)) for (const adresse of config.chatNotify.emails) assert.ok(empfaenger.includes(adresse), `${was}: Teamkopie an ${adresse}`) for (const kopie of teamkopien) { const inhalt = `${kopie.html}\n${kopie.text}` assert.equal(inhalt.includes(token), false, `${was}: die Teamkopie enthält den Token`) assert.equal(/auftrag\.html[?#]/.test(inhalt), false, `${was}: die Teamkopie enthält einen Bestätigungslink`) assert.match(kopie.text, /Kopie für das Team/, `${was}: Hinweis „Kopie für das Team“ statt des Knopfes`) } } const stripeAufrufe = (ab, methode, pfad) => st.calls.slice(ab).filter((aufruf) => aufruf.method === methode && (pfad instanceof RegExp ? pfad.test(aufruf.path) : aufruf.path === pfad)) const zeilen = (rechnungId) => st.invoices.get(rechnungId).lines.data function bezahlen(rechnungId) { const roh = st.invoices.get(rechnungId) assert.ok(roh, `Rechnung ${rechnungId} fehlt im Stripe-Nachbau`) st.invoices.set(rechnungId, { ...roh, status: 'paid', paid: true, amount_paid: roh.total, amount_remaining: 0, status_transitions: { ...roh.status_transitions, paid_at: Math.floor(Date.now() / 1000) }, }) invalidateFinanceCache() } async function rechnung(body, was) { return erfolg(await admin('POST', '/rechnungen', { daysUntilDue: 14, senden: false, ...body }), was).rechnung } async function konto(kundenId, projektId) { const body = erwarte(await admin('GET', `/projektkonten?kunde=${kundenId}`), 200, `Projektkonten von ${kundenId}`) const eintrag = body.konten.find((k) => k.projektId === projektId) assert.ok(eintrag, `Projekt ${projektId} fehlt in den Projektkonten von ${kundenId}`) return { body, konto: eintrag } } const AB_BASIS = { titel: 'Neue Website', art: 'festpreis', projektId: '', positionen: [{ beschreibung: 'Konzept und Gestaltung', menge: 1, einzelpreisCents: 50000 }], stundensatzCents: null, schaetzungMinuten: null, zahlungsplan: { art: 'nach_abnahme', prozent: null, text: '' }, zahlungszielTage: 14, auftragVom: addDays(HEUTE, -1), auftragWeg: 'telefon', startAm: addDays(HEUTE, 7), fertigBis: addDays(HEUTE, 40), zeitquelle: [], } async function entwurf(kundenId, ueberschreibe = {}) { const auftrag = erfolg(await admin('POST', '/auftraege', { ...AB_BASIS, kundenId, ...ueberschreibe }), `AB-Entwurf für ${kundenId}`).auftrag assert.equal(auftrag.status, 'entwurf') assert.equal(auftrag.nummer, '', 'Entwürfe haben keine Nummer') return auftrag } const lies = async (id) => erwarte(await admin('GET', `/auftraege/${id}`), 200, `AB ${id} lesen`).auftrag async function sende(id, was = 'AB senden') { const vorher = mails.sent.length const body = erwarte(await admin('POST', `/auftraege/${id}/senden`), 200, was) assert.equal(body.mailFehler, false, `${was}: Mail nicht zugestellt`) const { anKunde, teamkopien } = k31Mails(vorher, body.auftrag.kundenId) assert.equal(anKunde.length, 1, `${was}: genau eine K31 an den Kunden (${mails.sent.slice(vorher).map((m) => `${m.subject} an ${adressen(m.to)}`).join(' | ')})`) const token = tokenAus(anKunde[0]) pruefeTeamkopie(anKunde[0], token, teamkopien, was) return { auftrag: body.auftrag, mail: anKunde[0], token } } const ansicht = (token, n) => send('GET', `/api/auftrag/${token}`, { headers: ip(n) }) const bestaetigen = (token, n, body = { bestaetigt: true, fruehbeginn: false }, extra = {}) => send('POST', `/api/auftrag/${token}/bestaetigen`, { body, headers: { ...ip(n), ...(extra.headers || {}) }, cookie: extra.cookie }) const vergebeneNummern = () => [...aw.docs(AB()).values()].filter((d) => d.nummer).map((d) => d.nummer).sort() // --- Testdaten ----------------------------------------------------------------------------------------------- const ADRESSE = { street: 'Hauptstraße 1', postalCode: '10115', city: 'Berlin', country: 'Deutschland' } const kundendoc = (id, ueberschreibe = {}) => ({ $id: id, appwriteUserId: `u_${id}`, name: `Kontakt ${id}`, email: `${id}@kunde.example`, code: `K-${id}`, customerStatus: 'active', status: 'active', portalAccessEnabled: true, kundenart: 'unternehmen', ...ADRESSE, ...ueberschreibe, }) const KUNDEN = { kfirma: kundendoc('kfirma', { companyName: 'Beispiel GmbH', name: 'Anna Beispiel' }), kprivat: kundendoc('kprivat', { kundenart: 'privat', name: 'Petra Privat' }), kfremd: kundendoc('kfremd', { companyName: 'Fremd AG', name: 'Frank Fremd' }), kluecke: kundendoc('kluecke', { kundenart: '', name: 'Lena Lücke' }), } for (const doc of Object.values(KUNDEN)) aw.seed('customers', doc) const setzeKunde = (id, patch) => aw.seed('customers', { ...KUNDEN[id], ...patch }) function legeProjektAn(id, kundenId, ticketId, woid, name) { aw.seed('workorders', { $id: ticketId, woid, customerId: kundenId, topic: name, status: 'Open', type: 'Webpage' }) aw.seed('websiteProjects', { $id: id, customerId: kundenId, ticketId, projectName: name, projectType: 'website', status: 'active' }) } legeProjektAn('pfest', 'kfirma', 'tkfest', '100201', 'Website Beispiel') legeProjektAn('paufw', 'kfirma', 'tkaufw', '100202', 'Online-Shop Beispiel') legeProjektAn('pab', 'kfirma', 'tkab', '100203', 'Relaunch Größe & Maße') legeProjektAn('pstorno', 'kfirma', 'tkstorno', '100204', 'Landingpage Beispiel') legeProjektAn('pzuordnen', 'kfirma', 'tkzuordnen', '100205', 'Projekt zum Zuordnen') legeProjektAn('pfremd', 'kfremd', 'tkfremd', '100299', 'Fremdes Projekt') const blatt = (id, ticketId, ueberschreibe) => ({ $id: id, workorderId: ticketId, woid: ticketId === 'tkaufw' ? '100202' : '100299', serviceType: 'Remote', employeeName: 'Justin Klein', totalTime: 60, startDate: '06.10.2026', endDate: '06.10.2026', details: 'Arbeit', isComment: false, countsToPlan: false, ...ueberschreibe, }) const ANDREJ = [{ employeeId: 'andrej', name: 'Andrej Stevanovski', task: 'Farbkonzept' }] // Zeit des Projekts „Online-Shop“: 06.10. = 60 + 75 min, 07.10. = 50 → 60 min, 08.10. = 40 + 30 Unterstützung → 75 min; zusammen 270 min = 4,5 Std. aw.seed('worksheets', blatt('wsA', 'tkaufw', { totalTime: 60, details: 'Startseite' })) aw.seed('worksheets', blatt('wsB', 'tkaufw', { totalTime: 75, details: 'Kontaktformular' })) aw.seed('worksheets', blatt('wsC', 'tkaufw', { totalTime: 50, startDate: '07.10.2026', endDate: '07.10.2026', details: 'Impressum und Datenschutz' })) aw.seed('worksheets', blatt('wsD', 'tkaufw', { totalTime: 40, startDate: '08.10.2026', endDate: '08.10.2026', details: 'Farbkonzept', supporters: JSON.stringify(ANDREJ) })) // nie abrechenbar: Abo-Zeit, Kommentar, 0 Minuten; dazu ein Blatt eines anderen Kunden aw.seed('worksheets', blatt('wsAbo', 'tkaufw', { totalTime: 90, countsToPlan: true, details: 'Wartung im Abo' })) aw.seed('worksheets', blatt('wsKomm', 'tkaufw', { totalTime: 30, isComment: true, details: 'Interner Hinweis' })) aw.seed('worksheets', blatt('wsNull', 'tkaufw', { totalTime: 0, details: 'Telefonat ohne Zeit' })) aw.seed('worksheets', blatt('wsFremd', 'tkfremd', { totalTime: 60, details: 'Arbeit beim anderen Kunden' })) // --- D0: Einbindung, Seiten und Zugriffsschutz --------------------------------------------------------------- test('D0 Einbindung: server/index.js hängt die neuen Router ein (Admin-Pfade vor dem Finanzen-Router), das Team-Gerüst hängt hinter requireStaffJwt', () => { const index = fs.readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') const stelle = (pfad, was) => { const treffer = new RegExp(`app\\.use\\(\\s*['"]${pfad}['"]\\s*,\\s*\\w+\\s*\\)`).exec(index) assert.ok(treffer, `server/index.js: ${was} (${pfad}) fehlt`) return treffer.index } const finanzen = stelle('/api/admin/finanzen', 'Finanzen-Router') assert.ok(stelle('/api/admin/finanzen/projektkonten', 'Projektkonten') < finanzen, 'Projektkonten vor dem Finanzen-Router') assert.ok(stelle('/api/admin/finanzen/auftraege', 'Aufträge (Admin)') < finanzen, 'Aufträge vor dem Finanzen-Router') stelle('/api/auftrag', 'öffentliche Token-Routen') stelle('/api/auftraege', 'Kundenrouten') const team = fs.readFileSync(new URL('../server/routes/team/index.js', import.meta.url), 'utf8') const jwt = team.indexOf('router.use(requireStaffJwt)') assert.ok(jwt >= 0 && team.indexOf("router.use('/arbeitsblaetter'") > jwt, "Team: router.use('/arbeitsblaetter', …) steht hinter requireStaffJwt") }) test('D0 Seiten: die Bestätigungsseite ist noindex, die Widerrufsseite kennt den Projektauftrag', () => { const auftrag = fs.readFileSync(new URL('../public/auftrag.html', import.meta.url), 'utf8') assert.match(auftrag, /]*(?:name=["']robots["'][^>]*noindex|noindex[^>]*name=["']robots["'])/i, 'auftrag.html trägt noindex') const widerruf = fs.readFileSync(new URL('../public/widerrufen.html', import.meta.url), 'utf8') assert.match(widerruf, /value=["']projekt["']/, 'Vertragsart mit order=projekt') assert.match(widerruf, /Projektauftrag/, 'Beschriftung „Projektauftrag (Auftragsbestätigung)“') }) test('D0 Zugriff: ohne Anmeldung 401, Mitarbeiter ohne Admin-Label 403, Kunden-Cookie ersetzt kein JWT, unbekannte Token einheitlich 404', async () => { const adminRouten = [ ['GET', '/projektkonten?kunde=kfirma'], ['GET', '/projektkonten/pfest'], ['GET', '/auftraege?kunde=kfirma'], ['POST', '/auftraege'], ['GET', '/auftraege/x'], ['PATCH', '/auftraege/x'], ['DELETE', '/auftraege/x'], ['GET', '/auftraege/x/pdf'], ['POST', '/auftraege/x/senden'], ['POST', '/auftraege/x/erinnern'], ['POST', '/auftraege/x/neue-fassung'], ['POST', '/auftraege/x/stornieren'], ['POST', '/auftraege/x/projekt'], ['POST', '/rechnungen/vorschau'], ] for (const [methode, pfad] of adminRouten) { const body = methode === 'GET' || methode === 'DELETE' ? undefined : {} const was = `${methode} ${pfad}` erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body }), 401, `${was} ohne JWT`) erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, cookie: kundeCookie('kfirma') }), 401, `${was} mit Kunden-Cookie`) erwarte(await send(methode, `/api/admin/finanzen${pfad}`, { body, headers: STAFF }), 403, `${was} als Mitarbeiter ohne Admin-Label`) } const abgerechnet = '/api/team/arbeitsblaetter/abgerechnet?ticketId=tkaufw' erwarte(await send('GET', abgerechnet), 401, 'Team-Route ohne JWT') erwarte(await send('GET', abgerechnet, { cookie: kundeCookie('kfirma') }), 401, 'Team-Route mit Kunden-Cookie') erwarte(await send('GET', abgerechnet, { headers: { Authorization: 'Bearer jwt-kunde' } }), 403, 'Team-Route mit Kundenkonto ohne Label') assert.deepEqual(Object.keys(erwarte(await send('GET', abgerechnet, { headers: STAFF }), 200, 'Team-Route als Mitarbeiter')), ['abgerechnet']) for (const [methode, pfad] of [['GET', '/api/auftraege'], ['GET', '/api/auftraege/x/pdf'], ['POST', '/api/auftraege/x/bestaetigen']]) { erwarte(await send(methode, pfad, { body: methode === 'POST' ? { bestaetigt: true } : undefined }), 401, `${methode} ${pfad} ohne Sitzung`) } for (const token of ['x', 'kurz', 'A'.repeat(43), 'A'.repeat(44), `${'A'.repeat(42)}%2F`]) { erwarte(await ansicht(token, 1), 404, `Ansicht mit Token ${token}`, UNBEKANNT) erwarte(await send('GET', `/api/auftrag/${token}/pdf`, { headers: ip(1) }), 404, `PDF mit Token ${token}`, UNBEKANNT) erwarte(await bestaetigen(token, 2, { bestaetigt: true }), 404, `Bestätigen mit Token ${token}`, UNBEKANNT) } }) // --- D1: Einstellungen --------------------------------------------------------------------------------------- test('D1 Einstellungen: die Steuernummer wird geprüft und gespeichert (sie steht später auf dem AB-PDF)', async () => { assert.equal(erfolg(await admin('GET', '/einstellungen'), 'Einstellungen lesen').einstellungen.steuernummer, '') // V4: erlaubt sind Buchstaben, Ziffern, Leerzeichen, / - und . (auch eine USt-IdNr. „DE…“), höchstens 40 Zeichen for (const falsch of ['12/345/67890