Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-16-kundenansicht-11-umschalten-aufraeumen.md

1096 lines
110 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Kundenansicht neu, P10 Umschalten und Aufräumen: Implementation Plan
> **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:** Alle Kunden bekommen die neue Kundenansicht (Schalter `on`), Fassung 6 wird die geltende Abo-Fassung, und die alte Oberfläche samt Kaufwegen für Funktionen verschwindet, ohne dass Bestand (Webhooks, Rechnungen, Widerruf) leidet.
**Architecture:**
- **Teil A (Tasks 1 bis 6, Code):** alles, was das Umschalten braucht, hinter dem Schalter. Ein Bestandsskript für die Kundenart (E18), eine Weiche, die erst bei `on` die öffentlichen Seiten, das Manifest, `/dashboard.html` ohne Sitzung und die Fassung-6-Adresse neu ausliefert, zwei lesende Prüfskripte und eine Checkliste. Bis zum Umschalten verhält sich alles wie heute.
- **Teil B (Tasks 7 bis 13, live):** Auslieferung, Kundenart setzen, Vorflug, Schalter `on`, Nachprüfung, Offline-Cache. Jeder Live-Schritt ist ein eigener Task mit „Stop: Inhaber-OK nötig“. **Rückweg bis Task 13: `KUNDENANSICHT_NEU=off`.**
- **Teil C (Tasks 14 bis 25):** Fassung 6 festschreiben, Login von `app.js` lösen, alte Oberfläche löschen, Kaufwege zurückbauen, am Ende den Schalter vereinfachen. **Ab Task 14 gibt es keinen Rückweg per Schalter**, Fehler werden vorwärts korrigiert. Grund: `off` würde die alten Bestellseiten (Wortlaut Fassung 5) mit Fassung 6 als einziger geltender Fassung kombinieren.
**Tech Stack:** Node 20 (Prod-Container) und 24 (Host), Express 4, Appwrite-REST (`server/services/appwriteAdmin.js`), `node --test` mit `appwriteMock`, `testApp`, `fakeStripe`; Vanilla-ES-Module ohne Build; Sichtprüfung nur über das Werkzeug aus P0; Docker Compose auf dem Host für den Schalter.
**Spec:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (5.10 „Umschalten für alle“, E18, E19, E20, C8, C11, C14)
**Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md` (gilt wörtlich)
**Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht`, Branch `feature/kundenansicht-neu` (Hauptzweig, erst nach Phase E und ausdrücklichem OK des Inhabers). Zwischentags `kn-p10-a`, `kn-p10-b`, `kn-p10-c1` bis `kn-p10-c4`, Endtag `kn-p10`.
Kürzel: `KW` = `/root/reports/kundenansicht-neu-2026-10-15/werkzeug`, `RB` = `/root/reports/kundenansicht-neu-2026-10-15/umschalten` (Protokolle), `APPS` = `/home/webklar/apps`. Container `webklar-kundenbereich-server`, Live-Adresse `https://project.webklar.com`. Befehle nennen den Worktree-Pfad ausgeschrieben.
## Global Constraints
Alle Global Constraints der Übersicht gelten wörtlich (alte Ansicht bis zum Umschalten unverändert, Schalter `off | pilot | on`, Anrede „du“ in neuen Oberflächentexten, Hashes, rechtlich fest, Kundenart nur vom Server, Testbefehle mit Speicherbremse, Playwright nur über das Werkzeug, Commits mit Dateien einzeln und `Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>` als zweitem `-m`, Sicherheitsblock, kein Push, nichts in `build/`). Paketeigene Zusätze:
- **Live-Schritte** (Push, Skripte gegen Produktion, `.env`, Compose, Dienst neu starten) tragen den Satz „Stop: Inhaber-OK nötig“. Ein Agent führt sie nie von sich aus aus, er meldet dem Controller, was bereitliegt. Pushen tut nur der Inhaber: `git push origin '<tag>^{commit}:refs/heads/main'`, nur fast-forward, nie `--force`.
- **Geheimnisse:** `APPS/.env` wird nie ausgegeben. Erlaubt sind nur `command grep -a -n '^<SCHLÜSSEL>=' APPS/.env` für `KUNDENANSICHT_NEU`, `KUNDENANSICHT_NEU_PILOT_EMAILS`, `ABO_PILOT_EMAILS` und die `*_ENABLED`-Schalter sowie `sed -i` auf genau eine Zeile. Vor jeder Änderung an `.env` oder `docker-compose.yml` entsteht eine Sicherung `<Datei>.bak-<JJJJ-MM-TT>-kundenansicht`.
- **Dienst neu starten** nur mit `cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server`. Nie `preview-portal`, nie andere Dienste, nie ohne `--no-deps`.
- **Löschen** nur mit `git rm <Datei>` für Dateien, die der Plan einzeln nennt. Kein `rm -rf`.
- **Rechtstexte** ändert dieses Paket nie inhaltlich. Schlägt ein Test auf den Wortlaut der Fassung 6 fehl, stoppt der Agent und meldet die Assertion dem Controller (der Inhaber entscheidet).
- **Tests löschen** nur, wenn ihr Gegenstand verschwindet (Datei, Zweig, Schalter). Der Commit nennt Test und Grund. Was weiter gilt, wird umgestellt.
- **Skriptausgaben** nennen nur IDs und Zahlen, nie Namen, Adressen oder Beträge einzelner Kunden.
- **Schwere Läufe:** die ganze Suite nur in Task 1 und Task 25 (vorher `free -m`, nachher `ls -l /dev/null`). Dazwischen Einzeldateien, mehrere nur mit `--test-concurrency=1`.
## Review Focus
1. **Weiche liefert zur falschen Zeit oder gar nichts:** In `off` und `pilot` (auch mit `*`) darf sich keine öffentliche Seite ändern. In `on` bekommt auch ein Besucher ohne Sitzung die neue Seite (sonst blitzt `/dashboard.html` alt und dunkel auf, die PWA startet dort). Fehlt eine neue Datei, fällt die Weiche auf die alte zurück statt 500. Die Fassung-6-Adresse verlässt `public/` nie. Tests: Task 3, 4.
2. **Kundenart-Skript überschreibt Falsches:** bereits gesetztes `privat`, unbekannte Werte, Leerzeichen, ein zwischen Prüfen und Schreiben gesetzter Wert, falsche Bestätigungszahl, zweiter Lauf, Schreibfehler mittendrin. Erwartet: nur leere Felder werden `unternehmen`, nichts ohne passende Bestätigungszeile, keine Personendaten in der Ausgabe. Test: Task 2.
3. **Festschreiben macht Fassung 6 tot oder Altverträge unauffindbar:** Bei gleichen Werten von `termsVersion` und `termsVersionNeu` meldet eine „alt zuerst“-Prüfung (`fassungDerAnfrage`) immer `alt`, und der Weg der Fassung 6 läuft nie. Das Archiv v5 weicht von der geprüften Seite ab, das PDF für die Bestätigungsmail fehlt. Test: Task 14.
4. **Löschen reißt Gebrauchtes mit:** `app.js` trägt Login, Service-Worker-Registrierung, iOS-Fokuszoom und den Hash-Pin der Gutschrift-Bedingungen. Zwei CSS-Dateien lädt `auftrag.html` bzw. `analyse.js` noch. Die öffentlichen Seiten verschwinden nicht, wenn jemand den Schalter zurückstellt. Tests: Task 16, 17.
5. **Rückbau trifft den Bestand:** Zahlung einer vor dem Umschalten gestarteten Bestellung (SEPA braucht Tage), Widerruf gekaufter Funktionen innerhalb der Frist, Rechnungen. Erwartet: Webhook und Widerruf funktionieren weiter, neue Käufe sind 404. Tests: Task 5, 19, 20.
## Dateiübersicht
Besitz laut Zuständigkeitstabelle: `scripts/kundenart-bestand.mjs` und „Umschalten, Aufräumen alter Dateien“. Was darüber hinausgeht, trägt `VF` mit der Nummer der Vertragsfrage am Ende.
| Datei | Aufgabe | Task |
|---|---|---|
| `scripts/kundenart-bestand.mjs`, `test/kundenartBestand.test.js` | E18: Kundenart der Bestandseinträge | 2 |
| `server/routes/umschaltSeiten.js`, `test/knUmschaltSeiten.test.js`, `server/index.js` (eine Zeile direkt vor `app.use(express.static(publicDir))`, geteilt mit P0) (VF1) | Weiche | 3 |
| `scripts/umschalten-nachpruefung.mjs`, `test/knUmschaltNachpruefung.test.js` (VF1) | HTTP-Nachprüfung alt/neu | 4 |
| `scripts/kauf-bestand-pruefen.mjs`, `test/kaufBestandPruefen.test.js` (VF1) | Käufe lesen (nur lesend) | 5 |
| `docs/umschalten-kundenansicht.md`, `test/knUmschaltCheckliste.test.js` (VF1) | Checkliste in der Reihenfolge der Spec 5.10 | 6 |
| `public/sw.js:5` (`CACHE`), `test/knOfflineCache.test.js` | Offline-Seite in installierten Apps erneuern | 12 |
| `public/abo-bedingungen.html`, `public/WEBklar-Abo-Vertragsunterlagen.pdf` (+ `.sha256`; werden Fassung 6, verschoben aus `public/neu/rechtliches/`), `public/vertragsunterlagen/v5/*` (nur geprüft), `server/config.js:855`, `server/services/products.js` (Zeilen v6 und v5), `server/services/vertragsNachweis.js` (`fassungDerAnfrage`), `scripts/build-legal-pdfs.sh` (Modus `abo6`), `scripts/rechtliches-hochladen.mjs` (`DOKUMENTE_V6`), `test/knFassung6Fest.test.js` (VF3, VF4) | Fassung 6 festschreiben | 14 |
| Pins: `test/legalTexts.test.js:43,104-121`, `test/products.test.js:120,190`, `test/aboRoutes.test.js:87`, P8s `test/aboBedingungenV6.test.js`, `legalV6Build.test.js`, `rechtlichesHochladen.test.js` | Fassung 6 | 14 |
| `public/neu/oeffentlich/login.js` (neu), `public/neu/oeffentlich/login.html`, `test/knLoginModul.test.js` (VF5) | Login löst sich von `app.js` | 16 |
| gelöscht: `public/dashboard.html`, `app.js`, `login.html`, `kuendigen.html`, `widerrufen.html`, `offline.html`, `manifest.webmanifest`, `kundenbereich.css`, `login.css`, `legal-form.css`, `termine.css`, `shop.css`, `buchungen.css`, `zeiten.css`, `funktionen.css`, `analyse.css`; `test/knAufraeumen.test.js` (neu) | alte Oberfläche | 17 |
| zwölf Pin-Zeilen mit Datei:Zeile (Tabelle in Task 17) | Pins an der alten Oberfläche | 17 |
| `server/routes/funktionen.js:292-385,436`, `server/services/stripeBilling.js:666`, `server/services/siteFeatures.js:297`, `server/services/zeiten/mails.js` (`k29Mail`, `k30Mail`), `server/services/zeiten/aboAbgleich.js`, `test/knKaufRueckbau.test.js` | Kaufpfade zurückbauen | 20 |
| `public/termine.js`, `termine-fragebogen.js`, `shop.js`, `shop-anfrage.js`, `buchungen.js`, `buchungen-anfrage.js`, `zeiten.js`, `zeiten-bestellen.js`, `funktionen.js`, `analyse.js`, `funktion-bestellen.js`, `test/knAltZweigeWeg.test.js` (VF6) | Alt-Zweige der Module | 21 |
| `server/services/kundenansicht/schalter.js`, `server/config.js` (Abschnitt `kundenansichtNeu`), `server/index.js` (toter Handler), `test/knSchalterWeg.test.js` | Schalter vereinfachen | 23 |
---
### Task 1: Vorbedingungen und Basislinie
**Files:** keine Änderung, kein Commit.
**Interfaces:**
- Consumes: Hauptzweig nach Phase E mit P0 bis P8 gemergt (Tags `kn-p0` bis `kn-p8`, je mit `-haupt`); WOMS-Tag `kn-p9` im Repo `/home/webklar/apps/ticket.webklar.com/build`; die Verträge V1, V4, V12. Der Inhaber hat Vorschau, Pilot und Fassung 6 freigegeben und ausdrücklich „umschalten“ gesagt (Übersicht, Phase F).
- Produces: die Zahl `N0` (grüne Tests der ganzen Suite) als Vergleich für Task 25.
- [ ] **Step 1: Zustand prüfen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git branch --show-current && git status --short && git tag --list 'kn-p*'`
Expected: `feature/kundenansicht-neu`, Status leer (außer untracked Plan-Dateien unter `docs/superpowers/plans/`), Tags `kn-p0` bis `kn-p8`.
- [ ] **Step 2: Dateien der Verträge vorhanden**
Run:
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
ls server/services/kundenansicht/schalter.js server/services/kundenansicht/dashboardWeiche.js server/services/kundenart.js server/services/vertragsNachweis.js server/services/bestelltexte.js scripts/kundenansicht-setup.mjs scripts/rechtliches-hochladen.mjs public/neu/dashboard.html public/neu/manifest.webmanifest public/neu/oeffentlich/login.html public/neu/oeffentlich/kuendigen.html public/neu/oeffentlich/widerrufen.html public/neu/oeffentlich/offline.html public/vertragsunterlagen/v5/abo-bedingungen.html public/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf public/neu/rechtliches/abo-bedingungen.html public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf.sha256
command grep -a -n "termsVersionNeu" server/config.js
command grep -a -n "FASSUNGEN" server/services/products.js
command grep -a -n "WORTLAUT_STATUS" server/services/bestelltexte.js
command grep -a -c "Entwurf – Prüfung durch Kanzlei offen" public/neu/rechtliches/abo-bedingungen.html
```
Expected: `ls` ohne Fehler, die drei `grep` mit Treffern, `WORTLAUT_STATUS` ist `'freigegeben'` (Wortlaut vom Inhaber abgenommen, E20; P7 VF9), die Zahl der Entwurfsmarken ist `0` (P8 hat die Marken nach der Freigabe entfernt und die PDFs gebaut). Fehlt etwas oder steht noch `entwurf` da, stoppt der Agent und meldet es (VF3, VF4), er baut nichts nach. Die Fassung-6-Seite liegt laut P8 unter `public/neu/rechtliches/abo-bedingungen.html` mit dem PDF daneben.
- [ ] **Step 3: Basislinie der ganzen Suite**
Run: `free -m` (mindestens 2000 MB verfügbar, sonst warten und melden), dann `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 2400 nice -n 10 systemd-run --scope -q -p MemoryMax=2500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 node --test --test-concurrency=2 test/*.test.js`
Expected: `fail 0`. Notiere `pass` als `N0`. Danach `ls -l /dev/null` mit `crw-rw-rw-`.
---
### Task 2: Kundenart der Bestandseinträge (`scripts/kundenart-bestand.mjs`, E18)
**Files:**
- Create: `scripts/kundenart-bestand.mjs`
- Test: `test/kundenartBestand.test.js`
**Interfaces:**
- Consumes: `listAllDocuments(collectionId)`, `getDocument(collectionId, id)`, `updateDocument(collectionId, id, data)` aus `server/services/appwriteAdmin.js`; `config.collections.customers`; `KUNDENARTEN` (V4) aus `server/services/kundenart.js`.
- Produces:
```js
export function parseArgs(argv) // { modus: 'pruefen' } | { modus: 'ausfuehren', bestaetigung }; sonst Error "Aufruf: …"
export function bestaetigungsZeile(anzahl) // `Kundenart unternehmen setzen: ${anzahl}`
export async function pruefen({ log }) // { gesamt, mitKundenart: { unternehmen, privat }, ohne: string[], ohneNachStatus: { [customerStatus]: string[] }, unbekannt: string[] }, nur IDs
export async function ausfuehren({ bestaetigung, log }) // { gesetzt, uebersprungen, fehler: [{ id, grund }] }
```
- [ ] **Step 1: Failing test schreiben** (`test/kundenartBestand.test.js`; Kopf wie `test/featureConsentFelder.test.js:1-12`, aber mit `startAppwriteMock({ schema: { customers: ['name', 'email', 'companyName', 'customerStatus', 'kundenart'] } })`)
```js
const { parseArgs, bestaetigungsZeile, pruefen, ausfuehren } = await import('../scripts/kundenart-bestand.mjs')
const kunde = (id, customerStatus, kundenart) =>
aw.seed('customers', { $id: id, name: `Name ${id}`, email: `${id}@example.invalid`, companyName: `Firma ${id}`, customerStatus, ...(kundenart === undefined ? {} : { kundenart }) })
kunde('k_leer', 'customer', ''); kunde('k_fehlt', 'lead'); kunde('k_blank', 'lost', ' '); kunde('k_archiv', 'archived', '')
kunde('k_unt', 'customer', 'unternehmen'); kunde('k_priv', 'lead', 'privat'); kunde('k_fremd', 'customer', 'firma')
const OHNE = ['k_archiv', 'k_blank', 'k_fehlt', 'k_leer']
const stand = () => JSON.stringify([...aw.docs('customers')])
const sammler = () => { const zeilen = []; return { log: (z) => zeilen.push(z), text: () => zeilen.join('\n') } }
test('parseArgs: nur --pruefen oder --ausfuehren mit Bestätigung, alles andere wirft', () => {
assert.deepEqual(parseArgs(['--pruefen']), { modus: 'pruefen' })
assert.deepEqual(parseArgs(['--ausfuehren', '--bestaetigung=Kundenart unternehmen setzen: 4']), { modus: 'ausfuehren', bestaetigung: 'Kundenart unternehmen setzen: 4' })
for (const schlecht of [[], ['--ausfuehren'], ['--pruefen', '--ausfuehren'], ['--force'], ['--pruefen', '--bestaetigung=x']]) assert.throws(() => parseArgs(schlecht), /Aufruf/)
assert.equal(bestaetigungsZeile(42), 'Kundenart unternehmen setzen: 42')
})
test('pruefen zählt und nennt IDs, schreibt nichts, gibt keine Personendaten aus', async () => {
const vorher = stand(); const out = sammler()
const e = await pruefen({ log: out.log })
assert.deepEqual([e.gesamt, e.mitKundenart, [...e.ohne].sort(), e.unbekannt], [7, { unternehmen: 1, privat: 1 }, OHNE, ['k_fremd']])
assert.deepEqual(e.ohneNachStatus, { customer: ['k_leer'], lead: ['k_fehlt'], lost: ['k_blank'], archived: ['k_archiv'] })
assert.equal(stand(), vorher)
assert.match(out.text(), /Ohne Kundenart: 4/); assert.match(out.text(), /Unbekannte Werte: 1/); assert.match(out.text(), /Kundenart unternehmen setzen: 4/)
assert.doesNotMatch(out.text(), /Name |Firma |@example/)
})
test('ausfuehren ohne passende Bestätigungszeile schreibt nichts', async () => {
const vorher = stand()
for (const b of ['', 'ja', 'Kundenart unternehmen setzen: 3', 'Kundenart unternehmen setzen: 5', 'kundenart unternehmen setzen: 4']) await assert.rejects(ausfuehren({ bestaetigung: b, log: () => {} }), /Kundenart unternehmen setzen: 4/, b)
assert.equal(stand(), vorher)
})
test('ausfuehren setzt nur leere Felder, lässt alles andere stehen und ist wiederholbar', async () => {
const out = sammler()
const e = await ausfuehren({ bestaetigung: bestaetigungsZeile(4), log: out.log })
assert.deepEqual([e.gesetzt, e.uebersprungen, e.fehler], [4, 0, []])
const art = (id) => aw.docs('customers').get(id).kundenart
assert.deepEqual(OHNE.map(art), Array(4).fill('unternehmen')); assert.deepEqual(['k_unt', 'k_priv', 'k_fremd'].map(art), ['unternehmen', 'privat', 'firma'])
assert.equal(aw.docs('customers').get('k_leer').name, 'Name k_leer')
for (const id of OHNE) assert.match(out.text(), new RegExp(`\\t${id}\\t`)); assert.doesNotMatch(out.text(), /Name |Firma |@example/)
assert.equal((await ausfuehren({ bestaetigung: bestaetigungsZeile(0), log: () => {} })).gesetzt, 0)
})
test('Wert, der zwischen Prüfen und Schreiben gesetzt wurde, bleibt', async () => {
kunde('k_r1', 'lead', ''); kunde('k_r2', 'lead', '')
const e = await ausfuehren({ bestaetigung: bestaetigungsZeile(2), log: (z) => { if (/^Schreibe 2 /.test(z)) kunde('k_r1', 'lead', 'privat') } })
assert.deepEqual([e.gesetzt, e.uebersprungen], [1, 1])
assert.deepEqual(['k_r1', 'k_r2'].map((id) => aw.docs('customers').get(id).kundenart), ['privat', 'unternehmen'])
})
test('Schreibfehler stehen im Ergebnis, die übrigen Einträge werden trotzdem gesetzt', async () => {
kunde('k_e1', 'lead', ''); kunde('k_e2', 'lead', '')
aw.failNext({ collection: 'customers', method: 'PATCH', status: 500, message: 'kaputt' })
const e = await ausfuehren({ bestaetigung: bestaetigungsZeile(2), log: () => {} })
assert.deepEqual([e.gesetzt, e.fehler.length], [1, 1]); assert.match(e.fehler[0].grund, /kaputt/)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenartBestand.test.js`
Expected: FAIL mit `Cannot find module` auf `kundenart-bestand.mjs`.
- [ ] **Step 3: Implementieren** in `scripts/kundenart-bestand.mjs` (Vorlage für Aufbau und Einstieg: `scripts/feature-consent-felder-leeren.mjs`)
Regeln, die der Test nicht allein bestimmt:
1. Leer heißt `String(doc.kundenart ?? '').trim() === ''`, gültig ist ein Wert aus `KUNDENARTEN`, alles andere ist `unbekannt` und wird nie geschrieben.
2. `ausfuehren` ruft `pruefen`. Bei 0 leeren Einträgen kehrt es sofort zurück. Sonst muss `bestaetigung` exakt `bestaetigungsZeile(ohne.length)` sein, sonst `Error` mit dieser Zeile in der Meldung, vor jedem Schreiben.
3. Dann `log('Schreibe ${n} Einträge …')`, je ID `getDocument` neu lesen, nur bei noch leerem Feld `updateDocument(config.collections.customers, id, { kundenart: 'unternehmen' })` mit genau diesem einen Feld, sonst `uebersprungen`. Ein Schreibfehler landet in `fehler`, die Schleife läuft weiter.
4. Protokollzeile je Eintrag `<ISO-Zeit>\t<id>\t<vorher>\t->\tunternehmen`, am Ende `Summe gesetzt=… uebersprungen=… fehler=…`. Die Protokolldatei entsteht per `tee` nach `RB` (Task 8), nicht im Container.
5. `pruefen` druckt `Einträge gesamt: n`, `Ohne Kundenart: n`, `Unbekannte Werte: n`, je `customerStatus` eine Zeile `<status>: <ids>` und zuletzt die Bestätigungszeile `Kundenart unternehmen setzen: n` (Task 8 liest diese Zeilen).
6. Einstieg nur bei direktem Start. Exit 0 bei Erfolg, 1 bei Schreibfehlern, 2 bei falschem Aufruf oder falscher Bestätigung.
- [ ] **Step 4: Lauf grün**
Run: wie Step 2. Expected: PASS, 6 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add scripts/kundenart-bestand.mjs test/kundenartBestand.test.js
git commit -m "Kundenansicht neu P10: Skript setzt die Kundenart der Bestandseinträge auf Unternehmen (E18)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 3: Umschalt-Weiche (`server/routes/umschaltSeiten.js`)
**Files:**
- Create: `server/routes/umschaltSeiten.js`
- Modify: `server/index.js` (Import und eine Zeile `app.use(umschaltSeitenRouter({ publicDir }))` hinter P0s zwei Weichen-Zeilen und vor `app.use(express.static(publicDir))`)
- Test: `test/knUmschaltSeiten.test.js`
**Interfaces:**
- Consumes: `neueAnsichtModus(): 'off'|'pilot'|'on'` (V1), `termsDocFor(version)` aus `server/services/products.js` (liefert `{ url, pdfFile }`), `config.abo.termsVersionNeu`.
- Produces:
```js
export const UMSCHALT_SEITEN = Object.freeze({ // Adresse → Datei unter public/
'/dashboard.html': 'neu/dashboard.html', // greift nur, wo P0s Weiche nicht gegriffen hat, also ohne Sitzung
'/login.html': 'neu/oeffentlich/login.html',
'/kuendigen.html': 'neu/oeffentlich/kuendigen.html',
'/widerrufen.html': 'neu/oeffentlich/widerrufen.html',
'/offline.html': 'neu/oeffentlich/offline.html',
'/manifest.webmanifest': 'neu/manifest.webmanifest',
})
export function umschaltSeitenRouter({ publicDir, modus = neueAnsichtModus, fassung6 = fassung6Aus }): express.Router
// fassung6Aus() = config.abo.termsVersionNeu ? termsDocFor(config.abo.termsVersionNeu) : null → { url, pdfFile } | null
```
Regeln: nur GET und HEAD. Ist `modus() !== 'on'`, ruft jede Zeile `next()`. Bei `on` liefert `res.sendFile` die Datei mit `Cache-Control: no-cache`. Zusätzliche Zeilen, nur bei `on` und gesetztem `fassung6()`: `/abo-bedingungen.html` liefert `fassung6().url`, `/WEBklar-Abo-Vertragsunterlagen.pdf` liefert `fassung6().pdfFile`. Grund: Stripe-Checkout und die neuen Seiten verlinken `/abo-bedingungen.html` (`stripeBilling.js:148,162`) und müssen im Fassung-6-Weg deren Wortlaut zeigen. Jeder Pfad wird gegen `publicDir` geschützt (aufgelöster Pfad muss darunter liegen). Fehlt die Datei (`ENOENT`), ruft die Zeile `next()`.
- [ ] **Step 1: Failing test schreiben** (`test/knUmschaltSeiten.test.js`; Kopf wie `test/kundenansichtEnde.test.js:1-19` für Mock, Stripe, `useTestEnv`; dann dynamisch `startTestApp`, `umschaltSeitenRouter`, `UMSCHALT_SEITEN`; dazu `express`, `node:fs`, `node:os`, `node:path`, `node:url`)
```js
const publicDir = fileURLToPath(new URL('../public/', import.meta.url))
let modus = 'off'; let fassung6 = null
const router = express.Router()
router.use(umschaltSeitenRouter({ publicDir, modus: () => modus, fassung6: () => fassung6 }))
router.use(express.static(publicDir))
const app = await startTestApp({ '/': router })
const holen = async (pfad, basis = app.url) => { const r = await fetch(basis + pfad, { redirect: 'manual' }); return { status: r.status, bytes: Buffer.from(await r.arrayBuffer()), headers: r.headers } }
const datei = (rel) => readFileSync(path.join(publicDir, rel))
for (const [pfad, neu] of Object.entries(UMSCHALT_SEITEN)) test(`${pfad}: off und pilot liefern die Datei von heute, on die neue (no-cache)`, async () => {
for (const m of ['off', 'pilot']) { modus = m; const r = await holen(pfad); assert.ok(r.status === 200 && r.bytes.equals(datei(pfad.slice(1))), `${m} ${pfad}`) }
modus = 'on'; const r = await holen(pfad)
assert.ok(r.status === 200 && r.bytes.equals(datei(neu)), pfad); assert.equal(r.headers.get('cache-control'), 'no-cache')
})
test('Manifest bei on ist JSON', async () => { modus = 'on'; assert.match((await holen('/manifest.webmanifest')).headers.get('content-type'), /json/) })
test('Fassung 6: Seite und PDF kommen bei on aus der Neu-Fassung, sonst bleibt die Datei von heute', async () => {
fassung6 = { url: '/neu/oeffentlich/offline.html', pdfFile: 'WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf' } // Stellvertreter-Dateien
modus = 'pilot'; assert.ok((await holen('/abo-bedingungen.html')).bytes.equals(datei('abo-bedingungen.html')))
modus = 'on'
assert.ok((await holen('/abo-bedingungen.html')).bytes.equals(datei('neu/oeffentlich/offline.html')))
assert.ok((await holen('/WEBklar-Abo-Vertragsunterlagen.pdf')).bytes.equals(datei('WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf')))
fassung6 = null; assert.ok((await holen('/abo-bedingungen.html')).bytes.equals(datei('abo-bedingungen.html')))
})
test('Pfadwächter: eine Adresse außerhalb von public/ wird nie geliefert', async () => {
modus = 'on'; fassung6 = { url: '/../../package.json', pdfFile: '../../server/config.js' }
for (const p of ['/abo-bedingungen.html', '/WEBklar-Abo-Vertragsunterlagen.pdf']) assert.ok((await holen(p)).bytes.equals(datei(p.slice(1))), p)
})
test('fehlende neue Datei: Rückfall auf die alte statt 500', async () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'kn-weiche-')); writeFileSync(path.join(tmp, 'login.html'), 'ALT')
const a2 = express(); a2.use(umschaltSeitenRouter({ publicDir: tmp, modus: () => 'on', fassung6: () => null })); a2.use(express.static(tmp))
const srv = await new Promise((res) => { const s = a2.listen(0, '127.0.0.1', () => res(s)) })
try { const r = await holen('/login.html', `http://127.0.0.1:${srv.address().port}`); assert.deepEqual([r.status, r.bytes.toString()], [200, 'ALT']) } finally { srv.close() }
})
test('server/index.js hängt die Weiche hinter P0s Weichen und vor express.static ein', () => {
const index = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8')
const reihe = ["app.get('/dashboard.html', dashboardWeiche(", 'app.use(umschaltSeitenRouter(', 'app.use(express.static(publicDir))'].map((s) => index.indexOf(s))
assert.ok(reihe[0] > -1 && reihe.every((n, i) => i === 0 || n > reihe[i - 1]), reihe.join(','))
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knUmschaltSeiten.test.js`
Expected: FAIL mit `Cannot find module` auf `umschaltSeiten.js`.
- [ ] **Step 3: Implementieren** nach den Regeln oben. Eine Hilfsfunktion `unterPublic(publicDir, rel): string | null` löst `'.' + path.posix.normalize('/' + rel)` gegen `publicDir` auf und gibt `null` zurück, wenn das Ergebnis nicht unter `publicDir` liegt.
- [ ] **Step 4: Lauf grün, dazu die Pins am Einhängen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knUmschaltSeiten.test.js test/kundenansichtDashboard.test.js test/adminPanelGone.test.js test/legalDocs.test.js`
Expected: PASS, `fail 0`.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/routes/umschaltSeiten.js server/index.js test/knUmschaltSeiten.test.js
git commit -m "Kundenansicht neu P10: Umschalt-Weiche liefert öffentliche Seiten, Manifest und Fassung-6-Adresse bei on" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 4: Nachprüf-Skript (`scripts/umschalten-nachpruefung.mjs`)
**Files:**
- Create: `scripts/umschalten-nachpruefung.mjs`
- Test: `test/knUmschaltNachpruefung.test.js`
**Interfaces:**
- Consumes: nur HTTP (`fetch`), keine Cookies, kein Appwrite.
- Produces:
```js
export const PRUEFUNGEN = { neu: Pruefung[], alt: Pruefung[] } // Pruefung = { pfad, typ: 'html'|'json'|'pdf', enthaelt: string[], enthaeltNicht: string[] }
export function parseArgs(argv) // [basis, '--erwartet=neu'|'--erwartet=alt'] → { basis, erwartet }; basis nur http(s)://host[:port] ohne Pfad, sonst Error "Aufruf: …"
export async function nachpruefen(basis, erwartet, { fetchFn = fetch, ohne = [] } = {}) // → { ok, zeilen: [{ pfad, ok, grund }] }
```
Marker (fest vorgegeben; jede Zeile verlangt Status 200):
| Pfad | `neu`: enthält | `neu`: enthält nicht | `alt`: enthält |
|---|---|---|---|
| `/dashboard.html` (ohne Cookie) | `<meta name="kn-seite" content="neu">` | `kb-page` | `class="portal-page dashboard-page kb-page"` |
| `/login.html` | `content="#ffffff"`, `/neu/tokens.css` | `#9d3dfe`, `/login.css` | `/login.css`, `content="#09090b"` |
| `/kuendigen.html`, `/widerrufen.html` | `/neu/oeffentlich/oeffentlich.css`, `/legal-form.js` | `/legal-form.css` | `/legal-form.css` |
| `/offline.html` | `content="#ffffff"` | `#9d3dfe`, `<link` | `#9d3dfe` |
| `/manifest.webmanifest` (JSON) | `id` und `start_url` `/dashboard.html`, `scope` `/`, `background_color` und `theme_color` `#ffffff`, Shortcut-Namen `Chat`, `Rechnungen`, `Verträge` | | `background_color` `#000000` |
| `/abo-bedingungen.html` | `Version 6` | `Version 5 · Stand`, `Entwurf – Prüfung durch Kanzlei offen` | `Version 5 · Stand` |
| `/WEBklar-Abo-Vertragsunterlagen.pdf` | `application/pdf`, Beginn `%PDF-` | | |
- [ ] **Step 1: Failing test schreiben** (Kopf: `express`, `startTestApp`, `umschaltSeitenRouter`, `publicDir` wie in Task 3)
```js
// Stellvertreter-Antworten werden aus den Markern der Tabelle gebaut (Zeichenketten aus `enthaelt`, Manifest als JSON)
const MANIFEST = { id: '/dashboard.html', start_url: '/dashboard.html', scope: '/', background_color: '#ffffff', theme_color: '#ffffff', shortcuts: [{ name: 'Chat' }, { name: 'Rechnungen' }, { name: 'Verträge' }] }
const antwort = (p, erwartet) => p.typ === 'json' ? { status: 200, type: 'application/manifest+json', body: JSON.stringify(erwartet === 'neu' ? MANIFEST : { ...MANIFEST, background_color: '#000000', theme_color: '#000000' }) }
: p.typ === 'pdf' ? { status: 200, type: 'application/pdf', body: '%PDF-1.7 …' } : { status: 200, type: 'text/html', body: p.enthaelt.join(' ') }
const stub = (erwartet, aendern = () => null) => async (url) => {
const pfad = new URL(url).pathname; const a = aendern(pfad) ?? antwort(PRUEFUNGEN[erwartet].find((x) => x.pfad === pfad), erwartet)
if (a instanceof Error) throw a
return new Response(a.body, { status: a.status, headers: { 'content-type': a.type } })
}
test('neu und alt: alle Zeilen grün, eine Zeile je Prüfung', async () => {
for (const e of ['neu', 'alt']) { const r = await nachpruefen('https://stub.invalid', e, { fetchFn: stub(e) }); assert.deepEqual([r.ok, r.zeilen.length], [true, PRUEFUNGEN[e].length], e) }
})
test('neu gegen alte Seiten ist rot und nennt die Seiten', async () => {
const r = await nachpruefen('https://stub.invalid', 'neu', { fetchFn: async (u) => stub('alt')(u).catch(() => new Response('', { status: 404 })) })
assert.equal(r.ok, false); assert.ok(r.zeilen.filter((z) => !z.ok).map((z) => z.pfad).includes('/login.html'))
})
test('Status 500, Netzfehler und verbotener Marker färben genau ihre Zeile rot', async () => {
const r = await nachpruefen('https://stub.invalid', 'neu', { fetchFn: stub('neu', (p) => p === '/offline.html' ? { status: 500, type: 'text/html', body: '' } : p === '/kuendigen.html' ? new Error('ECONNRESET') : p === '/login.html' ? { status: 200, type: 'text/html', body: 'content="#ffffff" /neu/tokens.css #9d3dfe' } : null) })
assert.deepEqual(r.zeilen.filter((z) => !z.ok).map((z) => z.pfad).sort(), ['/kuendigen.html', '/login.html', '/offline.html'])
assert.match(r.zeilen.find((z) => z.pfad === '/offline.html').grund, /500/)
})
test('gegen die echten Dateien der Weiche: alt bei off, neu bei on (ohne die Fassung-6-Zeilen)', async () => {
let modus = 'off'; const router = express.Router()
router.use(umschaltSeitenRouter({ publicDir, modus: () => modus, fassung6: () => null })); router.use(express.static(publicDir))
const app = await startTestApp({ '/': router }); const OHNE = ['/abo-bedingungen.html', '/WEBklar-Abo-Vertragsunterlagen.pdf']
assert.deepEqual((await nachpruefen(app.url, 'alt', { ohne: OHNE })).zeilen.filter((z) => !z.ok), [])
modus = 'on'; const r = await nachpruefen(app.url, 'neu', { ohne: OHNE }); assert.deepEqual(r.zeilen.filter((z) => !z.ok), []); assert.equal(r.zeilen.length, PRUEFUNGEN.neu.length - 2)
})
test('parseArgs', () => {
assert.deepEqual(parseArgs(['https://project.webklar.com', '--erwartet=neu']), { basis: 'https://project.webklar.com', erwartet: 'neu' })
for (const schlecht of [[], ['https://x.invalid'], ['ftp://x.invalid', '--erwartet=neu'], ['https://x.invalid/pfad', '--erwartet=neu'], ['https://x.invalid', '--erwartet=beide']]) assert.throws(() => parseArgs(schlecht), /Aufruf/)
})
```
- [ ] **Step 2: Lauf, muss scheitern**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knUmschaltNachpruefung.test.js`
Expected: FAIL mit `Cannot find module` auf `umschalten-nachpruefung.mjs`.
- [ ] **Step 3: Implementieren.** Je Prüfung ein `fetch` mit `redirect: 'manual'` und Zeitlimit 15 s. `grund` nennt Status, fehlenden Marker, verbotenen Marker oder `Netzfehler`. Der Einstieg druckt je Zeile `ok` bzw. `FEHLER <pfad> <grund>` und setzt Exit 0 bzw. 1 (2 bei falschem Aufruf).
- [ ] **Step 4: Lauf grün.** Run: wie Step 2. Expected: PASS, 5 Tests. Passt ein Marker nicht zu den echten Dateien (Test „echten Dateien“ rot), ändert der Agent nur die Marker-Tabelle, nie die Seiten.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add scripts/umschalten-nachpruefung.mjs test/knUmschaltNachpruefung.test.js
git commit -m "Kundenansicht neu P10: Nachprüf-Skript prüft die ausgelieferten Seiten gegen alt oder neu" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 5: Käufe lesen (`scripts/kauf-bestand-pruefen.mjs`)
**Files:**
- Create: `scripts/kauf-bestand-pruefen.mjs`
- Test: `test/kaufBestandPruefen.test.js`
**Interfaces:**
- Consumes: `listAllDocuments` aus `appwriteAdmin.js`, `config.collections.portalFeatures`, `withdrawalDeadline(contractAt) → { lastDay, endsAt }` aus `server/services/deadlines.js:99`.
- Produces: `export async function pruefeKaeufe({ log, jetzt = new Date() })` → `{ offeneBestellungen: string[], fristLaeuft: string[], gekauftGesamt: number }` (nur Dokument-IDs). Rein lesend. Der Einstieg setzt Exit 1, wenn `offeneBestellungen` nicht leer ist.
- [ ] **Step 1: Failing test schreiben** (Kopf und Hilfen `feature(id, over)` und `sammler()` wie `test/featureConsentFelder.test.js:1-30`, Mock mit `portalFeatures: schemaKeys('portalFeatures')`; `jetzt = new Date('2026-11-10T10:00:00Z')`)
```js
feature('f_offen', { state: 'ordered', purchaseStatus: 'ordered' }) // wartet auf Zahlung
feature('f_frist', { state: 'setup', purchaseStatus: 'paid', unlockedByPurchase: true, orderedAt: '2026-11-08T09:00:00.000Z' }) // Frist läuft
feature('f_alt', { state: 'active', purchaseStatus: 'paid', unlockedByPurchase: true, orderedAt: '2026-09-20T09:00:00.000Z' }) // Frist vorbei
feature('f_abo', { state: 'active', unlockedBy: 'abo' }); feature('f_aus', { state: 'off' }) // nicht gekauft
test('zählt offene Bestellungen und laufende Fristen, schreibt nichts, nennt nur IDs', async () => {
const vorher = JSON.stringify([...aw.docs('portalFeatures')]); const out = sammler()
const e = await pruefeKaeufe({ log: out.log, jetzt })
assert.deepEqual([e.offeneBestellungen, e.fristLaeuft, e.gekauftGesamt], [['f_offen'], ['f_frist'], 2])
assert.equal(JSON.stringify([...aw.docs('portalFeatures')]), vorher)
assert.match(out.text(), /Offene Bestellungen: 1/); assert.match(out.text(), /Fristen laufen: 1/)
})
test('ohne Käufe: alles leer', async () => { aw.docs('portalFeatures').clear(); assert.deepEqual(await pruefeKaeufe({ log: () => {}, jetzt }), { offeneBestellungen: [], fristLaeuft: [], gekauftGesamt: 0 }) })
```
- [ ] **Step 2: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kaufBestandPruefen.test.js`. Expected: FAIL (`Cannot find module`).
- [ ] **Step 3: Implementieren.** „Gekauft“ heißt `state` in `setup | active | disabled` und `unlockedByPurchase`; „Frist läuft“ heißt `withdrawalDeadline(consentAt || orderedAt).endsAt > jetzt` (Näherung, nur zur Information); offen heißt `state === 'ordered'`. Ausgabezeilen `Offene Bestellungen: n (id, …)` und `Fristen laufen: n (id, …)`.
- [ ] **Step 4: Lauf grün.** Run: wie Step 2. Expected: PASS, 2 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add scripts/kauf-bestand-pruefen.mjs test/kaufBestandPruefen.test.js
git commit -m "Kundenansicht neu P10: Skript zählt offene Bestellungen und Widerrufsfristen gekaufter Funktionen" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 6: Umschalt-Checkliste (`docs/umschalten-kundenansicht.md`)
**Files:**
- Create: `docs/umschalten-kundenansicht.md`
- Test: `test/knUmschaltCheckliste.test.js`
**Interfaces:**
- Produces: eine Anleitung für Inhaber und Controller. Reihenfolge laut Spec 5.10: Kundenart setzen, Fassung 6 hochgeladen, WOMS-Pflichtfeld live, dann `KUNDENANSICHT_NEU=on`, Rückweg `off`.
- [ ] **Step 1: Failing test schreiben**
```js
const doc = readFileSync(new URL('../docs/umschalten-kundenansicht.md', import.meta.url), 'utf8')
const KOEPFE = ['## 1. Voraussetzungen', '## 2. Kundenart der Bestandseinträge setzen', '## 3. Fassung 6 hochgeladen', '## 4. WOMS-Pflichtfeld live', '## 5. Schalter auf on', '## 6. Nachprüfung', '## 7. Rückweg', '## 8. Danach: festschreiben und aufräumen']
test('Überschriften in der Reihenfolge der Spec 5.10', () => {
const pos = KOEPFE.map((k) => doc.indexOf(k)); assert.ok(pos.every((p, i) => p > -1 && (i === 0 || p > pos[i - 1])), pos.join(','))
})
test('nennt die Befehle und die Stopps', () => {
for (const s of ['kundenart-bestand.mjs --pruefen', '--ausfuehren', 'KUNDENANSICHT_NEU=on', 'KUNDENANSICHT_NEU=off', 'docker compose up -d --no-deps kundenbereich-server', '--erwartet=neu', 'docker-compose.yml', 'Stop: Inhaber-OK nötig', 'kein Rückweg per Schalter']) assert.ok(doc.includes(s), s)
})
```
- [ ] **Step 2: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knUmschaltCheckliste.test.js`. Expected: FAIL mit `ENOENT`.
- [ ] **Step 3: Schreiben.** Die acht Abschnitte in du-Form, je höchstens zehn Zeilen. Inhalt aus den Tasks 8 bis 11 und 14 (Befehle wörtlich übernehmen, `<N>` aus `--pruefen`). Abschnitt 7 nennt den Rückweg (`sed` auf `off`, derselbe Compose-Befehl, `--erwartet=alt`) und dass Verträge nach Fassung 6 gültig bleiben. Abschnitt 8 nennt „kein Rückweg per Schalter“ und den Beobachtungszeitraum.
- [ ] **Step 4: Lauf grün.** Run: wie Step 2. Expected: PASS, 2 Tests.
- [ ] **Step 5: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add docs/umschalten-kundenansicht.md test/knUmschaltCheckliste.test.js
git commit -m "Kundenansicht neu P10: Checkliste zum Umschalten in der Reihenfolge der Spec" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 7: Live, Teil A ausliefern
**Files:** keine. **Stop: Inhaber-OK nötig** (Push durch den Inhaber).
**Interfaces:**
- Consumes: Tasks 2 bis 6 committed.
- Produces: Teil A läuft in Produktion (Skripte im Container, Weiche mit Schalter `pilot`); für Kunden ändert sich nichts.
- [ ] **Step 1: Teil A als Ganzes prüfen und taggen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenartBestand.test.js test/knUmschaltSeiten.test.js test/knUmschaltNachpruefung.test.js test/kaufBestandPruefen.test.js test/knUmschaltCheckliste.test.js test/kundenansichtDashboard.test.js test/adminPanelGone.test.js test/legalDocs.test.js && git status --short && git tag kn-p10-a`
Expected: `fail 0`, Status leer.
- [ ] **Step 2: Übergabe an den Controller (Text für den Inhaber)**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git fetch origin && git log --oneline origin/main..kn-p10-a`
Der Controller schreibt dem Inhaber: „Teil A ändert für Kunden nichts, solange der Schalter nicht `on` ist. Push: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git push origin 'kn-p10-a^{commit}:refs/heads/main'`.“ **Stop: Inhaber-OK nötig.**
- [ ] **Step 3: Deploy prüfen (nach dem Push des Inhabers)**
Run: `git -C /home/webklar/apps/kundenbereich.webklar.com/build rev-parse --short HEAD && git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht rev-parse --short kn-p10-a && docker logs --since 5m webklar-kundenbereich-server 2>&1 | tail -5`
Expected: beide Kurzformen gleich (sonst zwei Minuten warten, der Webhook baut), im Log kein `Unbehandelter Fehler`.
- [ ] **Step 4: Ruhezustand prüfen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=alt`
Expected: Exit 0, alle Zeilen `ok`.
---
### Task 8: Live, Kundenart der Bestandseinträge setzen (E18)
**Files:** keine. **Stop: Inhaber-OK nötig** vor dem Schreiben.
**Interfaces:**
- Consumes: `scripts/kundenart-bestand.mjs` im Container (Task 7).
- Produces: `Ohne Kundenart: 0` in Produktion; Protokolle in `RB`.
- [ ] **Step 1: Prüfen (nur lesend)**
Run:
```bash
mkdir -p /root/reports/kundenansicht-neu-2026-10-15/umschalten
docker exec -w /app webklar-kundenbereich-server node scripts/kundenart-bestand.mjs --pruefen | tee /root/reports/kundenansicht-neu-2026-10-15/umschalten/kundenart-pruefen-$(date -u +%Y%m%d-%H%M%S).log
```
Expected: `Ohne Kundenart: <N>`, `Unbekannte Werte: 0` und die Bestätigungszeile `Kundenart unternehmen setzen: <N>`. Stand der Spec (15.10.): 42 Einträge, nämlich 6 Kunden, 31 Leads, 5 verlorene. Weicht `<N>` ab oder gibt es unbekannte Werte, nennt der Controller dem Inhaber die Zahlen und wartet.
- [ ] **Step 2: Freigabe.** Der Controller zeigt dem Inhaber die Zahlen aus Step 1 und fragt, ob alle diese Einträge auf `unternehmen` gesetzt werden sollen. **Stop: Inhaber-OK nötig.**
- [ ] **Step 3: Setzen**
Run:
```bash
set -o pipefail
docker exec -w /app webklar-kundenbereich-server node scripts/kundenart-bestand.mjs --ausfuehren --bestaetigung="Kundenart unternehmen setzen: <N>" | tee /root/reports/kundenansicht-neu-2026-10-15/umschalten/kundenart-ausfuehren-$(date -u +%Y%m%d-%H%M%S).log
```
Expected: Exit 0, letzte Zeile `Summe gesetzt=<N> uebersprungen=0 fehler=0`.
- [ ] **Step 4: Gegenprobe.** Run: Step 1 noch einmal. Expected: `Ohne Kundenart: 0`.
---
### Task 9: Live, Vorflug (nur lesend)
**Files:** keine. **Stop: Inhaber-OK nötig** für den Zeitpunkt des Umschaltens (Step 8).
**Interfaces:**
- Consumes: Tasks 7 und 8; `scripts/rechtliches-hochladen.mjs --probe` (P8); WOMS-Tag `kn-p9`.
- Produces: die Freigabe des Inhabers zum Umschalten, eine Sicherung von `.env`.
- [ ] **Step 1: Kundenart.** Run: `docker exec -w /app webklar-kundenbereich-server node scripts/kundenart-bestand.mjs --pruefen | command grep -a "Ohne Kundenart"`. Expected: `Ohne Kundenart: 0`. Sonst Task 8 wiederholen (ein Lead, der seitdem entstand, ist normal).
- [ ] **Step 2: Fassung 6 hochgeladen (E20).** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && node scripts/rechtliches-hochladen.mjs --probe`. Expected: Die Ausgabe zeigt für `abo-bedingungen` eine aktuelle Version, deren `source` auf `:public/neu/rechtliches/abo-bedingungen.html` endet (Format laut Übergabe von P8; die WOMS-Nummer `vN` zählt Uploads und ist nicht die Fassung). Der Inhaber bestätigt in WOMS unter „Rechtliches“, dass die Karte `abo-bedingungen` die Fassung 6 trägt. Fehlt sie, stoppt der Controller. Hochladen ohne `--probe` nur auf ausdrücklichen Auftrag des Inhabers.
- [ ] **Step 3: WOMS-Pflichtfeld live.** Run: `git -C /home/webklar/apps/ticket.webklar.com/build merge-base --is-ancestor kn-p9 HEAD && echo live`. Expected: `live`, und der Inhaber bestätigt: „Neuer Lead“ und „Neuer Kunde“ zeigen die Kundenart ohne Vorgabe.
- [ ] **Step 4: Setup und Wortlaut (P7 VF9).** Run: `docker exec -w /app webklar-kundenbereich-server node scripts/kundenansicht-setup.mjs --pruefen; docker exec -w /app webklar-kundenbereich-server grep -n "WORTLAUT_STATUS" server/services/bestelltexte.js`. Expected: Das Setup meldet keine fehlenden Attribute (insbesondere `featureConsents.kundenart`; ohne sie scheitert jede Bestellung der neuen Ansicht mit 500), und die Zeile mit der Definition von `WORTLAUT_STATUS` trägt `'freigegeben'`. Sonst stoppt der Controller; das Setup läuft nur auf ausdrückliche Anweisung nach OK des Inhabers.
- [ ] **Step 5: Schalter in Compose und `.env`.** Run: `command grep -a -n 'KUNDENANSICHT_NEU' /home/webklar/apps/docker-compose.yml; command grep -a -n '^KUNDENANSICHT_NEU' /home/webklar/apps/.env`. Expected: in `docker-compose.yml` zwei Zeilen `- KUNDENANSICHT_NEU=${KUNDENANSICHT_NEU:-off}` und `- KUNDENANSICHT_NEU_PILOT_EMAILS=${KUNDENANSICHT_NEU_PILOT_EMAILS:-}` (bei den `ZEITEN_*`-Zeilen um Zeile 1713), in `.env` `KUNDENANSICHT_NEU=pilot` und die Pilot-Liste. Fehlt eine Compose-Zeile, meldet der Controller es (VF2); Task 10 beginnt erst nach der Korrektur.
- [ ] **Step 6: Schalter, die den Katalog bestimmen.** Run: `command grep -a -n '^\(ABO_PILOT_EMAILS\|TERMINE_ENABLED\|SHOP_ENABLED\|UNTERKUNFT_ENABLED\|ZEITEN_ENABLED\|ANALYSE_ENABLED\|AUFTRAEGE_ENABLED\)=' /home/webklar/apps/.env`. Der Inhaber bestätigt jeden Wert. Besonders: `ABO_PILOT_EMAILS` muss `*` sein (sonst antwortet jede Abo-Bestellung eines Nicht-Pilot-Kunden mit 403); `ANALYSE_ENABLED` und `AUFTRAEGE_ENABLED` bleiben `off`, bis ihre eigenen Livegänge erledigt sind (ausgeschaltete Funktionen erscheinen nicht unter „Entdecken“, C9).
- [ ] **Step 7: Ruhe und Sicherung.** Run: `docker logs --since 72h webklar-kundenbereich-server 2>&1 | command grep -a -c 'Unbehandelter Fehler'` (Expected: 0, sonst die Zeilen ansehen), dann `cp -n /home/webklar/apps/.env /home/webklar/apps/.env.bak-$(date -u +%Y-%m-%d)-kundenansicht-on`.
- [ ] **Step 8: Freigabe zum Umschalten.** Der Controller nennt dem Inhaber das Ergebnis der Schritte 1 bis 7 und schlägt eine ruhige Uhrzeit vor. **Stop: Inhaber-OK nötig** („umschalten, jetzt“).
---
### Task 10: Live, Schalter auf `on` (Rückweg `off`)
**Files:** keine. **Stop: Inhaber-OK nötig** (Zeitpunkt aus Task 9).
**Interfaces:**
- Consumes: Freigabe aus Task 9, die Compose-Zeilen.
- Produces: `KUNDENANSICHT_NEU=on` in Produktion; Rückweg `off` bis Task 13.
- [ ] **Step 1: `.env` ändern**
Run: `sed -i 's/^KUNDENANSICHT_NEU=.*/KUNDENANSICHT_NEU=on/' /home/webklar/apps/.env && command grep -a -n '^KUNDENANSICHT_NEU=' /home/webklar/apps/.env`
Expected: genau die Zeile `KUNDENANSICHT_NEU=on`. Kommt keine Zeile, stoppt der Controller (der Vorflug war unvollständig).
- [ ] **Step 2: Dienst neu starten**
Run: `cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server && docker ps --filter name=webklar-kundenbereich-server --format '{{.Status}}'`
Expected: `Up …`. Antwortet die Seite die ersten Sekunden mit 502, wiederholt der Controller Step 3 nach 10 s.
- [ ] **Step 3: Nachprüfen**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=neu`
Expected: Exit 0, alle Zeilen `ok`, einschließlich `/abo-bedingungen.html` mit `Version 6` und des PDFs.
- [ ] **Step 4: Rückweg (nur bei Fehler, vorher kurz den Inhaber informieren)**
Run: `sed -i 's/^KUNDENANSICHT_NEU=.*/KUNDENANSICHT_NEU=off/' /home/webklar/apps/.env && cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server`, dann `node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=alt` aus dem Worktree.
Expected: Exit 0. Verträge, die unter Fassung 6 abgeschlossen wurden, bleiben gültig (Fassungstabelle und Archiv bleiben).
---
### Task 11: Live, Nachprüfungen (Routen, Login, Pilot-Konten, Mails)
**Files:** keine. **Stop: Inhaber-OK nötig** für den optionalen Probekauf.
**Interfaces:**
- Consumes: Task 10.
- Produces: der Beobachtungszeitraum (`RB/beobachtung.txt`) als Voraussetzung für Task 14.
- [ ] **Step 1: Routen.** Run: `curl -s https://project.webklar.com/api/auth/me`. Expected: JSON mit `"authenticated":false`; dazu der grüne Lauf aus Task 10 Step 3.
- [ ] **Step 2: Login und Oberfläche** (Inhaber mit zwei Konten, einem Betrieb und einem Pilot-Konto, auf Handy und Desktop). Erwartet: (a) `/login.html` ist weiß, die Anmeldung führt auf Website. (b) `#chat`, `#rechnungen`, `#wartung` und `#termine?bestellt=1&projekt=<id>` landen am richtigen Ort mit erhaltener Query. (c) „Verträge kündigen“ und „Vertrag widerrufen“ liegen höchstens zwei Taps entfernt, beide Seiten öffnen weiß. (d) Funktionen zeigt „Meine/Entdecken“ ohne Preise, „Einschalten“ eines Betriebs hat kein Häkchen. (e) Die Kundenansicht der Mitarbeiter (WOMS) zeigt die neue Oberfläche und sperrt Schreibaktionen.
- [ ] **Step 3: Mails.** Optional ein Probekauf mit dem Testkonto aus `ABO_TEST_EMAILS` (0,50 €). Erwartet: K01 an den Kunden und T02 ans Team kommen an, der Anhang `WEBklar-Abo-Vertragsunterlagen.pdf` ist Fassung 6, in `featureConsents` steht eine Zeile mit `featureKey` `abo` und der Kundenart. **Stop: Inhaber-OK nötig** für Kauf und Stornieren.
- [ ] **Step 4: Logs.** Run: `docker logs --since 30m webklar-kundenbereich-server 2>&1 | command grep -a -c -i 'Unbehandelter Fehler'`. Expected: 0.
- [ ] **Step 5: Installierte App.** Android öffnet weiß. iOS liest die Statusleiste beim Installieren: bleibt sie schwarz, wird die App vom Home-Bildschirm entfernt und neu hinzugefügt.
- [ ] **Step 6: Beobachtungszeit.** Beginn und Ende (Vorschlag: sieben Tage) stehen in `RB/beobachtung.txt`. Task 12 und 13 dürfen sofort laufen, Task 14 erst nach Ablauf und neuem OK des Inhabers.
---
### Task 12: Offline-Seite in installierten Apps erneuern (Code)
**Files:**
- Modify: `public/sw.js:5` (`CACHE`)
- Test: `test/knOfflineCache.test.js`
Hintergrund: Der Service Worker legt `/offline.html` beim Installieren ab (`cache.add(new Request(OFFLINE_URL, { cache: 'reload' }))`). Installieren läuft nur, wenn sich `sw.js` ändert. Die Änderung muss deshalb **nach** dem Umschalten live gehen (Task 13), sonst speichert sie die alte dunkle Seite erneut.
**Interfaces:** Produces `CACHE = 'kb-offline-v2'`; `OFFLINE_URL` bleibt `'/offline.html'`.
- [ ] **Step 1: Failing test schreiben**
```js
const sw = readFileSync(new URL('../public/sw.js', import.meta.url), 'utf8')
test('sw.js: Cache v2, Offline-Seite unverändert, alte Caches werden gelöscht', () => {
assert.match(sw, /const CACHE = 'kb-offline-v2'/); assert.doesNotMatch(sw, /kb-offline-v1/)
assert.match(sw, /const OFFLINE_URL = '\/offline\.html'/); assert.match(sw, /cache\.add\(new Request\(OFFLINE_URL, \{ cache: 'reload' \}\)\)/)
assert.match(sw, /if \(key !== CACHE\) await caches\.delete\(key\)/); assert.doesNotMatch(sw, /auftrag/)
})
```
- [ ] **Step 2: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knOfflineCache.test.js`. Expected: FAIL (`kb-offline-v1`).
- [ ] **Step 3: `CACHE` auf `'kb-offline-v2'` setzen.** Sonst nichts in `sw.js` ändern.
- [ ] **Step 4: Lauf grün, dazu der Pin auf `sw.js`.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knOfflineCache.test.js test/auftraegeKundenportalHuelle.test.js`. Expected: PASS.
- [ ] **Step 5: Commit und Tag**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add public/sw.js test/knOfflineCache.test.js
git commit -m "Kundenansicht neu P10: Service Worker legt die weiße Offline-Seite neu ab (Cache v2)" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git tag kn-p10-b
```
---
### Task 13: Live, Offline-Seite ausliefern
**Files:** keine. **Stop: Inhaber-OK nötig** (Push). Voraussetzung: Task 10 ist gelaufen.
**Interfaces:**
- Consumes: Tag `kn-p10-b` (Task 12), `KUNDENANSICHT_NEU=on` (Task 10).
- Produces: installierte Apps zeigen offline die weiße Seite.
- [ ] **Step 1: Push.** Der Inhaber pusht `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git push origin 'kn-p10-b^{commit}:refs/heads/main'`.
- [ ] **Step 2: Prüfen.** Run: `curl -s https://project.webklar.com/sw.js | command grep -a -c kb-offline-v2`. Expected: `1`. Dann in einem Browser mit besuchter Seite: Netz aus, Seite neu laden. Expected: weiße Offline-Seite.
---
### Task 14: Fassung 6 festschreiben (kein Rückweg per Schalter ab hier)
**Files:**
- Move (`git mv -f`): `public/neu/rechtliches/abo-bedingungen.html` nach `public/abo-bedingungen.html`, `public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf` nach `public/WEBklar-Abo-Vertragsunterlagen.pdf`, dazu `…pdf.sha256` (Prüfsumme der Seite) daneben. Einzige Änderung am Text der Seite: der PDF-Link `/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf` wird `/WEBklar-Abo-Vertragsunterlagen.pdf` (das Druck-PDF bleibt gleich, der Link steht nur am Bildschirm), die Prüfsumme daneben wird neu geschrieben. Die bisherigen Dateien (Fassung 5) liegen schon als Archiv in `public/vertragsunterlagen/v5/` (von P8).
- Modify: `server/config.js:855` (eine Konstante für beide Schlüssel), `server/services/products.js` (Zeilen v6 und v5 der Fassungstabelle), `server/services/vertragsNachweis.js` (`fassungDerAnfrage`), `server/routes/umschaltSeiten.js` (Fassung-6-Zeilen und Parameter `fassung6` entfallen, zwei Weiterleitungen kommen dazu), `scripts/build-legal-pdfs.sh` (Modus `abo6` entfällt), `scripts/rechtliches-hochladen.mjs` (`DOKUMENTE_V6`, Eintrag `abo-bedingungen`: neue Pfade). Das ist die Übergabe laut P8-Vertragsfrage 1.
- Pins: `test/legalTexts.test.js:43,104-121`, `test/products.test.js:120,190` und P8s Tests `Fassungstabelle`, `Fassung 5 bleibt bis P10`, `aktuelleAboFassung`, `test/aboRoutes.test.js:87`, `test/aboBedingungenV6.test.js` (Konstante `V6`), `test/legalV6Build.test.js` (`abo6` und PDF-Schleife), `test/rechtlichesHochladen.test.js` (Pfade des ersten Eintrags), `test/knUmschaltSeiten.test.js` (die zwei Fassung-6-Tests entfallen, ihr Gegenstand verschwindet)
- Test: `test/knFassung6Fest.test.js`
**Interfaces:**
- Consumes: `termsDocFor`, `contractAttachmentFor` (`products.js`), `fassungDerAnfrage(req, termsVersion)` (P7: `'alt' | 'neu' | null`).
- Produces: `config.abo.termsVersion === config.abo.termsVersionNeu` (Muster `^\d{4}-\d{2}-\d{2}-v6$`). `termsDocFor(<Fassung 6>)` liefert `url: '/abo-bedingungen.html'` und `pdfFile: 'WEBklar-Abo-Vertragsunterlagen.pdf'`. `termsDocFor(<Fassung 5>)` liefert die Archivwerte (`url: '/vertragsunterlagen/v5/abo-bedingungen.html'`, `pdfFile: 'vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf'`); das Feld `archiv` der Tabelle entfällt. `fassungDerAnfrage` prüft die Neu-Fassung zuerst: Ist `termsVersion` gleich der Neu-Fassung, gilt `neueAnsicht(req) ? 'neu' : null`, nie `'alt'`. `umschaltSeitenRouter` leitet `/neu/rechtliches/abo-bedingungen.html` und `/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf` dauerhaft (301, unabhängig vom Schalter) auf `/abo-bedingungen.html` bzw. `/WEBklar-Abo-Vertragsunterlagen.pdf`. Grund: Die zehn Funktionsseiten von P8 verlinken die Abo-Seite unter dem Entwurfspfad, und Kopien in WOMS können das PDF dort verlinken. Die Weiterleitungen halten diese Links gültig, ohne die freigegebenen Funktionsseiten und PDFs anzufassen.
- [ ] **Step 1: Beobachtungszeit und OK prüfen, Hashes ermitteln.** Der Inhaber hat nach der Beobachtungszeit (Task 11 Step 6) „festschreiben“ gesagt. Dann:
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && sha256sum public/WEBklar-Abo-Vertragsunterlagen.pdf public/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf public/neu/rechtliches/abo-bedingungen.html public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf && cat public/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf.sha256 && command grep -a -c 'href="/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf"' public/neu/rechtliches/abo-bedingungen.html && git status --short`
Expected: Zeile 1 = Zeile 2 (das PDF im Archiv v5 ist das heutige PDF; die HTML-Datei des Archivs unterscheidet sich nur im PDF-Link, das prüft `test/legalTexts.test.js` von P8), die Prüfsumme neben dem PDF gleicht Zeile 3 (Seite Fassung 6), die Zahl der Entwurfslinks ist `1`, der Status leer. Weicht etwas ab, stoppt der Agent (VF3). Die Werte heißen `V5_PDF_SHA` (Zeile 1), `V6_QUELLE_SHA` (Zeile 3) und `V6_PDF_SHA` (Zeile 4).
- [ ] **Step 2: Failing test schreiben** (`test/knFassung6Fest.test.js`; Kopf wie `test/knUmschaltSeiten.test.js` mit `createHash`, `config`, `termsDocFor`, `contractAttachmentFor`, `fassungDerAnfrage`)
```js
const V5_PDF_SHA = '<64 Hex aus Step 1>'; const V6_QUELLE_SHA = '<…>'; const V6_PDF_SHA = '<…>'
const LINK_ENTWURF = 'href="/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf"'; const LINK_HAUPT = 'href="/WEBklar-Abo-Vertragsunterlagen.pdf"'
const sha256 = (b) => createHash('sha256').update(b).digest('hex')
const sha = (rel) => sha256(readFileSync(new URL(`../${rel}`, import.meta.url)))
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
test('Hauptseite ist die Seite von P8 mit genau einem getauschten PDF-Link, ohne Entwurfsmarke; PDF, Prüfsumme und Archiv stimmen; die Entwurfsdateien sind weg', () => {
const seite = lies('public/abo-bedingungen.html')
assert.equal(seite.split(LINK_HAUPT).length, 2); assert.ok(!seite.includes(LINK_ENTWURF))
assert.equal(sha256(seite.replace(LINK_HAUPT, LINK_ENTWURF)), V6_QUELLE_SHA)
assert.equal(sha('public/WEBklar-Abo-Vertragsunterlagen.pdf'), V6_PDF_SHA)
assert.equal(lies('public/WEBklar-Abo-Vertragsunterlagen.pdf.sha256').trim(), sha('public/abo-bedingungen.html'))
assert.equal(sha('public/vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf'), V5_PDF_SHA)
for (const f of ['abo-bedingungen.html', 'WEBklar-Abo-Vertragsunterlagen.pdf', 'WEBklar-Abo-Vertragsunterlagen.pdf.sha256']) assert.equal(existsSync(new URL(`../public/neu/rechtliches/${f}`, import.meta.url)), false, f)
const text = seite.replace(/<[^>]+>/g, ' ')
assert.match(text, /Version 6 · Stand/); assert.doesNotMatch(text, /Entwurf – Prüfung durch Kanzlei offen/)
})
test('die Entwurfsadressen leiten dauerhaft weiter (Links aus den Funktionsseiten und aus Kopien bleiben gültig)', async () => {
const router = express.Router(); router.use(umschaltSeitenRouter({ publicDir, modus: () => 'off' })); router.use(express.static(publicDir))
const app = await startTestApp({ '/': router })
for (const [von, nach] of [['/neu/rechtliches/abo-bedingungen.html', '/abo-bedingungen.html'], ['/neu/rechtliches/WEBklar-Abo-Vertragsunterlagen.pdf', '/WEBklar-Abo-Vertragsunterlagen.pdf']]) {
const r = await fetch(app.url + von, { redirect: 'manual' }); assert.deepEqual([r.status, new URL(r.headers.get('location'), app.url).pathname], [301, nach], von)
}
})
test('termsDocFor und Mail-Anhang je Fassung', () => {
const v6 = config.abo.termsVersion; const { url, pdfFile } = termsDocFor(v6)
assert.deepEqual([url, pdfFile], ['/abo-bedingungen.html', 'WEBklar-Abo-Vertragsunterlagen.pdf'])
const v5 = termsDocFor('2026-09-30-v5'); assert.deepEqual([v5.url, v5.pdfFile], ['/vertragsunterlagen/v5/abo-bedingungen.html', 'vertragsunterlagen/v5/WEBklar-Abo-Vertragsunterlagen.pdf'])
for (const v of [v6, '2026-09-30-v5', '2026-09-30-v4', '2026-09-25-v3']) assert.equal(readFileSync(contractAttachmentFor(v).path).subarray(0, 5).toString(), '%PDF-', v)
})
test('eine Fassung in der Konfiguration', () => { assert.equal(config.abo.termsVersion, config.abo.termsVersionNeu); assert.match(config.abo.termsVersion, /^\d{4}-\d{2}-\d{2}-v6$/) })
test('fassungDerAnfrage: Fassung 6 ist für neue Sitzungen "neu", nie "alt"; v5, v4, leer sind null', () => {
const req = { session: { customerId: 'c3', appwriteUserId: 'u3', email: 'k3@example.invalid' } }; const gemerkt = config.kundenansichtNeu.mode
try {
config.kundenansichtNeu.mode = 'on'
assert.equal(fassungDerAnfrage(req, config.abo.termsVersion), 'neu')
for (const v of ['2026-09-30-v5', '2026-09-30-v4', '', undefined, 6]) assert.equal(fassungDerAnfrage(req, v), null, String(v))
config.kundenansichtNeu.mode = 'off'; assert.equal(fassungDerAnfrage(req, config.abo.termsVersion), null)
} finally { config.kundenansichtNeu.mode = gemerkt }
})
test('Build-Skript und Hochladen-Skript kennen nur noch die Hauptadresse', () => {
const sh = lies('scripts/build-legal-pdfs.sh')
assert.deepEqual(sh.split('\n').filter((l) => l.trim().startsWith('pdf ') && l.includes('abo-bedingungen')), [' pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf'])
assert.doesNotMatch(sh, /^abo6\)$/m); assert.doesNotMatch(sh, /abo6\|/)
assert.doesNotMatch(lies('scripts/rechtliches-hochladen.mjs'), /neu\/rechtliches\/(abo-bedingungen\.html|WEBklar-Abo-Vertragsunterlagen\.pdf)/)
})
```
- [ ] **Step 3: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knFassung6Fest.test.js`. Expected: FAIL (Hauptseite ist noch Fassung 5, `termsVersion` ist v5).
- [ ] **Step 4: Umsetzen.**
1. `git mv -f` für die drei Dateien aus „Files“. Dann in der Seite den einen PDF-Link tauschen (Entwurfspfad auf `/WEBklar-Abo-Vertragsunterlagen.pdf`) und die Prüfsumme neben dem PDF neu schreiben: `sha256sum public/abo-bedingungen.html | cut -d' ' -f1 > public/WEBklar-Abo-Vertragsunterlagen.pdf.sha256`. Die Seite sonst nicht bearbeiten, das PDF nicht neu erzeugen.
2. `server/config.js`: eine Konstante mit dem Wert der bisherigen `termsVersionNeu`, gelesen von `termsVersion` und `termsVersionNeu`.
3. `products.js`: Zeile v6 auf `/abo-bedingungen.html` und `WEBklar-Abo-Vertragsunterlagen.pdf`, Zeile v5 auf die Werte ihres Feldes `archiv` (das Feld entfällt). `endParagraph` bleiben.
4. `fassungDerAnfrage`: Reihenfolge wie bei „Produces“. `umschaltSeiten.js`: Fassung-6-Zeilen und die Parameter `fassung6` und `fassung6Aus` entfernen, die zwei Weiterleitungen ergänzen; die zwei Fassung-6-Tests in `knUmschaltSeiten.test.js` entfallen.
5. `scripts/build-legal-pdfs.sh`: den Modus `abo6` samt Kopfkommentar und der Nennung in der Usage-Zeile entfernen (`all)` und `funktionen6)` bleiben). `scripts/rechtliches-hochladen.mjs`: im Eintrag `abo-bedingungen` von `DOKUMENTE_V6` die Pfade `public/WEBklar-Abo-Vertragsunterlagen.pdf` und `public/abo-bedingungen.html`.
6. Pins von P8 und aus der Vorzeit: `legalTexts.test.js:104-111` auf `-v6$` und `Version 6 · Stand` (und `!includes('Version 5')`); `:43` bleibt (`CUR` ist jetzt Fassung 6); `:112-121` bleibt gültig (die Seite trägt nach dem Tausch `href="/WEBklar-Abo-Vertragsunterlagen.pdf"`). `aboBedingungenV6.test.js` verlangt dort den Entwurfslink und nicht den Hauptlink: diese eine Assertion kehrt sich um. `products.test.js:120,190` und P8s drei Tests auf die Werte aus „Produces“ (der Test `aktuelleAboFassung` erwartet nun für jeden Modus `config.abo.termsVersionNeu`). `aboBedingungenV6.test.js`: Konstante `V6` auf `public/abo-bedingungen.html`, `ENTWURF` bleibt `false`. `legalV6Build.test.js`: die `abo6`-Zeilen entfallen, die PDF-Schleife prüft den Eintrag `abo-bedingungen` an den neuen Pfaden. `rechtlichesHochladen.test.js`: Pfade des ersten Eintrags. `aboRoutes.test.js:87` bleibt `'/abo-bedingungen.html'`.
- [ ] **Step 5: Lauf grün, mit allen Pins auf die Seite**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knFassung6Fest.test.js test/knUmschaltSeiten.test.js test/legalTexts.test.js test/products.test.js test/aboRoutes.test.js test/buildLegalHtml.test.js test/aboBedingungenV6.test.js test/legalV6Build.test.js test/rechtlichesHochladen.test.js test/funktionenRechtV6.test.js test/aboWortlautV6.test.js test/kundenansichtEntscheidungen.test.js test/termineKanzleiPaket.test.js test/termineRechtstexte.test.js test/termineVertragsunterlagen.test.js test/zeitenRecht.test.js test/auftraegeRechtstexte.test.js test/knVertragsNachweis.test.js test/knAboFassung6.test.js test/knCheckoutFassung6.test.js test/knWechselFassung6.test.js test/knSpeicherFassung6.test.js test/knGutschriftFassung6.test.js test/knBestaetigungsmails.test.js test/planSwitch.test.js test/mailStorage.test.js`
Expected: `fail 0`. Schlägt eine Zeile auf den Wortlaut der Seite fehl (zum Beispiel `termineVertragsunterlagen.test.js:775-786`, `zeitenRecht.test.js:153`), stoppt der Agent und meldet die Assertion. Er ändert weder Seite noch Pin.
- [ ] **Step 6: Commit und Tag**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/config.js server/services/products.js server/services/vertragsNachweis.js server/routes/umschaltSeiten.js scripts/build-legal-pdfs.sh scripts/rechtliches-hochladen.mjs test/knFassung6Fest.test.js test/knUmschaltSeiten.test.js test/legalTexts.test.js test/products.test.js test/aboRoutes.test.js test/aboBedingungenV6.test.js test/legalV6Build.test.js test/rechtlichesHochladen.test.js
git commit -m "Kundenansicht neu P10: Fassung 6 ist die geltende Abo-Fassung, Fassung 5 liegt im Archiv" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git tag kn-p10-c1
```
(Die `git mv`-Verschiebungen stehen schon im Index.)
---
### Task 15: Live, Fassung 6 ausliefern
**Files:** keine. **Stop: Inhaber-OK nötig** (Push). Ab hier kein Rückweg per Schalter.
**Interfaces:**
- Consumes: Tag `kn-p10-c1`.
- Produces: `/abo-bedingungen.html` ist Fassung 6, das Archiv v5 ist erreichbar.
- [ ] **Step 1: Push.** Der Inhaber pusht `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git push origin 'kn-p10-c1^{commit}:refs/heads/main'`.
- [ ] **Step 2: Deploy und Seiten prüfen.** Run: `git -C /home/webklar/apps/kundenbereich.webklar.com/build rev-parse --short HEAD && cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git rev-parse --short kn-p10-c1 && node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=neu && curl -s -o /dev/null -w '%{http_code}\n' https://project.webklar.com/vertragsunterlagen/v5/abo-bedingungen.html`
Expected: gleiche Kurzformen, Exit 0, `200`. Dazu `curl -s -o /dev/null -w '%{http_code} %{redirect_url}\n' https://project.webklar.com/neu/rechtliches/abo-bedingungen.html`. Expected: `301 https://project.webklar.com/abo-bedingungen.html`.
- [ ] **Step 3: Altvertrag.** Ein Kunde mit Fassung 5 öffnet in der neuen Ansicht Verträge → Abo → „Bedingungen“. Expected: Das Archiv v5 öffnet (nicht Fassung 6). Der Controller prüft es mit einem Abo, dessen `termsVersion` auf `-v5` endet, oder lässt es den Inhaber mit seinem Konto prüfen.
- [ ] **Step 4: Logs.** Run: `docker logs --since 20m webklar-kundenbereich-server 2>&1 | command grep -a -c -i 'Unbehandelter Fehler'`. Expected: 0.
---
### Task 16: Login von `app.js` lösen (`public/neu/oeffentlich/login.js`)
**Files:**
- Create: `public/neu/oeffentlich/login.js`
- Modify: `public/neu/oeffentlich/login.html` (`<script src="/app.js">` wird `<script src="/neu/oeffentlich/login.js">`, der Inline-Aufruf `initLoginPage();` bleibt), bei Bedarf `public/neu/huelle.js` (Service-Worker-Registrierung und iOS-Fokuszoom, VF5)
- Test: `test/knLoginModul.test.js`
**Interfaces:**
- Consumes: der Login-Teil von `public/app.js` (Stand der Basis, Zeilen mit `command grep -a -n` bestätigen): `api` samt `sessionEndRedirected` (1-27), `showError`, `hideError` (32-40), `getQueryParam` (54), `safeRedirectUrl` bis `initLoginPage` (rund 421-568), `isIosDevice` (2157), `preventIosFocusZoom` samt Aufruf (2549-2554) und die Registrierung `navigator.serviceWorker.register('/sw.js')` bei `load` (2555-2560).
- Produces: ein **klassisches** Skript (kein Modul), das diese Funktionen Zeichen für Zeichen übernimmt, `window.initLoginPage = initLoginPage` setzt und beim `load` `/sw.js` registriert (nur wenn `'serviceWorker' in navigator && window.isSecureContext`). Es enthält nichts vom Dashboard.
- [ ] **Step 1: Failing test schreiben** (`test/knLoginModul.test.js`, `vm.createContext` wie `test/kundenansichtClient.test.js:60-91`)
```js
const SRC = readFileSync(new URL('../public/neu/oeffentlich/login.js', import.meta.url), 'utf8')
function kontext({ search = '', zugriff = { allowed: false }, secure = true } = {}) {
const geladen = []; const registriert = []
const ctx = vm.createContext({
window: { location: { search, href: '' }, isSecureContext: secure, addEventListener: (n, f) => { if (n === 'load') geladen.push(f) } },
document: { getElementById: () => null, body: { classList: { contains: () => false } }, querySelector: () => null },
navigator: { serviceWorker: { register: (u) => { registriert.push(u); return Promise.resolve() } }, userAgent: '', platform: '', maxTouchPoints: 0 },
fetch: async () => ({ ok: true, json: async () => zugriff }), URLSearchParams, URL, console, encodeURIComponent, setInterval, clearInterval,
})
vm.runInContext(SRC, ctx); return { ctx, geladen, registriert }
}
const ziel = (search, zugriff) => vm.runInContext('resolvePostLoginTarget()', kontext({ search, zugriff }).ctx)
test('next: nur /dashboard.html mit Hash aus Kleinbuchstaben, Fremdes fällt auf /dashboard.html', async () => {
for (const [next, soll] of [['/dashboard.html#chat', '/dashboard.html#chat'], ['/dashboard.html#Chat', '/dashboard.html'], ['//evil.example/x', '/dashboard.html'], ['https://evil.example/', '/dashboard.html'], ['/dashboard.html?x=1', '/dashboard.html'], ['', '/dashboard.html']]) assert.equal(await ziel(`?next=${encodeURIComponent(next)}`), soll, next)
})
test('Vorschau-Adresse nur mit Zugriff und nur unter *.project.webklar.com', async () => {
const v = 'https://kundenansicht-neu.project.webklar.com/'
assert.equal(await ziel(`?next=${encodeURIComponent(v)}`, { allowed: true }), v); assert.equal(await ziel(`?next=${encodeURIComponent(v)}`, { allowed: false }), '/dashboard.html')
assert.equal(await ziel(`?next=${encodeURIComponent('https://x.project.webklar.com.evil.example/')}`, { allowed: true }), '/dashboard.html')
})
test('Service Worker wird beim Laden registriert, nur im sicheren Kontext; initLoginPage ist global', () => {
const a = kontext(); a.geladen[0](); assert.deepEqual(a.registriert, ['/sw.js']); assert.equal(typeof a.ctx.window.initLoginPage, 'function')
const b = kontext({ secure: false }); b.geladen.forEach((f) => f()); assert.deepEqual(b.registriert, [])
})
test('login.html lädt login.js statt app.js, und die neue Hülle registriert den Service Worker und verhindert den iOS-Fokuszoom ebenfalls', () => {
const html = readFileSync(new URL('../public/neu/oeffentlich/login.html', import.meta.url), 'utf8')
assert.match(html, /<script src="\/neu\/oeffentlich\/login\.js"><\/script>/); assert.doesNotMatch(html, /\/app\.js/); assert.match(html, /initLoginPage\(\)/)
const huelle = readFileSync(new URL('../public/neu/huelle.js', import.meta.url), 'utf8')
assert.match(huelle, /serviceWorker\.register\('\/sw\.js'\)/); assert.match(huelle, /maximum-scale/)
})
```
- [ ] **Step 2: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knLoginModul.test.js`. Expected: FAIL (`ENOENT` auf `login.js`).
- [ ] **Step 3: Umsetzen.** `login.js` aus den genannten Stellen zusammensetzen (kopieren, nicht umschreiben). Fehlt in `public/neu/huelle.js` die Registrierung oder der iOS-Fokuszoom (`command grep -a -n 'serviceWorker\|maximum-scale' public/neu/huelle.js`), ergänzt der Agent dort den `load`-Block mit derselben Bedingung und `isIosDevice` samt `preventIosFocusZoom` aus `app.js:2157` und `:2549-2554`, ohne neue Importe (P1-Pins verbieten sie), und nennt es im Commit. Im Test `test/knAnsichtOeffentlich.test.js` (P2, Task 17) gilt der Skriptvergleich für `login.html` jetzt mit `login.js` an Stelle von `app.js`: diese eine Zeile anpassen.
- [ ] **Step 4: Lauf grün, dazu die Seiten-Tests.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knLoginModul.test.js test/knAnsichtOeffentlich.test.js test/knHuelleQuelltext.test.js test/knHuelleStart.test.js`. Expected: PASS.
- [ ] **Step 5: Sichtprüfung über das Werkzeug.** Run: `free -m`, dann `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh test/ui/kundenansicht-neu.ui.mjs /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p10 --only o-login --gate p2`. Expected: Exit 0; `390x844/o-login.png` zeigt das weiße Formular wie vorher.
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add public/neu/oeffentlich/login.js public/neu/oeffentlich/login.html test/knLoginModul.test.js test/knAnsichtOeffentlich.test.js
git commit -m "Kundenansicht neu P10: Login-Logik und Service-Worker-Registrierung liegen in login.js statt in app.js" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
(Hat der Agent `public/neu/huelle.js` ändern müssen, kommt diese Datei als eigenes `git add` mit in den Commit.)
---
### Task 17: Alte Oberfläche löschen
**Files:**
- Delete (`git rm`, einzeln): `public/dashboard.html`, `public/app.js`, `public/login.html`, `public/kuendigen.html`, `public/widerrufen.html`, `public/offline.html`, `public/manifest.webmanifest`, `public/kundenbereich.css`, `public/login.css`, `public/legal-form.css`, `public/termine.css`, `public/shop.css`, `public/buchungen.css`, `public/zeiten.css`, `public/funktionen.css`, `public/analyse.css`
- Bleiben (werden noch geladen): `public/auftrag.css` (`auftrag.html`), `public/geraete.css` (`analyse.js:342`), `public/chat-composer.css` und `.js`, `public/kundenansicht.js`, `public/style.css` (unbenutzt, aber nicht Teil der alten Oberfläche)
- Modify: `server/routes/umschaltSeiten.js` (Parameter `modus` entfällt, die Zeilen liefern immer), `test/knUmschaltSeiten.test.js` (Matrix wird „immer die neue Datei“)
- Test: `test/knAufraeumen.test.js`; Pins: Tabelle in Step 4
**Interfaces:** Produces `umschaltSeitenRouter({ publicDir })`: liefert die sechs Zeilen von `UMSCHALT_SEITEN` unbedingt. Ab jetzt ist die neue Oberfläche auch ohne `on` die einzige.
- [ ] **Step 1: Alte Zusicherungen einfrieren** (zwei Wegwerf-Skripte im Scratch-Ordner, nicht committen)
1. `test/support/modulKlassen.json`: die Klassenliste je Präfix, die der Abdeckungstest `test/kundenansichtNeuModulCss.test.js` (P4, Task 9) heute aus den alten CSS-Dateien liest (Funktion `klassen(...)` des Tests aufrufen). Der Test liest danach diese Datei.
2. `test/support/oeffentlichStand.json`: sichtbarer Text, Feldattribute und Skripte der vier alten Seiten, berechnet mit den Funktionen `sichtbarerText`, `attribute`, `skripte` aus `test/knAnsichtOeffentlich.test.js` (P2, Task 17). Der Test vergleicht danach die neuen Seiten damit. Für `login.html` steht dort `/neu/oeffentlich/login.js` statt `/app.js`.
- [ ] **Step 2: Failing test schreiben** (`test/knAufraeumen.test.js`)
```js
const ROOT = fileURLToPath(new URL('../', import.meta.url))
const GELOESCHT = ['public/dashboard.html', 'public/app.js', 'public/login.html', 'public/kuendigen.html', 'public/widerrufen.html', 'public/offline.html', 'public/manifest.webmanifest', 'public/kundenbereich.css', 'public/login.css', 'public/legal-form.css', 'public/termine.css', 'public/shop.css', 'public/buchungen.css', 'public/zeiten.css', 'public/funktionen.css', 'public/analyse.css']
const ALS_ADRESSE_GUELTIG = new Set(['dashboard.html', 'login.html', 'kuendigen.html', 'widerrufen.html', 'offline.html', 'manifest.webmanifest']) // bleiben Adressen der Weiche
const dateien = (dir, aus = []) => readdirSync(path.join(ROOT, dir), { withFileTypes: true }).flatMap((e) => { const rel = `${dir}/${e.name}`; return aus.some((a) => rel.startsWith(a)) ? [] : e.isDirectory() ? dateien(rel, aus) : /\.(m?js|html|css|json|sh)$/.test(e.name) ? [rel] : [] })
const ALLE = [...dateien('public', ['public/vertragsunterlagen', 'public/rechtliches']), ...dateien('server'), ...dateien('scripts'), ...dateien('test').filter((f) => f !== 'test/knAufraeumen.test.js')]
const text = (f) => readFileSync(path.join(ROOT, f), 'utf8')
test('gelöschte Dateien sind weg, die noch gebrauchten bleiben', () => {
for (const f of GELOESCHT) assert.equal(existsSync(path.join(ROOT, f)), false, f)
for (const f of ['public/auftrag.css', 'public/geraete.css', 'public/chat-composer.css', 'public/chat-composer.js', 'public/kundenansicht.js', 'public/neu/manifest.webmanifest']) assert.equal(existsSync(path.join(ROOT, f)), true, f)
})
test('nichts liest oder lädt eine gelöschte Datei', () => {
for (const f of ALLE) { const t = text(f)
for (const g of GELOESCHT) { const n = path.basename(g)
assert.ok(!t.includes(`public/${n}`), `${f}: Dateiform public/${n}`)
if (!ALS_ADRESSE_GUELTIG.has(n)) assert.doesNotMatch(t, new RegExp(`["'(=]/${n.replace('.', '\\.')}(?![\\w.-])`), `${f}: Adresse /${n}`) } }
})
test('jede CSS-Datei in public/ wird noch geladen (kein Waisenkind), ausgenommen style.css', () => {
for (const css of readdirSync(path.join(ROOT, 'public')).filter((n) => n.endsWith('.css') && n !== 'style.css')) assert.ok(ALLE.some((f) => text(f).includes(`/${css}`)), css)
})
test('Service Worker und iOS-Fokuszoom überleben app.js, in der Hülle und auf der Login-Seite', () => {
for (const f of ['public/neu/huelle.js', 'public/neu/oeffentlich/login.js']) { assert.ok(text(f).includes("serviceWorker.register('/sw.js')"), `${f}: Registrierung`); assert.ok(text(f).includes('maximum-scale'), `${f}: iOS-Fokuszoom`) }
})
test('Manifest: Identität unverändert, weiß, Shortcuts Chat, Rechnungen, Verträge', () => {
const m = JSON.parse(text('public/neu/manifest.webmanifest'))
assert.deepEqual([m.id, m.start_url, m.scope, m.display, m.background_color, m.theme_color], ['/dashboard.html', '/dashboard.html', '/', 'standalone', '#ffffff', '#ffffff'])
assert.deepEqual(m.shortcuts.map((s) => s.name), ['Chat', 'Rechnungen', 'Verträge'])
})
```
- [ ] **Step 3: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAufraeumen.test.js`. Expected: FAIL im ersten Test (die Dateien existieren) und im zweiten (Dateiform-Treffer in den alten Tests). Der Lauf nennt die Dateien, die noch auf Altes zeigen.
- [ ] **Step 4: Umsetzen.** Zuerst die Zeilen der Pin-Liste, dann `git rm`, dann die Weiche:
| Datei:Zeile | Pin | Aktion |
|---|---|---|
| `test/shopWiring.test.js:9-27` | `dashboard.html`-Links, `view-webshop`, `app.js` `VIEW_HASHES`, `FEATURE_LABELS`, `viewRefreshers`, `APP_PARENT` | löschen (Gegenstück: `test/knHuelleRouten.test.js`, `knHuelleSeite.test.js`) |
| `test/termineView.test.js:305-316` | wie oben für `termine` | nur die Zeilen mit `app.js`/`dashboard.html` löschen, Zeilen 314-316 (`funktionen.js`, zwei absolute Importe) bleiben |
| `test/unterkunftWiring.test.js:120-130` | wie oben für `buchungen` | die `app.js`/`dashboard.html`-Zeilen löschen, die Zeile zu `initBuchungen` bleibt |
| `test/zeitenBestellen.test.js:826-836` | wie oben für `zeiten`, dazu `zeiten.css`-Link | die `app.js`/`dashboard.html`-Zeilen löschen |
| `test/analyseView.test.js:56-66` | Analyse verdrahtet | die `app.js`/`dashboard.html`-Zeilen löschen, ab `const an =` bleibt es |
| `test/funktionenRoutes.test.js:502-507` | `FEATURE_LABELS`, `view-news aria-live` | diese Zeilen löschen, die Embed-Zeilen (`embed/news.js`) bleiben |
| `test/legalTexts.test.js:401-425` | Hash von `TAKEOVER_CONDITIONS` in `app.js` | auf `public/neu/ansicht-gutschrift.js` umstellen; Hash `2b7a745c…` und `conditionsVersion` bleiben. Die Zusatzzeile „sobald Ihr Abo läuft“ liest die Datei, die den Satz trägt (`command grep -a -rn 'sobald Ihr Abo läuft' public/neu`) |
| `test/zeitenEditor.test.js:1944-1950`, `test/buchungenUi.test.js`, `.sh-box[hidden]`- und `.bk-personenpreise`-Pins | Regeln in den alten Modul-CSS | auf `public/neu/module.css` umstellen, wenn die Regel dort steht (Farben nach Palette), sonst Pin löschen |
| `test/auftraegeKundenportalDashboard.test.js`, `auftraegeKundenportalPortal.test.js`, `termineFragebogenView.test.js` | Quelltext-Pins und `vm`-Ausschnitte aus `app.js`/`dashboard.html` | umstellen, wenn ein Gegenstück in `public/neu/` existiert; löschen, wenn ein P2/P3-Test dieselbe Aussage trägt |
| `test/auftraegeKundenportalHuelle.test.js:49-52`, `test/publicForms.test.js:1439-1450`, `test/auftraegeAblauf.test.js:288`, `test/auftraegeKundenportalWiderruf.test.js:9` | lesen `manifest.webmanifest`, `kuendigen.html`, `widerrufen.html` | auf `public/neu/manifest.webmanifest` bzw. `public/neu/oeffentlich/…` umstellen |
| `test/knAnsichtOeffentlich.test.js`, `test/kundenansichtNeuModulCss.test.js` | Paarvergleich alt/neu, alte CSS als Klassenquelle | auf die Dateien aus Step 1 umstellen |
| `test/support/knWortlautOrakel.js`, `test/knAnsichtOrakel.test.js`, `test/ui/*.ui.mjs`, die `dashboard.html` oder `app.js` laden (`command grep -a -l 'dashboard\.html\|/app\.js' test/ui/*.mjs`) | Orakel liest `app.js`; Playwright-Skripte der alten Seite (nicht Teil von `npm test`) | `git rm` (Orakel nur, wenn `command grep -a -ln knWortlautOrakel test` sonst nichts findet); Server-Hilfen `test/ui/*TestServer.mjs` bleiben, nur ihre Verweise auf die alte Seite entfallen; Abdeckung tragen Unit-Tests und Werkzeug-Gates |
Danach `git rm` der sechzehn Dateien aus „Files“, dann `umschaltSeitenRouter` ohne `modus`. In `test/knUmschaltSeiten.test.js` entfällt die Variable `modus` (auch in den Übergaben an den Router), die Matrix prüft nur noch, dass jede der sechs Zeilen die neue Datei mit `Cache-Control: no-cache` liefert; der Test „fehlende neue Datei“ bleibt, er nutzt ein eigenes Verzeichnis (ohne `modus`-Übergabe).
- [ ] **Step 5: Lauf grün, alle berührten Dateien**
Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knAufraeumen.test.js test/knUmschaltSeiten.test.js test/shopWiring.test.js test/termineView.test.js test/unterkunftWiring.test.js test/zeitenBestellen.test.js test/analyseView.test.js test/funktionenRoutes.test.js test/legalTexts.test.js test/zeitenEditor.test.js test/buchungenUi.test.js test/auftraegeKundenportalDashboard.test.js test/auftraegeKundenportalPortal.test.js test/termineFragebogenView.test.js test/auftraegeKundenportalHuelle.test.js test/publicForms.test.js test/auftraegeAblauf.test.js test/auftraegeKundenportalWiderruf.test.js test/knAnsichtOeffentlich.test.js test/kundenansichtNeuModulCss.test.js test/kundenansichtClient.test.js test/kundenansichtGuard.test.js test/kundenansicht.test.js`
Expected: `fail 0`. Meldet ein Test eine Datei, die dieser Plan nicht kennt, wird sie nach der Regel der Pin-Liste behandelt und im Commit genannt.
- [ ] **Step 6: Sichtprüfung über das Werkzeug, alle Gates, eine Instanz nach der anderen.** Run: `free -m`, dann `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && for g in p1 p2 p3 p4 p7; do /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh test/ui/kundenansicht-neu.ui.mjs /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p10-$g --gate $g || break; done; ls -l /dev/null`. Expected: jedes Gate Exit 0, `/dev/null` mit `crw-rw-rw-`.
- [ ] **Step 7: Commit und Tag**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/routes/umschaltSeiten.js test/knUmschaltSeiten.test.js test/knAufraeumen.test.js test/support/modulKlassen.json test/support/oeffentlichStand.json
git add <jede geänderte Testdatei aus der Pin-Liste einzeln>
git commit -m "Kundenansicht neu P10: alte Oberfläche gelöscht (dashboard.html, app.js, alte Seiten und CSS), Pins umgestellt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git tag kn-p10-c2
```
(Die `git rm`-Löschungen stehen schon im Index.)
---
### Task 18: Live, neue Oberfläche als einzige ausliefern
**Files:** keine. **Stop: Inhaber-OK nötig** (Push).
**Interfaces:**
- Consumes: Tag `kn-p10-c2`; `KUNDENANSICHT_NEU=on`.
- Produces: Die alte Oberfläche ist in Produktion nicht mehr erreichbar.
- [ ] **Step 1: Vorbedingung.** Run: `command grep -a -n '^KUNDENANSICHT_NEU=' /home/webklar/apps/.env`. Expected: `KUNDENANSICHT_NEU=on`. Sonst stoppt der Controller: Ab diesem Stand zeigt die Oberfläche auch bei `pilot` oder `off` die neue Seite, die Server-Pfade aber nicht (Task 23 räumt das auf).
- [ ] **Step 2: Push.** Der Inhaber pusht `git push origin 'kn-p10-c2^{commit}:refs/heads/main'`.
- [ ] **Step 3: Prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=neu; for p in app.js kundenbereich.css login.css; do curl -s -o /dev/null -w "$p %{http_code}\n" https://project.webklar.com/$p; done`. Expected: Exit 0 und dreimal `404`.
- [ ] **Step 4: Menschen prüfen** (Inhaber, Handy und Desktop): Anmelden, `#chat`, `#rechnungen`, Konto → Kündigen und Widerrufen, Abmelden. Installierte App im Flugmodus öffnen: weiße Offline-Seite. Expected: wie vor dem Push.
- [ ] **Step 5: Logs.** Run: `docker logs --since 20m webklar-kundenbereich-server 2>&1 | command grep -a -c -i 'Unbehandelter Fehler'`. Expected: 0.
---
### Task 19: Live, Bestand an Käufen prüfen (nur lesend)
**Files:** keine. **Stop: Inhaber-OK nötig** (Lauf gegen Produktion, nur lesend).
**Interfaces:**
- Consumes: `scripts/kauf-bestand-pruefen.mjs` im Container (Task 7).
- Produces: die Zahl der offenen Bestellungen als Tor für Task 20.
- [ ] **Step 1: Lauf.** Run: `docker exec -w /app webklar-kundenbereich-server node scripts/kauf-bestand-pruefen.mjs | tee /root/reports/kundenansicht-neu-2026-10-15/umschalten/kaeufe-$(date -u +%Y%m%d-%H%M%S).log`. Expected: `Offene Bestellungen: 0`, Exit 0. `Fristen laufen: n` ist nur eine Information (der Widerruf bleibt erhalten, Task 20 baut ihn nicht zurück).
- [ ] **Step 2: Entscheidung.** Ist `Offene Bestellungen` größer als 0, wartet der Controller (Checkout-Sitzungen gelten 24 Stunden, SEPA bis zu einer Woche) und wiederholt Step 1. Task 20 beginnt erst bei 0. Der Controller nennt dem Inhaber das Ergebnis.
---
### Task 20: Server-Kaufpfade zurückbauen
**Files:**
- Modify: `server/routes/funktionen.js` (`POST /:key/checkout` rund `:292-385`, `POST /zeiten/behalten` rund `:436`), danach jede Funktion, die nur diese Routen brauchte (Kandidaten: `createFeatureCheckout` `stripeBilling.js:666`, `markOrdered` `siteFeatures.js:297`, `pruefeAbo`, `pruefeInklusive` in `featureOrder.js`, der Startteil von `server/services/zeiten/behalten.js`)
- Modify: `server/services/zeiten/mails.js` (`k29Mail` rund `:100`, `k30Mail` rund `:113`) und ihre Aufrufer in `server/services/zeiten/aboAbgleich.js`
- Test: `test/knKaufRueckbau.test.js`; Reparatur oder Entfernen in `test/funktionenCheckout.test.js`, `test/zeitenCheckout.test.js`, `test/zeitenBehalten.test.js`, `test/featureCheckout.test.js`, `test/zeitenMails.test.js`, `test/aboFunktionenMails.test.js` (P6)
**Interfaces:** Produces: `POST /api/funktionen/:key/checkout` und `POST /api/funktionen/zeiten/behalten` antworten 404. `k29Mail` und `k30Mail` (Abo-Ende der Öffnungszeiten) liefern immer die Vorlagen K40 und K41 ohne Preis; die Parameter `modell` und `preis` (P6) entfallen, weil der 30-€-Weg „Behalten“ nicht mehr existiert (Spec 5.5). Die Vorlagen-Dateien K29 und K30 bleiben liegen (der Pin `test/mailTemplates.test.js` führt sie). **Bleiben, wie sie sind:** `markPaid`, `markCheckoutFailed`, `isFeatureCheckoutEvent`, `handleFeatureCheckoutEvent`, `onFeaturePaid`, `paidFeatureOrders`, `withdrawFeature`, die Nachweis-Schreibwege (`speichereZustimmung`), `backfillInvoiceLinks`, `runActivation`, `POST /api/funktionen/zeiten/einschalten`, die Mails K15 und T16, Rechnungen und `POST /api/abo/public/withdraw`. Preise in `config.js`, `oeffentlicheAnsicht` und die Antwortfelder von `GET /api/funktionen` bleiben (Pins, T19 und K15 für Bestand).
- [ ] **Step 1: Failing test schreiben** (`test/knKaufRueckbau.test.js`; Kopf wie `test/featurePaid.test.js`, dort den Aufbau der Fixture für eine bezahlte Funktion und den Webhook-Aufruf übernehmen)
```js
test('neue Käufe: die beiden Routen gibt es nicht mehr', async () => {
for (const key of ['news', 'termine', 'shop', 'unterkunft', 'zeiten']) assert.equal((await post(`/api/funktionen/${key}/checkout`, { projectId: 'p1' }, { cookie: customerCookie('c1') })).status, 404, key)
assert.equal((await post('/api/funktionen/zeiten/behalten', { projectId: 'p1' }, { cookie: customerCookie('c1') })).status, 404)
})
test('Bestand: Zahlung einer vor dem Umschalten gestarteten Bestellung wird weiter gebucht', async () => {
// Zustand ordered per aw.seed herstellen (nicht über die entfernte Route), dann checkout.session.completed über postWebhook
// Erwartet: state 'setup', unlockedByPurchase true, purchaseStatus 'paid', Einrichtungs-Ticket angelegt, K15 an den Kunden
})
test('Bestand: abgebrochene Sitzung setzt ordered auf off zurück (checkout.session.expired)', async () => { /* markCheckoutFailed über den Webhook */ })
test('Bestand: Widerruf einer gekauften Funktion wird angenommen', async () => {
// POST /api/abo/public/withdraw mit order 'funktion' für ein Konto mit gekaufter Funktion in der Frist → 200 { ok: true, receivedAt }
})
test('Öffnungszeiten-Abo-Ende: k29Mail und k30Mail liefern immer K40 und K41, ohne Preis', () => {
const frist = new Date('2026-11-16T12:00:00.000Z')
const a = k29Mail({ kunde: 'K', projekt: 'P', art: 'beendet', frist }); assert.equal(a.template, 'K40'); assert.equal('preis' in a.values, false)
assert.equal(k30Mail({ kunde: 'K', projekt: 'P', loeschdatum: frist }).template, 'K41')
})
test('Quelltext: createFeatureCheckout und markOrdered gibt es nicht mehr, die Webhook-Wege schon', () => {
const billing = readFileSync(new URL('../server/services/stripeBilling.js', import.meta.url), 'utf8'); const sf = readFileSync(new URL('../server/services/siteFeatures.js', import.meta.url), 'utf8')
assert.doesNotMatch(billing, /export (async )?function createFeatureCheckout/); assert.doesNotMatch(sf, /export (async )?function markOrdered/)
for (const n of ['markPaid', 'markCheckoutFailed', 'handleFeatureCheckoutEvent', 'onFeaturePaid', 'paidFeatureOrders']) assert.match(sf, new RegExp(`function ${n}\\b`), n)
})
```
(Die drei Kommentar-Rümpfe schreibt der Agent aus den Vorlagen in `featurePaid.test.js` und `publicForms.test.js`; die erwarteten Werte stehen in den Kommentaren.)
- [ ] **Step 2: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knKaufRueckbau.test.js`. Expected: FAIL (die Routen antworten noch, `createFeatureCheckout` existiert).
- [ ] **Step 3: Umsetzen.** Beide Routen entfernen, dann für jeden Kandidaten `command grep -a -rn '<name>' server scripts public`. Eine Funktion ohne Aufrufer außerhalb ihrer Definition fällt, eine mit Aufrufer bleibt. Die Liste „Bleiben“ oben wird nie angefasst.
- [ ] **Step 4: Alte Tests nachziehen.** Test für Test: Ist der Gegenstand eine entfernte Route, löschen. Sind es Webhook, Nachweis, Fristen oder Widerruf, den Ausgangszustand per `aw.seed` herstellen statt über die Route. Der Commit nennt jede gelöschte Datei und jeden gelöschten Testnamen.
- [ ] **Step 5: Lauf grün.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knKaufRueckbau.test.js test/featurePaid.test.js test/funktionenRoutes.test.js test/funktionenRecht.test.js test/rechtePrivatArchiv.test.js test/aboRoutes.test.js test/featureFreischaltung.test.js test/zeitenEinschalten.test.js test/featureTicket.test.js test/featureMails.test.js test/featureConsentTexte.test.js test/zeitenMails.test.js test/aboFunktionenMails.test.js test/zeitenAboAbgleich.test.js test/mailTemplates.test.js test/kundenansichtGuard.test.js`. Expected: `fail 0`.
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/routes/funktionen.js server/services/stripeBilling.js server/services/siteFeatures.js server/services/zeiten/mails.js server/services/zeiten/aboAbgleich.js test/knKaufRueckbau.test.js
git add <jede geänderte oder gelöschte Datei aus Step 3 und 4 einzeln>
git commit -m "Kundenansicht neu P10: Kaufwege für Funktionen entfernt, Webhook, Rechnungen und Widerruf bleiben" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
```
---
### Task 21: Alt-Zweige der Funktionsmodule entfernen
**Files:**
- Modify: `public/termine.js`, `termine-fragebogen.js`, `shop.js`, `shop-anfrage.js`, `buchungen.js`, `buchungen-anfrage.js`, `zeiten.js`, `zeiten-bestellen.js`, `funktionen.js`, `analyse.js`, `funktion-bestellen.js` (Fundstellen von P4: `termine.js:49-56,100-106,322-394`, `shop.js:64-74,195,253,295-360`, `buchungen.js:32-41,124-130,155-205`, `zeiten.js:55,62-73,132,161,218-226`, `funktionen.js:474-515`, `analyse.js:22-49,76-112`; per `command grep -a -n modell public/*.js` neu bestimmen)
- Test: `test/knAltZweigeWeg.test.js`; die Pins aus P4 (Importlisten und Verbote) auf den neuen Stand
**Interfaces:** Produces: Die Module lesen `modell` nie mehr, es gibt nur den Abo-Zweig. `renderEinschaltSeite` (`funktion-bestellen.js`) und `renderZeitenEinschalten` (`zeiten-bestellen.js`) bleiben, ihre Bestell- und Behalten-Gegenstücke entfallen.
- [ ] **Step 1: Failing test schreiben** (`test/knAltZweigeWeg.test.js`; `KEIN_PREIS` aus `test/support/modulAbo.js`, P4)
```js
const lies = (f) => readFileSync(new URL(`../public/${f}`, import.meta.url), 'utf8')
const MODULE = ['termine.js', 'termine-fragebogen.js', 'shop.js', 'shop-anfrage.js', 'buchungen.js', 'buchungen-anfrage.js', 'zeiten.js', 'zeiten-bestellen.js', 'funktionen.js', 'analyse.js', 'funktion-bestellen.js']
const BESTELL = ['funktion-bestellen.js', 'zeiten-bestellen.js', 'shop-anfrage.js', 'buchungen-anfrage.js']
test('kein Modul liest mehr `modell`', () => { for (const f of MODULE) assert.doesNotMatch(lies(f), /\bmodell\b/, f) })
test('Bestell- und Anfrageseiten: kein Preis, keine Zahlung, kein Behalten, keine Fortsetzung', () => {
for (const f of BESTELL) { assert.doesNotMatch(lies(f), KEIN_PREIS, f); assert.doesNotMatch(lies(f), /fortsetzen|wunschWeg|priceLabel|ustHinweis|unternehmer/, f) }
})
test('alle relativen und absoluten Importe der Module lösen sich auf', () => {
for (const f of MODULE) for (const m of lies(f).matchAll(/from '([./][^']+)'/g)) assert.ok(existsSync(new URL(m[1].startsWith('/') ? `../public${m[1]}` : `../public/${m[1]}`, import.meta.url)), `${f}: ${m[1]}`)
})
```
- [ ] **Step 2: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knAltZweigeWeg.test.js`. Expected: FAIL (`modell` kommt vor).
- [ ] **Step 3: Umsetzen.** Je `modell`-Stelle: Abo-Zweig behalten, `else` löschen, die Variable entfernen, wo sie konstant wird. Danach nicht mehr aufgerufene Funktionen und Importe löschen. Ein Treffer von `KEIN_PREIS` im Alt-Zweig wird gelöscht. Steht er in einem Text des Abo-Zweigs (P4, Abschnitt „Texte“), ist das ein Fehler von P4 und geht an den Controller.
- [ ] **Step 4: Pins und Tests der Module.** Die Importlisten-Pins aus P4 (`termineView.test.js:373-394,450-453`, `termineFragebogenView.test.js:994-1034,3072-3086`, `zeitenBestellen.test.js:841-865`, `zeitenView.test.js:390-399`, `shopWiring.test.js:40-42`) werden auf die kleineren Listen gesetzt (nur Löschungen, keine neuen Importe). Die P4-Fälle „ohne `modell`“ (alte Bestellseite, „Behalten“, Unternehmer-Haken, lange Reiterleiste) entfallen, ihr Gegenstand ist weg.
- [ ] **Step 5: Lauf grün, dazu die Modul-Tests und die Gates.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knAltZweigeWeg.test.js test/termineView.test.js test/termineFragebogenView.test.js test/shopView.test.js test/shopWiring.test.js test/buchungenUi.test.js test/unterkunftWiring.test.js test/zeitenView.test.js test/zeitenBestellen.test.js test/analyseView.test.js test/funktionenTiles.test.js test/funktionBestellenKey.test.js test/kundenansichtNeuModulCss.test.js`, dann `free -m` und `for g in p3 p4; do /root/reports/kundenansicht-neu-2026-10-15/werkzeug/run-browser.sh test/ui/kundenansicht-neu.ui.mjs /root/reports/kundenansicht-neu-2026-10-15/werkzeug/tmp/shots-p10-alt-$g --gate $g || break; done`. Expected: `fail 0` und beide Gates Exit 0.
- [ ] **Step 6: Commit**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add test/knAltZweigeWeg.test.js public/termine.js public/termine-fragebogen.js public/shop.js public/shop-anfrage.js public/buchungen.js public/buchungen-anfrage.js public/zeiten.js public/zeiten-bestellen.js public/funktionen.js public/analyse.js public/funktion-bestellen.js
git add <jede angepasste Testdatei einzeln>
git commit -m "Kundenansicht neu P10: Alt-Zweige der Funktionsmodule (Bestellseiten, Behalten, lange Reiter) entfernt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git tag kn-p10-c3
```
---
### Task 22: Live, Rückbau ausliefern
**Files:** keine. **Stop: Inhaber-OK nötig** (Push). Voraussetzung: Task 19 meldete 0 offene Bestellungen.
**Interfaces:**
- Consumes: Tag `kn-p10-c3`.
- Produces: Kaufwege und Alt-Zweige sind in Produktion weg.
- [ ] **Step 1: Push.** Der Inhaber pusht `git push origin 'kn-p10-c3^{commit}:refs/heads/main'`.
- [ ] **Step 2: Prüfen.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && node scripts/umschalten-nachpruefung.mjs https://project.webklar.com --erwartet=neu; curl -s -o /dev/null -w '%{http_code}\n' -X POST https://project.webklar.com/api/funktionen/termine/checkout`. Expected: Exit 0 und `404`.
- [ ] **Step 3: Menschen prüfen** (Inhaber): jede Funktion unter „Meine“ öffnen (News, Terminplaner, Webshop, Buchungskalender, Öffnungszeiten, Analyse falls an), eine Einschalten-Seite ansehen. Expected: wie vor dem Push, keine leeren Flächen, keine Fehlerkarte.
- [ ] **Step 4: Logs.** Run: `docker logs --since 20m webklar-kundenbereich-server 2>&1 | command grep -a -c -i 'Unbehandelter Fehler'`. Expected: 0.
---
### Task 23: Schalter-Code vereinfachen (`neueAnsicht` immer wahr)
**Files:**
- Modify: `server/services/kundenansicht/schalter.js` (drei Konstanten), `server/config.js` (Abschnitt `kundenansichtNeu` und `kundenansichtNeuEnabled` entfallen), `server/index.js` (der tote Handler `app.get('/dashboard.html', (req, res, next) => { … redirect('/login.html') … })` hinter `express.static` entfällt)
- Test: `test/knSchalterWeg.test.js`, `test/support/dateiListe.js` (die Hilfen `dateien` und `text` aus `test/knAufraeumen.test.js`, das sie danach von dort importiert); entfernen oder umstellen: `test/kundenansichtSchalter.test.js`, `test/kundenansichtDashboard.test.js`, `test/kundenansichtMe.test.js` (P0) und jede Zeile in `test/` mit `KUNDENANSICHT_NEU` oder `config.kundenansichtNeu`
**Interfaces:** Produces:
```js
export const neueAnsichtModus = () => 'on'
export const neueAnsichtFuerSession = () => true
export const neueAnsicht = () => true
```
Die Aufrufer ändern sich nicht. Die Alt-Zweige hinter `neueAnsicht(req)` bleiben als toter Code stehen und werden in Task 25 gezählt (sie zu entfernen hat keinen Nutzen und Risiko, der Inhaber entscheidet). `config.abo.termsVersionNeu` bleibt als gleichwertiger Alias. `GET /api/auth/me` liefert dadurch `ansicht: 'neu'` für jeden (das Werkzeug braucht keine Änderung).
- [ ] **Step 1: Failing test schreiben** (`test/knSchalterWeg.test.js`)
```js
test('schalter.js: drei Konstanten, jede Sitzung (auch keine) ist neu', async () => {
const { neueAnsichtModus, neueAnsichtFuerSession, neueAnsicht } = await import('../server/services/kundenansicht/schalter.js')
assert.equal(neueAnsichtModus(), 'on')
for (const s of [null, undefined, {}, { role: 'admin' }, { customerId: 'c1', appwriteUserId: 'u1', email: 'k@example.invalid' }]) assert.equal(neueAnsichtFuerSession(s), true)
assert.equal(neueAnsicht({}), true)
})
test('nichts in server/, scripts/, public/ kennt den Schalter noch', () => {
for (const f of [...dateien('server'), ...dateien('scripts'), ...dateien('public', ['public/vertragsunterlagen', 'public/rechtliches'])]) assert.doesNotMatch(text(f), /kundenansichtNeu|KUNDENANSICHT_NEU/, f)
})
test('Tests und Testhilfen setzen den Schalter nicht mehr', () => {
for (const f of dateien('test').filter((x) => x !== 'test/knSchalterWeg.test.js')) assert.doesNotMatch(text(f), /kundenansichtNeu|KUNDENANSICHT_NEU/, f)
})
test('der tote Dashboard-Handler hinter express.static ist weg', () => {
const index = text('server/index.js'); assert.ok(index.includes('dashboardWeiche(')); assert.doesNotMatch(index, /app\.get\('\/dashboard\.html', \(req, res, next\)/)
})
```
- [ ] **Step 2: Lauf, muss scheitern.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/knSchalterWeg.test.js`. Expected: FAIL.
- [ ] **Step 3: Umsetzen.** `schalter.js` und `config.js` wie oben. Dann `command grep -a -rln 'KUNDENANSICHT_NEU\|kundenansichtNeu' test` abarbeiten: Zeilen, die die Umgebung oder `config.kundenansichtNeu` setzen, löschen; Fälle, deren Ausgangslage „alte Ansicht“ ist (404 ohne neue Ansicht, unveränderte alte Antwort, Nicht-Pilot-Kunde), löschen; die übrigen bleiben unverändert. `kundenansichtSchalter.test.js` und die Modus-Matrix in `kundenansichtDashboard.test.js` entfallen, `kundenansichtMe.test.js` erwartet `ansicht: 'neu'`.
- [ ] **Step 4: Lauf grün, mit allen berührten Dateien.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/knSchalterWeg.test.js $(command grep -a -rl 'neueAnsicht\|dashboardWeiche' test/*.test.js | sort -u | tr '\n' ' ')`. Expected: `fail 0`.
- [ ] **Step 5: Commit und Tag**
```bash
cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht
git add server/services/kundenansicht/schalter.js server/config.js server/index.js test/knSchalterWeg.test.js test/support/dateiListe.js test/knAufraeumen.test.js
git add <jede geänderte Testdatei einzeln; gelöschte stehen per git rm im Index>
git commit -m "Kundenansicht neu P10: Schalter vereinfacht, neueAnsicht ist immer wahr, Konfiguration und toter Handler entfernt" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"
git tag kn-p10-c4
```
---
### Task 24: Live, Schalter-Code ausliefern und Umgebung bereinigen
**Files:** keine. **Stop: Inhaber-OK nötig** (Push, dann `.env` und Compose).
**Interfaces:**
- Consumes: Tag `kn-p10-c4`.
- Produces: Der Schalter existiert nirgends mehr; Compose und `.env` sind sauber (Step 3, optional).
- [ ] **Step 1: Push.** Der Inhaber pusht `git push origin 'kn-p10-c4^{commit}:refs/heads/main'`. Prüfen wie Task 22 Step 2 (`--erwartet=neu`, Exit 0).
- [ ] **Step 2: Anmelden prüfen.** Der Inhaber meldet sich auf dem Handy an und öffnet `#chat`. Expected: unverändert. Den Schalter gibt es serverseitig nicht mehr, Compose und `.env` sind jetzt wirkungslos.
- [ ] **Step 3: Umgebung bereinigen** (optional, nur auf Wunsch des Inhabers). Sicherungen: `cp -n /home/webklar/apps/.env /home/webklar/apps/.env.bak-$(date -u +%Y-%m-%d)-kundenansicht-ende` und `cp -n /home/webklar/apps/docker-compose.yml /home/webklar/apps/docker-compose.yml.bak-$(date -u +%Y-%m-%d)-kundenansicht-ende`. Dann `sed -i '/^KUNDENANSICHT_NEU/d' /home/webklar/apps/.env` und `sed -i '/KUNDENANSICHT_NEU/d' /home/webklar/apps/docker-compose.yml`, danach `cd /home/webklar/apps && docker compose up -d --no-deps kundenbereich-server`. Expected: Dienst `Up`, `command grep -a -c KUNDENANSICHT /home/webklar/apps/docker-compose.yml` gibt `0`.
---
### Task 25: Selbstprüfung
**Files:** keine.
**Interfaces:**
- Produces: Tag `kn-p10` und der Bericht an den Controller.
- [ ] **Step 1: Eigene Testdateien grün.** Run: `free -m`, dann `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenartBestand.test.js test/knUmschaltSeiten.test.js test/knUmschaltNachpruefung.test.js test/kaufBestandPruefen.test.js test/knUmschaltCheckliste.test.js test/knOfflineCache.test.js test/knFassung6Fest.test.js test/knLoginModul.test.js test/knAufraeumen.test.js test/knKaufRueckbau.test.js test/knAltZweigeWeg.test.js test/knSchalterWeg.test.js`. Expected: `fail 0`.
- [ ] **Step 2: Die ganze Suite.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 2400 nice -n 10 systemd-run --scope -q -p MemoryMax=2500M -p MemorySwapMax=0 env NODE_OPTIONS=--max-old-space-size=1024 node --test --test-concurrency=2 test/*.test.js; ls -l /dev/null`. Expected: `fail 0`. Die `pass`-Zahl liegt um die neuen Dateien über und um die entfernten Fälle (Schalter, Orakel, Kaufwege, Alt-Zweige) unter `N0`; die Differenz steht im Bericht. `/dev/null` zeigt `crw-rw-rw-`.
- [ ] **Step 3: Verträge abhaken.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && command grep -a -rc 'neueAnsicht(' server scripts | command grep -a -v ':0$'; ls public/vertragsunterlagen/v5; command grep -a -n 'Version 6' public/abo-bedingungen.html | head -2; git status --short`. Expected: Die Liste der Dateien mit toten Alt-Zweigen (`neueAnsicht(`) geht in den Bericht, das Archiv v5 enthält HTML und PDF, die Hauptseite nennt `Version 6`, der Status ist leer.
Abgehakt: **V1** (Schalter ist konstant `on`, `/dashboard.html` kommt vor `express.static` aus der Weiche), **V2** (die öffentlichen Seiten liefert `umschaltSeitenRouter` aus `public/neu/oeffentlich/`), **V4** (das Skript nimmt die Kundenart nie vom Client, es nutzt `KUNDENARTEN`), **V12** (Fassung 6 ist `/abo-bedingungen.html`, Fassung 5 liegt im Archiv, `config.abo.termsVersion` ist Fassung 6).
- [ ] **Step 4: Tag und Bericht.** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && git tag kn-p10 && git rev-parse --short kn-p10`. Letzte Nachricht des Agenten: Tag mit Commit-Hash, `N0` und die Endzahl der grünen Tests, `Summe gesetzt=` aus Task 8, die Dateien mit toten `neueAnsicht(`-Zweigen, alle gelöschten Testdateien aus den Tasks 17, 20, 21 und 23 mit Grund, die offenen Vertragsfragen.
---
## Vertragsfragen an den Controller
Alle Vorschläge sind im Plan umgesetzt; bitte bestätigen oder korrigieren, bevor P10 startet.
1. **VF1 Dateien außerhalb der Zuständigkeitstabelle.** P10 braucht zusätzlich `server/routes/umschaltSeiten.js`, `scripts/umschalten-nachpruefung.mjs`, `scripts/kauf-bestand-pruefen.mjs`, `docs/umschalten-kundenansicht.md`, `test/support/dateiListe.js`, `test/support/modulKlassen.json`, `test/support/oeffentlichStand.json` und die Tests `kundenartBestand`, `knUmschaltSeiten`, `knUmschaltNachpruefung`, `kaufBestandPruefen`, `knUmschaltCheckliste`, `knOfflineCache`, `knFassung6Fest`, `knLoginModul`, `knAufraeumen`, `knKaufRueckbau`, `knAltZweigeWeg`, `knSchalterWeg`. Dazu eine Zeile in `server/index.js` (geteilt mit P0, hinter dessen Weiche). Vorschlag: in die Zeile von P10 aufnehmen.
2. **VF2 Compose-Zeilen für den Schalter.** P0 beschreibt das Einschalten „in der Compose-Umgebung“, aber kein Paket legt die Zeilen `KUNDENANSICHT_NEU` und `KUNDENANSICHT_NEU_PILOT_EMAILS` in `/home/webklar/apps/docker-compose.yml` an (Datei außerhalb des Repos). Vorschlag: Der Controller legt sie in Phase E vor dem Pilot an; Task 9 Step 4 prüft sie.
3. **VF3 Fassung 6 und Archiv v5 (P8).** P10 baut auf dem Plan von P8 auf: Seite `public/neu/rechtliches/abo-bedingungen.html` mit PDF und `.sha256` daneben (die PDFs entstehen nach der Freigabe, P8 Task 11 Ablauf), Archiv v5 unter `public/vertragsunterlagen/v5/` (HTML bis auf den PDF-Link bytegleich, PDF bytegleich), `FASSUNGEN` mit dem Feld `archiv` an der Zeile v5. P10 führt die Übergabe aus P8-Vertragsfrage 1 aus: Verschieben, die Zeilen v6 und v5 der Tabelle, `abo6` im Build-Skript, `DOKUMENTE_V6` im Hochladen-Skript. Die Funktionsseiten von P8 bleiben unberührt, ihre Verweise auf den Entwurfspfad der Abo-Seite lösen 301-Weiterleitungen auf (Task 14). Bitte V12 um die Pfade ergänzen.
4. **VF4 `fassungDerAnfrage` (P7).** Sind `termsVersion` und `termsVersionNeu` gleich, meldet P7s Reihenfolge („alt“ zuerst) immer `alt`, und der Weg der Fassung 6 liefe nie. Vorschlag: P7 baut die Reihenfolge „Neu-Fassung zuerst“ schon jetzt und prüft sie mit gleichen Werten; P10 änderte dann in Task 14 nichts.
5. **VF5 Service-Worker-Registrierung und iOS-Fokuszoom.** Beides steht heute nur in `public/app.js`. Die neue Hülle (P1, `huelle.js`) muss die Registrierung enthalten, sonst fügt P10 sie in Task 16 ein. Vorschlag: P1 ergänzt sie, damit P10 nur prüft.
6. **VF6 Alt-Zweige in P4-Dateien.** P4 überlässt P10 das Entfernen der Alt-Zweige (VF6 von P4). Task 21 ändert dafür elf P4-Dateien und ihre Importlisten-Pins. Bitte bestätigen, dass das in P10 liegt.
7. **VF7 V1 und `/dashboard.html` ohne Sitzung.** V1 sagt „ohne Sitzung → false“. Für die Seite braucht P10 bei `on` die neue Seite auch ohne Sitzung (sonst startet die PWA auf der alten dunklen Seite). Task 3 löst das in der Weiche (Zeile `/dashboard.html` in `UMSCHALT_SEITEN`), nicht in `neueAnsichtFuerSession`. Vorschlag: V1 um diesen Satz ergänzen.
8. **VF8 Playwright-Skripte der alten Seite.** Task 17 löscht die `test/ui/*.ui.mjs`, die `dashboard.html` oder `app.js` laden. Sie gehören nicht zu `npm test`; die Abdeckung tragen Unit-Tests und die Werkzeug-Gates. Bitte bestätigen.
9. **VF9 Prüfung „Fassung 6 hochgeladen“.** Es gibt kein lesendes Werkzeug für die WOMS-Collection `legalVersions`. Task 9 stützt sich auf `rechtliches-hochladen.mjs --probe` (P8, listet Versionen mit `source` und fehlende Karten) und die Bestätigung des Inhabers. Die WOMS-Nummer `vN` zählt Uploads und ist nicht die Fassungsnummer, deshalb prüft der Plan die `source`. Bitte bestätigen.
10. **VF10 Beobachtungszeit und Rückweg.** Zwischen Task 11 und Task 14 liegt eine Beobachtungszeit (Vorschlag: sieben Tage). Task 14 ist der Punkt ohne Rückweg per Schalter und braucht ein eigenes OK des Inhabers.
11. **VF11 Umfang des Rückbaus.** Task 20 baut nur die zwei Routen und ihre Helfer zurück. Preise in `config.js`, die Antwortfelder von `GET /api/funktionen` und `oeffentlicheAnsicht` bleiben (Pins, Mails T19 und K15 für den Bestand). Task 23 lässt die Alt-Zweige hinter `neueAnsicht(` als toten Code stehen und zählt sie; ihr Entfernen ist eine eigene Entscheidung des Inhabers.
12. **VF12 Reste des 30-€-Behaltens außerhalb von P10.** P6 überlässt P10 die Route und die Texte K29/K30 (Task 20 stellt `k29Mail` und `k30Mail` auf K40 und K41 um, die Vorlagen-Dateien bleiben). P9 verweist `zeitenStatusText` („falls der Kunde die Funktion nicht behält“) und die Bestätigungstexte des Knopfs „Aktivieren (ohne Zahlung)“ ebenfalls an P10; beides liegt im WOMS-Repo. Vorschlag: eine eigene kleine WOMS-Änderung auf `test` mit Push durch den Inhaber, nicht in diesem Plan.
13. **VF13 Wortlaut `freigegeben` und Setup vor `on` (P7 VF9).** Task 1 und Task 9 prüfen, dass `WORTLAUT_STATUS` auf `'freigegeben'` steht und `scripts/kundenansicht-setup.mjs` gelaufen ist (`--pruefen`). Das Setup selbst läuft nicht in P10, sondern auf Anweisung des Controllers nach OK des Inhabers.