147 KiB
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=%Dnenntkn-p0undkn/server;ls server/services/kundenansicht/schalter.js node_modulesexistiert (Symlinkln -s ../build/node_modules node_modules, niegit add -A);command grep -a -c neueAnsichtFuerKunde server/services/kundenansicht/schalter.jszeigt mindestens 1 (P0-Erweiterung von V1 für Jobs ohne Cookie, Task 13 braucht sie; fehlt sie, den Controller melden und nicht selbst inschalter.jsergänzen);free -mzeigt mindestens 2000 MB verfügbar. - Alte Ansicht bytegleich:
oeffentlicheAnsicht(key)(Pinstest/funktionenRoutes.test.js:101undtest/zeitenEinschalten.test.js:588) und die Listenantwort ohne neue Ansicht bleiben unverändert. Einzige additive Ausnahme:featureViewliefertunlockedByfü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.mjsbleiben unberührt. Das Skript läuft in diesem Plan nie gegen Produktion, nur gegen Nachbauten. - Testkopf (neue Dateien mit Server-Zugriff), danach
configund Module dynamisch importieren.useTestEnvsetzt den Rest; Pilot über die Umgebung, E-Mail-Liste absichtlich mit Großschreibung und Leerzeichen:c1 (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' })k@example.invalid) und c4 (test@example.invalid) sind Pilot, c2 (k2@example.invalid) nicht. Tests, die den Modus wechseln, setzenconfig.kundenansichtNeu.modeund.pilotEmailsdirekt und stellen sie inafter()zurück. - Uhr: Routen nehmen
einschaltenDeps.jetzt()(ausserver/services/zeiten/einschalten.js). Tests setzenJETZT = new Date('2026-10-17T08:00:00.000Z')(ein Samstag). - Fehlerform neuer Routen:
{ error: <Text in du-Form>, code, ...extra }überfail()ausserver/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>undgit 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.
- Kundenart leer, unbekannt oder gefälscht (Übersicht-Review-Focus 1):
{},'Unternehmen','kaputt'gelten alsprivat; ein Body-Feldkundenartoderunternehmer: truevom Client ändert nichts; der Nachweis speichert die Kundenart des Dokuments. Die Kinder-unter-16-Aussage der Analyse ist im Abo-Modell serverseitig erzwungen: ohneaudienceConfirmed: true400zielgruppe_fehltund nichts geschrieben, mit Haken ein Nachweis mit Linkzeile, Zielgruppen-Zeile, Knopf und Kundenart. Tests: Task 2, 3, 10, 12, 13. - Pilot-Schalter an den Rändern (Übersicht-Review-Focus 2):
offmit*, 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 abpilot(beioffbleibt die WOMS-Anlage wie heute), Jobs ohne Cookie fragen den Schalter überneueAnsichtFuerKunde. Tests: Task 3, Task 10 („Einschalten nur in neuer Ansicht“), Task 11, Task 13. - Doppeltes oder widersprüchliches Einschalten (Übersicht-Review-Focus 4): zwei Taps nacheinander und gleichzeitig, schon aktiv, zweite Website, Zustand
setupohne Ticket (Heilung statt zweitem Ticket), Ticket scheitert beim Anlegen. Ergebnis: höchstens ein Ticket, ein Nachweis, klare 409-Codes. Tests: Task 9, 10. - 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“ (Stripetrialing):beginnt_spaetermit 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. - Alte Ansicht bei nicht gelaufenem Setup-Skript und neuen Registerfeldern: ohne
kundenartoderabrechnungschreibt der Code kein neues Attribut;oeffentlicheAnsichtund 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()ausscripts/analyse-setup.mjs(collectionExists,listAttributes,createAttribute,waitForAttribute),ZEITEN_FEATURE_ATTRIBUTESausserver/services/zeiten/schema.js. -
Produces (
config.jserst inmain()laden, wiescripts/feature-setup.mjs):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.jsschreiben
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.mjsanlegen
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.jsund Pintest/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
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:
KUNDENARTENausserver/services/auftraege/konstanten.js. -
Produces (V4):
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.'sanitizeCustomerinauth.jsliefert zusätzlichkundenart: kundenartGespeichert(doc)undkundenartWirksam: kundenartWirksam(doc)(Login, Kunde, Kundenansicht). -
Step 1: Failing test
test/kundenart.test.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.jsimplementieren
Exakter Vergleich mit KUNDENARTEN.includes(doc?.kundenart), kein Trimmen, kein Kleinschreiben. Nur Funktionen und Konstanten, keine Imports außer ./auftraege/konstanten.js.
- Step 4:
sanitizeCustomerinserver/routes/auth.jsergä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
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(sanitizeCustomerZ. 30,createCustomerWithPortalAccessZ. 68,updateCustomerWithPortalAccessZ. 138),server/routes/admin/customers.js(codedurchreichen, 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)nimmtpayload.kundenart. Pflicht nur, wennneueAnsichtModus() !== 'off': Beiofflegt 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“ geltenundefined,nullund''. Fehlt der Wert beipilot|on, oder ist ein mitgeschickter Wert in irgendeinem Modus ungültig:Errormitstatus: 400,code: 'kundenart_fehlt', TextKundenart ist erforderlich (unternehmen oder privat)., vor jedem Schreibzugriff (kein Appwrite-Konto, kein Dokument).updateCustomerWithPortalAccess(id, payload)nimmtkundenartoptional (nur beipayload.kundenart !== undefinedprüfen und schreiben, ungültig wie oben). Die Route antwortet dann{ error, code }.sanitizeCustomerliefertkundenart: kundenartGespeichert(doc). -
Step 1: Failing test
test/kundenartAdmin.test.js(Router/api/admin/customers, HeaderAuthorization: Bearer jwt-admin; Kopf:const modus = (m) => { config.kundenansichtNeu.mode = m },beforeEach(() => modus('pilot')),after()stellt den ursprünglichen Modus zurück)
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.jsgibtcodedurch
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
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_spaeterund ParameternowbeiaboDeckt):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 inACTIVE_STATUSES;produktist nur dann nichtnull, wennlaeuft; ohneproductKeygilt Wartung (Altbestand); ist das jüngstewechsel-Ereignis dieses Aboswiderrufen, gilthosting.gekuendigt=cancelAtPeriodEndodercancelAt.aboDecktprüft in der Reihenfolgekein_abo,falsches_abo,gekuendigt,beginnt_spaeter,nicht_aktiv(Status ≠active).beginnt_spaeter: StatustrialingmittrialEndals gültigem Zeitpunkt nachnow. 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;abisttrialEndals ISO (derselbe Zeitpunkt wieserviceStartsAtinGET /api/abo).trialingohne gültiges oder mit abgelaufenemtrialEndbleibtnicht_aktiv. Welche Lösung der Controller für diese Kunden will (Sperre mit Datum, wie gebaut, odertrialingals deckend), steht in Vertragsfrage 9.widerrufsfristBis= spätestes Ende der offenen Fristen vonabschlussundwechsel(Speicher zählt nicht) als ISO, sonstnull. Unbekannter Schlüssel:TypeError. Lesefehler werden weitergereicht (fail-closed). -
Step 1: Failing test
test/aboFunktionen.test.js(Schema:subscriptions,contractEvents)
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.jsimplementieren
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
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 nachapp.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):
export async function sammleFristen(customerId, { now = new Date() } = {}) // → { fristen: [{ quelle, bezeichnung, bis, featureKey?, projektName? }] (aufsteigend nach bis), fruehestesEnde: string|null, anzeigen: boolean }quelleundbezeichnung:abschluss„Abo-Abschluss“,wechsel„Wechsel ins Wartungs-Abo“,speicher„Zusatzspeicher“,funktion=FEATURE_LABELS[key].name(mitfeatureKey,projektName),projekt=Auftrag <Nummer>(nur beiauftraegeAktiv(kunde)und Snapshot-Kundenartprivat, Statusversendet|bestaetigt, Frist abgesendetAm).bisISO mitZ.featureKey/projektNamefehlen 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 vonsammleFristen. -
Step 1: Failing test
test/fristen.test.js(Schema:subscriptions,contractEvents,portalFeatures,featureConsents,auftraegemitdatetimes: AUFTRAEGE_DATETIMES;beforeEachleertcontractEvents,portalFeatures,auftraege, legt Wartung und Projekt p1 „Bäckerei“ an)
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.jsimplementieren
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
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(Eintragfeatures.<key>ergänzen, AbschnittaboFunktionenneu),server/services/featureRegistry.js - Test:
test/aboKatalogRegister.test.js
Interfaces:
- Consumes: bestehende Registereinträge (
label.name,leistungsbeschreibung,avv),legalPaths(analyseLegal.js). - Produces:
// 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.jsimportiert 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 Itemhinweis(haken: false) trägt fürshop,unterkunftundterminedie Pflicht aus den alten Häkchenverantwortung,stripeundgesundheit(Spec 5.6 Zeile F: „dazu ein Satz auf der Einschalt-Seite“);newsundzeitenhaben keins. Es ist die einzige Quelle:katalog[].consent.itemsenthält es dadurch automatisch, undkatalog[].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 wietest/featureRegistry.test.js:1-16, dazuimport { unterlagenFuer, analyseUnterlagen } from '../server/services/aboUnterlagen.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.jsundserver/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
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(speichereZustimmungZ. 79,speichereNachweisAusAnfrage) - Test:
test/featureConsentsKundenart.test.js
Interfaces:
-
Consumes:
KUNDENARTEN(Task 2),aboweg(Task 6). -
Produces:
speichereZustimmung({ …, kundenart })undspeichereNachweisAusAnfrage(req, { …, kundenart })mit optionalemkundenart: 'unternehmen'|'privat'. Das Feld wird nur geschrieben, wenn es übergeben wurde; ein anderer Wert istErrormitstatus: 400(Zustimmungsnachweis: Kundenart ungültig) ohne Schreibzugriff. -
Step 1: Failing test
test/featureConsentsKundenart.test.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
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(createFeatureTicketZ. 82),server/services/siteFeatures.js(featureViewZ. 151,legeTicketAn) - Test:
test/einrichtungsTicket.test.js
Interfaces:
-
Consumes: Setup-Attribute aus Task 1 (im Nachbau ist
workordersohne Schema). -
Produces (V7):
// 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
abrechnungsetzt das TicketeinrichtungFuer: featureKeyundeinrichtungAbrechnung: abrechnung; die Zeile steht als letzte Zeile der Details (nach der Kürzung auf 7000 Zeichen, auch im Notfallweg). Ohneabrechnungwerden weder Felder noch Zeile geschrieben. Ungültiger Wert:TypeErrorvor jedem Schreibzugriff.legeTicketAn(doc, …)übergibtdoc.einrichtungAbrechnung, wenn gesetzt.featureView(doc, key)liefertunlockedByfür alle Funktionen (Regel wie bisher beiaboInklusive:nullim Zustandoff);aboEndeAtundeinbaubleiben beiaboInklusive. -
Step 1: Failing test
test/einrichtungsTicket.test.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
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), aussiteFeatures.js:assertOwnProject,ensureFeatureDoc,getFeatureDoc,customerOf,stateFields,ticketWoid,unlockedByVon;withLocks;bestellFehler(featureOrder.js). -
Produces (V7):
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, mitcodeundbody): 409bereits_eingeschaltet,bestellung_laeuft(Zustandordered),abgeschaltet(Zustanddisabled),abo_fehlt(body.grund,body.wechsel, beibeginnt_spaeterzusätzlichbody.ab; Text überaboFehlerText); 503 bei nicht lesbarem Abo oder gescheitertem Ticket;assertOwnProject-Fehler (403, 409 archiviert);TypeErrorfürkeyaußerhalb des Registers (auchanalyse),by,abrechnung.Ablauf für
news|termine|shop|unterkunftunter der Sperrefeature-order:${customerId}:${projectId}: (1)by === 'kunde':aboDeckt(customerId, key, { now })frisch, sonstabo_fehlt(wechsel=grund === 'falsches_abo', beibeginnt_spaeterbody.ab); (2) Zustand lesen:setupohneticketIdmitunlockedByabo|projektist Heilung (nur Ticket anlegen, kein Nachweis, kein neuer Zustand), jeder anderesetup|activeistbereits_eingeschaltet,orderedunddisabledsiehe oben; (3)nachweis()(falls übergeben) vor dem Zustand; (4) Dokument aufsetupmitfeatureKey,unlockedBy(projektoderabo),unlockedByPurchase: false,einrichtungAbrechnung,disabledAt/disabledReason/aboEndeMailAt: null, beiby === 'team'Notizstaff.adminNoteoder<ISO> <staff.name>: Eingeschaltet (<abrechnung>); (5) Ticket (ticketTextemit dem neuen Dokument,createFeatureTicketmitabrechnung),ticketIdspeichern. Scheitert (5), bleibtsetupohne Ticket und der Aufruf wirft 503EINSCHALTEN_TEXTE.ticket; der nächste Aufruf heilt.zeitengeht übereinschaltenZeiten(Sperre, Nachweis, Ticket dort), das Ergebnis wird auf{ doc, ticketWoid }abgebildet. -
Step 1: Failing test
test/funktionEinschalten.test.js(Schema und Zeiten-Neubau-Stub wietest/zeitenEinschalten.test.js:11-24, 41-103, dazucontractEvents;beforeEachsetztaufrufe = [], leertportalFeatures,workorders,featureConsentsund legt Wartung für c1 an, ProjekteProjekt p1,Projekt p2,Projekt p3für c1 undp_fremdfür c2 wieprojekt()in dieser Datei)
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.jserweitern (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.jsimplementieren
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
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/einschaltenZ. ~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 Itemhinweisin 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' }(nachrequireSession, also unangemeldet 401). Reihenfolge der Prüfungen:req.session.viewAs→ 403In der Kundenansicht kann nicht eingeschaltet werden.- Schlüssel nicht im Register (auch
analyse,__proto__) → 404Unbekannte Funktion - 400:
projectIdfehlt (Bitte wähle ein Projekt.);zustimmungfehlt,versionkein Text,hakenkeine Liste oder nicht Teilmenge von['beginn']→zustimmung_fehlt(Bitte bestätige die Erklärung, um einzuschalten.) assertOwnProject(forNewOrder)(403, 409 archiviert)- 409 in V5-Reihenfolge:
nicht_verfuegbar(featureSperre; danach 503unterlagen_fehlenwiepruefeUnterlagen),abo_fehlt(Texte überaboFehlerText,body.grund,body.wechsel, bei Grundbeginnt_spaeterzusätzlichbody.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).nurBetriebund privat;NUR_BETRIEB_TEXT),bestellung_offen(Hookbestellpruefung,body.offen; TextEs fehlen noch Angaben, bevor du einschalten kannst.),beginn_noetig(privat,widerrufsfristBis > jetzt,hakenohnebeginn;body.ab= ISO von Fristende + 1 ms; TextFür den sofortigen Beginn brauchst du das Häkchen. Ohne Häkchen kannst du ab dem <TT.MM.JJJJ> einschalten.),bereits_eingeschaltet(nursetup|active, außersetupohne Ticket mitunlockedByabo|projekt: das heilt der Dienst),bestellung_laeuft,abgeschaltet,fassung_veraltet(version !== consentAbo(key).version;body.consent=consentAboAktiv(key, { beginn }); TextDie Erklärung wurde inzwischen geändert. Bitte lies sie noch einmal und schalte dann ein.) einschalten({ by: 'kunde', abrechnung: 'abo', nachweis: () => speichereNachweisAusAnfrage(req, { feature: aboweg(feature, { beginn: gesetzt }), customerId, projectId, klickAt, kundenart }) }).kundenartkommt nur aus dem Kundendokument.gesetzt= privat undhakenenthältbeginn; bei Betrieben wird ein mitgeschicktesbeginnignoriert und nicht gespeichert. Der Nachweis enthält in Anzeigereihenfolgeeinschalten-zeile, beishop,unterkunft,terminedenhinweis(haken: false), bei Bedarfbeginnund den Knopf. Weiche:router.post('/zeiten/einschalten', requireSession, alteAnsichtNur, …)mitalteAnsichtNur = (req, _res, next) => (neueAnsicht(req) ? next('route') : next()); in der neuen Ansicht trifftzeitendie neue Route.
-
Step 1: Failing test
test/funktionenEinschaltenRoute.test.js(Schema und Zeiten-Stub wie Task 9 plusshopConfig;beforeEach: Sammlungen leeren,seedFixtures, Wartung für c1, Projektep1,p1b,p1c,p_archiv(archived: true) für c1 undp_fremdfür c2,config.kundenansichtNeu.mode = 'pilot'mitpilotEmails = ['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'))
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
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):
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 }consentist die öffentliche Sicht vonconsentAbo(key)(nullbeianalyse), siehe Vertragsfrage 2.hinweis(V5-Erweiterung, vom Server geliefert, nie vom Client) ist der Text des Itemshinweisausconsent.itemsodernull(news, zeiten, analyse): die Pflicht aus den alten Häkchenverantwortung,stripe,gesundheit, die die Einschalt-Seite über dem Knopf zeigt.unterlagenkommt ausunterlagenFuer(key). Mit neuer Ansicht bekommt die Listenantwort zusätzlichmodell: 'abo',kundenart: kundenartWirksam(kunde),abo: await aboProdukt(customerId, { now }),katalog, undprojects[i].features.shop.anfrageStatusund.unterkunft.anfrageStatus. Ohne neue Ansicht ändert sich nichts (außerunlockedBy, Task 8). Der Katalog enthält kein Preisfeld. -
Step 1: Failing test
test/aboFunktionenListe.test.js(Kopf undbeforeEachwie Task 10, dazu Projektep1undp2für c1,config.analyse.enabled = false;abschluss()wie dort)
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
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(stelleAnfrageZ. 171),server/routes/shop.js(POST /anfrageZ. 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 optionalemkundenart. Ohne Angabe verhält sich alles wie bisher (unternehmerPflicht-truevom Client). Mit Angabe:'privat'→ 409 mitbody.code: 'nur_betrieb'undNUR_BETRIEB_TEXTnach Projekt- und Schalterprüfung, vor der Eingabe;'unternehmen'→unternehmerwird serverseitig auftruegesetzt (Client-Wert ignoriert),keineAusgeschlossenen: truebleibt Pflicht, die gespeicherte Anfrage trägt zusätzlichkundenart: 'unternehmen'. Die Route übergibtkundenart: kundenartWirksam(kundendokument)nur, wennneueAnsicht(req). Der Buchungskalender (unterkunft/anfrageFunktion.js) bleibt unverändert: seine Eingabe kennt keinunternehmer, undnurBetriebist dortfalse. -
Step 1: Failing test
test/aboAnfragen.test.js(Router/api/shopund/api/unterkunft, SchemashopConfig,ukEinstellungen,portalFeatures;mailSink;beforeEach: Pilot wie Task 10,config.shop.mode = config.unterkunft.mode = 'on', Projekt p1 für c1, Sammlungen leeren)
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.jsundroutes/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
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(analyseEligibilityZ. 57,activateAnalyseZ. 281,runAboCheck),server/services/aboFunktionen.js(aboModellFuerKunde),server/routes/funktionen.js(analyseView,/analyse/activate) - Test:
test/analyseAboModell.test.js(Aufbau wietest/analyseRoutes.test.js:9-60: Fakes füranalyseDeps.client,rebuild, Mail,config.analyse.enabled = true; Schema zusätzlichfeatureConsents)
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:
// 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-ModellavvVersionundlegalausanalyseUnterlagen()(die Oberfläche zeigt und sendet damit dieselbe Fassung, die der Server prüft und speichert), sonst wie bisher.runAboChecküberspringt eine aktive Analyse, wennaboModellFuerKunde(customerId)undaboLaeuftFuer(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 wennneueAnsicht(req)(alte Ansicht unverändert, ohne Nachweis infeatureConsents):- 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). activateAnalyse({ …, aboModell: true, nachweis })mitnachweis = () => speichereNachweisAusAnfrage(req, { feature: abowegAnalyse(), customerId, projectId, klickAt: einschaltenDeps.jetzt().toISOString(), kundenart }).kundenart=kundenartWirksam(Kundendokument), nie aus dem Body. Gespeichert wird infeatureConsents(featureKey: 'analyse') der Wortlaut in Anzeigereihenfolge: Linkzeile (A1), Zielgruppen-Zeile (A2,haken: true), KnopfAnalyse einschalten; dazu die Fassungen (zustimmung,leistungsbeschreibung,avv) und die Kundenart.- Der Nachweis entsteht nur bei einer neuen Aktivierung (Zustand ≠
active); wer nur die Farbe einer aktiven Analyse ändert, bekommt keinen zweiten.
- nach der
-
Step 1: Failing test
test/analyseAboModell.test.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
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/meund Login in allen drei Zweigen (Task 2), Anlegen Pflicht ab Schalterpilot(beioffwie heute) und Ändern optional mitkundenart_fehlt(Task 3),featureConsents.kundenartaus dem Setup-Skript, vonschema.jsgelesen (Task 1, 7) -
V5:
ENTHALTEN_IN,aboProdukt,aboDecktsamt Grundbeginnt_spaetermitab(Task 4); Listenfeldermodell,kundenart,abo,katalogsamthinweis,anfrageStatus(Task 11);POST /:key/einschaltenmit allen Codes in der Reihenfolge, 404 ohne neue Ansicht, 403 in der Kundenansicht, 404 füranalyse(Task 10);consentAboohnegesundheit, mit Itemhinweisfürshop,unterkunft,termine, im Nachweis gespeichert (Task 6, 7, 10); Shop-Unternehmer aus Kundenart undnur_betrieb(Task 12); Analyse im Abo-Modell mitaudienceConfirmed(400zielgruppe_fehlt) und Nachweis infeatureConsents(Task 13) -
V6:
sammleFristenundGET /api/fristen(Task 5) -
V7:
einschaltenmitunlockedBy,unlockedByPurchase: false, Sperre,createFeatureTicket(abrechnung), Zeiten über den bestehenden Weg (Task 8, 9); Ticket-Attribute im Setup-Skript (Task 1);featureView.unlockedByfü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
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(QuelleFUNKTIONEN_UNTERLAGEN_V6, neupdfAnhaenge),server/services/featureRegistry.js(consentAbo,consentAboAnalyse),server/config.js(aboFunktionen.texte.hinweisentfällt),server/services/analyse.js(k18Parts,sendK18,analyseDeps.legalPdfsV6) - Test:
test/aboFassung6Verdrahtung.test.js(neu; Kopf und Helfer wietest/funktionenEinschaltenRoute.test.jsundtest/analyseAboModell.test.js: Schema, Fakes,go,aktivieren,abo,sink; dazu die Importemkdtemp,mkdir,writeFileausnode:fs/promises,join,tmpdir,pdfAnhaenge,analyseDeps,reg;analyseDeps.legalPdfsund.legalPdfsV6werden inafter()auf die Originale zurückgesetzt). Angepasst werden nur die Alt-Erwartungen intest/aboKatalogRegister.test.js(Linkzeilen mitconfig.termine.avvVersionund2026-10-04,unterlagenFuer,analyseUnterlagen,consentAboAnalyse,aboweg-Versionen) undtest/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):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:
// 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 }ausFUNKTIONEN_UNTERLAGEN_V6[key];analyseUnterlagen()daraus (avvVersion=avv.version,legal.lb/legal.avv= dieurl).consentAbo(key):version=FUNKTIONEN_FASSUNG_V6; LinkzeilemitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, FUNKTIONEN_FASSUNG_V6)beitermine,shop,unterkunftund…einschaltenOhneAvvbeinews,zeiten; Itembeginn=WORTLAUT_V6.beginn.einrichtung; Itemhinweis=WORTLAUT_V6.hinweis[key].consentAboAnalyse(): LinkzeilemitFassung(WORTLAUT_V6.linkzeile.einschaltenMitAvv, <analyse avv.version>).config.aboFunktionen.fassungundtexte.beginnbleiben als Spiegel-Literale (config.jsimportiert keine Dienste;test/aboWortlautV6.test.jsaus P8 pinnt beide und läuft mit diesem Task grün statt rot).- K18 (
k18Parts,sendK18): Giltdoc.avvVersion === FUNKTIONEN_UNTERLAGEN_V6.analyse.avv.version, kommenavv_urlundlb_urlaus den V6-URLs (absolut) und die Anhänge ausanalyseDeps.legalPdfsV6(); sonst wie bisherlegalPathsundanalyseDeps.legalPdfs(version). Ohne diese Weiche fändelegalPaths('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
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
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
- Kundenart-Pflicht und Push-Reihenfolge (V4), entschieden und gebaut.
createCustomerWithPortalAccessverlangt die Kundenart nur beineueAnsichtModus() !== 'off'(Task 3). Damit ändert sich beioffnichts, 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-Tagkn-p9-kundenartvor dem Portal und in jedem Fall vor dem Wechsel aufpilot;kn-p9zusammen mit P6. P9 Vertragsfrage 3 ist damit geschlossen. katalog[].consentundkatalog[].hinweisfehlen 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ägtconsent: { version, knopf: 'Einschalten', items: [{ id, text, haken, nurPrivatInFrist? }] }(nullbeianalyse) in Anzeigereihenfolgeeinschalten-zeile,hinweis,beginn, dazuhinweis: string | null, der Text des Itemshinweis. Das Item gibt es nur beishop,unterkunftundtermineund trägt die Pflichten der alten Häkchenverantwortung,stripeundgesundheit(Spec 5.6 Zeile F, „dazu ein Satz auf der Einschalt-Seite“); es steht infeatureConsents.texte(Task 7, 10). Der Wortlaut ist zunächst der Entwurfconfig.aboFunktionen.texte.hinweis, wörtlich wieWORTLAUT_V6.hinweisaus P8; Task 15 stellt auf die Konstante um. Folge für P3:renderEinschalten(P3 Task 3) zeichnetkatalog[].hinweisalsp.kn-fn-hinweisdirekt über dem Knopf, nach der Linkzeile und vor dem Häkchenbeginn, damit Bildschirm und Nachweis dieselbe Reihenfolge haben (Test dort: Webshop-Einschalt-Seite zeigt den Händler-Satz).- Zusätzliche Antwortcodes der Einschalt-Route. Außer den sieben Codes aus V5 kommen vor:
bestellung_laeuft(Zustandordered),abgeschaltet(Zustanddisabled), 400zustimmung_fehlt, 503unterlagen_fehlen(wie die Bestellseiten) und 503 bei nicht lesbarem Abo.abo_fehltbekommt den Grundbeginnt_spaetermitbody.ab(Frage 9). Die Analyse-Aktivierung im Abo-Modell bekommt 400zielgruppe_fehlt(Task 13). Vorschlag: alles in V5 aufnehmen. - Fremde Dateien mit minimalen Änderungen.
server/index.js(1 Import, 1 Mount),server/routes/admin/customers.js(codedurchreichen),server/services/ticketAdmin.js(V7:abrechnung),server/services/siteFeatures.js(nurfeatureViewundlegeTicketAn;adminActivatebleibt P6),server/services/zeiten/einschalten.js(Parameterabrechnung,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. - 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)ticketNachlegenfür Dokumente mitunlockedByabo|projektohne Stripe-Sitzung aufeinschalten(Heilung) stattticketNachZahlungzu lenken, (c) die alten „nicht anrechnen“-Zeilen intermine/ticketDetails.js:38,shop/ticketDetails.js:57,unterkunft/ticketDetails.js:49bei gesetztem Marker wegzulassen (P5 hängt nur die neue Schlusszeile an). - (gestrichen) Die frühere Annahme zu
neueAnsichtFuerSessionfür Jobs entfällt: Eine Sitzung gilt nach V1 nur mitappwriteUserId, ein Job hat keine. Task 13 liest stattdessen das Kundendokument und ruftneueAnsichtFuerKunde({ customerId, email })aus P0 (Vorbereitung prüft, dass sie existiert). - 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 ändereconfig.aboFunktionen.texte.beginnund.fassung, entfällt: P8 darf laut Zuständigkeitstabelle nurabo.termsVersionNeuändern und hatconfig.aboFunktioneninkn/rechtgar 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. - (erledigt in Task 13) Analyse-Zielgruppenzeile (Spec 5.5, letzter Punkt): In der neuen Ansicht verlangt
POST /api/funktionen/analyse/activateaudienceConfirmed: true(400zielgruppe_fehlt) und schreibt einen Nachweis infeatureConsents(Linkzeile A1, Zielgruppen-Zeile A2, KnopfAnalyse einschalten, Kundenart, Fassungen). P4 Vertragsfrage 4 ist damit erledigt; P4 sendetaudienceConfirmed: truebereits. Für P6 (C5, Widerruf eines Wechsels) gilt: Die Analyse eines Hosting-Kunden bleibt;aboLaeuftFuer(customerId, 'analyse')aus Task 4 ist die Abfrage. - 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 auftrialing(stripeBilling.js:575-629), undaboDecktverlangtactive. Gebaut (Vorschlag A): eigener Grundbeginnt_spaetermitab(=trialEnd) und Text „Dein Abo beginnt am TT.MM.JJJJ. Ab dann kannst du einschalten.“ statt der Zahlungsmeldungnicht_aktiv(Task 4, 9, 10). Alternative B:trialingzählt als deckend, und das Häkchen-Verlangen läuft überbeginn_noetig; das schaltet eine Funktion ein, bevor die Leistung des Abos beginnt, und ist eine Vertragsfrage, die der Controller entscheidet. Folgen von A: P3fehlerAktionundTEXTEzeigen 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 weiterkein_abo(eigener Grund erst bei Entscheidung für A auch dort). - 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(auchconsentAbomit Hinweis,consentAboAnalyse,abowegAnalyse) und die Verdrahtung der Fassung 6 (Task 15: Katalog-unterlagen,consentAbo, Analyse-Katalog und K18 lesenFUNKTIONEN_UNTERLAGEN_V6undWORTLAUT_V6). Zeile P8: liefert die Konstanten inproducts.js, verdrahtet nicht. Ablaufhinweise: Task 15 läuft als „P7 Task 0“. - Ablaufhinweise, Push-Reihenfolge verbindlich: WOMS-Tag
kn-p9-kundenartvor dem Portal (spätestens vor dem Wechsel aufpilot),kn-p9zusammen mit P6. Schließt P9 Vertragsfrage 3 und Frage 1 hier. - V4:
createCustomerWithPortalAccessverlangt die Kundenart nur beineueAnsichtModus() !== 'off'. - V1:
neueAnsichtFuerKunde({ customerId, email })für Jobs ohne Sitzung (P0). - V5:
katalog[].hinweis(Server liefert, nie der Client); ItemhinweisinconsentAbo;aboDecktmit Grundbeginnt_spaeterundab,abo_fehltmitbody.ab;POST /analyse/activatein der neuen Ansicht mitaudienceConfirmed(400zielgruppe_fehlt) und Nachweis; Analyse-legalundavvVersionkommen im Abo-Modell aus der Fassung-6-Tabelle.
- Zuständigkeitstabelle, Zeile P5: zusätzlich
- 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 aufpilotim Deploy.