1501 lines
147 KiB
Markdown
1501 lines
147 KiB
Markdown
# 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: <Text in du-Form>, 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 <Dateien einzeln>` und `git commit -m "<Betreff>" -m "Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>"`.
|
||
|
||
## 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 `- <collection>.<key> FEHLT` loggen und `Alles vorhanden.` bzw. `N Eintrag/Eintraege fehlen.`; sonst `createAttribute`, danach `waitForAttribute`, Log `+ Attribut …` und `Fertig.`. Vorhandenes loggt `= <collection>.<key> (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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <Nummer>` (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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 6: Katalogfelder, `consentAbo` und Unterlagen-Quelle im Register
|
||
|
||
**Files:**
|
||
- Create: `server/services/aboUnterlagen.js`
|
||
- Modify: `server/config.js` (Eintrag `features.<key>` 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: <Entwurf unten>, hinweis: { shop, unterkunft, termine } /* Entwurf unten */, zielgruppe: <Analyse, unten>, analyseKnopf: 'Analyse einschalten' },
|
||
analyse: { name: 'Website-Analyse', beschreibung, bild, ablauf, einrichtung, nurBetrieb } }
|
||
config.features.<news|termine|shop|unterkunft|zeiten>.{ 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/<key>.webp`. `einschalten-zeile`: mit AVV (termine, shop, unterkunft) `Es gelten Leistungsbeschreibung und Auftragsverarbeitung (Fassung <lb.version>).`, ohne AVV (news, zeiten) `Es gilt die Leistungsbeschreibung (Fassung <lb.version>).` (`<lb.version>` = `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 <avvVersion>).` (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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <k@example.invalid>', ''])
|
||
})
|
||
```
|
||
|
||
- [ ] **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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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<Zeile>` 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 `<ISO> <staff.name>: Eingeschaltet (<abrechnung>)`; (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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <TT.MM.JJJJ> 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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<projectId, { shop: string, unterkunft: string }> ('' 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 <noreply@anthropic.com>"
|
||
```
|
||
|
||
---
|
||
|
||
### 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 = <public/> } = {}) // 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, <analyse avv.version>)`.
|
||
- `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 <noreply@anthropic.com>"
|
||
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.
|