# Kundenansicht neu, Paket P5: Abo-Modell Kern (Server) 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:** Der Server der Kundenansicht kennt das Abo-Modell der Funktionen: Kundenart, Katalog mit Abo-Zuordnung, Einschalten ohne Zahlung, gesammelte Widerrufsfristen und die Einrichtungs-Marker am Ticket. Alles nur für Sitzungen mit neuer Ansicht oder rein additiv. **Architecture:** Vier neue Dienste (`kundenart.js`, `aboFunktionen.js`, `fristen.js`, `funktionEinschalten.js`) und zwei Helfer (`aboKatalog.js` für den Katalog, `aboUnterlagen.js` als einzige Quelle der verlinkten Unterlagen). Bestehende Routen bekommen Zweige hinter `neueAnsicht(req)`. Das Einschalten folgt dem Muster `einschaltenZeiten` (Sperre `feature-order`, Nachweis vor Zustand, Ticket danach, Heilung statt Doppelticket). Neue Datenbankfelder legt nur `scripts/kundenansicht-setup.mjs` an; der Code schreibt sie nur, wenn ein Aufrufer die zugehörigen Parameter übergibt. Die Wortlaute und Unterlagen der Fassung 6 (`WORTLAUT_V6`, `FUNKTIONEN_UNTERLAGEN_V6` aus P8) verdrahtet Task 15, sobald `kn/recht` im Hauptzweig ist; bis dahin gelten die Entwurfstexte in `config.aboFunktionen` und die Alt-Unterlagen des Registers. **Tech Stack:** Node 20 (Host 24), Express 4, Appwrite-REST, `node --test` mit den Nachbauten aus `test/support/` (`appwriteMock`, `fakeStripe`, `testApp`, `fixtures`, `mailSink`). **Spec:** `docs/superpowers/specs/2026-10-15-kundenansicht-neu-design.md` (E3, E4, E6, E8, E9, E10, E14, E17, E21, 5.4, 5.5, 5.6, C1, C3, C4, C5, C6, C9, C10, C13, C15) **Übersicht/Verträge:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht/docs/superpowers/plans/2026-10-16-kundenansicht-00-uebersicht.md` (gilt wörtlich; dieses Paket liefert V4, V5, V6, V7 komplett) **Worktree und Branch:** `/home/webklar/apps/kundenbereich.webklar.com/dev-kn-server`, Branch `kn/server` (von Tag `kn-p0`). Nach Task 14 Tag `kn-p5`. Alle Pfade unten sind relativ zu diesem Worktree. **Ausnahme Task 15** (Fassung 6 verdrahten): läuft erst nach dem Merge von `kn/recht` im Hauptzweig `feature/kundenansicht-neu` (Worktree `/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht`) als erste Aufgabe von P7 („P7 Task 0“), Tag `kn-p5-v6`. ## Global Constraints Es gelten **alle** Global Constraints der Übersicht wörtlich (Schalter, Aussehen, du-Anrede, keine Funktionspreise, Hashes, Kundenart nur vom Server, Nachweise, Testbefehle, Commits, Sicherheitsblock, Prod-Daten). Paketeigene Zusätze: - **Vorbereitung (einmalig, kein Commit):** `git -C /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server log -1 --format=%D` nennt `kn-p0` und `kn/server`; `ls server/services/kundenansicht/schalter.js node_modules` existiert (Symlink `ln -s ../build/node_modules node_modules`, nie `git add -A`); `command grep -a -c neueAnsichtFuerKunde server/services/kundenansicht/schalter.js` zeigt mindestens 1 (P0-Erweiterung von V1 für Jobs ohne Cookie, Task 13 braucht sie; fehlt sie, den Controller melden und nicht selbst in `schalter.js` ergänzen); `free -m` zeigt mindestens 2000 MB verfügbar. - **Alte Ansicht bytegleich:** `oeffentlicheAnsicht(key)` (Pins `test/funktionenRoutes.test.js:101` und `test/zeitenEinschalten.test.js:588`) und die Listenantwort ohne neue Ansicht bleiben unverändert. Einzige additive Ausnahme: `featureView` liefert `unlockedBy` für alle Funktionen (V7). - **Schema:** Neue Attribute stehen nur in `scripts/kundenansicht-setup.mjs` (Flag `--pruefen`, Alias `--check`). `setup-abo-collections.mjs`, `feature-setup.mjs`, `zeiten-setup.mjs` bleiben unberührt. Das Skript läuft in diesem Plan nie gegen Produktion, nur gegen Nachbauten. - **Testkopf** (neue Dateien mit Server-Zugriff), danach `config` und Module dynamisch importieren. `useTestEnv` setzt den Rest; Pilot über die Umgebung, E-Mail-Liste absichtlich mit Großschreibung und Leerzeichen: ```js const aw = await startAppwriteMock({ schema: { /* schemaKeys(...) je Collection */ } }) const st = await startFakeStripe() useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) Object.assign(process.env, { KUNDENANSICHT_NEU: 'pilot', KUNDENANSICHT_NEU_PILOT_EMAILS: 'k@example.invalid, TEST@example.invalid' }) ``` c1 (`k@example.invalid`) und c4 (`test@example.invalid`) sind Pilot, c2 (`k2@example.invalid`) nicht. Tests, die den Modus wechseln, setzen `config.kundenansichtNeu.mode` und `.pilotEmails` direkt und stellen sie in `after()` zurück. - **Uhr:** Routen nehmen `einschaltenDeps.jetzt()` (aus `server/services/zeiten/einschalten.js`). Tests setzen `JETZT = new Date('2026-10-17T08:00:00.000Z')` (ein Samstag). - **Fehlerform neuer Routen:** `{ error: , code, ...extra }` über `fail()` aus `server/routes/funktionen.js`; Rohtexte von Appwrite oder Stripe nie. Neue Antworten und Fehlertexte enthalten kein `€`. - **Abgrenzung:** keine Mails, kein Ledger (`hourLedger.js`), keine Team-Aktivierung (`adminActivate`, `funktionenAdmin.js`). Das ist P6. - **Commit:** `git add ` und `git commit -m "" -m "Co-Authored-By: Claude Opus 5.5 "`. ## Review Focus Die fünf wahrscheinlichsten Fehlerfälle dieses Pakets, häufigster zuerst. Jede Zeile nennt den Task, dessen Tests sie festnageln. 1. **Kundenart leer, unbekannt oder gefälscht** (Übersicht-Review-Focus 1): `{}`, `'Unternehmen'`, `'kaputt'` gelten als `privat`; ein Body-Feld `kundenart` oder `unternehmer: true` vom Client ändert nichts; der Nachweis speichert die Kundenart des Dokuments. Die Kinder-unter-16-Aussage der Analyse ist im Abo-Modell serverseitig erzwungen: ohne `audienceConfirmed: true` 400 `zielgruppe_fehlt` und nichts geschrieben, mit Haken ein Nachweis mit Linkzeile, Zielgruppen-Zeile, Knopf und Kundenart. Tests: Task 2, 3, 10, 12, 13. 2. **Pilot-Schalter an den Rändern** (Übersicht-Review-Focus 2): `off` mit `*`, leere Liste, E-Mail mit Großbuchstaben und Leerzeichen, Mitarbeiter-Kundenansicht, unbekannter Schlüssel, `analyse`. Ohne neue Ansicht antwortet das Einschalten 404 und schreibt nichts. Die Kundenart-Pflicht beim Anlegen gilt nur ab `pilot` (bei `off` bleibt die WOMS-Anlage wie heute), Jobs ohne Cookie fragen den Schalter über `neueAnsichtFuerKunde`. Tests: Task 3, Task 10 („Einschalten nur in neuer Ansicht“), Task 11, Task 13. 3. **Doppeltes oder widersprüchliches Einschalten** (Übersicht-Review-Focus 4): zwei Taps nacheinander und gleichzeitig, schon aktiv, zweite Website, Zustand `setup` ohne Ticket (Heilung statt zweitem Ticket), Ticket scheitert beim Anlegen. Ergebnis: höchstens ein Ticket, ein Nachweis, klare 409-Codes. Tests: Task 9, 10. 4. **Abo- und Frist-Randfälle:** Hosting-Kunde bei Wartungs-Funktion (`wechsel: true`), gekündigt, `past_due`, widerrufener Wechsel, Abo endet zwischen Vorprüfung und Sperre; Privatkunde mit Abo „Beginn nach Fristende“ (Stripe `trialing`): `beginnt_spaeter` mit Datum statt Zahlungsmeldung; Fristende über Wochenende und Feiertage (Samstag + 14 Tage endet erst am Montag), Fristanzeige nur bei privat. Tests: Task 4, 5, 9, 10. 5. **Alte Ansicht bei nicht gelaufenem Setup-Skript und neuen Registerfeldern:** ohne `kundenart` oder `abrechnung` schreibt der Code kein neues Attribut; `oeffentlicheAnsicht` und die Liste ohne neue Ansicht bleiben unverändert. Tests: Task 6, 7, 8, 11. ## Dateiübersicht | Datei | Aufgabe | Task | |---|---|---| | `scripts/kundenansicht-setup.mjs` (neu) | legt `featureConsents.kundenart`, `workorders.einrichtungFuer`, `workorders.einrichtungAbrechnung`, `portalFeatures.einrichtungAbrechnung` (und `unlockedBy`, falls fehlend) an; `--pruefen` | 1 | | `test/support/schema.js` (geteilt, nur Quellen) | liest das Skript für `featureConsents` und `portalFeatures` | 1 | | `server/services/kundenart.js` (neu) | V4: `kundenartGespeichert`, `kundenartWirksam` | 2 | | `server/routes/auth.js` (nur `sanitizeCustomer`) | `kundenart` und `kundenartWirksam` in `/api/auth/me` und Login | 2 | | `server/services/customerAdmin.js` | Kundenart beim Anlegen Pflicht (nur bei Schalter `pilot` oder `on`), beim Ändern optional | 3 | | `server/services/aboFunktionen.js` (neu) | V5: `ENTHALTEN_IN`, `aboProdukt`, `aboDeckt` (auch `beginnt_spaeter`); dazu `aboLaeuftFuer`, `aboModellFuerKunde` | 4, 13 | | `server/services/fristen.js`, `server/routes/fristen.js` (neu) | V6: `sammleFristen`, `GET /api/fristen` | 5 | | `server/config.js` (Abschnitt `aboFunktionen`, Registerfelder in `features`) | Katalogfelder und Entwurfstexte (`beginn`, `hinweis`, Analyse-Zeilen); Task 15 stellt die Texte auf Fassung 6 | 6, 15 | | `server/services/featureRegistry.js` (Katalogfelder) | `katalogReihenfolge`, `katalogFelder`, `consentAbo` (mit Item `hinweis`), `consentAboAktiv`, `aboweg`, `consentAboAnalyse`, `abowegAnalyse` | 6, 15 | | `server/services/aboUnterlagen.js` (neu) | `unterlagenFuer`, `analyseUnterlagen`, `pdfAnhaenge`; einzige Quelle der verlinkten Unterlagen (Katalog, Nachweis, Analyse) | 6, 15 | | `server/services/featureConsents.js` (Attribut `kundenart`) | `kundenart` im Nachweis | 7 | | `server/services/funktionEinschalten.js` (neu) | V7: `einschalten` | 9 | | `server/services/aboKatalog.js` (neu) | `katalogFuerKunde`, `anfrageStatusJeProjekt` | 11 | | `server/routes/funktionen.js` (Katalog, Einschalten) | `POST /:key/einschalten`, Weiche an `/zeiten/einschalten`, Listenfelder, Analyse-Route (Zielgruppe, Nachweis, Fassung) | 10, 11, 13 | | `server/services/shop/anfrage.js`, `server/routes/shop.js` (nur Anfrage) | Unternehmer aus Kundenart, `nur_betrieb` | 12 | | `test/aboFassung6Verdrahtung.test.js` (neu) | Katalog, Nachweis, Analyse und K18 mit den Unterlagen und Wortlauten der Fassung 6 | 15 | | Fremde Dateien, jeweils minimal (siehe Vertragsfragen 4): `server/index.js` (1 Import, 1 Mount), `server/routes/admin/customers.js` (`code` durchreichen), `server/services/ticketAdmin.js` (`abrechnung`), `server/services/siteFeatures.js` (`featureView`, `legeTicketAn`), `server/services/zeiten/einschalten.js` (`abrechnung`, `vorpruefung`), `server/services/analyse.js` (Hosting im Abo-Modell, `nachweis`-Rückruf, K18 mit Fassung 6) | | 3, 5, 8, 9, 13, 15 | | Angepasster Pin: `test/featureSetup.test.js:191-192` | `schemaKeys('featureConsents')` kennt jetzt `kundenart` | 1 | --- ### Task 1: Setup-Skript und Schema-Quelle **Files:** - Create: `scripts/kundenansicht-setup.mjs` - Modify: `test/support/schema.js` (nur Quellen), `test/featureSetup.test.js:191-192` (Pin) - Test: `test/kundenansichtSetup.test.js` **Interfaces:** - Consumes: `createAppwriteClient()` aus `scripts/analyse-setup.mjs` (`collectionExists`, `listAttributes`, `createAttribute`, `waitForAttribute`), `ZEITEN_FEATURE_ATTRIBUTES` aus `server/services/zeiten/schema.js`. - Produces (`config.js` erst in `main()` laden, wie `scripts/feature-setup.mjs`): ```js export const KUNDENANSICHT_ATTRIBUTE = { featureConsents: [{ key: 'kundenart', type: 'string', size: 12, required: false }], workorders: [{ key: 'einrichtungFuer', type: 'string', size: 16, required: false }, { key: 'einrichtungAbrechnung', type: 'string', size: 8, required: false }], portalFeatures: [/* unlockedBy (aus ZEITEN_FEATURE_ATTRIBUTES), */ { key: 'einrichtungAbrechnung', type: 'string', size: 8, required: false }], } export async function main({ pruefen = false, client, log = console.log } = {}) // → { missing: [{ collection, ...attr }] } ``` - [ ] **Step 1: Failing test `test/kundenansichtSetup.test.js` schreiben** ```js import { KUNDENANSICHT_ATTRIBUTE, main } from '../scripts/kundenansicht-setup.mjs' // Kopf: startAppwriteMock(), useTestEnv(), dann const { config } = await import('../server/config.js') const client = (vorhanden = {}) => { const calls = { attrs: [], waits: [] } return { calls, collectionExists: async () => true, listAttributes: async (id) => (vorhanden[id] ?? []).map((key) => ({ key })), createAttribute: async (id, a) => calls.attrs.push(`${id}.${a.key}`), waitForAttribute: async (id, k) => calls.waits.push(`${id}.${k}`) } } const ID = () => config.collections const ALLE = () => [`${ID().featureConsents}.kundenart`, `${ID().portalFeatures}.einrichtungAbrechnung`, `${ID().portalFeatures}.unlockedBy`, `${ID().workorders}.einrichtungAbrechnung`, `${ID().workorders}.einrichtungFuer`] test('Schema: Schlüssel, Größen, nur optionale Strings; längste Werte passen hinein', () => { const kurz = Object.fromEntries(Object.entries(KUNDENANSICHT_ATTRIBUTE).map(([c, l]) => [c, l.map((a) => `${a.key}:${a.size}`)])) assert.deepEqual(kurz, { featureConsents: ['kundenart:12'], workorders: ['einrichtungFuer:16', 'einrichtungAbrechnung:8'], portalFeatures: ['unlockedBy:8', 'einrichtungAbrechnung:8'] }) assert.ok(Object.values(KUNDENANSICHT_ATTRIBUTE).flat().every((a) => a.type === 'string' && a.required === false)) assert.ok('unternehmen'.length <= 12 && 'projekt'.length <= 8 && 'unterkunft'.length <= 16) }) test('--pruefen meldet alle fünf Lücken und legt nichts an', async () => { const c = client(); const log = [] const r = await main({ pruefen: true, client: c, log: (z) => log.push(z) }) assert.deepEqual(r.missing.map((a) => `${a.collection}.${a.key}`).sort(), ALLE()) assert.deepEqual(c.calls, { attrs: [], waits: [] }) assert.ok(log.some((z) => z === `- ${ID().featureConsents}.kundenart FEHLT`)) }) test('ohne --pruefen: legt an und wartet; der zweite Lauf ist idempotent', async () => { const c = client(); await main({ client: c, log: () => {} }) assert.deepEqual([...c.calls.attrs].sort(), ALLE()); assert.deepEqual([...c.calls.waits].sort(), ALLE()) const voll = client({ [ID().featureConsents]: ['kundenart'], [ID().workorders]: ['einrichtungFuer', 'einrichtungAbrechnung'], [ID().portalFeatures]: ['unlockedBy', 'einrichtungAbrechnung'] }) assert.deepEqual((await main({ client: voll, log: () => {} })).missing, []); assert.deepEqual(voll.calls.attrs, []) }) test('schemaKeys liest das Skript; das Skript löscht nichts', () => { assert.equal(schemaKeys('featureConsents').at(-1), 'kundenart') const f = schemaKeys('portalFeatures'); assert.ok(f.includes('einrichtungAbrechnung')); assert.equal(new Set(f).size, f.length) assert.doesNotMatch(readFileSync(new URL('../scripts/kundenansicht-setup.mjs', import.meta.url), 'utf8'), /delete/i) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenansichtSetup.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `scripts/kundenansicht-setup.mjs`. - [ ] **Step 3: `scripts/kundenansicht-setup.mjs` anlegen** Aufbau wie `scripts/feature-setup.mjs`: je Collection `listAttributes`, Fehlendes in `missing` (mit `collection`) sammeln; im Prüfmodus nur `- . FEHLT` loggen und `Alles vorhanden.` bzw. `N Eintrag/Eintraege fehlen.`; sonst `createAttribute`, danach `waitForAttribute`, Log `+ Attribut …` und `Fertig.`. Vorhandenes loggt `= . (existiert)`. CLI mit `--pruefen`/`--check`, Exit 1 bei Lücken nur im Prüfmodus. Kein `delete`. - [ ] **Step 4: `test/support/schema.js` und Pin `test/featureSetup.test.js:191-192`** `schemaKeys('featureConsents')` hängt die Schlüssel von `KUNDENANSICHT_ATTRIBUTE.featureConsents` an; im `portalFeatures`-Zweig kommen die von `KUNDENANSICHT_ATTRIBUTE.portalFeatures` dazu, die Liste wird mit `[...new Set(...)]` entdoppelt (`unlockedBy` steht schon in den Zeiten-Feldern). Import des Skripts wie der von `feature-setup.mjs`. Der Pin erwartet jetzt `[...CONSENT_KEYS, 'kundenart']` bzw. `[...FEATURE_CONSENT_ATTRIBUTES.map((a) => a.key), 'kundenart']`. - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtSetup.test.js test/featureSetup.test.js test/zeitenSetup.test.js test/support.test.js test/auftraegeSchema.test.js` Expected: PASS, 0 fail. - [ ] **Step 6: Commit** ```bash git add scripts/kundenansicht-setup.mjs test/support/schema.js test/featureSetup.test.js test/kundenansichtSetup.test.js git commit -m "Kundenansicht P5: Setup-Skript für Kundenart und Einrichtungs-Marker" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 2: Kundenart-Hilfsfunktionen und `/api/auth/me` **Files:** - Create: `server/services/kundenart.js` - Modify: `server/routes/auth.js` (`sanitizeCustomer`, Zeile 47) - Test: `test/kundenart.test.js` **Interfaces:** - Consumes: `KUNDENARTEN` aus `server/services/auftraege/konstanten.js`. - Produces (V4): ```js export { KUNDENARTEN } // ['unternehmen', 'privat'] export function kundenartGespeichert(customerDoc) // 'unternehmen' | 'privat' | '' export function kundenartWirksam(customerDoc) // 'unternehmen' | 'privat'; leer/unbekannt → 'privat' export const NUR_BETRIEB_TEXT = 'Diese Funktion gibt es nur für Betriebe. Schreib uns im Chat, wenn du Fragen dazu hast.' ``` `sanitizeCustomer` in `auth.js` liefert zusätzlich `kundenart: kundenartGespeichert(doc)` und `kundenartWirksam: kundenartWirksam(doc)` (Login, Kunde, Kundenansicht). - [ ] **Step 1: Failing test `test/kundenart.test.js`** ```js test('nur exakte Werte zählen, alles andere ist leer bzw. privat', () => { for (const doc of [undefined, null, {}, { kundenart: '' }, { kundenart: 'Unternehmen' }, { kundenart: ' privat' }, { kundenart: 'firma' }, { kundenart: 7 }, 'unternehmen']) { assert.equal(kundenartGespeichert(doc), ''); assert.equal(kundenartWirksam(doc), 'privat') } assert.deepEqual([kundenartGespeichert({ kundenart: 'unternehmen' }), kundenartWirksam({ kundenart: 'unternehmen' })], ['unternehmen', 'unternehmen']) assert.deepEqual([kundenartGespeichert({ kundenart: 'privat' }), kundenartWirksam({ kundenart: 'privat' })], ['privat', 'privat']) assert.deepEqual(KUNDENARTEN, ['unternehmen', 'privat']) }) test('/api/auth/me und Login liefern kundenart und kundenartWirksam in allen drei Zweigen', async () => { // Konten wie test/staffLogin.test.js:14-30: aw.addUser u1/u2 mit Passwort, customers c1 (kundenart 'unternehmen'), c2 (ohne Feld), customerPortalAccess pa1/pa2 const login = await post('/api/auth/login', { email: 'k@example.invalid', password: 'passwort-eins' }) assert.deepEqual([login.body.customer.kundenart, login.body.customer.kundenartWirksam], ['unternehmen', 'unternehmen']) const cookie = login.headers.getSetCookie().find((c) => c.startsWith(`${config.cookieName}=`)).split(';')[0] // echter Cookie des Logins const me = await rawGet('/api/auth/me', { cookie }) assert.deepEqual([me.body.customer.kundenart, me.body.customer.kundenartWirksam], ['unternehmen', 'unternehmen']) const leer = await rawGet('/api/auth/me', { cookie: kundenansichtCookie({ customerId: 'c2' }) }) // Kundenansicht-Zweig, c2 ohne Feld assert.deepEqual([leer.body.customer.kundenart, leer.body.customer.kundenartWirksam], ['', 'privat']) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenart.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `kundenart.js`. - [ ] **Step 3: `server/services/kundenart.js` implementieren** Exakter Vergleich mit `KUNDENARTEN.includes(doc?.kundenart)`, kein Trimmen, kein Kleinschreiben. Nur Funktionen und Konstanten, keine Imports außer `./auftraege/konstanten.js`. - [ ] **Step 4: `sanitizeCustomer` in `server/routes/auth.js` ergänzen** Beide Felder anhängen; kein anderer Teil der Datei ändert sich. - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenart.test.js test/staffLogin.test.js test/kundenansichtEnde.test.js test/kundenansichtGuard.test.js` Expected: PASS, 0 fail. - [ ] **Step 6: Commit** ```bash git add server/services/kundenart.js server/routes/auth.js test/kundenart.test.js git commit -m "Kundenansicht P5: Kundenart-Helfer und Felder in /api/auth/me" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 3: Kundenart in der Kundenverwaltung (`customerAdmin.js`) **Files:** - Modify: `server/services/customerAdmin.js` (`sanitizeCustomer` Z. 30, `createCustomerWithPortalAccess` Z. 68, `updateCustomerWithPortalAccess` Z. 138), `server/routes/admin/customers.js` (`code` durchreichen, Z. 28-33 und 38-43) - Test: `test/kundenartAdmin.test.js` **Interfaces:** - Consumes: `KUNDENARTEN`, `kundenartGespeichert` (Task 2), `neueAnsichtModus` (V1, `server/services/kundenansicht/schalter.js`). - Produces (V4, mit einer Einschränkung gegenüber dem Wortlaut der Übersicht): `createCustomerWithPortalAccess(payload)` nimmt `payload.kundenart`. **Pflicht nur, wenn `neueAnsichtModus() !== 'off'`**: Bei `off` legt das WOMS Kunden wie heute ohne Kundenart an (Global Constraint „Alte Ansicht unverändert“, Phase E „Schalter off = für Kunden unsichtbar“), auch wenn das Portal vor dem WOMS gepusht wird. Als „nicht mitgeschickt“ gelten `undefined`, `null` und `''`. Fehlt der Wert bei `pilot|on`, oder ist ein mitgeschickter Wert in irgendeinem Modus ungültig: `Error` mit `status: 400`, `code: 'kundenart_fehlt'`, Text `Kundenart ist erforderlich (unternehmen oder privat).`, **vor** jedem Schreibzugriff (kein Appwrite-Konto, kein Dokument). `updateCustomerWithPortalAccess(id, payload)` nimmt `kundenart` optional (nur bei `payload.kundenart !== undefined` prüfen und schreiben, ungültig wie oben). Die Route antwortet dann `{ error, code }`. `sanitizeCustomer` liefert `kundenart: kundenartGespeichert(doc)`. - [ ] **Step 1: Failing test `test/kundenartAdmin.test.js`** (Router `/api/admin/customers`, Header `Authorization: Bearer jwt-admin`; Kopf: `const modus = (m) => { config.kundenansichtNeu.mode = m }`, `beforeEach(() => modus('pilot'))`, `after()` stellt den ursprünglichen Modus zurück) ```js const admin = { headers: { Authorization: 'Bearer jwt-admin' } } const neu = (extra = {}) => ({ name: 'Bäckerei Müller', email: 'mueller@example.invalid', password: 'geheim-1234', ...extra }) test('pilot und on: Anlegen ohne oder mit ungültiger Kundenart: 400 kundenart_fehlt, nichts geschrieben', async () => { for (const mode of ['pilot', 'on']) { modus(mode) for (const kundenart of [undefined, '', 'Unternehmen', 'firma', 5, null]) { const res = await post('/api/admin/customers', neu({ kundenart }), admin) assert.deepEqual([res.status, res.body], [400, { error: 'Kundenart ist erforderlich (unternehmen oder privat).', code: 'kundenart_fehlt' }], `${mode} ${String(kundenart)}`) } } assert.equal(aw.docs('customers').size, 0) }) test('Anlegen mit unternehmen oder privat; Antwort und Dokument tragen die Kundenart', async () => { const a = await post('/api/admin/customers', neu({ kundenart: 'unternehmen' }), admin) assert.deepEqual([a.status, a.body.customer.kundenart], [201, 'unternehmen']) assert.equal(aw.docs('customers').get(a.body.customer.$id).kundenart, 'unternehmen') const b = await post('/api/admin/customers', neu({ email: 'frau@example.invalid', kundenart: 'privat' }), admin) assert.equal(b.body.customer.kundenart, 'privat') }) test('Ändern: ohne Feld bleibt die Kundenart, gültig ändert sie, ungültig ist 400 ohne Änderung', async () => { const id = [...aw.docs('customers').keys()][0] assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { name: 'Neu' }, ...admin })).body.customer.kundenart, 'unternehmen') assert.equal((await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'privat' }, ...admin })).body.customer.kundenart, 'privat') const bad = await send('PATCH', `/api/admin/customers/${id}`, { body: { kundenart: 'x' }, ...admin }) assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt']); assert.equal(aw.docs('customers').get(id).kundenart, 'privat') }) test('off (Schalter aus): Anlegen ohne Kundenart wie heute, kein Feld im Dokument; ungültig mitgeschickt bleibt 400', async () => { // als letzter Test der Datei: legt Kunden an, die die Tests davor nicht sehen sollen modus('off') const a = await post('/api/admin/customers', neu({ email: 'off1@example.invalid' }), admin) assert.deepEqual([a.status, a.body.customer.kundenart], [201, '']) assert.equal(Object.hasOwn(aw.docs('customers').get(a.body.customer.$id), 'kundenart'), false) assert.equal((await post('/api/admin/customers', neu({ email: 'off2@example.invalid', kundenart: '' }), admin)).status, 201) assert.equal((await post('/api/admin/customers', neu({ email: 'off3@example.invalid', kundenart: 'privat' }), admin)).status, 201) const bad = await post('/api/admin/customers', neu({ email: 'off4@example.invalid', kundenart: 'firma' }), admin) assert.deepEqual([bad.status, bad.body.code], [400, 'kundenart_fehlt']) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/kundenartAdmin.test.js` Expected: FAIL (`201 !== 400` beim Anlegen ohne Kundenart). - [ ] **Step 3: `customerAdmin.js` ändern** Prüfung `KUNDENARTEN.includes(payload.kundenart)` zusammen mit den bestehenden Eingabeprüfungen (Name, E-Mail, Passwort), also vor `createAppwriteUser`: fehlender Wert nur bei `neueAnsichtModus() !== 'off'` ein Fehler, ein mitgeschickter ungültiger Wert immer; ein gültiger Wert wird auch bei `off` ins Dokument geschrieben, ein fehlender nie. Beim Ändern nur prüfen, wenn `payload.kundenart !== undefined`, und nur dann ins Update schreiben. Gemeinsamer Fehlerbauer `kundenartFehler()`. - [ ] **Step 4: `routes/admin/customers.js` gibt `code` durch** In den `catch`-Zweigen von POST und PATCH: `res.status(status).json({ error: …, ...(err.code ? { code: err.code } : {}) })`. - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenartAdmin.test.js test/rechtePrivatArchiv.test.js test/storageTasks.test.js` Expected: PASS, 0 fail. - [ ] **Step 6: Commit** ```bash git add server/services/customerAdmin.js server/routes/admin/customers.js test/kundenartAdmin.test.js git commit -m "Kundenansicht P5: Kundenart beim Anlegen Pflicht (ab Schalter pilot), beim Ändern optional" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 4: Abo-Zuordnung (`aboFunktionen.js`) **Files:** - Create: `server/services/aboFunktionen.js` - Test: `test/aboFunktionen.test.js` **Interfaces:** - Consumes: `getCurrentSubscription`, `ACTIVE_STATUSES` (`stripeBilling.js`), `listContractEvents`, `openWithdrawals` (`contractEvents.js`). - Produces (V5, mit zwei rückwärtskompatiblen Erweiterungen: Grund `beginnt_spaeter` und Parameter `now` bei `aboDeckt`): ```js export const ENTHALTEN_IN = Object.freeze({ analyse: ['hosting', 'wartung'], news: ['wartung'], termine: ['wartung'], shop: ['wartung'], unterkunft: ['wartung'], zeiten: ['wartung'] }) export async function aboProdukt(customerId, { now = new Date() } = {}) // → { produkt: 'hosting'|'wartung'|null, laeuft, gekuendigt, widerrufsfristBis: string|null } (genau diese vier Schlüssel) export async function aboDeckt(customerId, key, { now = new Date() } = {}) // → { ok: true } | { ok: false, grund: 'kein_abo'|'falsches_abo'|'gekuendigt'|'nicht_aktiv' } | { ok: false, grund: 'beginnt_spaeter', ab: string /*ISO*/ } export async function aboLaeuftFuer(customerId, key) // → boolean: läuft und enthält key; gekündigt und past_due zählen noch (für Abgleichsjobs) ``` Regeln: `laeuft` = Status in `ACTIVE_STATUSES`; `produkt` ist nur dann nicht `null`, wenn `laeuft`; ohne `productKey` gilt Wartung (Altbestand); ist das jüngste `wechsel`-Ereignis dieses Abos `widerrufen`, gilt `hosting`. `gekuendigt` = `cancelAtPeriodEnd` oder `cancelAt`. `aboDeckt` prüft in der Reihenfolge `kein_abo`, `falsches_abo`, `gekuendigt`, `beginnt_spaeter`, `nicht_aktiv` (Status ≠ `active`). **`beginnt_spaeter`:** Status `trialing` mit `trialEnd` als gültigem Zeitpunkt nach `now`. So steht ein Privatkunde, der das Abo ohne Häkchen bestellt hat (`startChoice: 'nach_frist'`, Stripe „Probezeit ohne Leistung“, `stripeBilling.js:575-629`), bis zum Leistungsbeginn; `ab` ist `trialEnd` als ISO (derselbe Zeitpunkt wie `serviceStartsAt` in `GET /api/abo`). `trialing` ohne gültiges oder mit abgelaufenem `trialEnd` bleibt `nicht_aktiv`. Welche Lösung der Controller für diese Kunden will (Sperre mit Datum, wie gebaut, oder `trialing` als deckend), steht in Vertragsfrage 9. `widerrufsfristBis` = spätestes Ende der offenen Fristen von `abschluss` und `wechsel` (Speicher zählt nicht) als ISO, sonst `null`. Unbekannter Schlüssel: `TypeError`. Lesefehler werden weitergereicht (fail-closed). - [ ] **Step 1: Failing test `test/aboFunktionen.test.js`** (Schema: `subscriptions`, `contractEvents`) ```js const JETZT = new Date('2026-10-17T08:00:00.000Z') const abo = (doc) => aw.seed('subscriptions', subscriptionDoc('sub_h', doc)) const ereignis = (id, type, contractAt, over = {}) => aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over }) const FAELLE = [ // [Name, Abo-Dokument|null, aboProdukt-Teil, aboDeckt(termine), aboDeckt(analyse)] ['kein Abo', null, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'], ['Wartung aktiv', wartungDoc, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null], ['Altbestand ohne productKey', { ...wartungDoc, productKey: undefined }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, null, null], ['Hosting aktiv', hostingDoc, { produkt: 'hosting', laeuft: true, gekuendigt: false }, 'falsches_abo', null], ['Wartung zum Periodenende gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'], ['Wartung mit cancelAt', { ...wartungDoc, cancelAt: '2026-12-01T00:00:00.000Z' }, { produkt: 'wartung', laeuft: true, gekuendigt: true }, 'gekuendigt', 'gekuendigt'], ['Wartung past_due', { ...wartungDoc, status: 'past_due' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'], ['Wartung trialing ohne Probezeit-Ende', { ...wartungDoc, status: 'trialing' }, { produkt: 'wartung', laeuft: true, gekuendigt: false }, 'nicht_aktiv', 'nicht_aktiv'], ['Wartung beendet', { ...wartungDoc, status: 'canceled' }, { produkt: null, laeuft: false, gekuendigt: false }, 'kein_abo', 'kein_abo'], ] for (const [name, doc, teil, termine, analyse] of FAELLE) test(`Abo-Lage: ${name}`, async () => { if (doc) abo(doc) assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { ...teil, widerrufsfristBis: null }) assert.deepEqual(await aboDeckt('c1', 'termine'), termine ? { ok: false, grund: termine } : { ok: true }) assert.deepEqual(await aboDeckt('c1', 'analyse'), analyse ? { ok: false, grund: analyse } : { ok: true }) }) test('Probezeit (Privatkunde bestellte ohne Häkchen, nach_frist): beginnt_spaeter mit Datum, nicht nicht_aktiv', async () => { const ENDE = '2026-11-03T05:00:00.000Z' // 06:00 Berlin am Tag nach dem Fristende const probe = { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: ENDE } abo(probe) assert.deepEqual(await aboProdukt('c1', { now: JETZT }), { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: null }) for (const key of ['termine', 'analyse']) assert.deepEqual(await aboDeckt('c1', key, { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE }, key) for (const trialEnd of [null, '', 'kaputt', '2026-10-17T07:59:59.000Z']) { // kein oder abgelaufenes Ende: wie bisher nicht_aktiv abo({ ...probe, trialEnd }); assert.deepEqual(await aboDeckt('c1', 'termine', { now: JETZT }), { ok: false, grund: 'nicht_aktiv' }, String(trialEnd)) } abo({ ...probe, cancelAtPeriodEnd: true }); assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'gekuendigt') abo({ ...hostingDoc, status: 'trialing', trialEnd: ENDE }); assert.equal((await aboDeckt('c1', 'termine', { now: JETZT })).grund, 'falsches_abo') // Reihenfolge: Abo vor Beginn assert.deepEqual(await aboDeckt('c1', 'analyse', { now: JETZT }), { ok: false, grund: 'beginnt_spaeter', ab: ENDE }) }) test('aboLaeuftFuer: gekündigt und past_due zählen noch, falsches Abo und beendet nicht', async () => { abo({ ...wartungDoc, cancelAtPeriodEnd: true }); assert.equal(await aboLaeuftFuer('c1', 'termine'), true) abo({ ...hostingDoc }); assert.deepEqual([await aboLaeuftFuer('c1', 'termine'), await aboLaeuftFuer('c1', 'analyse')], [false, true]) abo({ ...wartungDoc, status: 'canceled' }); assert.equal(await aboLaeuftFuer('c1', 'news'), false) }) test('widerrufener Wechsel: Spiegel sagt Wartung, es gilt Hosting', async () => { abo(wartungDoc); ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z', { status: 'widerrufen' }) assert.equal((await aboProdukt('c1')).produkt, 'hosting'); assert.deepEqual(await aboDeckt('c1', 'shop'), { ok: false, grund: 'falsches_abo' }) }) test('widerrufsfristBis: spätestes offenes Ende von Abschluss und Wechsel; Speicher, fremde und abgelaufene zählen nicht', async () => { abo(wartungDoc) ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z') // Sa + 14 Tage = Sa 31.10. (Feiertag), So 01.11. (Feiertag) → Mo 02.11. ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z') // → Mo 26.10., 23:59:59.999 MEZ ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) }) ereignis('ev_f', 'wechsel', '2026-10-15T09:00:00.000Z', { subscriptionId: 'sub_alt', status: 'widerrufen' }) assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-11-02T22:59:59.999Z') aw.docs('contractEvents').delete('ev_a'); assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, '2026-10-26T22:59:59.999Z') ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z'); aw.docs('contractEvents').delete('ev_w') // Frist endete Mo 05.10. assert.equal((await aboProdukt('c1', { now: JETZT })).widerrufsfristBis, null) }) test('Form: ENTHALTEN_IN eingefroren und genau die Schlüssel von V5; unbekannte Funktion ist ein Programmfehler', async () => { assert.ok(Object.isFrozen(ENTHALTEN_IN)); assert.deepEqual(Object.keys(ENTHALTEN_IN), ['analyse', 'news', 'termine', 'shop', 'unterkunft', 'zeiten']) assert.deepEqual(Object.keys(await aboProdukt('c1')).sort(), ['gekuendigt', 'laeuft', 'produkt', 'widerrufsfristBis']) await assert.rejects(() => aboDeckt('c1', 'gibtEsNicht'), TypeError) }) ``` (`beforeEach` leert `subscriptions` und `contractEvents`.) - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboFunktionen.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `aboFunktionen.js`. - [ ] **Step 3: `server/services/aboFunktionen.js` implementieren** Ein privater Leser `lies(customerId, now)` liefert Abo-Dokument, Produkt, Status und offene Fristen; `aboProdukt`, `aboDeckt`, `aboLaeuftFuer` bauen darauf auf (ein Lesepfad wie in `aboStatus.js`). Wechsel-Regel wie `aboStatus.js` (`lage`): jüngstes `wechsel`-Ereignis **desselben Abos** `widerrufen` → `hosting`. Fristen: `openWithdrawals(events, now)`, nur Typen `abschluss` und `wechsel`, Maximum von `until`. `beginnt_spaeter`: `trialing` und `Date.parse(trialEnd) > now.getTime()`, `ab = new Date(trialEnd).toISOString()`. - [ ] **Step 4: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboFunktionen.test.js test/aboStatus.test.js test/contractEvents.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/aboFunktionen.js test/aboFunktionen.test.js git commit -m "Kundenansicht P5: Abo-Zuordnung der Funktionen (aboProdukt, aboDeckt)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 5: Gesammelte Widerrufsfristen (`fristen.js`, `GET /api/fristen`) **Files:** - Create: `server/services/fristen.js`, `server/routes/fristen.js` - Modify: `server/index.js` (1 Import, 1 Mount nach `app.use('/api/funktionen', funktionenRoutes)`) - Test: `test/fristen.test.js` **Interfaces:** - Consumes: `listContractEvents`, `openWithdrawals`, `withdrawalDeadline` (`deadlines.js`), `paidFeatureOrders`, `FEATURE_LABELS` (`siteFeatures.js`), `listAuftraege` (`auftraege/auftragStore.js`), `auftraegeAktiv` (`auftraege/schalter.js`), `kundenartWirksam` (Task 2). - Produces (V6): ```js export async function sammleFristen(customerId, { now = new Date() } = {}) // → { fristen: [{ quelle, bezeichnung, bis, featureKey?, projektName? }] (aufsteigend nach bis), fruehestesEnde: string|null, anzeigen: boolean } ``` `quelle` und `bezeichnung`: `abschluss` „Abo-Abschluss“, `wechsel` „Wechsel ins Wartungs-Abo“, `speicher` „Zusatzspeicher“, `funktion` = `FEATURE_LABELS[key].name` (mit `featureKey`, `projektName`), `projekt` = `Auftrag ` (nur bei `auftraegeAktiv(kunde)` und Snapshot-Kundenart `privat`, Status `versendet|bestaetigt`, Frist ab `gesendetAm`). `bis` ISO mit `Z`. `featureKey`/`projektName` fehlen als Schlüssel, wo sie nicht gelten. `anzeigen` = `kundenartWirksam(kunde) === 'privat' && fristen.length > 0`. Lesefehler werden weitergereicht. `GET /api/fristen`: `requireSession`, `Cache-Control: no-store`, in der Kundenansicht erlaubt (lesend), Antwort = Rückgabe von `sammleFristen`. - [ ] **Step 1: Failing test `test/fristen.test.js`** (Schema: `subscriptions`, `contractEvents`, `portalFeatures`, `featureConsents`, `auftraege` mit `datetimes: AUFTRAEGE_DATETIMES`; `beforeEach` leert `contractEvents`, `portalFeatures`, `auftraege`, legt Wartung und Projekt p1 „Bäckerei“ an) ```js const JETZT = new Date('2026-10-17T08:00:00.000Z') const ereignis = (id, type, contractAt, over = {}) => aw.seed('contractEvents', { $id: id, customerId: 'c1', subscriptionId: 'sub_h', type, productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }), ...over }) const aufbau = () => { // Ende je Quelle: wechsel Mo 26.10., funktion Do 29.10., speicher Fr 30.10., abschluss Mo 02.11. ereignis('ev_a', 'abschluss', '2026-10-17T07:00:00.000Z'); ereignis('ev_w', 'wechsel', '2026-10-10T09:00:00.000Z') ereignis('ev_s', 'speicher', '2026-10-16T09:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T09:00:00.000Z', fromUnits: 0, toUnits: 1 }) }) ereignis('ev_m', 'speicher', '2026-10-16T10:00:00.000Z', { details: JSON.stringify({ contractAt: '2026-10-16T10:00:00.000Z', fromUnits: 2, toUnits: 1 }) }) // Minus: keine Frist aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', enabled: false, unlockedByPurchase: true, orderedAt: '2026-10-15T09:00:00.000Z' }) } test('mehrere Quellen, aufsteigend nach Ende; Speicher-Plus zählt, Minus nicht', async () => { aufbau(); const r = await sammleFristen('c1', { now: JETZT }) assert.deepEqual(r.fristen, [ { quelle: 'wechsel', bezeichnung: 'Wechsel ins Wartungs-Abo', bis: '2026-10-26T22:59:59.999Z' }, { quelle: 'funktion', bezeichnung: 'News-Pinnwand', bis: '2026-10-29T22:59:59.999Z', featureKey: 'news', projektName: 'Bäckerei' }, { quelle: 'speicher', bezeichnung: 'Zusatzspeicher', bis: '2026-10-30T22:59:59.999Z' }, { quelle: 'abschluss', bezeichnung: 'Abo-Abschluss', bis: '2026-11-02T22:59:59.999Z' }, ]) assert.deepEqual([r.fruehestesEnde, r.anzeigen], ['2026-10-26T22:59:59.999Z', true]) }) test('Kundenart: Betrieb hat Fristen, aber anzeigen false; privat, leer und unbekannt zeigen', async () => { aufbau() for (const [kundenart, anzeigen] of [['unternehmen', false], ['privat', true], ['', true], ['kaputt', true]]) { aw.seed('customers', { ...customers.c1, kundenart }); const r = await sammleFristen('c1', { now: JETZT }) assert.deepEqual([r.fristen.length, r.anzeigen], [4, anzeigen], kundenart) } }) test('abgelaufene oder fehlende Fristen: leer, fruehestesEnde null, anzeigen false', async () => { ereignis('ev_alt', 'abschluss', '2026-09-20T09:00:00.000Z') // Ende Mo 05.10. assert.deepEqual(await sammleFristen('c1', { now: JETZT }), { fristen: [], fruehestesEnde: null, anzeigen: false }) }) test('Aufträge zählen nur mit Schalter und Snapshot-Kundenart privat', async () => { const ab = (kundenart) => aw.seed('auftraege', { $id: 'ab1', nummer: 'AB-2026-0007', kundenId: 'c1', status: 'versendet', gesendetAm: '2026-10-16T09:00:00.000Z', kundeJson: JSON.stringify({ kundenart }) }) ab('privat'); config.auftraege.mode = 'off'; assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, []) config.auftraege.mode = 'on' assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, [{ quelle: 'projekt', bezeichnung: 'Auftrag AB-2026-0007', bis: '2026-10-30T22:59:59.999Z' }]) ab('unternehmen'); assert.deepEqual((await sammleFristen('c1', { now: JETZT })).fristen, []) }) test('GET /api/fristen: 401 ohne Anmeldung, Form, no-store, Kundenansicht erlaubt; Mount in server/index.js', async () => { assert.equal((await rawGet('/api/fristen')).status, 401) const res = await rawGet('/api/fristen', { cookie: customerCookie('c1') }) assert.deepEqual([res.status, Object.keys(res.body).sort(), res.headers.get('cache-control')], [200, ['anzeigen', 'fristen', 'fruehestesEnde'], 'no-store']) assert.equal((await rawGet('/api/fristen', { cookie: kundenansichtCookie({ customerId: 'c1' }) })).status, 200) const src = readFileSync(new URL('../server/index.js', import.meta.url), 'utf8') assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") > src.indexOf('app.use(sessionMiddleware())')) assert.ok(src.indexOf("app.use('/api/fristen', fristenRoutes)") < src.indexOf('app.use(express.static(publicDir))')) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/fristen.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `fristen.js`. - [ ] **Step 3: `server/services/fristen.js` implementieren** Kundendokument einmal lesen (`getDocument(config.collections.customers, …)`, 404 → `{}`). Quellen unabhängig sammeln, offen ist, was `now <= endsAt` erfüllt (`openWithdrawals` filtert selbst; Funktionen und Aufträge mit `withdrawalDeadline(...).endsAt`). Ereignis-Typ `speicher` kommt aus `openWithdrawals` (nur Plus). - [ ] **Step 4: Route und Mount** `server/routes/fristen.js`: `router.get('/', requireSession, …)` mit `getSessionCustomerId(req)`, Fehler als `500 { error: 'Die Fristen konnten gerade nicht geladen werden.' }`. In `server/index.js` Import `fristenRoutes` und `app.use('/api/fristen', fristenRoutes)` direkt nach der Zeile `/api/funktionen`. - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/fristen.test.js` Expected: PASS, 0 fail. - [ ] **Step 6: Commit** ```bash git add server/services/fristen.js server/routes/fristen.js server/index.js test/fristen.test.js git commit -m "Kundenansicht P5: gesammelte Widerrufsfristen (sammleFristen, GET /api/fristen)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 6: Katalogfelder, `consentAbo` und Unterlagen-Quelle im Register **Files:** - Create: `server/services/aboUnterlagen.js` - Modify: `server/config.js` (Eintrag `features.` ergänzen, Abschnitt `aboFunktionen` neu), `server/services/featureRegistry.js` - Test: `test/aboKatalogRegister.test.js` **Interfaces:** - Consumes: bestehende Registereinträge (`label.name`, `leistungsbeschreibung`, `avv`), `legalPaths` (`analyseLegal.js`). - Produces: ```js // server/config.js config.aboFunktionen = { fassung: '2026-10-16', reihenfolge: ['news','termine','shop','unterkunft','zeiten','analyse'], texte: { beginn: , hinweis: { shop, unterkunft, termine } /* Entwurf unten */, zielgruppe: , analyseKnopf: 'Analyse einschalten' }, analyse: { name: 'Website-Analyse', beschreibung, bild, ablauf, einrichtung, nurBetrieb } } config.features..{ beschreibung, bild, ablauf, einrichtung, nurBetrieb } // server/services/aboUnterlagen.js (Blattmodul: importiert nur config und analyseLegal.js; Task 15 stellt die Quelle auf FUNKTIONEN_UNTERLAGEN_V6) export function unterlagenFuer(key) // → { leistungsbeschreibung: { version, url } | null, avv: { version, url } | null }; Register-Dokumente, 'analyse' aus legalPaths(config.analyse.avvVersion); unbekannt: TypeError export function analyseUnterlagen() // → { avvVersion, legal: { lb, avv } } (URLs), aus unterlagenFuer('analyse') // server/services/featureRegistry.js export function katalogReihenfolge() // config.aboFunktionen.reihenfolge (Kopie) export function katalogFelder(key) // { beschreibung, bild, ablauf, einrichtung, nurBetrieb } | null (auch 'analyse') export function consentAbo(key) // { version, knopf: 'Einschalten', items: [{ id, text, haken, nurPrivatInFrist? }] } | null (analyse, unbekannt); baut die Items bei jedem Aufruf aus unterlagenFuer(key) und config.aboFunktionen.texte export function consentAboAktiv(key, { beginn = false } = {}) // wie angezeigt: 'einschalten-zeile' (haken false), falls vorhanden 'hinweis' (haken false), mit beginn zusätzlich 'beginn' (haken true) export function aboweg(feature, { beginn = false } = {}) // { ...feature, consent: consentAboAktiv(feature.key, { beginn }), leistungsbeschreibung, avv (aus unterlagenFuer), widerruf: null } export function consentAboAnalyse() // { version, avvVersion, knopf: 'Analyse einschalten', items: [{ id: 'einschalten-zeile', text, haken: false }, { id: 'zielgruppe', text, haken: true }] } export function abowegAnalyse() // { key: 'analyse', consent: consentAboAnalyse(), leistungsbeschreibung: { version: avvVersion }, avv: { version: avvVersion }, widerruf: null } (für speichereNachweisAusAnfrage) ``` `config.js` importiert keine Dienste; es hält nur Daten und Entwurfstexte. Die Items stehen in Anzeigereihenfolge (`einschalten-zeile`, `hinweis`, `beginn`); der Nachweis (Task 7) speichert sie in dieser Reihenfolge. Das Item `hinweis` (`haken: false`) trägt für `shop`, `unterkunft` und `termine` die Pflicht aus den alten Häkchen `verantwortung`, `stripe` und `gesundheit` (Spec 5.6 Zeile F: „dazu ein Satz auf der Einschalt-Seite“); `news` und `zeiten` haben keins. Es ist die einzige Quelle: `katalog[].consent.items` enthält es dadurch automatisch, und `katalog[].hinweis` (Task 11) ist nur sein Text. Feste Werte (Reihenfolge `news, termine, shop, unterkunft, zeiten, analyse`): | key | ablauf | einrichtung | nurBetrieb | beschreibung | |---|---|---|---|---| | news | direkt | meist in 1–2 Wochen | false | Zeig Neuigkeiten und Aktionen direkt auf deiner Website. Du schreibst sie im Portal, sie erscheinen sofort. | | termine | fragebogen | meist in 1–2 Wochen | false | Deine Kunden buchen Termine online, rund um die Uhr. Zeiten, Team und Buchungen verwaltest du im Portal. | | shop | anfrage | meist in 2–3 Wochen | true | Verkaufe Artikel direkt über deine Website. Die Zahlung läuft über dein eigenes Stripe-Konto. | | unterkunft | anfrage | meist in 1–2 Wochen | false | Gäste fragen deine Unterkunft online an, du gibst frei und behältst Belegung und Preise im Blick. Den Kalender baut WEBklar in deine Website ein. | | zeiten | direkt | meist in wenigen Tagen | false | Deine Öffnungszeiten stehen live auf deiner Website und sind immer aktuell. Du änderst sie im Portal, auch für Feiertage und Urlaub. | | analyse | direkt | meist in wenigen Tagen | false | Sieh, wie viele Besucher auf deine Website kommen und welche Seiten sie ansehen. Die Auswertung findest du direkt im Portal. | `bild` = `/neu/bilder/.webp`. `einschalten-zeile`: mit AVV (termine, shop, unterkunft) `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ).`, ohne AVV (news, zeiten) `Es gilt die Leistungsbeschreibung (Fassung ).` (`` = `unterlagenFuer(key).leistungsbeschreibung.version`). `beginn` (`haken: true`, `nurPrivatInFrist: true`), **Entwurf bis zur Verdrahtung der Fassung 6 (Task 15)**: `Bitte beginnt sofort mit der Einrichtung. Mir ist klar: Mit der vollständigen Einrichtung erlischt mein Widerrufsrecht, und wenn ich vorher widerrufe, zahle ich den bis dahin erbrachten Teil.` (liegt in `config.aboFunktionen.texte.beginn`, wörtlich wie `WORTLAUT_V6.beginn.einrichtung` aus P8). `hinweis` (nur diese drei Funktionen, Entwurf wörtlich wie `WORTLAUT_V6.hinweis` aus P8, in `config.aboFunktionen.texte.hinweis`): - shop: `Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.` - unterkunft: `Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.` - termine: `Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.` Analyse (`consentAboAnalyse`): `version` = `config.aboFunktionen.fassung`, `avvVersion` = `analyseUnterlagen().avvVersion`, Linkzeile `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ).` (derselbe Wortlaut wie A1 in P4), `zielgruppe` (`haken: true`, Text in `config.aboFunktionen.texte.zielgruppe`) `Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.` (A2 in P4), Knopf `Analyse einschalten`. - [ ] **Step 1: Failing test `test/aboKatalogRegister.test.js`** (Kopf wie `test/featureRegistry.test.js:1-16`, dazu `import { unterlagenFuer, analyseUnterlagen } from '../server/services/aboUnterlagen.js'`) ```js const KEYS = ['news', 'termine', 'shop', 'unterkunft', 'zeiten'] test('Reihenfolge und Felder je Funktion', () => { assert.deepEqual(reg.katalogReihenfolge(), [...KEYS, 'analyse']) const ablauf = { news: 'direkt', termine: 'fragebogen', shop: 'anfrage', unterkunft: 'anfrage', zeiten: 'direkt', analyse: 'direkt' } for (const key of [...KEYS, 'analyse']) { const f = reg.katalogFelder(key) assert.deepEqual(Object.keys(f).sort(), ['ablauf', 'beschreibung', 'bild', 'einrichtung', 'nurBetrieb']) assert.deepEqual([f.bild, f.ablauf, f.nurBetrieb], [`/neu/bilder/${key}.webp`, ablauf[key], key === 'shop'], key) assert.ok(f.beschreibung.split(/(?<=[.!?])\s+/).length <= 2, 'höchstens zwei Sätze') assert.doesNotMatch(f.beschreibung + f.einrichtung, /€|\bSie\b|\bIhre?[nmrs]?\b/, 'kein Preis, keine Sie-Form') } assert.equal(reg.katalogFelder('shop').einrichtung, 'meist in 2–3 Wochen'); assert.equal(reg.katalogFelder('gibtEsNicht'), null) }) const HINWEIS = { // Pflichten der alten Häkchen verantwortung, stripe, gesundheit; wörtlich wie WORTLAUT_V6.hinweis (P8) termine: 'Der Terminplaner ist nicht für den Gesundheitsbereich gedacht. Das fragen wir im Fragebogen ab.', shop: 'Du verkaufst in eigenem Namen und bist für deine Produkte, Preise und Rechtstexte verantwortlich. Die Zahlungen deiner Käufer laufen über dein eigenes Stripe-Konto.', unterkunft: 'Du vermietest in eigenem Namen und bist für Preise, Stornobedingungen, Rechnungen und Meldepflichten verantwortlich. Online-Zahlungen deiner Gäste laufen über dein eigenes Stripe-Konto.', } test('consentAbo: Linkzeile mit Fassung, Hinweis (termine, shop, unterkunft), beginn als Entwurf, kein Häkchen gesundheit', () => { const V = config.aboFunktionen.fassung for (const key of KEYS) { const c = reg.consentAbo(key) const ids = key in HINWEIS ? ['einschalten-zeile', 'hinweis', 'beginn'] : ['einschalten-zeile', 'beginn'] assert.deepEqual([c.version, c.knopf, c.items.map((i) => i.id)], [V, 'Einschalten', ids], key) assert.deepEqual(c.items.map((i) => i.haken), ids.map((id) => id === 'beginn'), `${key}: nur beginn ist ein Häkchen`) assert.deepEqual([c.items.at(-1).text, c.items.at(-1).nurPrivatInFrist], [config.aboFunktionen.texte.beginn, true], key) if (key in HINWEIS) assert.equal(c.items[1].text, HINWEIS[key], key) } assert.equal(reg.consentAbo('termine').items[0].text, `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.termine.avvVersion}).`) assert.equal(reg.consentAbo('zeiten').items[0].text, 'Es gilt die Leistungsbeschreibung (Fassung 2026-10-04).') assert.ok(!reg.consentAbo('termine').items.some((i) => i.id === 'gesundheit'), 'das Häkchen gesundheit entfällt, der Satz steht als Hinweis') assert.doesNotMatch(JSON.stringify(Object.values(HINWEIS)), /€|\bSie\b|\bIhre?[nmrs]?\b/) assert.equal(reg.consentAbo('analyse'), null) }) test('consentAboAktiv und aboweg zeigen nur, was verlangt ist; das alte Register bleibt unverändert', () => { assert.deepEqual(reg.consentAboAktiv('shop').items.map((i) => i.id), ['einschalten-zeile', 'hinweis']) assert.deepEqual(reg.consentAboAktiv('news').items.map((i) => i.id), ['einschalten-zeile']) assert.deepEqual(reg.consentAboAktiv('shop', { beginn: true }).items.map((i) => [i.id, i.haken]), [['einschalten-zeile', false], ['hinweis', false], ['beginn', true]]) const w = reg.aboweg(reg.featureConfig('termine'), { beginn: true }) assert.deepEqual([w.consent.version, w.widerruf, w.avv.version, w.leistungsbeschreibung.version, w.key], [config.aboFunktionen.fassung, null, config.termine.avvVersion, config.termine.avvVersion, 'termine']) assert.deepEqual(reg.featureConfig('termine').consent.items.map((i) => i.id), ['unterlagen', 'gesundheit', 'beginn', 'information']) assert.equal(Object.keys(reg.oeffentlicheAnsicht('termine')).length, 11, 'oeffentlicheAnsicht ohne neue Felder') }) test('unterlagenFuer und analyseUnterlagen: eine Quelle für Katalog, Nachweis und Analyse', () => { const t = unterlagenFuer('termine') assert.deepEqual([t.leistungsbeschreibung, t.avv], [{ version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url }, { version: config.termine.avvVersion, url: config.features.termine.avv.url }]) assert.deepEqual([unterlagenFuer('news').avv, unterlagenFuer('zeiten').avv], [null, null]) assert.ok(unterlagenFuer('news').leistungsbeschreibung.version) const a = analyseUnterlagen() assert.deepEqual(a, { avvVersion: config.analyse.avvVersion, legal: { lb: `/rechtliches/leistungsbeschreibung-website-analyse-${config.analyse.avvVersion}.html`, avv: `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html` } }) assert.throws(() => unterlagenFuer('gibtEsNicht'), TypeError) }) test('consentAboAnalyse und abowegAnalyse: Linkzeile, Zielgruppen-Häkchen und Knopf wörtlich wie in P4', () => { const c = reg.consentAboAnalyse() assert.deepEqual([c.version, c.avvVersion, c.knopf], [config.aboFunktionen.fassung, config.analyse.avvVersion, 'Analyse einschalten']) assert.deepEqual(c.items, [ { id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${config.analyse.avvVersion}).`, haken: false }, { id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true }, ]) const w = reg.abowegAnalyse() assert.deepEqual([w.key, w.leistungsbeschreibung.version, w.avv.version, w.widerruf, w.consent.knopf], ['analyse', config.analyse.avvVersion, config.analyse.avvVersion, null, 'Analyse einschalten']) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboKatalogRegister.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `aboUnterlagen.js`. - [ ] **Step 3: `server/config.js`** Abschnitt `aboFunktionen` (Werte aus der Tabelle, `texte.beginn`, `texte.hinweis`, `texte.zielgruppe`, `texte.analyseKnopf`) als Eigenschaft von `config`; je Registereintrag die fünf Felder. Kein Getter `consentAbo` und kein Dienstimport in `config.js`. `consent`, `consentFrei` und alles Bestehende bleiben unangetastet. - [ ] **Step 4: `server/services/aboUnterlagen.js` und `server/services/featureRegistry.js`** `aboUnterlagen.js` nach „Produces“ (`unterlagenFuer` liest `config.features[key].leistungsbeschreibung` und `.avv` bei jedem Aufruf, `analyse` aus `legalPaths(config.analyse.avvVersion)` mit den Schlüsseln `lb` und `avv`). In `featureRegistry.js` die Funktionen aus „Produces“: `consentAbo(key)` baut die Items nach den festen Werten oben (`null` für `analyse` und unbekannte Schlüssel), `katalogFelder('analyse')` liest `config.aboFunktionen.analyse`, `aboweg` überschreibt `leistungsbeschreibung` und `avv` aus `unterlagenFuer` (so ändert Task 15 nur die Quelle). `aboUnterlagen.js` ist ein Blattmodul; der statische Import in der Registry ist deshalb zulässig, der Kommentar im Dateikopf von `featureRegistry.js` („importiert keine Dienste statisch“) nennt diese Ausnahme. `oeffentlicheAnsicht` bleibt unverändert. - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboKatalogRegister.test.js test/featureRegistry.test.js test/featureConsentTexte.test.js test/legalDocs.test.js` Expected: PASS, 0 fail (`TEXTSTAND` und die Registerpins unverändert). - [ ] **Step 6: Commit** ```bash git add server/config.js server/services/aboUnterlagen.js server/services/featureRegistry.js test/aboKatalogRegister.test.js git commit -m "Kundenansicht P5: Katalogfelder, consentAbo mit Hinweis und Unterlagen-Quelle im Funktionen-Register" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 7: Kundenart im Zustimmungsnachweis **Files:** - Modify: `server/services/featureConsents.js` (`speichereZustimmung` Z. 79, `speichereNachweisAusAnfrage`) - Test: `test/featureConsentsKundenart.test.js` **Interfaces:** - Consumes: `KUNDENARTEN` (Task 2), `aboweg` (Task 6). - Produces: `speichereZustimmung({ …, kundenart })` und `speichereNachweisAusAnfrage(req, { …, kundenart })` mit optionalem `kundenart: 'unternehmen'|'privat'`. Das Feld wird **nur geschrieben, wenn es übergeben wurde**; ein anderer Wert ist `Error` mit `status: 400` (`Zustimmungsnachweis: Kundenart ungültig`) ohne Schreibzugriff. - [ ] **Step 1: Failing test `test/featureConsentsKundenart.test.js`** ```js // Kopf: const schema = { featureConsents: schemaKeys('featureConsents') }; const aw = await startAppwriteMock({ schema }) (schema bleibt änderbar wie test/featureSetup.test.js:193) const base = { customerId: 'c1', projectId: 'p1', featureKey: 'termine', stripeSessionId: '', fassungen: { zustimmung: 'x' }, texte: [{ id: 'knopf', text: 'Einschalten', haken: false }], ip: '203.0.113.9', userAgent: 'UA/1', konto: 'k', at: '2026-10-17T08:00:00.000Z' } test('kundenart wird gespeichert; ohne Angabe fehlt der Schlüssel, auch bei altem Schema', async () => { assert.equal((await speichereZustimmung({ ...base, kundenart: 'privat' })).kundenart, 'privat') schema.featureConsents = schemaKeys('featureConsents').filter((k) => k !== 'kundenart') // Setup-Skript noch nicht gelaufen assert.equal(Object.hasOwn(await speichereZustimmung(base), 'kundenart'), false) await assert.rejects(() => speichereZustimmung({ ...base, kundenart: 'privat' }), /Unknown attribute/) schema.featureConsents = schemaKeys('featureConsents') }) test('ungültige Kundenart: 400, nichts geschrieben', async () => { const vorher = aw.docs('featureConsents').size for (const kundenart of ['firma', '', 'Privat', 3, null]) await assert.rejects(() => speichereZustimmung({ ...base, kundenart }), (e) => e.status === 400) assert.equal(aw.docs('featureConsents').size, vorher) }) test('Nachweis aus der Anfrage im Abo-Modell: Zeile, Hinweis, optional Häkchen, Knopf; Fassungen ohne widerruf', async () => { const req = { ip: '203.0.113.9', get: (h) => (h === 'user-agent' ? 'UA/1' : ''), session: { name: 'Kunde Eins', email: 'k@example.invalid' } } const feature = reg.featureConfig('termine') await speichereNachweisAusAnfrage(req, { feature: reg.aboweg(feature), customerId: 'c1', projectId: 'p1', klickAt: '2026-10-17T08:00:00.000Z', kundenart: 'unternehmen' }) await speichereNachweisAusAnfrage(req, { feature: reg.aboweg(feature, { beginn: true }), customerId: 'c1', projectId: 'p2', klickAt: '2026-10-17T08:00:00.000Z', kundenart: 'privat' }) const [a, b] = [...aw.docs('featureConsents').values()].filter((d) => d.featureKey === 'termine' && d.projectId.startsWith('p')).slice(-2) assert.deepEqual(JSON.parse(a.texte).map((t) => t.id), ['einschalten-zeile', 'hinweis', 'knopf']) // termine trägt den Hinweis (Pflichten der alten Häkchen) assert.equal(JSON.parse(a.texte)[1].text, reg.consentAbo('termine').items[1].text) assert.deepEqual(JSON.parse(b.texte).map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['hinweis', false], ['beginn', true], ['knopf', false]]) assert.deepEqual(Object.keys(JSON.parse(a.fassungen)).sort(), ['avv', 'leistungsbeschreibung', 'zustimmung']) assert.deepEqual([a.kundenart, b.kundenart, a.konto, a.stripeSessionId], ['unternehmen', 'privat', 'Kunde Eins ', '']) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/featureConsentsKundenart.test.js` Expected: FAIL (`undefined !== 'privat'`). - [ ] **Step 3: `featureConsents.js` ändern** In `speichereZustimmung`: `kundenart` nur prüfen und aufnehmen, wenn `kundenart !== undefined` (dann muss es in `KUNDENARTEN` stehen, sonst 400 vor `createDocument`). `speichereNachweisAusAnfrage` reicht `kundenart` unverändert durch (nur wenn definiert). - [ ] **Step 4: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/featureConsentsKundenart.test.js test/featureConsents.test.js test/funktionenCheckout.test.js test/featurePaid.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/featureConsents.js test/featureConsentsKundenart.test.js git commit -m "Kundenansicht P5: Kundenart im Zustimmungsnachweis" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 8: Einrichtungs-Ticket mit Abrechnung und `featureView.unlockedBy` **Files:** - Modify: `server/services/ticketAdmin.js` (`createFeatureTicket` Z. 82), `server/services/siteFeatures.js` (`featureView` Z. 151, `legeTicketAn`) - Test: `test/einrichtungsTicket.test.js` **Interfaces:** - Consumes: Setup-Attribute aus Task 1 (im Nachbau ist `workorders` ohne Schema). - Produces (V7): ```js // ticketAdmin.js export const ABRECHNUNGS_ZEILEN = { abo: 'Zeit im Arbeitsblatt erfassen und aufs Abo-Stundenkonto anrechnen (Einrichtung aus dem Abo, Vorschuss möglich).', projekt: 'Zeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).' } createFeatureTicket({ customerId, project, featureKey, siteKey, topic, details, fallbackDetails, abrechnung = '' }) // abrechnung: ''|'abo'|'projekt' ``` Mit `abrechnung` setzt das Ticket `einrichtungFuer: featureKey` und `einrichtungAbrechnung: abrechnung`; die Zeile steht als **letzte Zeile** der Details (nach der Kürzung auf 7000 Zeichen, auch im Notfallweg). Ohne `abrechnung` werden weder Felder noch Zeile geschrieben. Ungültiger Wert: `TypeError` vor jedem Schreibzugriff. `legeTicketAn(doc, …)` übergibt `doc.einrichtungAbrechnung`, wenn gesetzt. `featureView(doc, key)` liefert `unlockedBy` für **alle** Funktionen (Regel wie bisher bei `aboInklusive`: `null` im Zustand `off`); `aboEndeAt` und `einbau` bleiben bei `aboInklusive`. - [ ] **Step 1: Failing test `test/einrichtungsTicket.test.js`** ```js const ALT = ['createdAt', 'customerId', 'customerLocation', 'customerName', 'details', 'priority', 'serviceType', 'status', 'systemType', 'topic', 'type', 'woid'] const base = { customerId: 'c1', project: { $id: 'p1', projectName: 'Bäckerei' }, featureKey: 'termine', siteKey: 'abcdefghijklmnop', topic: 'T', details: 'Zeile 1' } const nutzer = (doc) => Object.keys(doc).filter((k) => !k.startsWith('$')).sort() test('abrechnung abo und projekt: Felder und letzte Zeile', async () => { const { ticketId } = await createFeatureTicket({ ...base, abrechnung: 'abo' }); const t = aw.docs('workorders').get(ticketId) assert.deepEqual([t.einrichtungFuer, t.einrichtungAbrechnung], ['termine', 'abo']) assert.equal(t.details, `Zeile 1\n${ABRECHNUNGS_ZEILEN.abo}`) assert.ok(aw.docs('workorders').get((await createFeatureTicket({ ...base, abrechnung: 'projekt' })).ticketId).details.endsWith('\nZeit im Arbeitsblatt erfassen, nicht aufs Abo anrechnen (im Projekt enthalten).')) }) test('ohne abrechnung: keine neuen Felder, Details unverändert (alte Ansicht)', async () => { for (const abrechnung of [undefined, '']) { const t = aw.docs('workorders').get((await createFeatureTicket({ ...base, abrechnung })).ticketId); assert.deepEqual(nutzer(t), ALT); assert.equal(t.details, 'Zeile 1') } }) test('ungültiger Wert: TypeError, kein Ticket', async () => { const n = aw.docs('workorders').size; await assert.rejects(() => createFeatureTicket({ ...base, abrechnung: 'frei' }), TypeError); assert.equal(aw.docs('workorders').size, n) }) test('lange Details: gekürzt auf 7000, die Schlusszeile bleibt; auch im Notfallweg nach 400', async () => { const lang = Array.from({ length: 400 }, (_, i) => `Zeile ${i} ${'x'.repeat(30)}`).join('\n') const t = aw.docs('workorders').get((await createFeatureTicket({ ...base, details: lang, abrechnung: 'abo' })).ticketId) assert.ok(t.details.length <= 7000 && t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)) aw.failNext({ collection: 'workorders', method: 'POST', status: 400, message: 'zu lang' }) const n = aw.docs('workorders').get((await createFeatureTicket({ ...base, details: lang, fallbackDetails: 'Notfall', abrechnung: 'projekt' })).ticketId) assert.ok(n.details.startsWith('Notfall') && n.details.endsWith(ABRECHNUNGS_ZEILEN.projekt)) }) test('featureView: unlockedBy für alle Funktionen, aboEndeAt/einbau nur bei Zeiten', async () => { assert.equal((await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news')).unlockedBy, 'abo') assert.equal((await featureView({ state: 'setup', unlockedBy: 'projekt' }, 'shop')).unlockedBy, 'projekt') assert.equal((await featureView({ state: 'active', unlockedByPurchase: true }, 'termine')).unlockedBy, 'kauf') assert.equal((await featureView(undefined, 'news')).unlockedBy, null) assert.equal(Object.hasOwn(await featureView({ state: 'setup', unlockedBy: 'abo' }, 'news'), 'einbau'), false) }) test('ersetztes Ticket trägt die Abrechnung des Dokuments', async () => { // portalFeatures f_p1_news: state 'setup', unlockedBy 'abo', einrichtungAbrechnung 'abo', ticketId 'weg' (Ticket gelöscht), websiteProjects p1, customers c1 const { doc } = await ticketNachlegen({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'Kenso' }) assert.deepEqual([aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, aw.docs('workorders').get(doc.ticketId).einrichtungFuer], ['abo', 'news']) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/einrichtungsTicket.test.js` Expected: FAIL (`undefined !== 'termine'`). - [ ] **Step 3: `ticketAdmin.js` ändern** Parameter `abrechnung` prüfen (`''|'abo'|'projekt'`, sonst `TypeError`); lokale Funktion `mitAbrechnung(text)` kürzt den Text auf `7000 − Zeilenlänge − 1` (gleiche `kuerze`-Regeln) und hängt `\n` an, für Normal- und Notfallweg; Felder nur mit `abrechnung` ergänzen. `ABRECHNUNGS_ZEILEN` exportieren. - [ ] **Step 4: `siteFeatures.js` ändern** `featureView`: `unlockedBy` aus dem `mitAbo`-Zweig nehmen und für alle Schlüssel liefern; `aboEndeAt`/`einbau` bleiben im `mitAbo`-Zweig. `legeTicketAn`: `abrechnung: doc.einrichtungAbrechnung` mitgeben, wenn gesetzt. Nichts sonst in der Datei ändern (`adminActivate` gehört P6). - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/einrichtungsTicket.test.js test/ticketAdmin.test.js test/featureTicket.test.js test/siteFeatures.test.js test/termineTicket.test.js test/zeitenTicket.test.js` Expected: PASS, 0 fail. - [ ] **Step 6: Commit** ```bash git add server/services/ticketAdmin.js server/services/siteFeatures.js test/einrichtungsTicket.test.js git commit -m "Kundenansicht P5: Einrichtungs-Ticket mit Abrechnung, unlockedBy für alle Funktionen" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 9: Generisches Einschalten (`funktionEinschalten.js`) **Files:** - Create: `server/services/funktionEinschalten.js` - Modify: `server/services/zeiten/einschalten.js` (`einschaltenZeiten`, `holeZeitenNach`) - Test: `test/funktionEinschalten.test.js` **Interfaces:** - Consumes: `aboDeckt` (Task 4), `ticketTexte`, `featureConfig` (Registry), `createFeatureTicket({ abrechnung })` (Task 8), aus `siteFeatures.js`: `assertOwnProject`, `ensureFeatureDoc`, `getFeatureDoc`, `customerOf`, `stateFields`, `ticketWoid`, `unlockedByVon`; `withLocks`; `bestellFehler` (`featureOrder.js`). - Produces (V7): ```js export const ABRECHNUNGEN = Object.freeze(['abo', 'projekt']) export const EINSCHALTEN_TEXTE = Object.freeze({ bereits: 'Diese Funktion ist für dieses Projekt schon eingeschaltet.', laeuft: 'Für diese Funktion läuft schon eine Bestellung.', abgeschaltet: 'Diese Funktion wurde für dieses Projekt abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.', ticket: 'Die Einrichtung konnte gerade nicht angelegt werden. Bitte versuch es gleich noch einmal.', abo: { kein_abo: 'Für diese Funktion brauchst du ein laufendes Abo.', falsches_abo: 'Diese Funktion ist im Wartungs-Abo enthalten.', gekuendigt: 'Dein Abo ist gekündigt. Zum Einschalten brauchst du ein laufendes Abo.', nicht_aktiv: 'Dein Abo ist gerade nicht aktiv, zum Beispiel wegen einer offenen Zahlung. Sobald es wieder aktiv ist, kannst du die Funktion einschalten.', beginnt_spaeter: 'Dein Abo beginnt am {datum}. Ab dann kannst du einschalten.' }, nichtPruefbar: 'Gerade nicht prüfbar – bitte versuch es gleich noch einmal.' }) export function aboFehlerText(grund, ab = null) // EINSCHALTEN_TEXTE.abo[grund]; bei 'beginnt_spaeter' wird {datum} durch den Berliner Tag von ab (berlinDay aus deadlines.js) als TT.MM.JJJJ ersetzt, z. B. 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.' export async function einschalten({ customerId, projectId, key, by, abrechnung, nachweis = null, staff = null, now = new Date() }) // → { doc, ticketWoid } // einschaltenZeiten({ …, abrechnung = '', vorpruefung = null }): abrechnung überschreibt unlockedBy ('projekt'|'abo') und setzt einrichtungAbrechnung; vorpruefung ersetzt pruefeAboFuerEinschalten unter der Sperre (nur by 'kunde') ``` Fehler (`bestellFehler`, mit `code` und `body`): 409 `bereits_eingeschaltet`, `bestellung_laeuft` (Zustand `ordered`), `abgeschaltet` (Zustand `disabled`), `abo_fehlt` (`body.grund`, `body.wechsel`, bei `beginnt_spaeter` zusätzlich `body.ab`; Text über `aboFehlerText`); 503 bei nicht lesbarem Abo oder gescheitertem Ticket; `assertOwnProject`-Fehler (403, 409 archiviert); `TypeError` für `key` außerhalb des Registers (auch `analyse`), `by`, `abrechnung`. Ablauf für `news|termine|shop|unterkunft` unter der Sperre `feature-order:${customerId}:${projectId}`: (1) `by === 'kunde'`: `aboDeckt(customerId, key, { now })` frisch, sonst `abo_fehlt` (`wechsel` = `grund === 'falsches_abo'`, bei `beginnt_spaeter` `body.ab`); (2) Zustand lesen: `setup` ohne `ticketId` mit `unlockedBy` `abo|projekt` ist **Heilung** (nur Ticket anlegen, kein Nachweis, kein neuer Zustand), jeder andere `setup|active` ist `bereits_eingeschaltet`, `ordered` und `disabled` siehe oben; (3) `nachweis()` (falls übergeben) **vor** dem Zustand; (4) Dokument auf `setup` mit `featureKey`, `unlockedBy` (`projekt` oder `abo`), `unlockedByPurchase: false`, `einrichtungAbrechnung`, `disabledAt/disabledReason/aboEndeMailAt: null`, bei `by === 'team'` Notiz `staff.adminNote` oder ` : Eingeschaltet ()`; (5) Ticket (`ticketTexte` mit dem neuen Dokument, `createFeatureTicket` mit `abrechnung`), `ticketId` speichern. Scheitert (5), bleibt `setup` ohne Ticket und der Aufruf wirft 503 `EINSCHALTEN_TEXTE.ticket`; der nächste Aufruf heilt. `zeiten` geht über `einschaltenZeiten` (Sperre, Nachweis, Ticket dort), das Ergebnis wird auf `{ doc, ticketWoid }` abgebildet. - [ ] **Step 1: Failing test `test/funktionEinschalten.test.js`** (Schema und Zeiten-Neubau-Stub wie `test/zeitenEinschalten.test.js:11-24, 41-103`, dazu `contractEvents`; `beforeEach` setzt `aufrufe = []`, leert `portalFeatures`, `workorders`, `featureConsents` und legt Wartung für c1 an, Projekte `Projekt p1`, `Projekt p2`, `Projekt p3` für c1 und `p_fremd` für c2 wie `projekt()` in dieser Datei) ```js const E = (over = {}) => einschalten({ customerId: 'c1', projectId: 'p1', key: 'news', by: 'kunde', abrechnung: 'abo', nachweis, now: JETZT, ...over }) let aufrufe; const nachweis = async () => { aufrufe.push(aw.docs('portalFeatures').get('f_p1_news')?.state ?? 'kein Dokument') } const spuren = () => ({ docs: aw.docs('portalFeatures').size, tickets: aw.docs('workorders').size }) test('Erfolg im Abo: setup, unlockedBy abo, Marker, genau ein Ticket, Nachweis vor dem Zustand', async () => { const { doc, ticketWoid } = await E() assert.deepEqual([doc.state, doc.enabled, doc.unlockedBy, doc.unlockedByPurchase, doc.einrichtungAbrechnung], ['setup', false, 'abo', false, 'abo']) assert.equal(aufrufe.length, 1); assert.ok(['kein Dokument', 'off'].includes(aufrufe[0]), 'Nachweis lief vor dem Zustand') const [t] = [...aw.docs('workorders').values()] assert.deepEqual([aw.docs('workorders').size, t.topic, t.einrichtungFuer, t.einrichtungAbrechnung, t.woid], [1, 'News-Pinnwand einbauen – Projekt p1', 'news', 'abo', ticketWoid]) assert.ok(t.details.endsWith(ABRECHNUNGS_ZEILEN.abo)); assert.equal(doc.ticketId, t.$id) }) test('Team im Projekt: ohne Abo-Prüfung, unlockedBy projekt, Notiz und Ticketzeile', async () => { aw.docs('subscriptions').clear() const { doc } = await E({ by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' }, nachweis: null }) assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung], ['projekt', 'projekt']); assert.match(doc.adminNote, /Andrej: Eingeschaltet \(projekt\)$/) assert.ok([...aw.docs('workorders').values()][0].details.endsWith(ABRECHNUNGS_ZEILEN.projekt)) }) test('Konflikte mit klaren Codes, ohne zweites Ticket oder zweiten Nachweis', async () => { await E(); const s = spuren(); const n = aufrufe.length await assert.rejects(E(), (e) => e.status === 409 && e.code === 'bereits_eingeschaltet' && e.body.code === 'bereits_eingeschaltet') aw.docs('portalFeatures').get('f_p1_news').state = 'ordered'; await assert.rejects(E(), (e) => e.code === 'bestellung_laeuft') aw.docs('portalFeatures').get('f_p1_news').state = 'disabled'; await assert.rejects(E(), (e) => e.code === 'abgeschaltet') assert.deepEqual([spuren(), aufrufe.length], [s, n]) }) test('Heilung: setup ohne Ticket legt nur das Ticket an; gekauft (kauf) wird nicht geheilt', async () => { aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', enabled: false, unlockedBy: 'abo', unlockedByPurchase: false, einrichtungAbrechnung: 'abo' }) const { doc } = await E(); assert.deepEqual([aufrufe.length, aw.docs('workorders').size, doc.state, Boolean(doc.ticketId)], [0, 1, 'setup', true]) await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet') aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', state: 'setup', unlockedBy: 'kauf', unlockedByPurchase: true }) await assert.rejects(E(), (e) => e.code === 'bereits_eingeschaltet') }) test('Ticket scheitert: 503, setup bleibt, der nächste Aufruf heilt ohne zweites Ticket', async () => { aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) await assert.rejects(E(), (e) => e.status === 503 && e.message === EINSCHALTEN_TEXTE.ticket) assert.deepEqual([aw.docs('portalFeatures').get('f_p1_news').state, aw.docs('workorders').size], ['setup', 0]) await E(); assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1]) }) test('gleichzeitig: ein Erfolg, ein 409, ein Ticket, ein Nachweis', async () => { const r = await Promise.allSettled([E(), E()]) assert.deepEqual(r.map((x) => x.status).sort(), ['fulfilled', 'rejected']); assert.equal(r.find((x) => x.status === 'rejected').reason.code, 'bereits_eingeschaltet') assert.deepEqual([aw.docs('workorders').size, aufrufe.length], [1, 1]) }) test('Abo fehlt unter der Sperre: abo_fehlt mit Grund und wechsel, nichts geschrieben', async () => { aw.seed('subscriptions', subscriptionDoc('sub_h', hostingDoc)) // Hosting läuft, news ist Wartung await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'falsches_abo' && e.body.wechsel === true && e.message === EINSCHALTEN_TEXTE.abo.falsches_abo) aw.docs('subscriptions').clear(); await assert.rejects(E(), (e) => e.body.grund === 'kein_abo' && e.body.wechsel === false) aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' })) // Probezeit: Abo beginnt später await assert.rejects(E(), (e) => e.status === 409 && e.code === 'abo_fehlt' && e.body.grund === 'beginnt_spaeter' && e.body.wechsel === false && e.body.ab === '2026-11-03T05:00:00.000Z' && e.message === 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.') assert.equal(aboFehlerText('beginnt_spaeter', '2026-11-03T05:00:00.000Z'), 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.'); assert.equal(aboFehlerText('kein_abo'), EINSCHALTEN_TEXTE.abo.kein_abo) assert.deepEqual([spuren(), aufrufe.length], [{ docs: 0, tickets: 0 }, 0]) }) test('Öffnungszeiten über den bestehenden Weg: Marker, unlockedBy je Abrechnung, kein Preistext im Abo-Fehler', async () => { const { doc } = await E({ key: 'zeiten', nachweis: async () => {} }); assert.deepEqual([doc.unlockedBy, doc.einrichtungAbrechnung, doc.state], ['abo', 'abo', 'setup']) assert.equal(aw.docs('workorders').get(doc.ticketId).einrichtungAbrechnung, 'abo') aw.docs('subscriptions').clear(); await assert.rejects(E({ key: 'zeiten', projectId: 'p2' }), (e) => e.code === 'abo_fehlt' && !/€/.test(e.message)) const t = await E({ key: 'zeiten', projectId: 'p3', by: 'team', abrechnung: 'projekt', staff: { name: 'Andrej' } }); assert.equal(t.doc.unlockedBy, 'projekt') }) test('Parameter und Projekt', async () => { for (const bad of [{ key: 'analyse' }, { key: 'foo' }, { by: 'x' }, { abrechnung: 'frei' }]) await assert.rejects(E(bad), TypeError) await assert.rejects(E({ projectId: 'p_fremd' }), (e) => e.status === 403) assert.deepEqual(spuren(), { docs: 0, tickets: 0 }) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/funktionEinschalten.test.js` Expected: FAIL mit `ERR_MODULE_NOT_FOUND` für `funktionEinschalten.js`. - [ ] **Step 3: `zeiten/einschalten.js` erweitern** (Standardverhalten unverändert) `einschaltenZeiten`: neue Parameter `abrechnung = ''` und `vorpruefung = null`; Schritt 1 ruft `vorpruefung` statt `pruefeAboFuerEinschalten`, wenn übergeben; `unlockedBy` = `gekauft ? 'kauf' : abrechnung === 'projekt' ? 'projekt' : abrechnung === 'abo' ? 'abo' : (bisherige Regel)`; `einrichtungAbrechnung` nur mit `abrechnung` mitschreiben. `holeZeitenNach` gibt `frisch.einrichtungAbrechnung` (falls gesetzt) als `abrechnung` an `createFeatureTicket`. Keine Mails ändern. - [ ] **Step 4: `server/services/funktionEinschalten.js` implementieren** Nach dem Ablauf oben. `vorpruefung` für `zeiten` ist dieselbe frische `aboDeckt`-Prüfung wie bei den anderen Funktionen (liefert `abo_fehlt`, nie den 30-€-Text). Lesefehler des Abos: 503 `nichtPruefbar` (fail-closed). `ticketWoid` über `ticketWoid(doc.ticketId)`. - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/funktionEinschalten.test.js test/zeitenEinschalten.test.js test/zeitenActivate.test.js test/zeitenAboAbgleich.test.js test/zeitenTicket.test.js` Expected: PASS, 0 fail (die vorhandenen Zeiten-Tests unverändert grün). - [ ] **Step 6: Commit** ```bash git add server/services/funktionEinschalten.js server/services/zeiten/einschalten.js test/funktionEinschalten.test.js git commit -m "Kundenansicht P5: generisches Einschalten im Abo (einschalten, Heilung, Ticket-Marker)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 10: Route `POST /api/funktionen/:key/einschalten` **Files:** - Modify: `server/routes/funktionen.js` (neue Route nach `/zeiten/einschalten`, Weiche an `/zeiten/einschalten` Z. ~393) - Test: `test/funktionenEinschaltenRoute.test.js` **Interfaces:** - Consumes: `neueAnsicht` (V1, `server/services/kundenansicht/schalter.js`), `einschalten`, `EINSCHALTEN_TEXTE`, `aboFehlerText` (Task 9), `aboProdukt`, `aboDeckt` (Task 4), `kundenartWirksam`, `NUR_BETRIEB_TEXT` (Task 2), `consentAboAktiv`, `consentAbo`, `aboweg` (nimmt das Item `hinweis` in den Nachweis auf), `katalogFelder` (Task 6), `speichereNachweisAusAnfrage` (Task 7), `featureView`, `featureSperre`, `featureHaken`, `pruefeUnterlagen`, `einschaltenDeps.jetzt`, `berlinDay`. - Produces (V5): `POST /api/funktionen/:key/einschalten`, Body `{ projectId, zustimmung: { version, haken: [] | ['beginn'] } }`, Antwort 200 `{ feature: featureView(doc, key), ticketWoid }`. `neueAnsicht(req)` falsch → 404 `{ error: 'Nicht gefunden' }` (nach `requireSession`, also unangemeldet 401). Reihenfolge der Prüfungen: 1. `req.session.viewAs` → 403 `In der Kundenansicht kann nicht eingeschaltet werden.` 2. Schlüssel nicht im Register (auch `analyse`, `__proto__`) → 404 `Unbekannte Funktion` 3. 400: `projectId` fehlt (`Bitte wähle ein Projekt.`); `zustimmung` fehlt, `version` kein Text, `haken` keine Liste oder nicht Teilmenge von `['beginn']` → `zustimmung_fehlt` (`Bitte bestätige die Erklärung, um einzuschalten.`) 4. `assertOwnProject(forNewOrder)` (403, 409 archiviert) 5. 409 in V5-Reihenfolge: `nicht_verfuegbar` (`featureSperre`; danach 503 `unterlagen_fehlen` wie `pruefeUnterlagen`), `abo_fehlt` (Texte über `aboFehlerText`, `body.grund`, `body.wechsel`, bei Grund `beginnt_spaeter` zusätzlich `body.ab`; das ist der Privatkunde, der das Abo ohne Häkchen bestellt hat und dessen Leistung erst nach der Widerrufsfrist beginnt; Lesefehler 503), `nur_betrieb` (`katalogFelder(key).nurBetrieb` und privat; `NUR_BETRIEB_TEXT`), `bestellung_offen` (Hook `bestellpruefung`, `body.offen`; Text `Es fehlen noch Angaben, bevor du einschalten kannst.`), `beginn_noetig` (privat, `widerrufsfristBis > jetzt`, `haken` ohne `beginn`; `body.ab` = ISO von Fristende + 1 ms; Text `Für den sofortigen Beginn brauchst du das Häkchen. Ohne Häkchen kannst du ab dem einschalten.`), `bereits_eingeschaltet` (nur `setup|active`, **außer** `setup` ohne Ticket mit `unlockedBy` `abo|projekt`: das heilt der Dienst), `bestellung_laeuft`, `abgeschaltet`, `fassung_veraltet` (`version !== consentAbo(key).version`; `body.consent` = `consentAboAktiv(key, { beginn })`; Text `Die Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.`) 6. `einschalten({ by: 'kunde', abrechnung: 'abo', nachweis: () => speichereNachweisAusAnfrage(req, { feature: aboweg(feature, { beginn: gesetzt }), customerId, projectId, klickAt, kundenart }) })`. `kundenart` kommt nur aus dem Kundendokument. `gesetzt` = privat und `haken` enthält `beginn`; bei Betrieben wird ein mitgeschicktes `beginn` ignoriert und nicht gespeichert. Der Nachweis enthält in Anzeigereihenfolge `einschalten-zeile`, bei `shop`, `unterkunft`, `termine` den `hinweis` (`haken: false`), bei Bedarf `beginn` und den Knopf. Weiche: `router.post('/zeiten/einschalten', requireSession, alteAnsichtNur, …)` mit `alteAnsichtNur = (req, _res, next) => (neueAnsicht(req) ? next('route') : next())`; in der neuen Ansicht trifft `zeiten` die neue Route. - [ ] **Step 1: Failing test `test/funktionenEinschaltenRoute.test.js`** (Schema und Zeiten-Stub wie Task 9 plus `shopConfig`; `beforeEach`: Sammlungen leeren, `seedFixtures`, Wartung für c1, Projekte `p1`, `p1b`, `p1c`, `p_archiv` (`archived: true`) für c1 und `p_fremd` für c2, `config.kundenansichtNeu.mode = 'pilot'` mit `pilotEmails = ['k@example.invalid', 'test@example.invalid']`, `config.termine.mode = config.shop.mode = 'on'`, `config.zeiten.mode = 'pilot'` mit `['*']`, `config.features[key].unterlagenBereit = async () => true`, `einschaltenDeps.jetzt = () => JETZT`, `setMailTransportForTests(sink)`; `c1 = customerCookie('c1')`) ```js const Z = (key, haken = []) => ({ version: consentAbo(key).version, haken }) // consentAbo aus featureRegistry.js const go = (key, body = {}, cookie = c1) => post(`/api/funktionen/${key}/einschalten`, { projectId: 'p1', zustimmung: Z(key), ...body }, { cookie }) const abschluss = (contractAt = JETZT.toISOString()) => aw.seed('contractEvents', { $id: 'ev_a', customerId: 'c1', subscriptionId: 'sub_h', type: 'abschluss', productKey: 'wartung', status: 'erledigt', consentAt: contractAt, details: JSON.stringify({ contractAt }) }) const nichts = () => assert.deepEqual([aw.docs('portalFeatures').size, aw.docs('workorders').size, aw.docs('featureConsents').size], [0, 0, 0]) test('Erfolg (Betrieb, Wartung): Form, Zustand, Ticket, Nachweis mit Kundenart, keine Mail, kein Preis', async () => { aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }); const r = await go('news') assert.deepEqual([r.status, Object.keys(r.body).sort(), r.body.feature.state, r.body.feature.unlockedBy], [200, ['feature', 'ticketWoid'], 'setup', 'abo']) assert.equal(r.body.ticketWoid, r.body.feature.ticketWoid); assert.equal(aw.docs('workorders').size, 1); assert.deepEqual([sink.sent.length, st.calls.length], [0, 0], 'keine Mail, kein Stripe') const n = [...aw.docs('featureConsents').values()][0]; assert.deepEqual([JSON.parse(n.texte).map((t) => t.id), n.kundenart, n.stripeSessionId], [['einschalten-zeile', 'knopf'], 'unternehmen', '']) assert.doesNotMatch(JSON.stringify(r.body), /€/) }) test('Einschalten nur in neuer Ansicht (Pilot-Ränder)', async () => { const faelle = [['Kunde nicht in der Liste', 'pilot', ['k2@example.invalid']], ['off schlägt *', 'off', ['*']], ['Pilot mit leerer Liste', 'pilot', []]] for (const [name, mode, liste] of faelle) { config.kundenansichtNeu.mode = mode; config.kundenansichtNeu.pilotEmails = liste; assert.equal((await go('news')).status, 404, name); nichts() } config.kundenansichtNeu.mode = 'pilot'; config.kundenansichtNeu.pilotEmails = ['*']; assert.equal((await go('news', { projectId: 'p1b' })).status, 200) // * lässt jeden Kunden durch config.kundenansichtNeu.pilotEmails = ['test@example.invalid'] // Session-E-Mail mit Großbuchstaben und Leerzeichen trifft die Liste (nicht 404) const schreibweise = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', name: 'Kunde Eins', email: ' TEST@Example.INVALID ' }) assert.notEqual((await go('news', { projectId: 'p1c' }, schreibweise)).status, 404) assert.equal((await go('news', {}, kundenansichtCookie({ customerId: 'c1' }))).status, 403) // Mitarbeiter-Kundenansicht im Pilot: gesperrt, nicht 404 assert.equal((await post('/api/funktionen/news/einschalten', {})).status, 401) for (const key of ['analyse', 'foo', '__proto__']) assert.equal((await go(key)).status, 404, key) }) test('Weiche an /zeiten/einschalten: alte Ansicht alter Weg, neue Ansicht neuer Weg', async () => { config.kundenansichtNeu.mode = 'off'; const alt = await post('/api/funktionen/zeiten/einschalten', { projectId: 'p1', zustimmung: { version: config.features.zeiten.consentFrei.version, haken: ['leistung'] } }, { cookie: c1 }) assert.deepEqual([alt.status, Object.keys(alt.body).sort()], [200, ['einbau', 'feature']]); aw.docs('portalFeatures').clear() config.kundenansichtNeu.mode = 'pilot'; const neu = await go('zeiten'); assert.deepEqual([neu.status, Object.keys(neu.body).sort()], [200, ['feature', 'ticketWoid']]) }) test('Kundenart (leer, unbekannt, gefälscht) und Frist: beginn_noetig mit ab, Häkchen, Betrieb ohne Häkchen', async () => { abschluss() // Samstag 17.10. + 14 Tage → Frist bis Mo 02.11., ab = Di 03.11. 00:00 Berlin for (const kundenart of [undefined, '', 'kaputt', 'Unternehmen']) { aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) }) const r = await go('news', { kundenart: 'unternehmen', unternehmer: true }); assert.deepEqual([r.status, r.body.code, r.body.ab], [409, 'beginn_noetig', '2026-11-02T23:00:00.000Z'], String(kundenart)); assert.match(r.body.error, /03\.11\.2026/); nichts() } assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200) const p = JSON.parse([...aw.docs('featureConsents').values()][0].texte); assert.deepEqual(p.map((t) => [t.id, t.haken]), [['einschalten-zeile', false], ['beginn', true], ['knopf', false]]) aw.docs('portalFeatures').clear(); aw.docs('workorders').clear(); aw.docs('featureConsents').clear(); aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) assert.equal((await go('news', { zustimmung: Z('news', ['beginn']) })).status, 200) // Betrieb: beginn wird ignoriert assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((t) => t.id), ['einschalten-zeile', 'knopf']) aw.docs('portalFeatures').clear(); aw.seed('customers', { ...customers.c1 }); abschluss('2026-09-01T09:00:00.000Z') // privat, Frist vorbei: kein Häkchen nötig assert.equal((await go('news')).status, 200) }) test('Prüfreihenfolge der 409-Codes', async () => { config.termine.mode = 'off'; aw.docs('subscriptions').clear(); assert.equal((await go('termine')).body.code, 'nicht_verfuegbar') config.termine.mode = 'on'; assert.equal((await go('termine')).body.code, 'abo_fehlt') // vor nur_betrieb aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)); assert.equal((await go('shop')).body.code, 'nur_betrieb') // privat, vor bestellung_offen aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }); const o = await go('shop'); assert.deepEqual([o.body.code, o.body.offen], ['bestellung_offen', ['Deine Webshop-Anfrage ist noch nicht freigegeben.']]) await go('news'); assert.equal((await go('news', { zustimmung: { version: 'alt', haken: [] } })).body.code, 'bereits_eingeschaltet') // vor fassung_veraltet const alt = await go('zeiten', { zustimmung: { version: 'alt', haken: [] } }); assert.deepEqual([alt.status, alt.body.code, alt.body.consent.version], [409, 'fassung_veraltet', config.aboFunktionen.fassung]) }) test('Hinweis (Pflichten der alten Häkchen): der Satz der Funktion steht im Nachweis; news hat keinen', async () => { aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) const alt = ['termine', 'shop'].map((k) => config.features[k].bestellpruefung) for (const k of ['termine', 'shop']) config.features[k].bestellpruefung = async () => [] // Fragebogen und Freigabe sind hier nicht das Thema try { for (const key of ['termine', 'shop']) { const r = await go(key); assert.equal(r.status, 200, `${key} ${JSON.stringify(r.body)}`) const t = JSON.parse([...aw.docs('featureConsents').values()].at(-1).texte) assert.deepEqual(t.map((x) => x.id), ['einschalten-zeile', 'hinweis', 'knopf'], key) assert.equal(t[1].text, consentAbo(key).items[1].text, key); assert.equal(t[1].haken, false) } } finally { ['termine', 'shop'].forEach((k, i) => { if (alt[i] === undefined) delete config.features[k].bestellpruefung; else config.features[k].bestellpruefung = alt[i] }) } assert.match(consentAbo('shop').items[1].text, /eigenem Namen.*eigenes Stripe-Konto/); assert.match(consentAbo('termine').items[1].text, /Gesundheitsbereich/) aw.docs('featureConsents').clear(); assert.equal((await go('news')).status, 200) assert.deepEqual(JSON.parse([...aw.docs('featureConsents').values()][0].texte).map((x) => x.id), ['einschalten-zeile', 'knopf']) }) test('Probezeit (Privatkunde bestellte das Abo ohne Häkchen): abo_fehlt beginnt_spaeter mit Datum statt Zahlungsmeldung', async () => { aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-11-03T05:00:00.000Z' })) const r = await go('news') assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel, r.body.ab], [409, 'abo_fehlt', 'beginnt_spaeter', false, '2026-11-03T05:00:00.000Z']) assert.equal(r.body.error, 'Dein Abo beginnt am 03.11.2026. Ab dann kannst du einschalten.'); assert.doesNotMatch(r.body.error, /Zahlung/); nichts() aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, status: 'trialing', startChoice: 'nach_frist', trialEnd: '2026-10-01T05:00:00.000Z' })) // Probezeit vorbei, Stripe noch nicht nachgezogen assert.equal((await go('news')).body.grund, 'nicht_aktiv') }) test('Doppelt und widersprüchlich: nacheinander, gleichzeitig, zweite Website, Zustände', async () => { aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) assert.deepEqual([(await go('news')).status, (await go('news')).body.code], [200, 'bereits_eingeschaltet']); assert.deepEqual([aw.docs('workorders').size, aw.docs('featureConsents').size], [1, 1]) assert.equal((await go('news', { projectId: 'p1b' })).status, 200); assert.equal(aw.docs('workorders').size, 2) // zweite Website, eigenes Ticket const gl = await Promise.all([go('news', { projectId: 'p1c' }), go('news', { projectId: 'p1c' })]); assert.deepEqual(gl.map((x) => x.status).sort(), [200, 409]); assert.equal(aw.docs('workorders').size, 3) aw.docs('portalFeatures').get('f_p1_news').state = 'active'; assert.equal((await go('news')).body.code, 'bereits_eingeschaltet') aw.seed('portalFeatures', { $id: 'f_p1_news', customerId: 'c1', projectId: 'p1', featureKey: 'news', siteKey: 'abcdefghijklmnop', state: 'setup', unlockedBy: 'abo', unlockedByPurchase: false }) assert.equal((await go('news')).status, 200) // setup ohne Ticket: Heilung, kein 409 }) test('Abo-Grenzfälle', async () => { const faelle = [['Hosting + Wartungs-Funktion', hostingDoc, 'falsches_abo', true], ['gekündigt', { ...wartungDoc, cancelAtPeriodEnd: true }, 'gekuendigt', false], ['past_due', { ...wartungDoc, status: 'past_due' }, 'nicht_aktiv', false], ['beendet', { ...wartungDoc, status: 'canceled' }, 'kein_abo', false]] for (const [name, doc, grund, wechsel] of faelle) { aw.seed('subscriptions', subscriptionDoc('sub_h', doc)); const r = await go('termine'); assert.deepEqual([r.status, r.body.code, r.body.grund, r.body.wechsel], [409, 'abo_fehlt', grund, wechsel], name) } aw.seed('subscriptions', subscriptionDoc('sub_h', wartungDoc)) config.features.news.bestellpruefung = async () => { aw.docs('subscriptions').clear(); return [] } // Abo endet nach der Vorprüfung, vor der Sperre const spaet = await go('news'); assert.deepEqual([spaet.status, spaet.body.code], [409, 'abo_fehlt']); nichts(); delete config.features.news.bestellpruefung }) test('Eingabe und Archiv', async () => { for (const [body, status] of [[{ projectId: '' }, 400], [{ zustimmung: undefined }, 400], [{ zustimmung: { version: 'x', haken: 'beginn' } }, 400], [{ zustimmung: Z('news', ['x']) }, 400], [{ projectId: 'p_fremd' }, 403], [{ projectId: 'p_archiv' }, 409]]) assert.equal((await go('news', body)).status, status, JSON.stringify(body)) assert.equal((await go('news', { zustimmung: undefined })).body.code, 'zustimmung_fehlt'); nichts() }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/funktionenEinschaltenRoute.test.js` Expected: FAIL (`404` statt `200`, die Route fehlt). - [ ] **Step 3: Route und Weiche in `server/routes/funktionen.js`** Wie oben; Uhr `einschaltenDeps.jetzt()`; `fail(res, err, 'Die Funktion konnte gerade nicht eingeschaltet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')`; `beginn_noetig`-Datum mit `berlinDay(ab)` umgedreht zu `TT.MM.JJJJ`. Das Kundendokument einmal lesen (`getDocument(config.collections.customers, customerId)`, 404 → `{}`). - [ ] **Step 4: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/funktionenEinschaltenRoute.test.js test/funktionenRoutes.test.js test/zeitenEinschalten.test.js test/funktionenCheckout.test.js test/kundenansichtGuard.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/routes/funktionen.js test/funktionenEinschaltenRoute.test.js git commit -m "Kundenansicht P5: POST /api/funktionen/:key/einschalten (nur neue Ansicht)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 11: Katalog und Anfragestatus in `GET /api/funktionen` **Files:** - Create: `server/services/aboKatalog.js` - Modify: `server/routes/funktionen.js` (`router.get('/')`) - Test: `test/aboFunktionenListe.test.js` **Interfaces:** - Consumes: `katalogReihenfolge`, `katalogFelder`, `consentAbo`, `featureConfig`, `featureSperre` (Registry), `unterlagenFuer` (Task 6), `ENTHALTEN_IN`, `aboProdukt` (Task 4), `kundenartWirksam` (Task 2), `leseShopConfig` (`shop/anfrage.js`), `ladeEinstellungen` (`unterkunft/speicher.js`), `config.analyse`. - Produces (V5): ```js export async function katalogFuerKunde({ email }) // → katalog[] in der Reihenfolge von katalogReihenfolge(); analyse nur bei config.analyse.enabled export async function anfrageStatusJeProjekt({ docs, projects }) // → Map ('' ohne Dokument) // Eintrag: { key, name, beschreibung, bild, enthaltenIn, verfuegbar, ablauf, einrichtung, unterlagen: { leistungsbeschreibung, avv }, nurBetrieb, consent, hinweis } ``` `consent` ist die öffentliche Sicht von `consentAbo(key)` (`null` bei `analyse`), siehe Vertragsfrage 2. `hinweis` (V5-Erweiterung, vom Server geliefert, nie vom Client) ist der Text des Items `hinweis` aus `consent.items` oder `null` (news, zeiten, analyse): die Pflicht aus den alten Häkchen `verantwortung`, `stripe`, `gesundheit`, die die Einschalt-Seite über dem Knopf zeigt. `unterlagen` kommt aus `unterlagenFuer(key)`. Mit neuer Ansicht bekommt die Listenantwort zusätzlich `modell: 'abo'`, `kundenart: kundenartWirksam(kunde)`, `abo: await aboProdukt(customerId, { now })`, `katalog`, und `projects[i].features.shop.anfrageStatus` und `.unterkunft.anfrageStatus`. Ohne neue Ansicht ändert sich nichts (außer `unlockedBy`, Task 8). Der Katalog enthält kein Preisfeld. - [ ] **Step 1: Failing test `test/aboFunktionenListe.test.js`** (Kopf und `beforeEach` wie Task 10, dazu Projekte `p1` und `p2` für c1, `config.analyse.enabled = false`; `abschluss()` wie dort) ```js const OLD_KEYS = ['consentText', 'features', 'legal', 'projects', 'setupEstimate', 'shop', 'termine', 'unterkunft', 'ustHinweis', 'zeiten'] test('ohne neue Ansicht: Top-Level-Schlüssel unverändert, keine Anfragestatus', async () => { config.kundenansichtNeu.mode = 'off'; const b = await get('/api/funktionen', { cookie: c1 }) assert.deepEqual(Object.keys(b).sort(), OLD_KEYS); assert.equal(Object.hasOwn(b.projects[0].features.shop, 'anfrageStatus'), false) }) test('mit neuer Ansicht: Modell, Kundenart, Abo, Katalog in fester Reihenfolge', async () => { abschluss() // offener Abschluss vom 17.10. const b = await get('/api/funktionen', { cookie: c1 }) assert.deepEqual([b.modell, b.kundenart, b.abo], ['abo', 'privat', { produkt: 'wartung', laeuft: true, gekuendigt: false, widerrufsfristBis: '2026-11-02T22:59:59.999Z' }]) aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) // Kundenart aus dem Dokument, auch in der Mitarbeiter-Kundenansicht assert.equal((await get('/api/funktionen', { cookie: kundenansichtCookie({ customerId: 'c1' }) })).kundenart, 'unternehmen'); aw.seed('customers', customers.c1) assert.deepEqual(b.katalog.map((e) => e.key), ['news', 'termine', 'shop', 'unterkunft', 'zeiten']) // analyse nur bei ANALYSE_ENABLED config.analyse.enabled = true; const mit = await get('/api/funktionen', { cookie: c1 }); assert.equal(mit.katalog.at(-1).key, 'analyse'); assert.equal(mit.katalog.at(-1).enthaltenIn, 'hosting') const t = b.katalog.find((e) => e.key === 'termine') assert.deepEqual(Object.keys(t).sort(), ['ablauf', 'beschreibung', 'bild', 'consent', 'einrichtung', 'enthaltenIn', 'hinweis', 'key', 'name', 'nurBetrieb', 'unterlagen', 'verfuegbar']) assert.deepEqual([t.name, t.enthaltenIn, t.ablauf, t.bild, t.nurBetrieb, t.consent.items.map((i) => i.id)], ['Terminplaner', 'wartung', 'fragebogen', '/neu/bilder/termine.webp', false, ['einschalten-zeile', 'hinweis', 'beginn']]) const hinweise = Object.fromEntries(b.katalog.map((e) => [e.key, e.hinweis])) // Pflichten der alten Häkchen: Server liefert den Satz, aus demselben Item wie der Nachweis assert.deepEqual(Object.keys(hinweise).filter((k) => hinweise[k] !== null), ['termine', 'shop', 'unterkunft']) assert.deepEqual([hinweise.shop, hinweise.termine], [b.katalog.find((e) => e.key === 'shop').consent.items[1].text, t.consent.items[1].text]); assert.match(hinweise.shop, /eigenem Namen.*eigenes Stripe-Konto/) assert.deepEqual([hinweise.news, hinweise.zeiten, mit.katalog.at(-1).hinweis], [null, null, null]) assert.equal(b.katalog.find((e) => e.key === 'shop').nurBetrieb, true); assert.doesNotMatch(JSON.stringify(b.katalog), /€|priceLabel/) assert.deepEqual(t.unterlagen.leistungsbeschreibung, { version: config.termine.avvVersion, url: config.features.termine.leistungsbeschreibung.url }) assert.equal(mit.katalog.at(-1).unterlagen.avv.url, `/rechtliches/avv-website-analyse-${config.analyse.avvVersion}.html`); config.analyse.enabled = false }) test('verfuegbar folgt dem Schalter je Funktion; nicht verfügbare stehen trotzdem im Katalog', async () => { config.termine.mode = 'off'; config.shop.mode = 'pilot'; config.shop.pilotEmails = ['k2@example.invalid'] const v = Object.fromEntries((await get('/api/funktionen', { cookie: c1 })).katalog.map((e) => [e.key, e.verfuegbar])); assert.deepEqual([v.news, v.termine, v.shop], [true, false, false]) }) test('anfrageStatus je Projekt für Shop und Buchungskalender', async () => { // portalFeatures f_p1_shop + shopConfig f_p1_shop (anfrageStatus 'offen'); f_p1_unterkunft + ukEinstellungen f_p1_unterkunft (anfrageStatus 'angenommen'); Projekt p2 ohne Dokumente const b = await get('/api/funktionen', { cookie: c1 }); const f = (id) => b.projects.find((p) => p.projectId === id).features assert.deepEqual([f('p1').shop.anfrageStatus, f('p1').unterkunft.anfrageStatus, f('p2').shop.anfrageStatus, f('p2').unterkunft.anfrageStatus], ['offen', 'angenommen', '', '']) assert.equal(Object.hasOwn(f('p1').news, 'anfrageStatus'), false) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboFunktionenListe.test.js` Expected: FAIL (`undefined !== 'abo'`). - [ ] **Step 3: `server/services/aboKatalog.js`** `name`: `featureConfig(key).label.name`, bei `analyse` `config.aboFunktionen.analyse.name`. `unterlagen`: `unterlagenFuer(key)` (Task 6). `hinweis`: Text des Items mit `id === 'hinweis'` oder `null`. `verfuegbar`: `featureSperre(key, email) === null`, bei `analyse` `true`. `anfrageStatusJeProjekt` liest je Projekt mit vorhandenem Funktionsdokument genau eine Konfiguration (`leseShopConfig(doc.$id)?.anfrageStatus`, `ladeEinstellungen(doc.$id)?.anfrageStatus`), sonst `''`; Lesefehler einer Konfiguration ergibt `''` und einen Logeintrag mit Art und Status. - [ ] **Step 4: `router.get('/')` ergänzen** Nur wenn `neueAnsicht(req)`: nach dem Aufbau der Antwort die vier Felder und die Anfragestatus anhängen; Kundendokument einmal lesen. Der bestehende `res.json`-Aufbau bleibt zeichengleich. - [ ] **Step 5: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboFunktionenListe.test.js test/funktionenRoutes.test.js test/zeitenEinschalten.test.js test/funktionenCheckout.test.js` Expected: PASS, 0 fail. - [ ] **Step 6: Commit** ```bash git add server/services/aboKatalog.js server/routes/funktionen.js test/aboFunktionenListe.test.js git commit -m "Kundenansicht P5: Katalog, Abo-Stand und Anfragestatus in GET /api/funktionen" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 12: Shop-Anfrage im Abo-Modell (Unternehmer aus Kundenart) **Files:** - Modify: `server/services/shop/anfrage.js` (`stelleAnfrage` Z. 171), `server/routes/shop.js` (`POST /anfrage` Z. 213) - Test: `test/aboAnfragen.test.js` **Interfaces:** - Consumes: `neueAnsicht` (V1), `kundenartWirksam`, `NUR_BETRIEB_TEXT` (Task 2). - Produces: `stelleAnfrage({ customerId, projectId, eingabe, person, now, kundenart })` mit optionalem `kundenart`. Ohne Angabe verhält sich alles wie bisher (`unternehmer` Pflicht-`true` vom Client). Mit Angabe: `'privat'` → 409 mit `body.code: 'nur_betrieb'` und `NUR_BETRIEB_TEXT` **nach** Projekt- und Schalterprüfung, **vor** der Eingabe; `'unternehmen'` → `unternehmer` wird serverseitig auf `true` gesetzt (Client-Wert ignoriert), `keineAusgeschlossenen: true` bleibt Pflicht, die gespeicherte Anfrage trägt zusätzlich `kundenart: 'unternehmen'`. Die Route übergibt `kundenart: kundenartWirksam(kundendokument)` nur, wenn `neueAnsicht(req)`. Der Buchungskalender (`unterkunft/anfrageFunktion.js`) bleibt unverändert: seine Eingabe kennt kein `unternehmer`, und `nurBetrieb` ist dort `false`. - [ ] **Step 1: Failing test `test/aboAnfragen.test.js`** (Router `/api/shop` und `/api/unterkunft`, Schema `shopConfig`, `ukEinstellungen`, `portalFeatures`; `mailSink`; `beforeEach`: Pilot wie Task 10, `config.shop.mode = config.unterkunft.mode = 'on'`, Projekt p1 für c1, Sammlungen leeren) ```js const anfrage = (extra = {}) => ({ warenart: 'waren', beschreibung: 'Backwaren aus eigener Herstellung', artikelzahl: 'bis20', versand: false, abholung: true, keineAusgeschlossenen: true, ...extra }) const senden = (a, cookie = c1) => post('/api/shop/anfrage', { projectId: 'p1', anfrage: a }, { cookie }) test('privat (Kundenart leer): 409 nur_betrieb, auch mit gefälschtem unternehmer: true; nichts geschrieben, keine Mail', async () => { const r = await senden(anfrage({ unternehmer: true })) assert.deepEqual([r.status, r.body.code, r.body.error], [409, 'nur_betrieb', NUR_BETRIEB_TEXT]); assert.deepEqual([aw.docs('shopConfig').size, sink.sent.length], [0, 0]) }) test('Betrieb: unternehmer kommt vom Server, Anfrage speichert die Kundenart', async () => { aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) const r = await senden(anfrage()); assert.deepEqual([r.status, r.body.anfrage.status], [200, 'offen']) assert.deepEqual([r.body.anfrage.anfrage.unternehmer, r.body.anfrage.anfrage.kundenart], [true, 'unternehmen']) aw.docs('shopConfig').clear(); aw.docs('portalFeatures').clear() const ohne = await senden(anfrage({ keineAusgeschlossenen: false })); assert.deepEqual([ohne.status, Object.keys(ohne.body.fehler)], [400, ['keineAusgeschlossenen']]) }) test('alte Ansicht unverändert: unternehmer bleibt Pflicht, privat darf anfragen', async () => { config.kundenansichtNeu.mode = 'off' const r = await senden(anfrage()); assert.deepEqual([r.status, Object.keys(r.body.fehler)], [400, ['unternehmer']]) assert.equal((await senden(anfrage({ unternehmer: true }))).status, 200) }) test('Buchungskalender: privat darf in der neuen Ansicht anfragen (nurBetrieb false)', async () => { const r = await post('/api/unterkunft/anfrage-funktion', { projectId: 'p1', art: 'ferienwohnung', einheiten: 2, portale: ['booking'] }, { cookie: c1 }) assert.equal(r.status, 200); assert.equal(r.body.anfrage.status, 'offen') }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboAnfragen.test.js` Expected: FAIL (`200 !== 409`, die privat-Anfrage wird angenommen). - [ ] **Step 3: `shop/anfrage.js` und `routes/shop.js` ändern** In `stelleAnfrage` nach `pruefeZugang` die Kundenart-Weiche (nur wenn `kundenart !== undefined`), `unternehmer: true` in die Eingabe setzen, nach `normalisiereAnfrage` `kundenart` an das gespeicherte Objekt hängen. Die Route liest das Kundendokument nur bei `neueAnsicht(req)`. - [ ] **Step 4: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/aboAnfragen.test.js test/shopAnfrage.test.js test/unterkunftAnfrageFunktion.test.js test/funktionenShopTextform.test.js` Expected: PASS, 0 fail. - [ ] **Step 5: Commit** ```bash git add server/services/shop/anfrage.js server/routes/shop.js test/aboAnfragen.test.js git commit -m "Kundenansicht P5: Shop-Anfrage im Abo-Modell (Unternehmer aus Kundenart)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 13: Analyse im Abo-Modell (hosting-fähig, Zielgruppe erzwungen, Nachweis) **Files:** - Modify: `server/services/analyse.js` (`analyseEligibility` Z. 57, `activateAnalyse` Z. 281, `runAboCheck`), `server/services/aboFunktionen.js` (`aboModellFuerKunde`), `server/routes/funktionen.js` (`analyseView`, `/analyse/activate`) - Test: `test/analyseAboModell.test.js` (Aufbau wie `test/analyseRoutes.test.js:9-60`: Fakes für `analyseDeps.client`, `rebuild`, Mail, `config.analyse.enabled = true`; Schema zusätzlich `featureConsents`) **Interfaces:** - Consumes: `aboDeckt`, `aboLaeuftFuer` (Task 4), `neueAnsichtFuerKunde({ customerId, email })` (P0, V1-Erweiterung für Jobs ohne Sitzung: `off` → false, `on` → true, `pilot` → `'*'` oder getrimmte, klein geschriebene E-Mail in der Liste; die Mitarbeiter-Kundenansicht gibt es dort nicht), `consentAboAnalyse`, `abowegAnalyse` (Task 6), `analyseUnterlagen` (Task 6), `speichereNachweisAusAnfrage` (Task 7), `kundenartWirksam` (Task 2), `bestellFehler` (`featureOrder.js`). - Produces: ```js // aboFunktionen.js export async function aboModellFuerKunde(customerId) // → boolean: liest das Kundendokument (404 → false) und ruft neueAnsichtFuerKunde({ customerId, email: doc.email }); für Jobs ohne Cookie. NICHT neueAnsichtFuerSession: eine Sitzung braucht dort auch appwriteUserId, die ein Job nicht hat // analyse.js analyseEligibility(customerId, projectId, { aboModell = false } = {}) // aboModell: aboDeckt(customerId, 'analyse').ok statt „Wartung läuft“; sonst Grund 'kein_abo' activateAnalyse({ …, aboModell = false, nachweis = null }) // aboModell: erwartete AVV-Fassung = consentAboAnalyse().avvVersion statt config.analyse.avvVersion; // nachweis: async () => void, läuft im selben try wie recordConsent, nach dem Dokument und vor recordConsent; ein Fehler nimmt alles zurück (kein „on“ ohne Nachweis) export const ELIGIBILITY_MESSAGES_ABO = { kein_abo: 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.' } export const eligibilityMessage = (reason, { aboModell = false } = {}) ``` `analyseView(customerId, projectId, { aboModell })` liefert im Abo-Modell `avvVersion` und `legal` aus `analyseUnterlagen()` (die Oberfläche zeigt und sendet damit dieselbe Fassung, die der Server prüft und speichert), sonst wie bisher. `runAboCheck` überspringt eine aktive Analyse, wenn `aboModellFuerKunde(customerId)` und `aboLaeuftFuer(customerId, 'analyse')` gelten (gekündigt zählt bis zum Abo-Ende); ohne neue Ansicht bleibt der Lauf bytegleich (Wartung-Regel, K19). **Route `POST /analyse/activate`, nur wenn `neueAnsicht(req)`** (alte Ansicht unverändert, ohne Nachweis in `featureConsents`): 1. nach der `viewAs`-Sperre und vor jedem Schreiben: `req.body.audienceConfirmed !== true` → `bestellFehler(400, 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', 'zielgruppe_fehlt')`. Die Aussage ist damit serverseitig erzwungen und nicht mehr nur ein Häkchen im Browser (Spec 4, 5.5, C3). 2. `activateAnalyse({ …, aboModell: true, nachweis })` mit `nachweis = () => speichereNachweisAusAnfrage(req, { feature: abowegAnalyse(), customerId, projectId, klickAt: einschaltenDeps.jetzt().toISOString(), kundenart })`. `kundenart` = `kundenartWirksam(Kundendokument)`, nie aus dem Body. Gespeichert wird in `featureConsents` (`featureKey: 'analyse'`) der Wortlaut in Anzeigereihenfolge: Linkzeile (A1), Zielgruppen-Zeile (A2, `haken: true`), Knopf `Analyse einschalten`; dazu die Fassungen (`zustimmung`, `leistungsbeschreibung`, `avv`) und die Kundenart. 3. Der Nachweis entsteht nur bei einer neuen Aktivierung (Zustand ≠ `active`); wer nur die Farbe einer aktiven Analyse ändert, bekommt keinen zweiten. - [ ] **Step 1: Failing test `test/analyseAboModell.test.js`** ```js // Helfer: abo(doc) = aw.seed('subscriptions', subscriptionDoc('sub_h', doc)); aktiveAnalyse(c, p) = aw.seed('portalFeatures', { $id: `f_${p}_analyse`, customerId: c, projectId: p, featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghijklmnop' }) // consent() = { accepted: true, avvVersion: analyseUnterlagen().avvVersion }; putSiteAufrufe = die Aufrufe des Fakes analyseDeps.client.putSite als { active, … }; beforeEach leert putSiteAufrufe, portalFeatures, featureConsents und stellt den Modus 'pilot' mit c1 in der Liste her // Importe: analyseUnterlagen (aboUnterlagen.js), aboModellFuerKunde (aboFunktionen.js) test('Eignung: Hosting zählt nur im Abo-Modell; gekündigt und kein Abo nicht', async () => { abo(hostingDoc) assert.deepEqual(await an.analyseEligibility('c1', 'p1'), { ok: false, reason: 'kein_abo' }) // alte Regel const neu = await an.analyseEligibility('c1', 'p1', { aboModell: true }); assert.deepEqual([neu.ok, neu.project.$id], [true, 'p1']) abo({ ...hostingDoc, cancelAtPeriodEnd: true }); assert.deepEqual(await an.analyseEligibility('c1', 'p1', { aboModell: true }), { ok: false, reason: 'kein_abo' }) aw.docs('subscriptions').clear(); assert.equal((await an.analyseEligibility('c1', 'p1', { aboModell: true })).reason, 'kein_abo') assert.equal(an.eligibilityMessage('kein_abo', { aboModell: true }), 'Die Website-Analyse gibt es nur mit einem laufenden Hosting- oder Wartungs-Abo.') assert.equal(an.eligibilityMessage('kein_abo'), 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.') }) const aktivieren = (extra = {}, cookie = c1) => post('/api/funktionen/analyse/activate', { projectId: 'p1', consent: consent(), audienceConfirmed: true, ...extra }, { cookie }) const keinSchreiben = () => assert.deepEqual([aw.docs('featureConsents').size, putSiteAufrufe.length, aw.docs('portalFeatures').get('f_p1_analyse')?.state ?? 'off'], [0, 0, 'off']) // putSiteAufrufe: Fake von analyseDeps.client.putSite test('Liste und Aktivieren über die Routen: neue Ansicht mit Hosting ja, alte Ansicht nein', async () => { abo(hostingDoc) const b = await get('/api/funktionen', { cookie: c1 }); const a = b.projects.find((p) => p.projectId === 'p1').analyse assert.deepEqual([a.eligible, a.reason, a.avvVersion, a.legal], [true, null, analyseUnterlagen().avvVersion, analyseUnterlagen().legal]) // Ansicht zeigt die Fassung, die der Server prüft assert.equal((await aktivieren()).status, 200) config.kundenansichtNeu.mode = 'off' const alt = (await get('/api/funktionen', { cookie: c1 })).projects.find((p) => p.projectId === 'p1').analyse; assert.deepEqual([alt.eligible, alt.reasonText], [false, 'Die Website-Analyse gibt es nur mit einem laufenden Wartungs-Abo.']) }) test('Zielgruppe serverseitig erzwungen: ohne audienceConfirmed true 400 zielgruppe_fehlt, nichts geschrieben', async () => { abo(hostingDoc) for (const audienceConfirmed of [undefined, false, 'true', 1, null]) { const r = await aktivieren({ audienceConfirmed }); assert.deepEqual([r.status, r.body], [400, { error: 'Bitte bestätige, dass sich deine Website nicht gezielt an Kinder unter 16 Jahren richtet.', code: 'zielgruppe_fehlt' }], String(audienceConfirmed)); keinSchreiben() } assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: '1999-01-01' } })).status, 400) // falsche Fassung: weiter abgelehnt, nichts geschrieben keinSchreiben() }) test('Nachweis der Aktivierung: Linkzeile, Zielgruppen-Zeile, Knopf, Fassungen und die Kundenart des Dokuments', async () => { abo(hostingDoc) for (const [kundenart, soll] of [['unternehmen', 'unternehmen'], ['privat', 'privat'], [undefined, 'privat'], ['kaputt', 'privat']]) { aw.docs('portalFeatures').clear(); aw.docs('featureConsents').clear(); aw.seed('customers', { ...customers.c1, ...(kundenart === undefined ? {} : { kundenart }) }) const r = await aktivieren({ kundenart: 'unternehmen', unternehmer: true }); assert.equal(r.status, 200, String(kundenart)) // Body-Werte zur Kundenart zählen nicht const [n] = [...aw.docs('featureConsents').values()]; assert.equal(aw.docs('featureConsents').size, 1) assert.deepEqual([n.featureKey, n.projectId, n.customerId, n.kundenart, n.stripeSessionId], ['analyse', 'p1', 'c1', soll, ''], String(kundenart)) assert.deepEqual(JSON.parse(n.texte), [ { id: 'einschalten-zeile', text: `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung ${analyseUnterlagen().avvVersion}).`, haken: false }, { id: 'zielgruppe', text: 'Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.', haken: true }, { id: 'knopf', text: 'Analyse einschalten', haken: false }, ]) assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: config.aboFunktionen.fassung, leistungsbeschreibung: analyseUnterlagen().avvVersion, avv: analyseUnterlagen().avvVersion }) assert.equal(aw.docs('portalFeatures').get('f_p1_analyse').audienceConfirmed, true) } assert.equal((await aktivieren({ accentColor: '#222222' })).status, 200); assert.equal(aw.docs('featureConsents').size, 1) // nur die Farbe einer aktiven Analyse: kein zweiter Nachweis }) test('Nachweis scheitert: alles zurückgenommen (kein on ohne Nachweis), der nächste Versuch klappt', async () => { abo(hostingDoc); aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 }) const r = await aktivieren(); assert.equal(r.status, 500) assert.deepEqual([aw.docs('featureConsents').size, aw.docs('portalFeatures').get('f_p1_analyse').state, putSiteAufrufe.map((a) => a.active)], [0, 'off', [true, false]]) // Messdienst angemeldet und wieder zurückgenommen assert.equal((await aktivieren()).status, 200); assert.equal(aw.docs('featureConsents').size, 1) }) test('alte Ansicht unverändert: ohne audienceConfirmed 200, kein Eintrag in featureConsents', async () => { config.kundenansichtNeu.mode = 'off'; abo({ ...wartungDoc }) assert.equal((await aktivieren({ audienceConfirmed: undefined })).status, 200); assert.equal(aw.docs('featureConsents').size, 0) }) test('Tagesprüfung: Hosting-Analyse bleibt für Kunden der neuen Ansicht, sonst endet sie wie bisher (K19)', async () => { const stelle = (mode, pilotEmails = []) => Object.assign(config.kundenansichtNeu, { mode, pilotEmails }) const lauf = async () => { aw.docs('portalFeatures').clear(); aktiveAnalyse('c1', 'p1'); return (await an.runAboCheck({ now: JETZT })).deactivated.length } // Kunde c1: k@example.invalid abo(hostingDoc) for (const [mode, liste, deaktiviert] of [['on', [], 0], ['pilot', ['k@example.invalid'], 0], ['pilot', ['*'], 0], ['pilot', ['k2@example.invalid'], 1], ['pilot', [], 1], ['off', ['*'], 1]]) { stelle(mode, liste); assert.equal(await lauf(), deaktiviert, `${mode} ${liste}`) } aw.seed('customers', { ...customers.c1, email: ' K@Example.Invalid ' }); stelle('pilot', ['k@example.invalid']); assert.equal(await lauf(), 0, 'E-Mail des Kunden mit Großbuchstaben und Leerzeichen trifft die Liste') aw.seed('customers', customers.c1) stelle('on'); abo({ ...wartungDoc, cancelAtPeriodEnd: true }); assert.equal(await lauf(), 0, 'gekündigt, aber noch laufend') abo({ ...wartungDoc, status: 'canceled' }); assert.equal(await lauf(), 1, 'beendet') assert.equal(await aboModellFuerKunde('gibtEsNicht'), false) // kein Kundendokument: kein Abo-Modell }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/analyseAboModell.test.js` Expected: FAIL (`{ ok: false, reason: 'kein_abo' }` statt `ok: true` bei `aboModell: true`). - [ ] **Step 3: `analyse.js`, `aboFunktionen.js`, Route ändern** `aboModellFuerKunde` liest das Kundendokument (404 → `false`) und ruft `neueAnsichtFuerKunde({ customerId, email: doc.email })`. `analyseEligibility` und `activateAnalyse` bekommen die Parameter, der Standard bleibt die bisherige strenge Wartungs-Regel und die Fassung `config.analyse.avvVersion`. In `activateAnalyse` ruft `await nachweis?.()` direkt vor `analyseDeps.recordConsent(entry)` im selben `try` (ein Fehler dort löst die vorhandene Rücknahme aus; ein Nachweis ohne Aktivierung ist harmloser als ein „on“-Eintrag ohne Nachweis, deshalb diese Reihenfolge). `audienceTextVersion` und `avvVersion` des Eintrags und des Dokuments sind im Abo-Modell `consentAboAnalyse().avvVersion`. `analyseView` bekommt `{ aboModell }` und liefert dann `avvVersion` und `legal` aus `analyseUnterlagen()`. Die Route prüft `audienceConfirmed` und baut `nachweis` wie oben beschrieben; das Kundendokument wird einmal gelesen (`getDocument(config.collections.customers, customerId)`, 404 → `{}`). In `runAboCheck` vor `deactivateAnalyse`: `if (await aboModellFuerKunde(doc.customerId) && (await aboLaeuftFuer(doc.customerId, 'analyse'))) continue` (im selben `try`, Lesefehler beenden nie). - [ ] **Step 4: Grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/analyseAboModell.test.js test/analyse.test.js test/analyseRoutes.test.js test/analyseLegal.test.js test/funktionenRoutes.test.js` Expected: PASS, 0 fail (die vorhandenen Analyse-Tests, auch zur alten Ansicht, unverändert grün). - [ ] **Step 5: Commit** ```bash git add server/services/analyse.js server/services/aboFunktionen.js server/routes/funktionen.js test/analyseAboModell.test.js git commit -m "Kundenansicht P5: Analyse im Abo-Modell (Hosting, Zielgruppe erzwungen, Nachweis, Tagesprüfung)" -m "Co-Authored-By: Claude Opus 5.5 " ``` --- ### Task 14: Selbstprüfung **Files:** keine Änderungen (Korrekturen nur als eigener Fix-Commit auf den betroffenen Task). - [ ] **Step 1: Speicher prüfen** Run: `free -m` Expected: mindestens 2000 MB verfügbar, sonst warten und den Controller melden. - [ ] **Step 2: Alle neuen Testdateien grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/kundenansichtSetup.test.js test/kundenart.test.js test/kundenartAdmin.test.js test/aboFunktionen.test.js test/fristen.test.js test/aboKatalogRegister.test.js test/featureConsentsKundenart.test.js test/einrichtungsTicket.test.js test/funktionEinschalten.test.js test/funktionenEinschaltenRoute.test.js test/aboFunktionenListe.test.js test/aboAnfragen.test.js test/analyseAboModell.test.js` Expected: PASS, 0 fail, 0 skipped. - [ ] **Step 3: Berührte bestehende Tests grün** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && timeout 900 nice -n 10 node --max-old-space-size=1024 --test --test-concurrency=1 test/featureSetup.test.js test/zeitenSetup.test.js test/support.test.js test/auftraegeSchema.test.js test/staffLogin.test.js test/kundenansichtEnde.test.js test/kundenansichtGuard.test.js test/rechtePrivatArchiv.test.js test/storageTasks.test.js test/aboStatus.test.js test/contractEvents.test.js test/featureRegistry.test.js test/featureConsentTexte.test.js test/legalDocs.test.js test/featureConsents.test.js test/funktionenCheckout.test.js test/featurePaid.test.js test/ticketAdmin.test.js test/featureTicket.test.js test/siteFeatures.test.js test/termineTicket.test.js test/zeitenTicket.test.js test/zeitenEinschalten.test.js test/zeitenActivate.test.js test/zeitenAboAbgleich.test.js test/funktionenRoutes.test.js test/shopAnfrage.test.js test/unterkunftAnfrageFunktion.test.js test/funktionenShopTextform.test.js test/analyse.test.js test/analyseRoutes.test.js` Expected: PASS, 0 fail. - [ ] **Step 4: Verträge abhaken** - [ ] V4: `kundenart.js` (Task 2), `/api/auth/me` und Login in allen drei Zweigen (Task 2), Anlegen Pflicht ab Schalter `pilot` (bei `off` wie heute) und Ändern optional mit `kundenart_fehlt` (Task 3), `featureConsents.kundenart` aus dem Setup-Skript, von `schema.js` gelesen (Task 1, 7) - [ ] V5: `ENTHALTEN_IN`, `aboProdukt`, `aboDeckt` samt Grund `beginnt_spaeter` mit `ab` (Task 4); Listenfelder `modell`, `kundenart`, `abo`, `katalog` samt `hinweis`, `anfrageStatus` (Task 11); `POST /:key/einschalten` mit allen Codes in der Reihenfolge, 404 ohne neue Ansicht, 403 in der Kundenansicht, 404 für `analyse` (Task 10); `consentAbo` ohne `gesundheit`, mit Item `hinweis` für `shop`, `unterkunft`, `termine`, im Nachweis gespeichert (Task 6, 7, 10); Shop-Unternehmer aus Kundenart und `nur_betrieb` (Task 12); Analyse im Abo-Modell mit `audienceConfirmed` (400 `zielgruppe_fehlt`) und Nachweis in `featureConsents` (Task 13) - [ ] V6: `sammleFristen` und `GET /api/fristen` (Task 5) - [ ] V7: `einschalten` mit `unlockedBy`, `unlockedByPurchase: false`, Sperre, `createFeatureTicket(abrechnung)`, Zeiten über den bestehenden Weg (Task 8, 9); Ticket-Attribute im Setup-Skript (Task 1); `featureView.unlockedBy` für alle (Task 8) - [ ] Review Focus 1, 2, 4 der Übersicht: Tests in Task 2, 3, 10, 12, 13 (1), Task 3, 10, 11, 13 (2), Task 9 und 10 (4) - [ ] Task 15 (Fassung 6 verdrahten) ist hier **nicht** erledigt: Er läuft erst nach dem Merge von `kn/recht` (siehe Task 15). Die letzte Nachricht nennt ihn als offen. - [ ] **Step 5: Git sauber, nur eigene Dateien, Fremdes unberührt** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kn-server && git status --short && git diff --stat kn-p0..HEAD && git diff --stat kn-p0..HEAD -- scripts/setup-abo-collections.mjs scripts/feature-setup.mjs scripts/zeiten-setup.mjs public build ; ls -l /dev/null` Expected: `git status` zeigt höchstens `?? node_modules`; `--stat` listet nur Dateien aus der Dateiübersicht; die zweite `--stat`-Abfrage bleibt leer; `/dev/null` zeigt `crw-rw-rw-`. - [ ] **Step 6: Tag setzen** ```bash git tag kn-p5 ``` --- ### Task 15: Fassung 6 verdrahten (Nachlauf, erst nach dem Merge von `kn/recht`) **Ort und Voraussetzung.** Dieser Task läuft **nicht** in `dev-kn-server`, sondern im Hauptzweig `feature/kundenansicht-neu` (`/home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht`), nachdem `kn/server` (P5, P6) und `kn/recht` (P8) gemerged sind. Der Controller führt ihn als erste Aufgabe von P7 aus („P7 Task 0“). Warum ein eigener Task: P8 liefert nur die Konstanten (`WORTLAUT_V6`, `FUNKTIONEN_UNTERLAGEN_V6` in `products.js`), und P5 verlinkt und speichert bis hierher die Alt-Unterlagen mit Preisen und Kaufbedingungen und die Entwurfstexte. Erst dieser Task macht die Linkzeile „Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung …)“ und den Nachweis in `featureConsents` deckungsgleich mit den Dokumenten, die E6 und 5.9 ablösen. Vorab prüfen: `command grep -a -c FUNKTIONEN_UNTERLAGEN_V6 server/services/products.js` zeigt mindestens 1 und `ls server/services/aboUnterlagen.js` existiert; sonst anhalten und den Controller melden. **Files:** - Modify: `server/services/aboUnterlagen.js` (Quelle `FUNKTIONEN_UNTERLAGEN_V6`, neu `pdfAnhaenge`), `server/services/featureRegistry.js` (`consentAbo`, `consentAboAnalyse`), `server/config.js` (`aboFunktionen.texte.hinweis` entfällt), `server/services/analyse.js` (`k18Parts`, `sendK18`, `analyseDeps.legalPdfsV6`) - Test: `test/aboFassung6Verdrahtung.test.js` (neu; Kopf und Helfer wie `test/funktionenEinschaltenRoute.test.js` und `test/analyseAboModell.test.js`: Schema, Fakes, `go`, `aktivieren`, `abo`, `sink`; dazu die Importe `mkdtemp`, `mkdir`, `writeFile` aus `node:fs/promises`, `join`, `tmpdir`, `pdfAnhaenge`, `analyseDeps`, `reg`; `analyseDeps.legalPdfs` und `.legalPdfsV6` werden in `after()` auf die Originale zurückgesetzt). Angepasst werden nur die Alt-Erwartungen in `test/aboKatalogRegister.test.js` (Linkzeilen mit `config.termine.avvVersion` und `2026-10-04`, `unterlagenFuer`, `analyseUnterlagen`, `consentAboAnalyse`, `aboweg`-Versionen) und `test/aboFunktionenListe.test.js` (`unterlagen`, analyse-URL); die Tests aus Task 10 und 13 lesen die Fassung dynamisch und bleiben unverändert. **Interfaces:** - Consumes (P8, `server/services/products.js`): ```js FUNKTIONEN_FASSUNG_V6 // '2026-10-16' WORTLAUT_V6 // .linkzeile.{ einschaltenMitAvv, einschaltenOhneAvv } mit {fassung}; .beginn.einrichtung; .hinweis.{ shop, unterkunft, termine } mitFassung(text, fassung) FUNKTIONEN_UNTERLAGEN_V6 // { [key]: { lb: Dok, avv: Dok | null } }, Dok = { version, url, pdfFile, anhangName }; Schlüssel news, termine, shop, unterkunft, zeiten, analyse ``` - Produces: dieselben Signaturen wie in Task 6, 11 und 13; nur die Quelle wechselt. Neu: ```js // aboUnterlagen.js export async function pdfAnhaenge(dokumente, { basis = } = {}) // dokumente: [{ pdfFile, anhangName }] → [{ filename: anhangName, content, contentType: 'application/pdf' }]; wirft, wenn eine Datei fehlt, leer ist oder nicht mit %PDF- beginnt // analyse.js analyseDeps.legalPdfsV6 // = () => pdfAnhaenge([V6.analyse.avv, V6.analyse.lb]) (Reihenfolge wie legalPdfAttachments('analyse'): avv, lb) ``` - `unterlagenFuer(key)` = `{ leistungsbeschreibung: { version, url }, avv: { version, url } | null }` aus `FUNKTIONEN_UNTERLAGEN_V6[key]`; `analyseUnterlagen()` daraus (`avvVersion` = `avv.version`, `legal.lb`/`legal.avv` = die `url`). - `consentAbo(key)`: `version` = `FUNKTIONEN_FASSUNG_V6`; Linkzeile `mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, FUNKTIONEN_FASSUNG_V6)` bei `termine`, `shop`, `unterkunft` und `…einschaltenOhneAvv` bei `news`, `zeiten`; Item `beginn` = `WORTLAUT_V6.beginn.einrichtung`; Item `hinweis` = `WORTLAUT_V6.hinweis[key]`. `consentAboAnalyse()`: Linkzeile `mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, )`. - `config.aboFunktionen.fassung` und `texte.beginn` bleiben als Spiegel-Literale (`config.js` importiert keine Dienste; `test/aboWortlautV6.test.js` aus P8 pinnt beide und läuft mit diesem Task grün statt rot). - K18 (`k18Parts`, `sendK18`): Gilt `doc.avvVersion === FUNKTIONEN_UNTERLAGEN_V6.analyse.avv.version`, kommen `avv_url` und `lb_url` aus den V6-URLs (absolut) und die Anhänge aus `analyseDeps.legalPdfsV6()`; sonst wie bisher `legalPaths` und `analyseDeps.legalPdfs(version)`. Ohne diese Weiche fände `legalPaths('analyse', '2026-10-16')` die Dateien nicht (sie liegen unter `/neu/rechtliches/`), und die K18 ginge für jede neue Aktivierung nie hinaus. - [ ] **Step 1: Failing test `test/aboFassung6Verdrahtung.test.js`** ```js const { WORTLAUT_V6, FUNKTIONEN_FASSUNG_V6: F6, FUNKTIONEN_UNTERLAGEN_V6: U6, mitFassung } = await import('../server/services/products.js') const MIT_AVV = ['termine', 'shop', 'unterkunft'] test('Katalog: unterlagen zeigen auf die Dokumente der Fassung 6, nie auf Alt-Dokumente', async () => { config.analyse.enabled = true; const b = await get('/api/funktionen', { cookie: c1 }) assert.deepEqual(b.katalog.map((e) => e.key), Object.keys(U6)) for (const e of b.katalog) { const u = U6[e.key] assert.deepEqual(e.unterlagen, { leistungsbeschreibung: { version: u.lb.version, url: u.lb.url }, avv: u.avv ? { version: u.avv.version, url: u.avv.url } : null }, e.key) assert.match(e.unterlagen.leistungsbeschreibung.url, /^\/neu\/rechtliches\/[a-z-]+-2026-10-16\.html$/, e.key) } assert.doesNotMatch(JSON.stringify(b.katalog), /"\/(rechtliches|vertragsunterlagen)\//, 'kein Alt-Dokument mit Preisen'); config.analyse.enabled = false }) test('consentAbo: Version, Linkzeile, beginn und Hinweis wörtlich aus P8; Spiegel in config', () => { for (const key of ['news', ...MIT_AVV, 'zeiten']) { const c = reg.consentAbo(key) assert.equal(c.version, F6, key) assert.equal(c.items[0].text, mitFassung(MIT_AVV.includes(key) ? WORTLAUT_V6.linkzeile.einschaltenMitAvv : WORTLAUT_V6.linkzeile.einschaltenOhneAvv, F6), key) assert.equal(c.items.at(-1).text, WORTLAUT_V6.beginn.einrichtung, key) assert.equal(c.items.find((i) => i.id === 'hinweis')?.text, WORTLAUT_V6.hinweis[key], key) // bei news und zeiten beide undefined } assert.deepEqual([config.aboFunktionen.fassung, config.aboFunktionen.texte.beginn], [F6, WORTLAUT_V6.beginn.einrichtung]) assert.equal(reg.aboweg(reg.featureConfig('shop')).avv.url, U6.shop.avv.url) }) test('Einschalten: der Nachweis trägt Wortlaut und Fassungen der Fassung 6, auch den Hinweis', async () => { aw.seed('customers', { ...customers.c1, kundenart: 'unternehmen' }) const alt = config.features.termine.bestellpruefung; config.features.termine.bestellpruefung = async () => [] try { assert.equal((await go('news')).status, 200); assert.equal((await go('termine')).status, 200) } finally { if (alt === undefined) delete config.features.termine.bestellpruefung; else config.features.termine.bestellpruefung = alt } const [n, t] = [...aw.docs('featureConsents').values()] assert.equal(JSON.parse(n.texte)[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenOhneAvv, F6)) assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: F6, leistungsbeschreibung: F6 }) assert.deepEqual(JSON.parse(t.texte).map((x) => x.text).slice(0, 2), [mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, F6), WORTLAUT_V6.hinweis.termine]) assert.deepEqual(JSON.parse(t.fassungen), { zustimmung: F6, leistungsbeschreibung: F6, avv: F6 }) }) test('Analyse im Abo-Modell: Ansicht, Prüfung und Nachweis mit der Fassung 6', async () => { abo(hostingDoc); const V = U6.analyse const a = (await get('/api/funktionen', { cookie: c1 })).projects.find((p) => p.projectId === 'p1').analyse assert.deepEqual([a.avvVersion, a.legal], [V.avv.version, { lb: V.lb.url, avv: V.avv.url }]) assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: config.analyse.avvVersion } })).status, 400) // die alte Fassung wird im Abo-Modell nicht mehr angenommen assert.equal(aw.docs('featureConsents').size, 0) assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: V.avv.version } })).status, 200) const n = [...aw.docs('featureConsents').values()][0] assert.equal(JSON.parse(n.texte)[0].text, mitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, V.avv.version)) assert.deepEqual(JSON.parse(n.fassungen), { zustimmung: F6, leistungsbeschreibung: V.lb.version, avv: V.avv.version }) assert.equal(aw.docs('portalFeatures').get('f_p1_analyse').avvVersion, V.avv.version) }) test('K18 zu einer Analyse der Fassung 6 verlinkt und hängt die Dokumente der Fassung 6 an; alte Fassungen gehen den alten Weg', async () => { const V = U6.analyse; const gefragt = []; const pdf = (filename) => ({ filename, content: Buffer.from('%PDF-1.4'), contentType: 'application/pdf' }) analyseDeps.legalPdfsV6 = async () => { gefragt.push('v6'); return [pdf(V.avv.anhangName), pdf(V.lb.anhangName)] } analyseDeps.legalPdfs = async (v) => { gefragt.push(`alt ${v}`); return [pdf('alt.pdf')] } abo(hostingDoc); assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: V.avv.version } })).status, 200) const roh = JSON.stringify(sink.sent.at(-1)) for (const erwartet of [V.avv.url, V.lb.url, V.avv.anhangName, V.lb.anhangName, V.avv.version]) assert.ok(roh.includes(erwartet), erwartet) assert.deepEqual(gefragt, ['v6']) aw.docs('portalFeatures').clear(); config.kundenansichtNeu.mode = 'off'; abo(wartungDoc); gefragt.length = 0 // alte Ansicht: alte Fassung, alter Weg assert.equal((await aktivieren({ consent: { accepted: true, avvVersion: config.analyse.avvVersion } })).status, 200); assert.deepEqual(gefragt, [`alt ${config.analyse.avvVersion}`]) }) test('pdfAnhaenge: liest die PDFs; fehlende, leere und Nicht-PDF-Dateien werfen', async () => { const dir = await mkdtemp(join(process.env.TMPDIR ?? tmpdir(), 'kn-pdf-')) // im eigenen Scratch-Ordner, wird nicht gelöscht await mkdir(join(dir, 'neu/rechtliches'), { recursive: true }) const dok = (name) => ({ pdfFile: `neu/rechtliches/${name}.pdf`, anhangName: `${name}-2026-10-16.pdf` }) await writeFile(join(dir, dok('ok').pdfFile), '%PDF-1.4 x'); await writeFile(join(dir, dok('leer').pdfFile), ''); await writeFile(join(dir, dok('text').pdfFile), 'hallo') assert.deepEqual((await pdfAnhaenge([dok('ok')], { basis: dir })).map((x) => [x.filename, x.contentType]), [['ok-2026-10-16.pdf', 'application/pdf']]) for (const name of ['fehlt', 'leer', 'text']) await assert.rejects(() => pdfAnhaenge([dok(name)], { basis: dir }), name) }) ``` - [ ] **Step 2: Rot** Run: `cd /home/webklar/apps/kundenbereich.webklar.com/dev-kundenansicht && timeout 900 nice -n 10 node --max-old-space-size=1024 --test test/aboFassung6Verdrahtung.test.js` Expected: FAIL (Alt-Dokumente unter `/rechtliches/`, `pdfAnhaenge is not a function`). - [ ] **Step 3: Umsetzen** `aboUnterlagen.js`: `unterlagenFuer` und `analyseUnterlagen` aus `FUNKTIONEN_UNTERLAGEN_V6`, `pdfAnhaenge` nach dem Muster von `legalPdfAttachments` (`legalDocs.js`: `readFile`, Prüfung auf `%PDF-`, nie ein leerer oder falscher Anhang). `featureRegistry.js`: `consentAbo` und `consentAboAnalyse` lesen `FUNKTIONEN_FASSUNG_V6`, `WORTLAUT_V6` und `mitFassung` (statischer Import aus `products.js`, ein Blattmodul), nicht mehr `config.aboFunktionen.texte`; in `config.js` entfällt `texte.hinweis`. `analyse.js`: die Weiche in `k18Parts` und `sendK18` nach „Produces“, `analyseDeps.legalPdfsV6` als Standardabhängigkeit. Die Alt-Erwartungen in den beiden genannten Testdateien auf die Werte der Fassung 6 umstellen. - [ ] **Step 4: 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/aboFassung6Verdrahtung.test.js test/aboWortlautV6.test.js test/aboKatalogRegister.test.js test/aboFunktionenListe.test.js test/funktionenEinschaltenRoute.test.js test/analyseAboModell.test.js test/analyse.test.js test/analyseRoutes.test.js test/legalDocs.test.js` Expected: PASS, 0 fail; der Verdrahtungstest in `aboWortlautV6.test.js` läuft und ist grün (nicht übersprungen). - [ ] **Step 5: Commit und Tag** ```bash git add server/services/aboUnterlagen.js server/services/featureRegistry.js server/config.js server/services/analyse.js test/aboFassung6Verdrahtung.test.js test/aboKatalogRegister.test.js test/aboFunktionenListe.test.js git commit -m "Kundenansicht P5: Fassung 6 verdrahtet (Katalog, Einschalt-Nachweis, Analyse, K18)" -m "Co-Authored-By: Claude Opus 5.5 " git status --short; ls -l /dev/null; git tag kn-p5-v6 ``` Expected: `git status --short` leer (außer `?? node_modules`), `/dev/null` zeigt `crw-rw-rw-`. --- ## Vertragsfragen an den Controller 1. **Kundenart-Pflicht und Push-Reihenfolge (V4), entschieden und gebaut.** `createCustomerWithPortalAccess` verlangt die Kundenart nur bei `neueAnsichtModus() !== 'off'` (Task 3). Damit ändert sich bei `off` nichts, auch wenn das Portal vor dem WOMS gepusht wird (Global Constraint „Alte Ansicht unverändert“, Phase E „Schalter off = für Kunden unsichtbar“). Die Push-Reihenfolge gilt trotzdem verbindlich und steht als Eintrag für die Übersicht in Frage 10: WOMS-Tag `kn-p9-kundenart` vor dem Portal und in jedem Fall vor dem Wechsel auf `pilot`; `kn-p9` zusammen mit P6. P9 Vertragsfrage 3 ist damit geschlossen. 2. **`katalog[].consent` und `katalog[].hinweis` fehlen in V5.** Die Oberfläche braucht Fassung, Linkzeile, Hinweis, Häkchen-Wortlaut und Knopftext, die der Server später als Nachweis speichert (nie Texte aus dem Client). Gebaut: jeder Katalogeintrag trägt `consent: { version, knopf: 'Einschalten', items: [{ id, text, haken, nurPrivatInFrist? }] }` (`null` bei `analyse`) in Anzeigereihenfolge `einschalten-zeile`, `hinweis`, `beginn`, dazu `hinweis: string | null`, der Text des Items `hinweis`. Das Item gibt es nur bei `shop`, `unterkunft` und `termine` und trägt die Pflichten der alten Häkchen `verantwortung`, `stripe` und `gesundheit` (Spec 5.6 Zeile F, „dazu ein Satz auf der Einschalt-Seite“); es steht in `featureConsents.texte` (Task 7, 10). Der Wortlaut ist zunächst der Entwurf `config.aboFunktionen.texte.hinweis`, wörtlich wie `WORTLAUT_V6.hinweis` aus P8; Task 15 stellt auf die Konstante um. Folge für P3: `renderEinschalten` (P3 Task 3) zeichnet `katalog[].hinweis` als `p.kn-fn-hinweis` direkt über dem Knopf, nach der Linkzeile und vor dem Häkchen `beginn`, damit Bildschirm und Nachweis dieselbe Reihenfolge haben (Test dort: Webshop-Einschalt-Seite zeigt den Händler-Satz). 3. **Zusätzliche Antwortcodes der Einschalt-Route.** Außer den sieben Codes aus V5 kommen vor: `bestellung_laeuft` (Zustand `ordered`), `abgeschaltet` (Zustand `disabled`), 400 `zustimmung_fehlt`, 503 `unterlagen_fehlen` (wie die Bestellseiten) und 503 bei nicht lesbarem Abo. `abo_fehlt` bekommt den Grund `beginnt_spaeter` mit `body.ab` (Frage 9). Die Analyse-Aktivierung im Abo-Modell bekommt 400 `zielgruppe_fehlt` (Task 13). Vorschlag: alles in V5 aufnehmen. 4. **Fremde Dateien mit minimalen Änderungen.** `server/index.js` (1 Import, 1 Mount), `server/routes/admin/customers.js` (`code` durchreichen), `server/services/ticketAdmin.js` (V7: `abrechnung`), `server/services/siteFeatures.js` (nur `featureView` und `legeTicketAn`; `adminActivate` bleibt P6), `server/services/zeiten/einschalten.js` (Parameter `abrechnung`, `vorpruefung`; Standard unverändert), `server/services/analyse.js` (Hosting im Abo-Modell, `nachweis`-Rückruf, K18-Weiche für die Fassung 6). Vorschlag: P5 bekommt diese Stellen zugewiesen; P6 vermeidet die genannten Funktionen. 5. **Zusatzattribut `portalFeatures.einrichtungAbrechnung`** (intern, im Setup-Skript). Es hält die Abrechnung am Funktionsdokument, damit jedes Nachlegen oder Ersetzen eines Tickets (`ticketNachlegen`, `holeZeitenNach`) den Marker setzt. P6 nutzt es, um (a) K27 bei Zeiten im neuen Weg zu unterdrücken, sobald K38 sendet, (b) `ticketNachlegen` für Dokumente mit `unlockedBy` `abo|projekt` ohne Stripe-Sitzung auf `einschalten` (Heilung) statt `ticketNachZahlung` zu lenken, (c) die alten „nicht anrechnen“-Zeilen in `termine/ticketDetails.js:38`, `shop/ticketDetails.js:57`, `unterkunft/ticketDetails.js:49` bei gesetztem Marker wegzulassen (P5 hängt nur die neue Schlusszeile an). 6. **(gestrichen)** Die frühere Annahme zu `neueAnsichtFuerSession` für Jobs entfällt: Eine Sitzung gilt nach V1 nur mit `appwriteUserId`, ein Job hat keine. Task 13 liest stattdessen das Kundendokument und ruft `neueAnsichtFuerKunde({ customerId, email })` aus P0 (Vorbereitung prüft, dass sie existiert). 7. **Verdrahtung der Fassung 6 steht in Task 15** (Ausführung als „P7 Task 0“ im Hauptzweig nach dem Merge von `kn/recht`). Die frühere Annahme, P8 ändere `config.aboFunktionen.texte.beginn` und `.fassung`, entfällt: P8 darf laut Zuständigkeitstabelle nur `abo.termsVersionNeu` ändern und hat `config.aboFunktionen` in `kn/recht` gar nicht. P8 liefert die Konstanten, P5 (Task 15) liest sie. Bis dahin gelten Entwurfstexte (`beginn`, `hinweis`) und die Alt-Unterlagen des Registers; Livegang nur mit Task 15, sonst verlinken und speichern Katalog, Nachweis und Analyse die Alt-Dokumente mit Preisen. 8. **(erledigt in Task 13)** Analyse-Zielgruppenzeile (Spec 5.5, letzter Punkt): In der neuen Ansicht verlangt `POST /api/funktionen/analyse/activate` `audienceConfirmed: true` (400 `zielgruppe_fehlt`) und schreibt einen Nachweis in `featureConsents` (Linkzeile A1, Zielgruppen-Zeile A2, Knopf `Analyse einschalten`, Kundenart, Fassungen). P4 Vertragsfrage 4 ist damit erledigt; P4 sendet `audienceConfirmed: true` bereits. Für P6 (C5, Widerruf eines Wechsels) gilt: Die Analyse eines Hosting-Kunden bleibt; `aboLaeuftFuer(customerId, 'analyse')` aus Task 4 ist die Abfrage. 9. **Privatkunde in der Probezeit („Beginn nach Fristende“, Stripe `trialing`).** Spec 5.5 Schritt 5 und C4 wollen für Privatkunden in der Frist das Häkchen oder, ohne Häkchen, einen Knopf mit dem Fristende-Datum. Wer das Abo ohne Häkchen bestellt hat, steht aber auf `trialing` (`stripeBilling.js:575-629`), und `aboDeckt` verlangt `active`. Gebaut (Vorschlag A): eigener Grund `beginnt_spaeter` mit `ab` (= `trialEnd`) und Text „Dein Abo beginnt am TT.MM.JJJJ. Ab dann kannst du einschalten.“ statt der Zahlungsmeldung `nicht_aktiv` (Task 4, 9, 10). Alternative B: `trialing` zählt als deckend, und das Häkchen-Verlangen läuft über `beginn_noetig`; das schaltet eine Funktion ein, bevor die Leistung des Abos beginnt, und ist eine Vertragsfrage, die der Controller entscheidet. Folgen von A: P3 `fehlerAktion` und `TEXTE` zeigen den Text und das Datum im Knopf; P6 (`adminActivate`) und P9 (WOMS zeigt den Grund) kennen den neuen Grund; die Analyse-Eignung meldet in der Probezeit weiter `kein_abo` (eigener Grund erst bei Entscheidung für A auch dort). 10. **Einträge für die Übersicht (der Controller trägt sie ein; dieser Plan darf die Übersicht nicht ändern).** - *Zuständigkeitstabelle, Zeile P5:* zusätzlich `server/services/aboUnterlagen.js` (neu), `server/services/featureRegistry.js` (auch `consentAbo` mit Hinweis, `consentAboAnalyse`, `abowegAnalyse`) und die Verdrahtung der Fassung 6 (Task 15: Katalog-`unterlagen`, `consentAbo`, Analyse-Katalog und K18 lesen `FUNKTIONEN_UNTERLAGEN_V6` und `WORTLAUT_V6`). Zeile P8: liefert die Konstanten in `products.js`, verdrahtet nicht. Ablaufhinweise: Task 15 läuft als „P7 Task 0“. - *Ablaufhinweise, Push-Reihenfolge verbindlich:* WOMS-Tag `kn-p9-kundenart` vor dem Portal (spätestens vor dem Wechsel auf `pilot`), `kn-p9` zusammen mit P6. Schließt P9 Vertragsfrage 3 und Frage 1 hier. - *V4:* `createCustomerWithPortalAccess` verlangt die Kundenart nur bei `neueAnsichtModus() !== 'off'`. - *V1:* `neueAnsichtFuerKunde({ customerId, email })` für Jobs ohne Sitzung (P0). - *V5:* `katalog[].hinweis` (Server liefert, nie der Client); Item `hinweis` in `consentAbo`; `aboDeckt` mit Grund `beginnt_spaeter` und `ab`, `abo_fehlt` mit `body.ab`; `POST /analyse/activate` in der neuen Ansicht mit `audienceConfirmed` (400 `zielgruppe_fehlt`) und Nachweis; Analyse-`legal` und `avvVersion` kommen im Abo-Modell aus der Fassung-6-Tabelle. 11. **Livegang-Voraussetzung für K18.** Die Analyse-K18 der Fassung 6 hängt PDFs aus `public/neu/rechtliches/` an (P8 Task 12, erst nach der Freigabe). Fehlt eine Datei, geht die K18 nicht hinaus und wird vom Tagesabgleich erneut versucht, ohne Fehlermeldung für den Kunden. Bitte in die Übergabe-Notiz: PDFs liegen vor dem Wechsel auf `pilot` im Deploy.