From 52456dac760b797225b290a481749eabffbe25ab Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:43:17 +0000 Subject: [PATCH 01/28] Terminplaner TP2 C10a: Nachbesserung 1 (Abo-Hinweis vom Server, R111) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GET /api/funktionen liefert bei termine.verfuegbar ein fuenftes Feld hinweis (aboMeldung mit dem Register des Terminplaners, null bei bestellbar true). Die Kachel zeigt diesen Text maskiert an; die Browser-Kopie der Abo-Texte und der Gleichheitstest entfallen. Der Link "Zu Verträge" ist eine gemeinsame Konstante fuer Analyse und Terminplaner und erscheint bei kein_abo. Co-Authored-By: Claude Sonnet 5.5 --- public/funktionen.js | 32 +++++------- server/routes/funktionen.js | 11 ++-- test/funktionenTermine.test.js | 46 ++++++++++++++-- test/funktionenTiles.test.js | 95 ++++++++++++++++++++++------------ test/ui/termine-portal.ui.mjs | 20 ++++++- 5 files changed, 141 insertions(+), 63 deletions(-) diff --git a/public/funktionen.js b/public/funktionen.js index b75d396..91e0473 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -112,6 +112,9 @@ function newsTile(projects, price = PRICE) { ` } +// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn +const ZU_VERTRAEGE_LINK = '' + /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ const analyseOf = (project) => project.analyse || { comingSoon: true } const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') @@ -136,7 +139,7 @@ function analyseTile(projects) { else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '' } else if (state === 'active') action = `` else if (state === 'off') action = `` - else if (a.reason === 'kein_abo') action = '

Im Wartungs-Abo enthalten.

' + else if (a.reason === 'kein_abo') action = `

Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE_LINK}

` else action = `

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}

` const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return ` @@ -153,29 +156,15 @@ function analyseTile(projects) { /* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. termine (Antwort von /api/funktionen) traegt bei verfuegbar: true bestellbar (true | false | null = Abo nicht lesbar), - grund (kein_abo | gekuendigt | nicht_aktiv | null), preis und einrichtung. Das Abo betrifft nur die Bestellung: ohne laufendes - Abo gibt es im Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */ + grund (kein_abo | gekuendigt | nicht_aktiv | null), preis, einrichtung und hinweis (Abo-Text des Servers, aboMeldung in + server/services/featureOrder.js, bei bestellbar true null). Das Abo betrifft nur die Bestellung: ohne laufendes Abo gibt es im + Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */ const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.' const termineOf = (project) => project.features?.termine || { state: 'off' } // Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js // mit einem umgebogenen Import). Aenderungen an beiden Stellen machen. const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`) -// Abo-Hinweise der Kachel. Kopie von aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte) fuer den -// Terminplaner; test/funktionenTiles.test.js haelt beide gleich. kein_abo steht wie bei der Analyse im Wortlaut der Kachel. -const TERMINE_ABO_TEXTE = { - gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.', - nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', - unbekannt: 'Das Abo konnte gerade nicht geprüft werden.', -} -const KEIN_ABO_HINWEIS = 'Im Wartungs-Abo enthalten. ' - -function termineAboHinweis(angebot) { - if (angebot.bestellbar !== false) return TERMINE_ABO_TEXTE.unbekannt // null (Lesefehler) oder Feld fehlt: nicht geprueft - if (angebot.grund === 'kein_abo') return KEIN_ABO_HINWEIS - return Object.hasOwn(TERMINE_ABO_TEXTE, angebot.grund) && angebot.grund !== 'unbekannt' ? TERMINE_ABO_TEXTE[angebot.grund] : TERMINE_ABO_TEXTE.unbekannt -} - // Entwurf: Funktionsdokument im Zustand Aus mit siteKey (entsteht beim ersten Speichern des Fragebogens) const hatEntwurf = (project) => termineOf(project).state === 'off' && Boolean(termineOf(project).siteKey) @@ -216,7 +205,10 @@ function termineTile(projects, angebot = {}) { const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : '' action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`) } else { - extra = `

${termineAboHinweis(angebot)}

` + // Hinweis des Servers (maskiert), bei kein_abo dazu der Weg in die Verträge; ohne Text bleibt nur der Link oder nichts + const hinweis = typeof angebot.hinweis === 'string' ? esc(angebot.hinweis) : '' + const zeile = [hinweis, angebot.bestellbar === false && angebot.grund === 'kein_abo' ? ZU_VERTRAEGE_LINK : ''].filter(Boolean).join(' ') + if (zeile) extra = `

${zeile}

` } const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return ` @@ -695,7 +687,7 @@ export async function initAnalyse(root, { projectId } = {}) { if (a.state === 'active') renderAnalyseStats(stage, project, reload) else if (a.state === 'disabled') stage.innerHTML = '

Analyse ist abgeschaltet

Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

' else if (a.state === 'unknown') stage.innerHTML = '

Die Daten konnten gerade nicht geladen werden.

' - else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}

` + else if (!a.eligible) stage.innerHTML = `

Analyse nicht verfügbar

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ${ZU_VERTRAEGE_LINK}` : ''}

` else renderAnalyseActivation(stage, project, reload) } diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 9c862ff..9630f98 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -18,7 +18,7 @@ import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsf import { termineFuerKunde } from '../services/termine/schalter.js' import { aboMemo } from '../services/aboStatus.js' import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js' -import { bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' +import { aboMeldung, bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js' import { ELIGIBILITY_MESSAGES, @@ -94,8 +94,10 @@ const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv'] /** * Terminplaner-Teil der Antwort von GET /api/funktionen. Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false }, * das Abo wird dann nicht gelesen. Verfuegbar: dazu bestellbar (true | false | null), grund (kein_abo | gekuendigt | nicht_aktiv | - * null), preis und einrichtung aus dem Register. Ein Lesefehler im Abo ergibt bestellbar: null und kostet die Liste nichts; im - * Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen oder IDs enthalten). + * null), preis und einrichtung aus dem Register sowie hinweis, der Abo-Text fuer den Kunden (aboMeldung, einzige Quelle der + * Abo-Hinweise; der Browser zeigt ihn nur an). hinweis ist null, solange bestellbar true ist. Ein Lesefehler im Abo ergibt + * bestellbar: null und kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen + * oder IDs enthalten). */ async function termineAngebot(email, customerId) { if (!termineFuerKunde(email)) return { verfuegbar: false } @@ -109,7 +111,8 @@ async function termineAngebot(email, customerId) { } catch (err) { console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) } - return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '' } + const hinweis = bestellbar === true ? null : aboMeldung(register, grund ?? 'unbekannt') + return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis } } // Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index a4ee08b..0491cd8 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -20,6 +20,7 @@ const { default: teamRoutes } = await import('../server/routes/team/index.js') const sf = await import('../server/services/siteFeatures.js') const { loeschTag } = await import('../server/services/termine/frist.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') +const { aboMeldung } = await import('../server/services/featureOrder.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes }) after(() => Promise.all([app.close(), aw.close(), st.close()])) @@ -165,7 +166,8 @@ test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false b assert.equal(await verfuegbar(customerCookie('c2')), true) }) -// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung nur bei verfuegbar: true +// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung, hinweis nur bei verfuegbar: true +// hinweis (R111) = aboMeldung des Registers: der Browser zeigt den Text nur an und hat keine Kopie; bei bestellbar true null const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) const angebot = (bestellbar, grund) => ({ verfuegbar: true, @@ -173,6 +175,7 @@ const angebot = (bestellbar, grund) => ({ grund, preis: config.features.termine.priceLabel, einrichtung: config.features.termine.setupEstimate, + hinweis: bestellbar === true ? null : aboMeldung(config.features.termine, grund ?? 'unbekannt'), }) test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null, Preis und Einrichtung aus dem Register', async (t) => { @@ -182,7 +185,38 @@ test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null const termine = await termineAntwort() assert.deepEqual(termine, angebot(true, null)) assert.ok(termine.preis && termine.einrichtung, 'das Register liefert beide Werte') - assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung']) + assert.deepEqual(Object.keys(termine), ['verfuegbar', 'bestellbar', 'grund', 'preis', 'einrichtung', 'hinweis']) + assert.equal(termine.hinweis, null, 'bestellbar: kein Hinweis (das Feld steht da, ist aber null)') +}) + +test('GET /api/funktionen: hinweis ist der Text von aboMeldung mit dem Register des Terminplaners, je Grund', async (t) => { + t.after(() => aw.seed('subscriptions', hostingDoc)) + termineMit('pilot', ['*']) + const faelle = [ + ['kein_abo', () => {}, 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'], + ['gekuendigt', () => wartung({ cancelAtPeriodEnd: true }), 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.'], + [ + 'nicht_aktiv', + () => wartung({ status: 'past_due' }), + 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', + ], + ] + for (const [grund, richteEin, text] of faelle) { + aw.seed('subscriptions', hostingDoc) + richteEin() + const termine = await termineAntwort() + assert.equal(termine.bestellbar, false, grund) + assert.equal(termine.grund, grund) + assert.equal(termine.hinweis, text, grund) + assert.equal(termine.hinweis, aboMeldung(config.features.termine, grund), grund) + } + // ein Lesefehler: bestellbar null, grund null => Text zu 'unbekannt' + aw.seed('subscriptions', hostingDoc) + wartung() + t.mock.method(console, 'error', () => {}) + aw.failNext({ collection: 'subscriptions', status: 500, message: 'x' }) + const unbekannt = await termineAntwort() + assert.deepEqual([unbekannt.bestellbar, unbekannt.grund, unbekannt.hinweis], [null, null, 'Das Abo konnte gerade nicht geprüft werden.']) }) test('GET /api/funktionen: ohne Wartungs-Abo (nur Hosting, gar kein Abo, beendet) => bestellbar false, grund kein_abo', async (t) => { @@ -212,11 +246,15 @@ test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die const fehler = t.mock.method(console, 'error', () => {}) termineMit('pilot', ['*']) wartung() + // eigenes Projekt mit aktivem Terminplaner: der Test haengt nicht von den Zustaenden aus frueheren Tests ab + aw.seed('websiteProjects', { $id: 'pt_lesefehler', customerId: 'c1', projectName: 'Lesefehler Salon' }) + aw.seed('portalFeatures', { $id: 'f_pt_lesefehler_termine', customerId: 'c1', projectId: 'pt_lesefehler', featureKey: 'termine', siteKey: 'abcdefghijklmnop', state: 'active', enabled: true }) aw.failNext({ collection: 'subscriptions', status: 500, message: 'geheimer Appwrite-Text' }) const res = await get('/api/funktionen', { cookie }) assert.deepEqual(res.termine, angebot(null, null)) - for (const id of ['pt1', 'pt2']) assert.ok(res.projects.some((p) => p.projectId === id), `Projekt ${id} ist in der Liste`) - assert.equal(featuresOf({ body: res }, 'pt1').termine.state, 'active', 'die Zustaende der Projekte bleiben') + assert.equal(res.termine.hinweis, 'Das Abo konnte gerade nicht geprüft werden.') + assert.ok(res.projects.some((p) => p.projectId === 'pt_lesefehler'), 'das Projekt ist in der Liste') + assert.equal(featuresOf({ body: res }, 'pt_lesefehler').termine.state, 'active', 'die Zustaende der Projekte bleiben') assert.ok(!JSON.stringify(res).includes('geheimer')) assert.ok(fehler.mock.calls.every((aufruf) => !String(aufruf.arguments[0]).includes('geheimer')), 'auch nicht im Log') assert.ok(fehler.mock.calls.length >= 1, 'der Fehler steht im Log') diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index ac7dc23..8f7a10a 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -4,8 +4,6 @@ import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { aboMeldung } from '../server/services/featureOrder.js' -import { featureConfig } from '../server/services/featureRegistry.js' // public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet) const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-')) @@ -149,7 +147,7 @@ test('statisch: News postet weiter consent: true an /api/funktionen/news/checkou const withTermine = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state, ...extra } } }) const noAnalyse = { comingSoon: true } // Standardantwort bei verfuegbar: true wie GET /api/funktionen (Kunde mit Wartungs-Abo); abo ueberschreibt einzelne Felder -const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' } +const ABO_OK = { bestellbar: true, grund: null, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen', hinweis: null } async function renderTermine(projects, { verfuegbar, anfragen = {}, abo = {} } = {}) { const calls = [] globalThis.fetch = async (url) => { @@ -178,10 +176,10 @@ test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt } }) -// Die Abo-Hinweise der Kachel kommen aus aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte); -// die Kachel steht im Browser und kennt den Text nur als Kopie: dieser Test haelt beide gleich. -const TERMINE_REGISTER = featureConfig('termine') -const KEIN_ABO_HINWEIS = /Im Wartungs-Abo enthalten\. ' const knopfDerKachel = (kachel) => kachel.match(/]*fn-tile-cta[^>]*>([^<]*)<\/button>/) test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftext und Ziel', async () => { @@ -195,10 +193,10 @@ test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftex ['Aktiv', [withTermine('a', 'active')], {}, 'on', 'Aktiv', 'Termine öffnen', 'termine'], ['Abgeschaltet', [withTermine('a', 'disabled')], {}, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'], // das Abo betrifft nur die Bestellung: wer schon bestellt hat oder nutzt, behaelt seinen Weg in die Ansicht - ['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'], - ['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'], - ['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'], - ['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'], + ['Zahlung offen, Abo gekündigt', [withTermine('a', 'ordered')], { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt.' }, 'setup', 'Zahlung offen', 'Status ansehen', 'termine'], + ['In Einrichtung, Abo nicht lesbar', [withTermine('a', 'setup')], { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'setup', 'In Einrichtung', 'Status ansehen', 'termine'], + ['Aktiv, Abo gekündigt', [withTermine('a', 'active')], { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt.' }, 'on', 'Aktiv', 'Termine öffnen', 'termine'], + ['Abgeschaltet, Abo nicht aktiv', [withTermine('a', 'disabled')], { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis nicht aktiv.' }, 'off', 'Abgeschaltet', 'Termine ansehen', 'termine'], ] for (const [name, projects, abo, klasse, pille, knopfText, ziel] of zeilen) { const { html } = await renderTermine(projects, { verfuegbar: true, abo }) @@ -210,44 +208,75 @@ test('Terminplaner-Kachel, Tabelle: jede Zeile mit Pille, Pillenklasse, Knopftex assert.ok(knopf[0].includes(`data-view="${ziel}" data-project="a"`), `${name}: Ziel ${knopf[0]}`) assert.doesNotMatch(kachel, /Wir schalten den Terminplaner für dich frei/, name) assert.doesNotMatch(kachel, /data-view="chat"/, `${name}: kein Chat-Knopf mehr`) + assert.doesNotMatch(kachel, /Serverhinweis/, `${name}: der Abo-Hinweis gilt nur im Zustand Aus`) } }) -test('Terminplaner-Kachel: Aus ohne Abo ⇒ kein Knopf und kein Weg in die Ansicht, Hinweis je Grund', async () => { - const hinweise = [ - ['kein_abo', { bestellbar: false, grund: 'kein_abo' }, KEIN_ABO_HINWEIS], - ['gekuendigt', { bestellbar: false, grund: 'gekuendigt' }, new RegExp(`${aboMeldung(TERMINE_REGISTER, 'gekuendigt')}`)], - ['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv' }, new RegExp(aboMeldung(TERMINE_REGISTER, 'nicht_aktiv').replace(/[()]/g, '\\$&'))], - ['null (Lesefehler)', { bestellbar: null, grund: null }, /Das Abo konnte gerade nicht geprüft werden\./], - ['Feld fehlt (ältere Antwort)', { bestellbar: undefined, grund: undefined }, /Das Abo konnte gerade nicht geprüft werden\./], - ['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues' }, /Das Abo konnte gerade nicht geprüft werden\./], +test('Terminplaner-Kachel: Aus ohne Abo => kein Knopf und kein Weg in die Ansicht, der Hinweis kommt vom Server (Link zu Verträge nur bei kein_abo)', async () => { + const faelle = [ + ['kein_abo', { bestellbar: false, grund: 'kein_abo', hinweis: 'Serverhinweis ohne Abo.' }, `Serverhinweis ohne Abo. ${ZU_VERTRAEGE}`], + ['gekuendigt', { bestellbar: false, grund: 'gekuendigt', hinweis: 'Serverhinweis gekündigt & beendet.' }, 'Serverhinweis gekündigt & beendet.'], + ['nicht_aktiv', { bestellbar: false, grund: 'nicht_aktiv', hinweis: 'Serverhinweis „nicht aktiv“ (offen).' }, 'Serverhinweis „nicht aktiv“ (offen).'], + ['unbekannt (Lesefehler: bestellbar null)', { bestellbar: null, grund: null, hinweis: 'Serverhinweis nicht prüfbar.' }, 'Serverhinweis nicht prüfbar.'], + ['unbekannter Grund', { bestellbar: false, grund: 'etwas_neues', hinweis: 'Serverhinweis zu etwas Neuem.' }, 'Serverhinweis zu etwas Neuem.'], ] // mit und ohne Entwurf: auch ein vorhandener Entwurf oeffnet ohne Abo keinen Weg for (const projekte of [[withTermine('a', 'off')], [withTermine('a', 'off', { siteKey: 'abcdefghijklmnop' })]]) { - for (const [name, abo, hinweis] of hinweise) { + for (const [name, abo, erwartet] of faelle) { const { html, calls } = await renderTermine(projekte, { verfuegbar: true, abo }) const kachel = tile(html, 'termine') assert.match(kachel, /fn-pill-off">Aus${erwartet}

`), `${name}: ${kachel}`) assert.doesNotMatch(kachel, /data-view="termine"/, name) assert.doesNotMatch(kachel, /fn-tile-cta/, `${name}: kein Knopf`) assert.doesNotMatch(html, /data-view="termine"/, `${name}: nirgends ein Weg in die Ansicht`) assert.deepEqual(calls, ['/api/funktionen'], name) + // nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text + assert.equal(kachel.split('data-view=').length - 1, abo.grund === 'kein_abo' ? 1 : 0, `${name}: Links`) } } - // nur kein_abo verweist auf die Vertraege; die uebrigen Hinweise sind reiner Text - const gekuendigt = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt' } })).html, 'termine') - assert.doesNotMatch(gekuendigt, /data-view=/) }) -test('Terminplaner-Kachel: die Abo-Hinweise stimmen wortgleich mit aboMeldung überein (gekündigt, nicht aktiv, nicht lesbar)', async () => { - for (const grund of ['gekuendigt', 'nicht_aktiv']) { - const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund } }) - assert.ok(tile(html, 'termine').includes(`

${aboMeldung(TERMINE_REGISTER, grund)}

`), grund) +test('Terminplaner-Kachel: der Hinweis des Servers kommt maskiert in die Kachel (auch bei kein_abo neben dem Link)', async () => { + const bose = '"\'&' + const maskiert = '<img src=x onerror=1>"'&' + for (const grund of ['gekuendigt', 'nicht_aktiv', 'kein_abo']) { + const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund, hinweis: bose } }) + const kachel = tile(html, 'termine') + assert.doesNotMatch(html, /${maskiert}${grund === 'kein_abo' ? ` ${ZU_VERTRAEGE}` : ''}

`), `${grund}: ${kachel}`) } - const { html } = await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: null, grund: null } }) - assert.ok(tile(html, 'termine').includes(`

${aboMeldung(TERMINE_REGISTER, 'unbekannt')}

`)) - assert.equal(aboMeldung(TERMINE_REGISTER, 'unbekannt'), 'Das Abo konnte gerade nicht geprüft werden.') +}) + +test('Terminplaner-Kachel: fehlt der Hinweis (leer, null, kein Text) gibt es keine Zeile und keinen Knopf; kein_abo behält den Link', async () => { + for (const hinweis of [undefined, null, '', 5, { text: 'x' }]) { + const ohne = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'gekuendigt', hinweis } })).html, 'termine') + assert.match(ohne, /fn-pill-off">Aus${ZU_VERTRAEGE}

`), String(hinweis)) + assert.doesNotMatch(keinAbo, /fn-tile-cta|data-view="termine"/) + } +}) + +test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein Hinweis erscheint nicht', async () => { + const kachel = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { hinweis: 'Serverhinweis darf nicht erscheinen.' } })).html, 'termine') + assert.equal(knopfDerKachel(kachel)[1], 'Terminplaner einrichten · 149 €') + assert.doesNotMatch(kachel, /Serverhinweis|fn-count/) +}) + +test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge steht einmal im Quelltext (Analyse und Terminplaner teilen ihn)', async () => { + assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/) + assert.equal(src.split('data-view="vertraege"').length - 1, 1) + // Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link + const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse') + assert.ok(analyse.includes(`

Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}

`), analyse) + const andere = tile(await render([project('a', { eligible: false, reason: 'gekuendigt', reasonText: 'Gekündigt' })]), 'analyse') + assert.ok(andere.includes('

Gekündigt

') && !andere.includes('data-view='), andere) + const stage = await renderActivation({ state: undefined, eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' }) + assert.ok(stage.innerHTML.includes(`

Kein Abo ${ZU_VERTRAEGE}

`), stage.innerHTML) + const termine = tile((await renderTermine([withTermine('a', 'off')], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis: 'T' } })).html, 'termine') + assert.ok(termine.includes(ZU_VERTRAEGE), termine) }) test('Terminplaner-Kachel: ordered und setup fragen /api/termine nicht nach einer Zahl (nur aktive Websites)', async () => { @@ -347,11 +376,11 @@ test('Terminplaner-Kachel: Zielprojekt = aktiv, dann ordered/setup, dann abgesch }) test('Terminplaner-Kachel: mehrere Websites ohne Abo ⇒ die Pille bleibt „Aus“, nirgends ein Knopf (auch mit Entwurf)', async () => { - const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo' } }) + const { html } = await renderTermine([withTermine('a', 'off'), withTermine('b', 'off', { siteKey: 'abcdefghijklmnop' })], { verfuegbar: true, abo: { bestellbar: false, grund: 'kein_abo', hinweis: 'Serverhinweis ohne Abo.' } }) const kachel = tile(html, 'termine') assert.match(kachel, /fn-pill-off">AusServerhinweis ohne Abo. ${ZU_VERTRAEGE}

`), kachel) }) test('Terminplaner-Kachel: Projektname und Zustand kommen nur maskiert in die Kachel', async () => { diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs index cd9d7cd..4dd2255 100644 --- a/test/ui/termine-portal.ui.mjs +++ b/test/ui/termine-portal.ui.mjs @@ -125,6 +125,15 @@ function alleStatus() { ] } +// Der Abo-Text kommt in der Antwort von /api/funktionen vom Server (termine.hinweis, aboMeldung in featureOrder.js); die Fixture +// liefert feste Texte, die Kachel zeigt sie nur an +const FX_ABO_HINWEISE = { + kein_abo: 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.', + gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.', + nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', + unbekannt: 'Das Abo konnte gerade nicht geprüft werden.', +} + /** Antworten der Fixture auf /api/funktionen und /api/termine (Teil von page.route). */ async function fixtureAntwort(fx, route, req, url, json) { const p = url.pathname @@ -132,7 +141,14 @@ async function fixtureAntwort(fx, route, req, url, json) { const m = req.method() if (p === '/api/funktionen') { const termine = fx.verfuegbar - ? { verfuegbar: true, bestellbar: fx.bestellbar, grund: fx.aboGrund, preis: '149 €', einrichtung: 'meist innerhalb von 1–2 Wochen' } + ? { + verfuegbar: true, + bestellbar: fx.bestellbar, + grund: fx.aboGrund, + preis: '149 €', + einrichtung: 'meist innerhalb von 1–2 Wochen', + hinweis: fx.bestellbar === true ? null : FX_ABO_HINWEISE[fx.aboGrund ?? 'unbekannt'], + } : { verfuegbar: false } return json({ projects: fx.projekte, termine }) } @@ -324,7 +340,7 @@ async function pruefungen() { await knopf(kachel, 'Fragebogen fortsetzen').waitFor() // Aus ohne Abo: kein Knopf, Hinweis; die Hinweise der uebrigen Gruende const faelle = [ - ['kein_abo', 'Im Wartungs-Abo enthalten.'], + ['kein_abo', 'Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.'], ['gekuendigt', 'Dein Wartungs-Abo ist gekündigt.'], ['nicht_aktiv', 'Dein Wartungs-Abo ist gerade nicht aktiv'], ] From 87d267e51a500a2874214c2e53c8bda6ec8a2396 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:10:54 +0000 Subject: [PATCH 02/28] Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen: off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt= und #termine?projekt= waehlen das Projekt, "Danke!" nur mit bestellt=1, die Adresse wird einmal gelesen und bereinigt. R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf, solange die Bestellung offen ist). Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und Status der Einstellungen erst ab der Freischaltung. Co-Authored-By: Claude Sonnet 5.5 --- public/funktion-bestellen.js | 23 +- public/funktionen.js | 6 +- public/termine-tab-einstellungen.js | 2 + public/termine-tab-leistungen.js | 6 +- public/termine.js | 219 ++++++++-- public/vertraege-link.js | 5 + server/routes/termine.js | 9 +- test/funktionenTiles.test.js | 10 +- test/support/fakeDom.js | 4 +- test/termineFragebogenRoutes.test.js | 56 ++- test/termineFragebogenView.test.js | 575 ++++++++++++++++++++++++++- test/termineView.test.js | 27 +- test/ui/termine-portal.ui.mjs | 206 +++++++++- 13 files changed, 1077 insertions(+), 71 deletions(-) create mode 100644 public/vertraege-link.js diff --git a/public/funktion-bestellen.js b/public/funktion-bestellen.js index 0d9c151..8dfcad5 100644 --- a/public/funktion-bestellen.js +++ b/public/funktion-bestellen.js @@ -259,10 +259,14 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { * Karte für eine Bestellung im Zustand ordered („Zahlung offen“) oder setup („In Einrichtung“). * projekt = Eintrag des Projekts aus GET /api/funktionen (projectId), bestellung = projekt.features.termine (state, orderedAt, ticketWoid, * invoiceUrl), einrichtung = Text der Zeiteinschätzung (Register: setupEstimate), danke = Rückkehr von Stripe, kundenansicht = Sitzung in der - * Kundenansicht. weiter() wird gerufen, wenn „Zahlung fortsetzen“ mit 409 zustimmung_fehlt antwortet (keine offene Sitzung mit Nachweis): es - * lädt die Seite neu und zeigt wieder das Bestellformular. Fehlt weiter, steht die Meldung des Servers am Knopf. + * Kundenansicht, meldung = Text, der am Knopf „Zahlung fortsetzen“ steht (z. B. der Fehler eines Versuchs vor dem Neuzeichnen). + * weiter(fall) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (Ruling R110): fall = { bestellen, meldung }, bestellen ist wahr bei 409 + * zustimmung_fehlt (keine offene Sitzung mit Nachweis: die Ansicht zeigt wieder das Bestellformular), meldung der Fehlertext der Antwort. + * weiter() lädt neu und zeichnet die Ansicht nach dem Zustand von neuem, damit keine veraltete Karte stehen bleibt (z. B. wenn die Bestellung + * inzwischen bezahlt ist und der Server 400 antwortet); wirft es, bleibt die Karte stehen und der Fehler steht am Knopf. Fehlt weiter, + * steht die Meldung des Servers am Knopf. */ -export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht } = {}, { weiter } = {}) { +export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung } = {}, { weiter } = {}) { const bestellt = bestellung?.state === 'ordered' const datum = datumTag(bestellung?.orderedAt) const ticket = bestellung?.ticketWoid @@ -275,7 +279,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans !bestellt && text(einrichtung) ? zeile('Zeiteinschätzung', einrichtung) : null, ].filter(Boolean) - const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' }) + const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' }, text(meldung) || null) const fortsetzenKnopf = bestellt ? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: kundenansicht === true, onclick: () => fortsetzen() }, 'Zahlung fortsetzen') : null @@ -289,15 +293,18 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans if (geheZu(antwort?.url)) return // die Seite wird verlassen meldungEl.textContent = ZAHLUNGSSEITE } catch (err) { - if (err?.status === 409 && err.code === 'zustimmung_fehlt' && typeof weiter === 'function') { + const nachricht = err?.message || ZAHLUNGSSEITE + const zustimmungFehlt = err?.status === 409 && err.code === 'zustimmung_fehlt' + if (typeof weiter === 'function') { try { - await weiter() + await weiter({ bestellen: zustimmungFehlt, meldung: nachricht }) return // die Ansicht zeichnet sich neu } catch (fehler) { - meldungEl.textContent = fehler?.message || ZAHLUNGSSEITE + // Neuladen gescheitert: bei fehlender Zustimmung ist das der Grund, der den Kunden betrifft, sonst bleibt der Fehler des Versuchs + meldungEl.textContent = zustimmungFehlt ? fehler?.message || ZAHLUNGSSEITE : nachricht } } else { - meldungEl.textContent = err?.message || ZAHLUNGSSEITE + meldungEl.textContent = nachricht } } fortsetzenKnopf.disabled = false diff --git a/public/funktionen.js b/public/funktionen.js index 91e0473..d4a67d6 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -5,6 +5,7 @@ import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' +import { ZU_VERTRAEGE } from './vertraege-link.js' const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' @@ -112,8 +113,9 @@ function newsTile(projects, price = PRICE) { ` } -// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn -const ZU_VERTRAEGE_LINK = '' +// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn, und die Ansicht +// #termine (termine.js) nimmt Text, Ziel und Klasse aus derselben Quelle (vertraege-link.js) +const ZU_VERTRAEGE_LINK = `` /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ const analyseOf = (project) => project.analyse || { comingSoon: true } diff --git a/public/termine-tab-einstellungen.js b/public/termine-tab-einstellungen.js index 851a20b..c7ebc38 100644 --- a/public/termine-tab-einstellungen.js +++ b/public/termine-tab-einstellungen.js @@ -341,6 +341,8 @@ function einbau(ctx) { /* ------------------------------------------------------------ Export, Status --- */ function exportUndStatus(ctx) { + // vor der Freischaltung (off, ordered, setup) gibt es weder Termine zum Exportieren noch ein „Aktiv“; null: der Aufrufer laesst den Abschnitt weg + if (['off', 'ordered', 'setup'].includes(ctx.daten.state)) return null const seit = aktivSeitText(ctx.daten.aktivSeit) const abgeschaltet = ctx.daten.state === 'disabled' return abschnitt('Export und Status', null, diff --git a/public/termine-tab-leistungen.js b/public/termine-tab-leistungen.js index 03dba47..6271d7e 100644 --- a/public/termine-tab-leistungen.js +++ b/public/termine-tab-leistungen.js @@ -92,7 +92,8 @@ export function render(panel, ctx) { }) aktualisiere() - wurzel.replaceChildren( + // neu.leiste ist null, solange die Funktion nur lesbar ist: replaceChildren wuerde sonst den Text "null" in die Seite schreiben + wurzel.replaceChildren(...[ sp.konflikt, h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')), @@ -100,7 +101,8 @@ export function render(panel, ctx) { neu.box, leiste, kopfFehler, - leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.')) + leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'), + ].filter(Boolean)) } zeichne() } diff --git a/public/termine.js b/public/termine.js index 4b1dadd..7db98a9 100644 --- a/public/termine.js +++ b/public/termine.js @@ -1,10 +1,16 @@ /* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle: - initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern. + initTermine(rootEl, { projectId }): Projektauswahl und je nach Zustand der Funktion (GET /api/termine/einstellungen, state) die Ansicht: + off: ohne laufendes Wartungs-Abo (daten.abo.ok) der Hinweis wie in der Kachel (Text vom Server, GET /api/funktionen termine.hinweis; kein + Fragebogen), sonst der Fragebogen mit der Bestellseite (termine-fragebogen.js); ordered und setup: die Statuskarte (funktion-bestellen.js) + und darunter die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (der Reiter Termine entfaellt, der Server antwortet dort + mit 409); active: die Reiter, ohne Einstellung die Vorlagenauswahl; disabled: der Banner der Abschaltung (abschaltText) und die Reiter nur + zum Lesen. Rueckkehr von Stripe: #termine?bestellt=1&projekt= (Zahlung) und #termine?projekt= (Abbruch) waehlen das Projekt, + „Danke!“ gibt es nur mit bestellt=1; beides wird einmal gelesen und aus der Adresse genommen. Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js, termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js). ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden } (baueCtx): Zustand und Pruefung "bin ich noch aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen - (die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen, + (die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer alles ausser off und active, hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */ @@ -14,19 +20,40 @@ import { render as renderTermine } from './termine-tab-termine.js' import { render as renderLeistungen } from './termine-tab-leistungen.js' import { render as renderTeam } from './termine-tab-team.js' import { render as renderEinstellungen } from './termine-tab-einstellungen.js' +import { renderFragebogen } from './termine-fragebogen.js' +import { renderBestellen, statusKarte } from './funktion-bestellen.js' +import { ZU_VERTRAEGE } from './vertraege-link.js' // Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute) const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' } let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s) /* --------------------------------------------------------- Einstieg --- */ +/** + * Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews): #termine?bestellt=1&projekt= nach der Zahlung + * (success_url) und #termine?projekt= nach dem Abbruch (cancel_url). Gelesen wird einmal, danach steht nur noch #termine da. + */ +function leseRueckkehr() { + const hash = window.location.hash || '' + if (!hash.startsWith('#termine?')) return { bestellt: false, projekt: '' } + const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1)) + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#termine`) + return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' } +} + +const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off' +const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup' +// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js) +const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey) + export async function initTermine(root, { projectId } = {}) { if (!root) return - const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab + const rueckkehr = leseRueckkehr() + const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab delete root.dataset.wunschProjekt if (wunsch) auswahl.projectId = wunsch const { projects = [], termine } = await request('/api/funktionen') - const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null } + const s = { root, projects, termine: termine || null, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false } S = s if (!termine?.verfuegbar) { root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.')) @@ -36,10 +63,13 @@ export async function initTermine(root, { projectId } = {}) { root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) return } - const zustand = (p) => p.features?.termine?.state + // Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, bestellt, abgeschaltet, Entwurf, das erste s.projekt = projects.find((p) => p.projectId === auswahl.projectId) - || projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0] + || projects.find((p) => zustandVon(p) === 'active') || projects.find((p) => istBestellt(zustandVon(p))) + || projects.find((p) => zustandVon(p) === 'disabled') || projects.find(hatEntwurf) || projects[0] auswahl.projectId = s.projekt.projectId + // „Danke!“ nur nach der Zahlung (bestellt=1) und nur fuer das Projekt, fuer das bezahlt wurde + s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) s.buehne = h('div', { id: 'termine-buehne' }) root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne) await zeigeProjekt(s) @@ -55,6 +85,7 @@ function projektwahl(s) { auswahl.projectId = select.value s.projekt = s.projects.find((p) => p.projectId === select.value) s.tag = heuteBerlin() + s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam zeigeProjekt(s) }) return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select) @@ -62,6 +93,18 @@ function projektwahl(s) { const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`) +/** + * Liste der Funktionen neu holen (GET /api/funktionen): Projekte, das aktuelle Projekt darin, Preis, Einrichtungsdauer und Abo-Hinweis des + * Servers. Wirft bei einem Fehler und aendert dann nichts. + */ +async function ladeFunktionen(s) { + const antwort = await request('/api/funktionen') + const projects = Array.isArray(antwort.projects) ? antwort.projects : [] + s.projects = projects + s.termine = antwort.termine || null + s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt +} + /** * Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis. * abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz). @@ -73,19 +116,13 @@ function hinweisAbgeschaltet(projectId, abgeschaltet) { } /** - * Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort + * Je nach Zustand der Funktion die Ansicht zeigen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch: die Antwort * einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen. */ -async function zeigeProjekt(s) { +async function zeigeProjekt(s, optionen = {}) { const projekt = s.projekt const lauf = ++s.lauf const aktuell = () => S === s && s.lauf === lauf - const zustand = projekt.features?.termine?.state || 'off' - s.nurLesen = zustand !== 'active' - if (zustand !== 'active' && zustand !== 'disabled') { - s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true })) - return - } s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let daten try { @@ -95,8 +132,39 @@ async function zeigeProjekt(s) { return } if (!aktuell()) return + await zeichneAnsicht(s, { daten, aktuell, ...optionen }) +} + +/** + * Ansicht nach einer gescheiterten Aktion der Statuskarte neu aufbauen (Ruling R110): Liste und Daten frisch holen und erst dann zeichnen. + * Scheitert das Laden, wirft die Funktion und die Karte bleibt mit ihrer Meldung stehen. optionen: bestellformular (ordered zeigt das + * Bestellformular statt der Karte), meldung ({ text, art }, nur im Zustand ordered noch von Belang). + */ +async function ladeNeu(s, optionen = {}) { + const lauf = s.lauf + await ladeFunktionen(s) + const daten = await ladeDaten(s.projekt.projectId) + if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt + const nr = ++s.lauf + await zeichneAnsicht(s, { daten, aktuell: () => S === s && s.lauf === nr, ...optionen }) +} + +/** Die Ansicht zum Zustand aus den frisch geladenen Daten (daten.state; faellt auf den Eintrag der Liste zurueck). */ +async function zeichneAnsicht(s, { daten, aktuell, bestellformular = false, meldung = null }) { + const projekt = s.projekt s.daten = daten - if (daten.einstellung) zeigeReiter(s, { projekt, aktuell }) + const zustand = daten.state || zustandVon(projekt) + s.nurLesen = zustand !== 'active' && zustand !== 'off' + if (zustand === 'off') { + if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell }) + else await zeigeAboHinweis(s, { aktuell }) + return + } + if (istBestellt(zustand)) { + await zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) + return + } + if (daten.einstellung) zeigeReiter(s, { projekt, aktuell, zustand }) else if (s.nurLesen) { s.buehne.replaceChildren( hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet), @@ -104,6 +172,82 @@ async function zeigeProjekt(s) { } else zeigeVorlagen(s, { projekt, aktuell }) } +const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + +/* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */ +/** + * Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis wie in der Kachel. Der Text kommt ausschliesslich vom Server + * (GET /api/funktionen, termine.hinweis = aboMeldung in server/services/featureOrder.js), nie aus einer Kopie im Browser; bei + * kein_abo kommt der Link zu den Vertraegen dazu (Quelle: vertraege-link.js). Fehlt der Text, weil sich das Abo seit dem Laden der Liste + * geaendert hat, wird die Liste noch einmal geholt. + */ +async function zeigeAboHinweis(s, { aktuell }) { + if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') { + try { + await ladeFunktionen(s) + } catch { + // ohne Text bleibt der Hinweis bei der Ueberschrift (und dem Link bei kein_abo) + } + if (!aktuell()) return + } + const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : '' + const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo' + ? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text) + : null + s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' }, + h('h3', null, 'Terminplaner einrichten'), + hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null)) +} + +/** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */ +function zeigeFragebogen(s, { projekt, aktuell }) { + s.meldungEl = neueMeldung() + const wurzel = h('div', { class: 'tm-fb-wurzel' }) + s.buehne.replaceChildren(s.meldungEl, wurzel) + renderFragebogen(wurzel, baueCtx(s, { projekt, aktuell })) +} + +/* ----------------------------------------------- Zustand ordered, setup --- */ +const GESPERRT = 'Deine Angaben sind seit der Bestellung gesperrt. Änderungen laufen über den Chat.' + +/** + * Statuskarte (Zahlung offen | In Einrichtung) und darunter die Reiter zum Lesen. Der Eintrag der Liste liefert Bestelldatum, Ticketnummer und + * Rechnung; stimmt sein Zustand nicht mit dem der Daten ueberein (die Liste ist aelter, z. B. nach der Zahlung), wird sie neu geholt. + * Die Karte meldet ueber weiter(), wenn „Zahlung fortsetzen“ scheitert: ladeNeu zeichnet die Ansicht dann neu (bei 409 zustimmung_fehlt + * mit dem Bestellformular statt der Karte). Das Bestellformular (daten.bestellung, R110) zeigt dieselbe Seite wie der Fragebogen. + */ +async function zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) { + if (s.projekt.features?.termine?.state !== zustand) { + try { + await ladeFunktionen(s) + } catch { + // es gilt der Zustand der Daten; was nur die Liste kennt (Ticketnummer, Rechnung), fehlt dann in der Karte + } + if (!aktuell()) return + } + const projekt = s.projekt + const eintrag = { ...projekt.features?.termine, state: zustand } + const danke = s.danke + s.danke = false // nur beim ersten Zeichnen nach der Rueckkehr + const formular = bestellformular && zustand === 'ordered' && Boolean(s.daten.bestellung) + const kopf = (ctx) => { + if (formular) { + const seite = h('div', { class: 'tm-fb-bestellen' }) + renderBestellen(seite, { ctx, daten: s.daten, zumSchritt: () => ctx.meldung(GESPERRT, 'fehler') }) + return seite + } + return statusKarte({ + projekt, + bestellung: eintrag, + einrichtung: s.termine?.einrichtung, + danke, + kundenansicht: s.daten.kundenansicht === true, + meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist + }, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) }) + } + zeigeReiter(s, { projekt, aktuell, zustand, kopf, meldung: formular && meldung ? { text: meldung.text, art: 'ok' } : null }) +} + /* ---------------------------------------------------- Vorlage wählen --- */ function zeigeVorlagen(s, { projekt, aktuell }) { const fehler = h('p', { class: 'tm-fehler', role: 'alert' }) @@ -201,19 +345,27 @@ export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = return ctx } -function zeigeReiter(s, { projekt, aktuell }) { +/** + * Die Reiter (Hinweisleiste, Meldung, Reiterleiste, Panel). zustand steuert, was dazugehoert: disabled hat den Banner der Abschaltung, + * active den Hinweis auf fehlende Pflichtangaben, ordered und setup keinen der beiden und keinen Reiter „Termine“ (der Server antwortet + * dort mit 409). kopf(ctx) liefert ein Element, das ueber der Meldung steht (die Statuskarte), meldung ({ text, art }) wird nach dem + * ersten Zeichnen angezeigt. Ohne Einstellung gibt es keine Reiter, dann bleibt es beim Kopf. + */ +function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' : 'active', kopf = null, meldung = null }) { const projectId = projekt.projectId const nurLesen = s.nurLesen - s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + s.meldungEl = neueMeldung() const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter }) + const reiterListe = istBestellt(zustand) ? REITER.filter((r) => r.key !== 'termine') : REITER + const kopfEl = kopf ? kopf(ctx) : null // Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten. const hinweisBox = h('div', { class: 'tm-hinweise' }) function zeichneHinweise() { const hinweise = [] - if (nurLesen) { + if (zustand === 'disabled') { hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet)) - } else if ((s.daten.fehlt || []).length) { + } else if (!nurLesen && (s.daten.fehlt || []).length) { hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`), h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen'))) @@ -222,24 +374,33 @@ function zeigeReiter(s, { projekt, aktuell }) { } zeichneHinweise() + if (!s.daten.einstellung) { + s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl].filter(Boolean)) + if (meldung) zeigeMeldung(s, meldung.text, meldung.art) + return + } + const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' }) - const knoepfe = REITER.map((r) => h('button', { + let aktiv = null // Schluessel des gezeigten Reiters + const knoepfe = reiterListe.map((r) => h('button', { type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key, onclick: () => waehleReiter(r.key), }, r.label)) const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe) leiste.addEventListener('keydown', (ev) => { - const i = REITER.findIndex((r) => r.key === auswahl.reiter) - const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] + const i = reiterListe.findIndex((r) => r.key === aktiv) + const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key] if (ziel === undefined) return ev.preventDefault() - waehleReiter(REITER[ziel].key) + waehleReiter(reiterListe[ziel].key) knoepfe[ziel].focus() }) - function waehleReiter(key) { - const reiter = REITER.find((r) => r.key === key) || REITER[0] - auswahl.reiter = reiter.key + function waehleReiter(key, { merken = true } = {}) { + // ein Reiter, den es in diesem Zustand nicht gibt (Termine in ordered und setup), wird nicht gemerkt: die Auswahl gilt fuer die naechste Website weiter + const reiter = reiterListe.find((r) => r.key === key) || reiterListe[0] + if (merken && reiter.key === key) auswahl.reiter = reiter.key + aktiv = reiter.key for (const k of knoepfe) { const an = k.dataset.reiter === reiter.key k.setAttribute('aria-selected', String(an)) @@ -253,7 +414,7 @@ function zeigeReiter(s, { projekt, aktuell }) { .catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) } - s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel) - waehleReiter(auswahl.reiter) + s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl, leiste, panel].filter(Boolean)) // replaceChildren(null) schriebe „null“ in die Seite + waehleReiter(auswahl.reiter, { merken: false }) + if (meldung) zeigeMeldung(s, meldung.text, meldung.art) } - diff --git a/public/vertraege-link.js b/public/vertraege-link.js new file mode 100644 index 0000000..b258dd0 --- /dev/null +++ b/public/vertraege-link.js @@ -0,0 +1,5 @@ +/* Der Link „Zu Verträge“ in den Hinweisen ohne Wartungs-Abo (Grund kein_abo): die einzige Quelle für Text, Ziel und Klasse. Gelesen von + funktionen.js (Kachel Analyse, Analyse-Seite, Kachel Terminplaner; baut daraus HTML) und von termine.js (Hinweis in der Ansicht #termine; + baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe. + Die Hinweistexte selbst stehen nicht hier: sie kommen vom Server (aboMeldung in server/services/featureOrder.js). */ +export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' }) diff --git a/server/routes/termine.js b/server/routes/termine.js index aba52d0..40878c5 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -311,7 +311,9 @@ async function aboFuerVorschau(customerId) { * Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true). * Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag * ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der - * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links + * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und + * offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird + * dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links * gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active). */ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => { @@ -322,6 +324,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou const customerId = getSessionCustomerId(req) const register = featureConfig('termine') const abo = state === 'off' ? await aboFuerVorschau(customerId) : null + const bestellbar = state === 'off' || state === 'ordered' // die Sicht der Bestellung (offen, bestellung) gibt es nur vor der Zahlung res.json({ state, abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null, @@ -334,8 +337,8 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou kundenansicht: isKundenansicht(req.session), ...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }), abo, - offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null, - bestellung: state === 'off' ? await bestellungAnsicht(register) : null, + offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null, + bestellung: bestellbar ? await bestellungAnsicht(register) : null, feeds: e && state === 'active' ? feedLinks(ctx) : {}, siteKey: ctx.feature.siteKey || null, einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {}, diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index 8f7a10a..b559f7e 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -5,12 +5,13 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js werden auf die Dateien umgeleitet) +// public/funktionen.js wirklich laden (die absoluten Importe /analyse-view.js und /geraete.js und der relative ./vertraege-link.js werden auf die Dateien umgeleitet) const dir = mkdtempSync(join(tmpdir(), 'fn-tiles-')) const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8') .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) .replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`) + .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`) const file = join(dir, 'funktionen.mjs') writeFileSync(file, src) const { initFunktionen, initNews, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href) @@ -265,9 +266,12 @@ test('Terminplaner-Kachel: mit Abo (bestellbar true) steht der Knopf da und ein assert.doesNotMatch(kachel, /Serverhinweis|fn-count/) }) -test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge steht einmal im Quelltext (Analyse und Terminplaner teilen ihn)', async () => { +test('Der Browser hat keine Kopie der Abo-Hinweise; der Link zu Verträge hat eine einzige Quelle (vertraege-link.js: Analyse, Terminplaner-Kachel und Ansicht #termine teilen sie)', async () => { assert.doesNotMatch(src, /TERMINE_ABO_TEXTE|termineAboHinweis|KEIN_ABO_HINWEIS|ist gekündigt\. Für|gerade nicht aktiv|nicht geprüft werden/) - assert.equal(src.split('data-view="vertraege"').length - 1, 1) + const quelle = readFileSync(new URL('../public/vertraege-link.js', import.meta.url), 'utf8') + assert.equal(quelle.split("view: 'vertraege'").length - 1, 1, 'Ziel, Text und Klasse stehen genau einmal in vertraege-link.js') + assert.equal(src.split('data-view="vertraege"').length - 1, 0, 'funktionen.js baut den Link aus ZU_VERTRAEGE, ohne eigenes Ziel') + assert.equal(src.split('ZU_VERTRAEGE_LINK =').length - 1, 1, 'ein Baustein für alle Stellen') // Analyse-Kachel und -Seite (unveraendert) und Terminplaner-Kachel zeigen genau diesen Link const analyse = tile(await render([project('a', { eligible: false, reason: 'kein_abo', reasonText: 'Kein Abo' })]), 'analyse') assert.ok(analyse.includes(`

Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE}

`), analyse) diff --git a/test/support/fakeDom.js b/test/support/fakeDom.js index 0d2c117..44d5b59 100644 --- a/test/support/fakeDom.js +++ b/test/support/fakeDom.js @@ -3,7 +3,7 @@ // const fx = installFakeDom({ antworten: [{ status: 400, body: { fehler: {...} } }] }) // try { ...Client-Code bauen, fx.klick(knopf), fx.fetchLog pruefen... } finally { fx.restore() } // -// installFakeDom stellt `document`, `window` (nur location und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den +// installFakeDom stellt `document`, `window` (nur location mit href, pathname, search, hash und history.replaceState), `CSS.escape` und `fetch` auf globalThis, restore() stellt den // alten Zustand wieder her. `h` aus public/termine-dom.js liest `document` bei jedem Aufruf: der Client braucht keine Einhaengepunkte. // Wurzeln, die `isConnected` brauchen (neuerSpeicher.nochHier), haengt der Test selbst an `document.body`. // @@ -479,7 +479,7 @@ const GLOBALE = ['document', 'window', 'CSS', 'fetch'] export function installFakeDom({ antworten = [] } = {}) { const vorher = Object.fromEntries(GLOBALE.map((name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)])) const document = neuesDokument() - const location = { href: 'http://localhost/', hash: '' } + const location = { href: 'http://localhost/', pathname: '/dashboard.html', search: '', hash: '' } const window = { document, location, diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js index cb2cb86..7886e6a 100644 --- a/test/termineFragebogenRoutes.test.js +++ b/test/termineFragebogenRoutes.test.js @@ -322,8 +322,8 @@ test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippe } }) -test('abo, offen und bestellung gibt es nur in off; sonst null', async () => { - for (const zustand of ['ordered', 'setup', 'active', 'disabled']) { +test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110); in setup, active und disabled null', async () => { + for (const zustand of ['setup', 'active', 'disabled']) { await deleteDocument('portalFeatures', FEATURE).catch(() => {}) await deleteDocument('termineConfig', FEATURE).catch(() => {}) setzeZustand(zustand) @@ -332,11 +332,57 @@ test('abo, offen und bestellung gibt es nur in off; sonst null', async () => { assert.equal(body.offen, null, zustand) assert.equal(body.bestellung, null, zustand) } - setzeZustand('off') + for (const zustand of ['off', 'ordered']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.notEqual(body.offen, null, zustand) + assert.notEqual(body.bestellung, null, zustand) + assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`) + } +}) + +// ---------------------------------------------------------------- ordered: bestellung und offen nur lesend (R110) +// "Zahlung fortsetzen" ohne Nachweis (409 zustimmung_fehlt) zeigt im Zustand ordered wieder das Bestellformular; dafuer braucht der +// Client bestellung und offen auch dort. Das Abo gehoert nicht dazu: es wird in ordered nicht gelesen, offen hat keinen Abo-Punkt (h). + +test('ordered: bestellung ist die Sicht des Registers wie in off, offen die Bestellprüfung ohne Abo-Punkt, abo null, nichts wird geschrieben', async () => { + const stand = setzeZustand('ordered') + aw.resetListCalls() + const vorher = groessen() + const { status, body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(status, 200) + assert.equal(body.state, 'ordered') + assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T)))) + assert.equal(body.bestellung.consent.knopf, T.consent.knopf) + assert.deepEqual(body.offen, [], 'bestellbarer Entwurf: keine offenen Punkte, auch ohne Abo-Punkt') + assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, mitAbo: false })) + assert.equal(body.abo, null) + assert.deepEqual(aw.listCalls('subscriptions'), [], 'in ordered wird das Abo nicht gelesen') + assert.deepEqual(groessen(), vorher, 'read-only: nichts angelegt, nichts protokolliert') +}) + +test('ordered: ein unvollständiger Entwurf zeigt seine offenen Punkte (nie den Abo-Punkt), ohne Einstellung den Punkt „Branche“', async () => { + setzeZustand('ordered', { stand: { ...bestellbarerStand({ now: JETZT }), fragebogen: leerFragebogen() } }) const { body } = await send('GET', einstellungenUrl(), { cookie }) - assert.notEqual(body.abo, null) - assert.notEqual(body.offen, null) + assert.ok(body.offen.length > 0) + assert.ok(body.offen.some((p) => p.schritt === 'leistungen')) + assert.ok(body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Abo-Punkt (h)') + await deleteDocument('termineConfig', FEATURE) + const ohne = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(ohne.status, 200) + assert.deepEqual(ohne.body.offen, [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]) + assert.notEqual(ohne.body.bestellung, null) +}) + +test('ordered in der Kundenansicht: bestellung und offen ebenfalls da, kundenansicht true', async () => { + setzeZustand('ordered') + const { status, body } = await send('GET', einstellungenUrl(), kv()) + assert.equal(status, 200) + assert.equal(body.kundenansicht, true) assert.notEqual(body.bestellung, null) + assert.deepEqual(body.offen, []) }) test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => { diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js index 245fc5b..1e1e274 100644 --- a/test/termineFragebogenView.test.js +++ b/test/termineFragebogenView.test.js @@ -1443,35 +1443,72 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und assert.equal(fx.window.location.href, KNOPF_URL) })) -test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({ +test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({ antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }], }, async (fx) => { - let weiter = 0 - const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } }) + const aufrufe = [] + const wurzel = karte({}, {}, { weiter: async (fall) => { aufrufe.push(fall) } }) await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]')) - assert.equal(weiter, 1) + assert.deepEqual(aufrufe, [{ bestellen: true, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }]) assert.equal(fx.window.location.href, 'http://localhost/') })) -test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({ - antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }], +test('statusKarte ordered: jeder andere Fehler ruft weiter({ bestellen: false, meldung }) (R110), die Karte bleibt nie veraltet stehen', mitDom({ + antworten: [ + { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, + { status: 500, body: {} }, + ], }, async (fx) => { - let weiter = 0 + const aufrufe = [] + // wie im Browser zeichnet sich die Ansicht nach weiter() neu: der Knopf der alten Karte bleibt gesperrt, jeder Fall hat seine Karte + for (let i = 0; i < 3; i += 1) await fx.klick(fx.finde(karte({}, {}, { weiter: (fall) => { aufrufe.push(fall) } }), '[data-aktion="fortsetzen"]')) + assert.deepEqual(aufrufe, [ + { bestellen: false, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }, + { bestellen: false, meldung: 'Der Terminplaner ist für dieses Projekt schon bestellt.' }, + { bestellen: false, meldung: 'Fehler 500' }, + ], 'auch bei 400 (Zustand inzwischen setup oder active) und 409 ohne zustimmung_fehlt') +})) + +test('statusKarte ordered: scheitert weiter() (Neuladen nicht möglich), bleibt die Karte mit dem Fehler am Knopf; ohne weiter() steht die Meldung des Servers dort', mitDom({ + antworten: [ + { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, + { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, + { body: { url: 'http://unsicher.example/x' } }, + ], +}, async (fx) => { + const kaputt = () => { throw new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.') } + const mit = karte({}, {}, { weiter: kaputt }) + const meldungMit = fx.finde(mit, '[data-meldung="fortsetzen"]') + const knopfMit = fx.finde(mit, '[data-aktion="fortsetzen"]') + await fx.klick(knopfMit) + assert.equal(fx.text(meldungMit), 'Keine Verbindung. Bitte versuch es gleich noch einmal.', 'bei fehlender Zustimmung der Grund des Neuladens') + assert.equal(knopfMit.disabled, false) + await fx.klick(knopfMit) + assert.equal(fx.text(meldungMit), 'Der Terminplaner ist für dieses Projekt schon bestellt.', 'sonst der Fehler des Versuchs') const ohne = karte() const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]') await fx.klick(knopf) assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.') assert.equal(knopf.disabled, false) - const mit = karte({}, {}, { weiter: () => { weiter += 1 } }) - await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]')) - assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt') - assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.') + const ohneZwei = karte() + await fx.klick(fx.finde(ohneZwei, '[data-aktion="fortsetzen"]')) + assert.equal(fx.text(fx.finde(ohneZwei, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.') const unsicher = karte() await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]')) assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation') assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/) })) +test('statusKarte: meldung steht beim Zeichnen am Knopf „Zahlung fortsetzen“ (als Text)', mitDom(undefined, (fx) => { + const wurzel = karte({}, { meldung: 'Deine Zahlung läuft schon. x' }) + assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. x') + assert.equal(fx.alle(wurzel, 'b').length, 0) + assert.equal(fx.text(fx.finde(karte(), '[data-meldung="fortsetzen"]')), '') +})) + test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => { const wurzel = karte({}, { kundenansicht: true }) const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]') @@ -2965,3 +3002,519 @@ test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf, for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`) assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um') }) + +// ------------------------------------------------------- Ansicht #termine nach Zustand und Rückkehr von Stripe (TP2, Task C10b) +// initTermine in public/termine.js. Der Fake-fetch antwortet hier nach Adresse statt der Reihe nach: Antworten von GET /api/funktionen (dort +// steht der Abo-Hinweis, R111) und GET /api/termine/einstellungen liefert der Test. Ein Array in der Tabelle sind aufeinanderfolgende Antworten +// (die letzte gilt weiter). Der Zustand der Huelle (Auswahl des Projekts und des Reiters) bleibt zwischen den Tests im Modul: jeder Test nennt +// sein Projekt (projectId), und kein Test verlangt einen bestimmten Reiter, ohne ihn zu waehlen. +const AN_FUNKTIONEN = 'GET /api/funktionen' +const AN_CHECKOUT = 'POST /api/funktionen/termine/checkout' +const an = (projectId) => `GET /api/termine/einstellungen?projectId=${projectId}` +const SERVER_HINWEIS = 'Dieser Satz steht nur beim Server, so wie der Server ihn schreibt.' +const AUFRUFE = (fx) => fx.fetchLog.map((a) => `${a.method} ${a.url}`) + +function routen(fx, tabelle) { + globalThis.fetch = async (url, optionen = {}) => { + const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined } + fx.fetchLog.push(aufruf) + let eintrag = tabelle[`${aufruf.method} ${aufruf.url}`] + if (eintrag === undefined && aufruf.method === 'GET' && aufruf.url.startsWith('/api/termine/buchungen')) eintrag = { body: { buchungen: [] } } + if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0] + if (typeof eintrag === 'function') eintrag = eintrag(aufruf) + const { status = 200, body = {} } = eintrag ?? { status: 404, body: { error: `nicht vorgesehen: ${aufruf.method} ${aufruf.url}` } } + return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) } + } +} + +/** Ansicht öffnen (Adresse mit hash, Tabelle der Antworten) und warten, bis auch die Reiter gezeichnet sind. => Wurzel der Ansicht. */ +async function oeffneAnsicht(fx, { projectId = 'p1', hash = '', tabelle }) { + routen(fx, tabelle) + fx.window.location.hash = hash + const wurzel = h('div') + document.body.append(wurzel) + await huelle.initTermine(wurzel, { projectId }) + await fx.ruhe() + return wurzel +} + +const projektEintrag = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, termine: { state, ...extra } } }) +const GEORDERT = { orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null } +const funktionenAntwort = (projekte, termine = {}) => ({ + body: { projects: projekte, termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: EINRICHTUNG, hinweis: null, ...termine } }, +}) +const ohneAbo = (grund, hinweis = SERVER_HINWEIS) => ({ bestellbar: false, grund, hinweis }) +const ABGESCHALTET = { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' } + +/** Antwort von GET /api/termine/einstellungen im jeweiligen Zustand (off: Entwurf mit Abo, ordered: dazu die Sicht der Bestellung ohne Abo). */ +async function ansichtDaten(state, extra = {}) { + const basis = { ...fbDaten({ state }), abo: null, offen: null, bestellung: null, feeds: {}, anfragenOffen: 0, aktivSeit: null, abgeschaltet: null } + if (state === 'off') return { ...basis, ...fbDaten(), ...extra } + if (state === 'ordered') return { ...basis, offen: [], bestellung: await bestellungTest(), ...extra } + if (state === 'active') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', einbau: { snippet: '' }, ...extra } + if (state === 'disabled') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', abgeschaltet: ABGESCHALTET, ...extra } + return { ...basis, ...extra } +} +const reiterTexte = (fx, wurzel) => fx.alle(wurzel, '[role="tab"]').map((tab) => fx.text(tab)) +const meldungDerHuelle = (fx, wurzel) => fx.text(fx.finde(wurzel, '.tm-meldung')) +/** Antwort-Tabelle für ein Projekt p1 im Zustand state mit den Daten daten. */ +async function einProjekt(state, { eintrag = {}, daten = {}, termine = {}, tabelle = {} } = {}) { + return { + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', state, eintrag)], termine), + [an('p1')]: { body: await ansichtDaten(state, daten) }, + ...tabelle, + } +} + +test('Ansicht off mit laufendem Abo: der Fragebogen (Fortschrittsleiste im Baum), weder Hinweis noch Reiter', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { fragebogen: fbBis(1) } }) }) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt ol.tm-schritte'), 'renderFragebogen hat gezeichnet') + assert.equal(fbSchritt(fx, wurzel), 'wer', 'Start beim ersten offenen Schritt des Entwurfs') + assert.ok(fx.finde(wurzel, '[data-hinweis="abo"]') === null) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')]) +})) + +test('Ansicht off: ohne Entwurf die Branchenwahl; die Meldung der Hülle (ctx.meldung) steht über dem Fragebogen', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { + daten: { einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO }, + tabelle: { + 'POST /api/termine/einstellungen/vorlage': { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + [an('p1')]: [{ body: fbStart() }, { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }], + 'PUT /api/termine/fragebogen': fbAntwort(1), + }, + }) }) + assert.equal(fbSchritt(fx, wurzel), 'branche') + assert.equal(meldungDerHuelle(fx, wurzel), '') + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.equal(fbSchritt(fx, wurzel), 'wer') + assert.match(meldungDerHuelle(fx, wurzel), /^Die Branche „Friseur und Salon“ ist angelegt\./, 'ohne meldungEl bliebe die Meldung stumm') + assert.equal(fx.finde(wurzel, '.tm-meldung').getAttribute('role'), 'status') +})) + +test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link zu Verträge), kein Fragebogen, keine weitere Abfrage', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' } }, termine: ohneAbo('kein_abo') }) }) + const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') + assert.ok(karteEl) + assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert') + const link = fx.alle(karteEl, 'button') + assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']]) + assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel') + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen') + assert.equal(fx.alle(wurzel, 'input').length, 0) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')]) +})) + +test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes abo zeigen nur den Text des Servers, ohne Link', mitDom(undefined, async (fx) => { + const faelle = [ + ['gekuendigt', { ok: false, grund: 'gekuendigt' }, ohneAbo('gekuendigt', 'Server: Dein Wartungs-Abo ist gekündigt.')], + ['nicht_aktiv', { ok: false, grund: 'nicht_aktiv' }, ohneAbo('nicht_aktiv', 'Server: Dein Wartungs-Abo ist gerade nicht aktiv.')], + ['Lesefehler (abo null)', null, { bestellbar: null, grund: null, hinweis: 'Server: Das Abo konnte gerade nicht geprüft werden.' }], + ['abo fehlt in der Antwort', undefined, ohneAbo('gekuendigt', 'Server: ohne abo.')], + ] + for (const [name, abo, termine] of faelle) { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo }, termine }) }) + const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') + assert.ok(karteEl, name) + assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name) + assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name) + } +})) + +test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => { + const bose = ' & "Anführungszeichen"' + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), bose) +})) + +test('Ansicht off ohne Abo: fehlt der Text in der Liste (das Abo hat sich seit dem Laden geändert), wird die Liste noch einmal geholt', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { + daten: { abo: { ok: false, grund: 'kein_abo' } }, + tabelle: { [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'off')]), funktionenAntwort([projektEintrag('p1', 'off')], ohneAbo('kein_abo'))] }, + }) }) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN]) + assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), `${SERVER_HINWEIS} Zu Verträge`) + // bleibt der Text auch dann aus, steht nur die Überschrift (kein erfundener Ersatztext) + const leer = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } } }) }) + assert.ok(fx.finde(leer, '[data-hinweis="abo"] p') === null) + assert.equal(fx.text(fx.finde(leer, '[data-hinweis="abo"] h3')), 'Terminplaner einrichten') +})) + +test('Ansicht off: in der Kundenansicht gilt dasselbe (Fragebogen mit Hinweis der Kundenansicht, ohne Abo der Hinweis)', mitDom(undefined, async (fx) => { + const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true } }) }) + assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/) + const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true, abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt') }) }) + assert.ok(fx.finde(ohne, '[data-hinweis="abo"]')) + assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null) +})) + +test('Ansicht active ohne Einstellung: weiter die Vorlagenauswahl von TP1 (kein Fragebogen), mit Einstellung die vier Reiter', mitDom(undefined, async (fx) => { + const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { einstellung: null, version: 0, fragebogen: null } }) }) + assert.equal(fx.alle(ohne, 'input[name="termine-vorlage"]').length, 1) + assert.ok(fx.text(ohne).includes('Womit möchtest du starten?')) + assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null) + assert.ok(fx.finde(ohne, '.tm-reiterleiste') === null) + const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('active') }) + assert.deepEqual(reiterTexte(fx, mit), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(mit, '.tm-statuskarte') === null) + assert.ok(fx.finde(mit, '.tm-hinweise .tm-box-warn') === null, 'weder Abschaltung noch fehlende Angaben') +})) + +test('Ansicht active: fehlende Pflichtangaben zeigen den Hinweis „Noch nicht buchbar“ weiter wie bisher', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { fehlt: ['betrieb.telefon'] } }) }) + assert.match(fx.text(fx.finde(wurzel, '.tm-hinweise .tm-box-warn')), /Noch nicht buchbar/) +})) + +for (const [state, pille, ticket] of [['ordered', 'Zahlung offen', null], ['setup', 'In Einrichtung', 100342]]) { + test(`Ansicht ${state}: Statuskarte „${pille}“ und die Reiter Leistungen, Team & Plätze, Einstellungen nur zum Lesen, ohne Reiter „Termine“`, mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt(state, { + eintrag: { ...GEORDERT, ticketWoid: ticket, invoiceUrl: state === 'setup' ? 'https://invoice.stripe.test/i/in_1' : null }, + daten: { fehlt: ['betrieb.telefon'], aktivSeit: null }, + }) }) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl) + assert.equal(karteEl.getAttribute('data-state'), state) + assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), pille) + assert.match(fx.text(karteEl), /Bestellt am\s*05\.10\.2026/, 'Bestelldatum aus dem Eintrag der Liste') + if (state === 'setup') { + assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/) + assert.match(fx.text(karteEl), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`), 'Einrichtungsdauer aus GET /api/funktionen') + } + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen') + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(wurzel, '#tm-tab-termine') === null) + assert.ok(fx.finde(wurzel, '.tm-hinweise .tm-box-warn') === null, 'weder Banner der Abschaltung noch „Noch nicht buchbar“') + // jeder Reiter ist schreibgeschützt: Felder gesperrt, keine Knöpfe zum Ändern + for (const [key, ohneKnopf] of [['leistungen', '[data-werkzeug="leistung-neu"]'], ['team', '[data-werkzeug="ressource-neu"]'], ['einstellungen', 'button[type="submit"]']]) { + await fx.klick(fx.finde(wurzel, `#tm-tab-${key}`)) + await fx.ruhe() + const panel = fx.finde(wurzel, '#tm-panel') + assert.ok(fx.finde(panel, ohneKnopf) === null, `${key}: nichts zum Ändern`) + assert.ok(fx.finde(panel, 'fieldset[disabled]') || key !== 'einstellungen', `${key}: gesperrt`) + assert.ok(!fx.text(panel).includes('null'), `${key}: kein „null“ als Text (replaceChildren mit null)`) + } + // „Export und Status“ des Reiters Einstellungen sagt vor der Freischaltung nicht „Aktiv“ + assert.ok(fx.finde(fx.finde(wurzel, '#tm-panel'), '.tm-status') === null) + assert.equal(fx.alle(fx.finde(wurzel, '#tm-panel'), 'a').length, 0, 'noch nichts zum Exportieren') + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'der Reiter „Termine“ wird nicht gelesen (der Server antwortete mit 409)') + })) +} + +test('Ansicht ordered/setup: die Pfeiltasten wandern nur durch die drei Reiter, ein gemerkter Reiter „Termine“ fällt auf den ersten zurück und bleibt für andere Websites gemerkt', mitDom(undefined, async (fx) => { + const tabelle = { + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'active'), projektEintrag('p2', 'ordered', GEORDERT)]), + [an('p1')]: { body: await ansichtDaten('active') }, + [an('p2')]: { body: await ansichtDaten('ordered') }, + } + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', tabelle }) + await fx.klick(fx.finde(wurzel, '#tm-tab-termine')) + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2') + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'der erste Reiter, den es gibt') + await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowLeft' }) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'einstellungen', 'Pfeil links vom ersten Reiter springt zum letzten') + await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowRight' }) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen') + // zurück zur aktiven Website: der zuvor gewählte Reiter „Termine“ ist wieder da + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'die Wahl in p2 gilt ab jetzt') +})) + +test('Ansicht ordered in der Kundenansicht: „Zahlung fortsetzen“ ist gesperrt', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { eintrag: GEORDERT, daten: { kundenansicht: true } }) }) + assert.equal(fx.finde(wurzel, '[data-aktion="fortsetzen"]').disabled, true) +})) + +test('Ansicht ordered/setup ohne Einstellung: nur die Statuskarte, keine Reiter', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('setup', { eintrag: GEORDERT, daten: { einstellung: null, version: 0 } }) }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.ok(!fx.text(wurzel).includes('null')) +})) + +test('Ansicht disabled: Banner der Abschaltung (hinweisAbgeschaltet) mit Löschdatum aus abgeschaltet.loeschungAm und Export-Link, alle vier Reiter, unverändert (E11)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled') }) + const text = ansicht.abschaltText(ABGESCHALTET) + const banner = fx.finde(wurzel, '.tm-hinweise .tm-box-warn') + assert.ok(banner) + assert.equal(fx.text(banner), `${text.davor}exportieren${text.danach}`) + assert.match(fx.text(banner), /03\.11\.2026|2026-11-03/, 'das Löschdatum aus loeschungAm') + assert.deepEqual(fx.alle(banner, 'a').map((a) => [fx.text(a), a.getAttribute('href')]), [['exportieren', '/api/termine/export.csv?projectId=p1']]) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null) + await fx.klick(fx.finde(wurzel, '#tm-tab-einstellungen')) + await fx.ruhe() + assert.match(fx.text(fx.finde(wurzel, '.tm-status')), /Abgeschaltet/, 'Export und Status bleiben in disabled') +})) + +test('Ansicht disabled ohne Einstellung: Banner und „Keine Termine eingerichtet“ wie bisher', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled', { daten: { einstellung: null, version: 0 } }) }) + assert.ok(fx.finde(wurzel, '.tm-box-warn[role="note"]')) + assert.ok(fx.text(wurzel).includes('Keine Termine eingerichtet')) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) +})) + +test('Ansicht: der Zustand der Daten gilt, auch wenn die Liste einen anderen kennt (Liste: off, Daten: active)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: { [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off')]), [an('p1')]: { body: await ansichtDaten('active') } } }) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) +})) + +test('Ansicht ordered: kennt die Liste einen älteren Zustand als die Daten (nach der Zahlung), wird sie neu geholt und die Karte zeigt die Ticketnummer', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: { + [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])], + [an('p1')]: { body: await ansichtDaten('setup') }, + } }) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN]) + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung') + assert.match(fx.text(fx.finde(wurzel, '.tm-statuskarte')), /Ticketnummer\s*#100342/) + // lässt sich die Liste nicht holen, gilt der Zustand der Daten, ohne Ticket + const ohne = await oeffneAnsicht(fx, { tabelle: { + [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), { status: 500, body: {} }], + [an('p1')]: { body: await ansichtDaten('setup') }, + } }) + assert.equal(fx.text(fx.finde(ohne, '.tm-statuskarte .tm-pille')), 'In Einrichtung') + assert.match(fx.text(fx.finde(ohne, '.tm-statuskarte')), /Wir legen dein Ticket gerade an\./) + assert.ok(fx.finde(ohne, '[data-aktion="fortsetzen"]') === null, 'kein „Zahlung fortsetzen“ für eine bezahlte Bestellung') +})) + +test('Ansicht: ein Fehler beim Laden zeigt die Fehlerkarte mit „Noch einmal versuchen“, das lädt neu', mitDom(undefined, async (fx) => { + const tabelle = await einProjekt('off', { tabelle: { [an('p1')]: [{ status: 500, body: { error: 'Der Server hat gerade Pause.' } }, { body: await ansichtDaten('off') }] } }) + const wurzel = await oeffneAnsicht(fx, { tabelle }) + assert.equal(fx.text(fx.finde(wurzel, '[role="alert"] p')), 'Der Server hat gerade Pause.') + await fx.klick(fx.finde(wurzel, '[role="alert"] button')) + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt')) +})) + +// ------------------------------------------------------- Rückkehr von Stripe +const zweiProjekte = async (zustandP2 = 'ordered', extraP2 = GEORDERT) => ({ + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off'), projektEintrag('p2', zustandP2, extraP2)]), + [an('p1')]: { body: await ansichtDaten('off') }, + [an('p2')]: { body: await ansichtDaten(zustandP2) }, +}) +/** Aufrufe von history.replaceState mitschreiben. */ +function merkeAdressen(fx) { + const adressen = [] + const echt = fx.window.history.replaceState + fx.window.history.replaceState = (zustand, titel, url) => { adressen.push(url); echt(zustand, titel, url) } + return adressen +} + +test('Rückkehr nach der Zahlung: #termine?bestellt=1&projekt=p2 wählt p2, zeigt „Danke!“, bereinigt die Adresse (einmal gelesen)', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2', 'das Projekt der Rückkehr (vor dem gemerkten p1)') + assert.equal(fx.text(fx.finde(wurzel, '[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.') + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'Zahlung offen') + assert.deepEqual(adressen, ['/dashboard.html#termine'], 'Pfad und Query bleiben, der Hash bis auf #termine weg') + assert.equal(fx.window.location.hash, '#termine') + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p2')], 'nur das Projekt der Rückkehr wird geladen') + // ein zweiter Aufruf (Hash ist bereinigt) kennt weder Rückkehr noch Danke, behält aber die Auswahl + const wieder = await oeffneAnsicht(fx, { projectId: '', hash: fx.window.location.hash, tabelle: await zweiProjekte() }) + assert.ok(fx.finde(wieder, '[data-danke]') === null) + assert.equal(fx.finde(wieder, '#termine-project').value, 'p2') +})) + +test('Rückkehr nach der Zahlung, wenn der Webhook schon durch ist (setup): „Danke!“ auch dann', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('setup', { ...GEORDERT, ticketWoid: 100342 }) }) + assert.ok(fx.finde(wurzel, '[data-danke]')) + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung') +})) + +test('Rückkehr nach dem Abbruch: #termine?projekt=p2 wählt p2 ohne „Danke!“, die Adresse ist bereinigt; „Zahlung fortsetzen“ ist da', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?projekt=p2', tabelle: await zweiProjekte() }) + assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2') + assert.ok(fx.finde(wurzel, '[data-danke]') === null) + assert.ok(!fx.text(wurzel).includes('Danke!')) + assert.ok(fx.finde(wurzel, '[data-aktion="fortsetzen"]')) + assert.deepEqual(adressen, ['/dashboard.html#termine']) + assert.equal(fx.window.location.hash, '#termine') +})) + +test('Rückkehr: bestellt=1 ohne Projekt gilt dem gewählten Projekt; ein unbekanntes Projekt wird ignoriert (kein „Danke!“ für ein anderes)', mitDom(undefined, async (fx) => { + const ohneProjekt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(ohneProjekt, '[data-danke]')) + assert.equal(fx.window.location.hash, '#termine') + const unbekannt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=gibtsnicht', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(unbekannt, '[data-danke]') === null) + assert.equal(fx.finde(unbekannt, '#termine-project').value, 'p2', 'es bleibt bei der gewählten Website') + assert.equal(fx.window.location.hash, '#termine') +})) + +test('Rückkehr: „Danke!“ nur für das Projekt der Zahlung und nur beim ersten Zeichnen, nicht nach einem Wechsel der Website', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(wurzel, '[data-danke]')) + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 ist die Website im Zustand off') + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2') + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '[data-danke]') === null, 'der Dank war einmal') +})) + +test('Rückkehr: bestellt=1 für ein Projekt, das nicht bestellt ist (active, off), zeigt kein „Danke!“; fremde Hashes werden nicht angefasst', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const aktiv = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('active', {}) }) + assert.ok(fx.finde(aktiv, '[data-danke]') === null) + assert.ok(!fx.text(aktiv).includes('Danke!')) + assert.equal(adressen.length, 1) + const fremd = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#news?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(fremd, '[data-danke]') === null, 'die Rückkehr der News gehört nicht hierher') + assert.equal(adressen.length, 1, 'und ihr Hash bleibt stehen') + assert.equal(fx.window.location.hash, '#news?bestellt=1&projekt=p2') +})) + +test('Rückkehr: Projekt-ID aus der Adresse landet nur als Wert in der Anfrage (codiert), nie im Baum', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=%3Cimg%20src%3Dx%3E', tabelle: await zweiProjekte() }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + assert.ok(!AUFRUFE(fx).some((a) => a.includes(' projektEintrag('p1', 'ordered', GEORDERT) +const fortsetzenKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="fortsetzen"]') + +test('R110: 409 zustimmung_fehlt ⇒ Liste und Daten werden neu geholt und das Bestellformular steht an der Stelle der Karte', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }, + }) }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')]) + assert.deepEqual(fx.fetchLog[2].body, { projectId: 'p1', fortsetzen: true }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null, 'die Karte ist weg') + const seite = fx.finde(wurzel, '.tm-bestellen') + assert.ok(seite, 'das Bestellformular') + assert.equal(fx.text(bestellKnopf(fx, wurzel)), (await bestellungTest()).consent.knopf, 'Knopftext aus der Antwort des Servers') + assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'gesperrt, bis alle Haken stehen') + assert.equal(hakenFelder(fx, seite).length, registerHaken().length) + assert.equal(meldungDerHuelle(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.') + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'die Reiter bleiben, nur lesbar') + // „Ändern“ führt nicht in den (gesperrten) Fragebogen, sondern erklärt, warum nicht + await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"]')) + assert.match(meldungDerHuelle(fx, wurzel), /seit der Bestellung gesperrt/) + // nach dem Setzen aller Haken geht die neue Zustimmung an den Checkout und es folgt die Zahlungsseite + routen(fx, { [AN_CHECKOUT]: { body: { url: KNOPF_URL } } }) + await alleAnhaken(fx, seite) + assert.equal(bestellKnopf(fx, wurzel).disabled, false) + await fx.klick(bestellKnopf(fx, wurzel)) + const bestellt = fx.fetchLog.at(-1) + assert.equal(bestellt.url, '/api/funktionen/termine/checkout') + assert.equal(bestellt.body.zustimmung.version, (await bestellungTest()).consent.version) + assert.equal(fx.window.location.href, KNOPF_URL) +})) + +test('R110: 400 (die Bestellung ist inzwischen bezahlt, der Server kennt sie nicht mehr als offen) ⇒ die Ansicht folgt dem neuen Zustand, die Karte steht nicht veraltet da', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])], + [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('setup') }], + }, + }) }) + const alteKarte = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl !== alteKarte, 'neu gezeichnet') + assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), 'In Einrichtung') + assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/) + assert.ok(fortsetzenKnopf(fx, wurzel) === null) + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null, 'kein Bestellformular für eine bezahlte Bestellung') + assert.equal(meldungDerHuelle(fx, wurzel), '', 'der Fehler des alten Zustands steht nicht in der neuen Karte') + assert.ok(!fx.text(wurzel).includes('stimme der Erklärung')) +})) + +test('R110: ist die Funktion nach dem Webhook aktiv, zeigt die Ansicht die vier Reiter statt der Karte', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'active')])], + [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('active') }], + }, + }) }) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.equal(meldungDerHuelle(fx, wurzel), '') +})) + +test('R110: ein anderer Fehler bei noch offener Bestellung (409, die Sitzung läuft schon) ⇒ die Karte wird neu gezeichnet und der Fehler steht am Knopf', mitDom(undefined, async (fx) => { + const FEHLER = 'Deine Zahlung läuft gerade oder ist schon da. Wir richten den Terminplaner ein, sobald sie bei uns ist.' + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: FEHLER } } }, + }) }) + const alteKarte = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')]) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl !== alteKarte) + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), FEHLER) + assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false, 'der Knopf ist frei') + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) +})) + +test('R110: lässt sich die Ansicht nicht neu laden, bleibt die Karte stehen und der Fehler steht am Knopf (bei fehlender Zustimmung der Grund des Neuladens)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: [{ status: 409, body: { error: 'Deine Zahlung läuft gerade.' } }, { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }], + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), { status: 500, body: { error: 'Der Server hat gerade Pause.' } }], + }, + }) }) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl, 'dieselbe Karte') + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft gerade.') + assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl) + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Der Server hat gerade Pause.') + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) +})) + +test('R110: wechselt der Nutzer die Website, während neu geladen wird, zeichnet die späte Antwort nichts mehr', mitDom(undefined, async (fx) => { + let freigeben + const spaet = new Promise((fertig) => { freigeben = fertig }) + const tabelle = { ...(await zweiProjekte()), [AN_CHECKOUT]: { status: 409, body: { error: 'Deine Zahlung läuft gerade.' } } } + const wurzel = await oeffneAnsicht(fx, { projectId: 'p2', tabelle }) + // ab jetzt hängt das Laden der Daten von p2, bis der Test es freigibt + const antwort = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (String(url) !== '/api/termine/einstellungen?projectId=p2') return antwort(url, optionen) + fx.fetchLog.push({ method: 'GET', url: String(url), body: undefined }) + await spaet + return { ok: true, status: 200, json: async () => structuredClone(await ansichtDaten('setup')) } + } + const klick = fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 steht da') + freigeben() + await klick + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'die späte Antwort für p2 hat p1 nicht überschrieben') + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) +})) diff --git a/test/termineView.test.js b/test/termineView.test.js index 75b619e..0937957 100644 --- a/test/termineView.test.js +++ b/test/termineView.test.js @@ -312,6 +312,8 @@ test('Portal: Ansicht #termine ist verdrahtet, funktionen.js lädt keine Terminp const fn = lies('public/funktionen.js') assert.deepEqual(fn.match(/from '\/[^']+'/g), ["from '/analyse-view.js'", "from '/geraete.js'"]) assert.doesNotMatch(fn, /from '\/termine/) + // dazu genau ein relativer Import: die gemeinsame Quelle des Links „Zu Verträge“ (Reiner Datenmodul, kein Terminplaner-Modul; C10b) + assert.deepEqual(fn.match(/from '\.[^']+'/g), ["from './vertraege-link.js'"]) assert.doesNotMatch(fn, /import\s*\(/, 'auch kein dynamischer Import') }) @@ -365,10 +367,33 @@ test('Ansicht-Module: Reiter sind Module mit render(panel, ctx), die Huelle kenn } }) +test('Ansicht nach Zustand (TP2 C10b): termine.js lädt Fragebogen, Statuskarte und die Quelle des Links relativ, kennt keinen Abo-Text und liest die Rückkehr von Stripe einmal', () => { + const ohneKommentare = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|\s)\/\/.*$/gm, '') + const huelle = ohneKommentare(lies('public/termine.js')) + assert.ok(huelle.includes("import { renderFragebogen } from './termine-fragebogen.js'")) + assert.ok(huelle.includes("import { renderBestellen, statusKarte } from './funktion-bestellen.js'")) + assert.ok(huelle.includes("import { ZU_VERTRAEGE } from './vertraege-link.js'")) + assert.doesNotMatch(huelle, /from '\/|import\s*\(/, 'nur relative, statische Importe') + // R111: der Abo-Hinweis kommt vom Server (GET /api/funktionen, termine.hinweis), nie als Text aus dem Browser; R112: die alten Texte sind weg + assert.doesNotMatch(huelle, /Wartungs-Abo|gekündigt|nicht aktiv|nicht geprüft|brauchst du ein laufendes/, 'keine Kopie der Abo-Hinweise') + assert.doesNotMatch(huelle, /noch nicht freigeschaltet|Wir schalten den Terminplaner für dich frei/, 'die Texte der Ansicht vor C10b') + assert.match(huelle, /s\.termine\??\.hinweis/, 'der Text kommt aus der Antwort von /api/funktionen') + assert.match(huelle, /ZU_VERTRAEGE\.view/, 'der Link nimmt Ziel, Text und Klasse aus der gemeinsamen Quelle') + assert.doesNotMatch(huelle, /'vertraege'|"vertraege"|Zu Verträge/, 'und schreibt sie nicht noch einmal hin') + // Rückkehr von Stripe: die Adresse wird an genau einer Stelle gelesen und bereinigt + assert.equal(huelle.match(/history\.replaceState/g).length, 1) + assert.equal(huelle.match(/location\.hash/g).length, 1) + assert.match(huelle, /bestellt'\) === '1'/, 'danke nur mit bestellt=1') + // die Quelle des Links ist reine Daten ohne Import + const link = lies('public/vertraege-link.js') + assert.doesNotMatch(ohneKommentare(link), /\bimport\b/) + assert.match(link, /export const ZU_VERTRAEGE = Object\.freeze\(\{ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' \}\)/) +}) + test('Fragebogen (TP2 C9a): renderFragebogen wird nur über termine.js geladen, nie absolut, nie von einem Reiter oder von funktionen.js', () => { const dateien = readdirSync(new URL('../public/', import.meta.url)).filter((f) => /\.(js|html)$/.test(f)) const ladende = dateien.filter((f) => /termine-fragebogen\.js/.test(lies(`public/${f}`))) - assert.ok(ladende.every((f) => f === 'termine.js'), `nur die Huelle darf den Fragebogen laden: ${ladende.join(', ')}`) + assert.deepEqual(ladende, ['termine.js'], `nur die Huelle darf den Fragebogen laden (C10b verdrahtet ihn): ${ladende.join(', ')}`) for (const f of dateien) assert.doesNotMatch(lies(`public/${f}`), /['"]\/termine-fragebogen\.js['"]/, `${f}: kein absoluter Import`) assert.match(lies('public/termine-fragebogen.js'), /export function renderFragebogen\(root, ctx\)/) }) diff --git a/test/ui/termine-portal.ui.mjs b/test/ui/termine-portal.ui.mjs index 4dd2255..b9820f8 100644 --- a/test/ui/termine-portal.ui.mjs +++ b/test/ui/termine-portal.ui.mjs @@ -68,6 +68,21 @@ function portalHilfe(p, json) { return json({}) } +// Branchen und Bestellsicht, wie GET /api/termine/einstellungen sie liefert (Server: BRANCHEN, bestellungAnsicht); feste Texte der Fixture +const FX_BRANCHEN = [ + { key: 'salon', name: 'Friseur und Salon', gesundheit: false }, + { key: 'kosmetik', name: 'Kosmetik und Wellness', gesundheit: false }, + { key: 'gesundheit', name: 'Gesundheitsbereich', gesundheit: true }, +] +const FX_BESTELLUNG = { + preis: '149 €', + preisHinweis: 'ohne Umsatzsteuer gemäß § 19 UStG', + einrichtung: 'meist innerhalb von 1–2 Wochen', + consent: { version: 'fx-1', knopf: 'Zahlungspflichtig bestellen · 149 €', items: [{ id: 'verfuegbar', text: 'Ich bestelle den Terminplaner verbindlich.', haken: true }] }, + dokumente: [], + belehrung: 'Widerrufsbelehrung der Fixture. Du hast das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.', +} + /** Fixture: Daten, die page.route statt der Schnittstellen /api/funktionen und /api/termine liefert. */ function neueFixture() { const fx = { @@ -82,14 +97,25 @@ function neueFixture() { { key: 'beratung', name: 'Beratung und Coaching', beschreibung: 'Gespräche vor Ort oder online.' }, ], fehlt: [], + fragebogen: { schema: 1, branche: 'salon', bestaetigt: [], bundeslandGewaehlt: false, preiseGeprueft: false, einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' } }, + offen: [], + checkout: { status: 200, body: { url: 'https://checkout.stripe.test/c/pay/cs_test_fx' } }, // Antwort auf POST /api/funktionen/termine/checkout anfragenOffen: 0, buchungen: [], aufrufe: [], } + // GET /api/termine/einstellungen. off: Entwurf mit Abo (abo), offen und Bestellsicht; ordered: Bestellsicht und offen, aber kein abo (R110) fx.daten = (state = fx.projekte[0].features.termine.state) => ({ state, aktivSeit: '2026-10-01T09:00:00.000Z', einstellung: fx.einstellung, version: fx.einstellung ? 3 : 0, vorlagen: fx.vorlagen, feeds: {}, siteKey: KEY, einbau: { snippet: '' }, anfragenOffen: fx.buchungen.filter((b) => b.status === 'angefragt').length || fx.anfragenOffen, fehlt: fx.fehlt, abgeschaltet: state === 'disabled' ? fx.abgeschaltet ?? { grund: null, seit: null, loeschungAm: null } : null, + branchen: FX_BRANCHEN, + fragebogen: fx.fragebogen, + kundenansicht: false, + ...(fx.einstellung ? {} : { betriebVorschlag: { name: 'Salon Müller', adresse: 'Hauptstr. 1, 67722 Winnweiler', email: 'info@salon-mueller.example', telefon: '', websiteUrl: '' } }), + abo: state === 'off' ? (fx.bestellbar === true ? { ok: true } : fx.bestellbar === false ? { ok: false, grund: fx.aboGrund } : null) : null, + offen: state === 'off' || state === 'ordered' ? fx.offen : null, + bestellung: state === 'off' || state === 'ordered' ? FX_BESTELLUNG : null, }) return fx } @@ -152,10 +178,14 @@ async function fixtureAntwort(fx, route, req, url, json) { : { verfuegbar: false } return json({ projects: fx.projekte, termine }) } + if (p === '/api/funktionen/termine/checkout' && m === 'POST') { + fx.aufrufe.push(['checkout', req.postDataJSON()]) + return json(fx.checkout.body, fx.checkout.status) + } if (p === '/api/termine/einstellungen' && m === 'GET') { const projekt = q.get('projectId') if (fx.verzoegerung?.[projekt]) await new Promise((fertig) => { setTimeout(fertig, fx.verzoegerung[projekt]) }) - const daten = fx.daten() + const daten = fx.daten(fx.projekte.find((p) => p.projectId === projekt)?.features?.termine?.state) if (fx.ohneEinstellungFuer?.includes(projekt)) Object.assign(daten, { einstellung: null, version: 0 }) return json(daten) } @@ -268,7 +298,7 @@ const formular = (page, name) => page.getByRole('form', { name }) const fehlerAm = (loc, pfad) => loc.locator(`[data-fehler="${pfad}"]`) const warteAnsicht = async (page) => { await page.locator('#view-termine.active').waitFor() - await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info').first().waitFor() + await page.locator('#termine-body .tm-reiterleiste, #termine-body .tm-vorlagen, #termine-body .tm-info, #termine-body .tm-fragebogen, #termine-body .tm-statuskarte').first().waitFor() } // ---- Gaeste ueber die oeffentliche Schnittstelle buchen (echte Pruefungen): Link-Token aus der Mail @@ -448,11 +478,12 @@ async function pruefungen() { await warteAnsicht(page) await page.locator('.tm-reiterleiste').waitFor() eq(await page.locator('#termine-project').inputValue(), 'p2', 'Auswahl: die Website der Kachel') - // andere Website ohne Terminplaner: Hinweis statt Reiter + // andere Website, Terminplaner nicht bestellt (Aus, das Abo läuft): der Fragebogen statt der Reiter (C10b ersetzt den Hinweis „noch nicht freigeschaltet“) await page.locator('#termine-project').selectOption('p1') - await page.getByText('noch nicht freigeschaltet').waitFor() + await page.locator('#termine-body .tm-fragebogen').waitFor() eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter') - await knopf(page.locator('#termine-body'), 'Chat öffnen').waitFor() + assert(!(await textVon(page.locator('#termine-body'))).includes('noch nicht freigeschaltet'), 'der alte Text ist weg') + eq(await page.locator('#termine-body [data-view="chat"]').count(), 0, 'kein Chat-Knopf der alten Ansicht') await shot(page, 'projektwahl') await page.locator('#termine-project').selectOption('p2') await page.locator('.tm-reiterleiste').waitFor() @@ -473,6 +504,171 @@ async function pruefungen() { })(), }) + // ------------------------------------------------------------ Ansicht nach Zustand (TP2 C10b) + /** Fixture mit einer Website im Zustand state (Eintrag der Liste wie GET /api/funktionen ihn liefert). */ + const imZustand = (state, extra = {}) => { + const fx = neueFixture() + fx.projekte[0].features.termine = { state, ...extra } + return fx + } + const GEORDERT = { orderedAt: '2026-10-04T08:00:00.000Z', ticketWoid: null, invoiceUrl: null } + + await check('fx_ansicht_aus_fragebogen_und_hinweis', async (page) => { + const fx = page.fx + // das Abo läuft: der Fragebogen mit Fortschrittsleiste, keine Reiter + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('#termine-body nav.tm-fortschritt').waitFor() + eq(await page.locator('.tm-reiterleiste').count(), 0, 'keine Reiter') + eq(await page.locator('[data-hinweis="abo"]').count(), 0, 'kein Hinweis') + eq(await page.locator('li.tm-schritt[aria-current="step"]').getAttribute('data-schritt'), 'branche', 'Start beim ersten offenen Schritt') + await shot(page, 'ansicht-fragebogen') + // ohne Abo: nur der Hinweis des Servers, bei kein_abo mit dem Link zu den Verträgen; kein Fragebogen + fx.bestellbar = false + for (const [grund, mitLink] of [['kein_abo', true], ['gekuendigt', false], ['nicht_aktiv', false], ['unbekannt', false]]) { + fx.aboGrund = grund === 'unbekannt' ? null : grund + fx.bestellbar = grund === 'unbekannt' ? null : false + await page.reload() + await page.locator('[data-hinweis="abo"]').waitFor() + const hinweis = page.locator('[data-hinweis="abo"]') + assert((await textVon(hinweis)).includes(FX_ABO_HINWEISE[grund]), `${grund}: Text des Servers in ${await textVon(hinweis)}`) + eq(await hinweis.locator('[data-view="vertraege"]').count(), mitLink ? 1 : 0, `${grund}: Link zu Verträge`) + eq(await page.locator('#termine-body nav.tm-fortschritt').count(), 0, `${grund}: kein Fragebogen`) + eq(await page.locator('#termine-body input').count(), 0, `${grund}: keine Felder`) + eq(await page.locator('.tm-reiterleiste').count(), 0, `${grund}: keine Reiter`) + if (grund === 'kein_abo') await shot(page, 'ansicht-ohne-abo') + } + // der Link führt zu den Verträgen + fx.bestellbar = false + fx.aboGrund = 'kein_abo' + await page.reload() + await knopf(page.locator('[data-hinweis="abo"]'), 'Zu Verträge').click() + await page.locator('#view-vertraege.active').waitFor() + }, { fixture: imZustand('off') }) + + for (const [state, pille, extra] of [ + ['ordered', 'Zahlung offen', GEORDERT], + ['setup', 'In Einrichtung', { ...GEORDERT, ticketWoid: 100342, invoiceUrl: 'https://invoice.stripe.test/i/in_1' }], + ]) { + await check(`fx_ansicht_${state}`, async (page) => { + await oeffne(page, '#termine') + await warteAnsicht(page) + const karte = page.locator('.tm-statuskarte') + await karte.waitFor() + eq(await textVon(karte.locator('.tm-pille')), pille, 'Pille') + assert((await textVon(karte)).includes('Bestellt am'), 'Bestelldatum') + if (state === 'setup') assert((await textVon(karte)).includes('#100342'), 'Ticketnummer') + else eq(await karte.locator('[data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen') + eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'drei Reiter, keiner für Termine') + eq(await page.locator('#tm-tab-termine').count(), 0, 'kein Reiter Termine') + eq(await page.locator('.tm-hinweise .tm-box').count(), 0, 'weder Banner der Abschaltung noch „Noch nicht buchbar“') + await shot(page, `ansicht-${state}`) + // schreibgeschützt: Leistungen ohne „+ Leistung anlegen“, Einstellungen gesperrt, ohne Speichern, ohne „Aktiv“-Status und Export + await page.locator('#tm-tab-leistungen').click() + await page.locator('#tm-panel .tm-tab').waitFor() + eq(await page.locator('[data-werkzeug="leistung-neu"]').count(), 0, 'Leistungen: nichts zum Anlegen') + assert(!(await textVon(page.locator('#tm-panel'))).split('\n').some((zeile) => zeile.trim() === 'null'), 'Leistungen: kein Text „null“') + await page.locator('#tm-tab-einstellungen').click() + await page.locator('#tm-panel .tm-tab').waitFor() + eq(await page.locator('#tm-panel fieldset[disabled]').count() > 0, true, 'Einstellungen gesperrt') + eq(await page.locator('#tm-panel button[type="submit"]').count(), 0, 'Einstellungen: kein Speichern') + eq(await page.locator('#tm-panel .tm-status').count(), 0, 'kein „Aktiv“-Status vor der Freischaltung') + eq(await page.locator('#tm-panel a[download]').count(), 0, 'kein Export vor der Freischaltung') + eq(page.fx.aufrufe.filter((a) => a[0] === 'liste').length, 0, 'der Reiter Termine wird nicht gelesen') + }, { fixture: imZustand(state, extra) }) + } + + await check('fx_ansicht_bestellt_390', async (page) => { + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + eq(await passtInBreite(page), true, 'kein Streifen rechts') + const kleine = await page.evaluate(() => [...document.querySelectorAll('#view-termine button')] + .filter((e) => e.offsetParent !== null && e.getBoundingClientRect().height < 43.5).map((e) => `${e.textContent.trim().slice(0, 24)} ${Math.round(e.getBoundingClientRect().height)}`)) + eq(kleine, [], 'Knöpfe mindestens 44 px hoch') + await shot(page, '390-ansicht-bestellt') + }, { fixture: imZustand('ordered', GEORDERT), context: { viewport: { width: 390, height: 844 } } }) + + await check('fx_rueckkehr_zahlung', async (page) => { + await oeffne(page, '#termine?bestellt=1&projekt=p2') + await warteAnsicht(page) + await page.locator('.tm-statuskarte [data-danke]').waitFor() + eq(await textVon(page.locator('[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.', 'Danke') + eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Zahlung') + eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt') + await shot(page, 'rueckkehr-zahlung') + // neu geladen: kein Danke mehr, die Auswahl bleibt nicht (Seite neu), aber die Adresse ist sauber + await page.reload() + await warteAnsicht(page) + eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Neuladen') + }, { + fixture: (() => { + const fx = neueFixture() + fx.projekte = [ + { projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } }, + { projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } }, + ] + return fx + })(), + }) + + await check('fx_rueckkehr_abbruch', async (page) => { + await oeffne(page, '#termine?projekt=p2') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + eq(await page.locator('[data-danke]').count(), 0, 'kein Danke nach dem Abbruch') + eq(await page.locator('#termine-project').inputValue(), 'p2', 'Website der Bestellung') + eq(await page.locator('.tm-statuskarte [data-aktion="fortsetzen"]').count(), 1, 'Zahlung fortsetzen') + eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt') + }, { + fixture: (() => { + const fx = neueFixture() + fx.projekte = [ + { projectId: 'p1', projectName: 'Alte Website', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'active' } } }, + { projectId: 'p2', projectName: 'Salon Müller', liveUrl: '', features: { news: { state: 'off' }, termine: { state: 'ordered', ...GEORDERT } } }, + ] + return fx + })(), + }) + + await check('fx_zahlung_fortsetzen_zustimmung_fehlt', async (page) => { + const fx = page.fx + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click() + // die Karte weicht dem Bestellformular (R110), die Reiter bleiben + await page.locator('.tm-bestellen').waitFor() + eq(await page.locator('.tm-statuskarte').count(), 0, 'keine Karte mehr') + eq(fx.aufrufe.filter((a) => a[0] === 'checkout').map((a) => a[1]), [{ projectId: 'p1', fortsetzen: true }], 'Zahlung fortsetzen ohne Zustimmung') + assert((await textVon(meldung(page))).includes('Bitte stimme der Erklärung zu'), 'Meldung der Hülle') + const bestellen = page.locator('[data-aktion="bestellen"]') + eq(await bestellen.isDisabled(), true, 'gesperrt bis zum Haken') + await page.locator('.tm-bestellen input[type="checkbox"]').check() + eq(await bestellen.isDisabled(), false, 'frei nach dem Haken') + eq(await page.getByRole('tab').allInnerTexts(), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'Reiter bleiben') + await shot(page, 'zahlung-fortsetzen-bestellformular') + }, { + fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }), + }) + + await check('fx_zahlung_fortsetzen_inzwischen_bezahlt', async (page) => { + const fx = page.fx + await oeffne(page, '#termine') + await warteAnsicht(page) + await page.locator('.tm-statuskarte').waitFor() + eq(await textVon(page.locator('.tm-statuskarte .tm-pille')), 'Zahlung offen', 'zuerst offen') + // inzwischen kam die Zahlung an (Webhook): der Server kennt keine offene Bestellung mehr (400) + fx.projekte[0].features.termine = { state: 'setup', ...GEORDERT, ticketWoid: 100343 } + await knopf(page.locator('.tm-statuskarte'), 'Zahlung fortsetzen').click() + await page.locator('.tm-statuskarte .tm-pille', { hasText: 'In Einrichtung' }).waitFor() + assert((await textVon(page.locator('.tm-statuskarte'))).includes('#100343'), 'Ticketnummer der frischen Liste') + eq(await page.locator('[data-aktion="fortsetzen"]').count(), 0, 'kein Knopf mehr') + eq(await page.locator('.tm-bestellen').count(), 0, 'kein Bestellformular') + }, { + fixture: Object.assign(imZustand('ordered', GEORDERT), { checkout: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }), + }) + // ------------------------------------------------------------ Reiter-Geruest await check('fx_reiter_geruest', async (page) => { await oeffne(page, '#termine') From 1ac142b92fbac1e51b00526bbf60b7f1b66d7e9b Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:34:38 +0000 Subject: [PATCH 03/28] Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick Co-Authored-By: Claude Sonnet 5.5 --- test/ui/termine-fragebogen.ui.mjs | 739 ++++++++++++++++++++++++++++++ test/ui/termineTestServer.mjs | 142 +++++- 2 files changed, 876 insertions(+), 5 deletions(-) create mode 100644 test/ui/termine-fragebogen.ui.mjs diff --git a/test/ui/termine-fragebogen.ui.mjs b/test/ui/termine-fragebogen.ui.mjs new file mode 100644 index 0000000..914246a --- /dev/null +++ b/test/ui/termine-fragebogen.ui.mjs @@ -0,0 +1,739 @@ +// Playwright-Checks Terminplaner TP2, Verkauf: vom Fragebogen bis zum Bestellklick (public/funktionen.js Kachel, termine.js Huelle, +// termine-fragebogen.js Schritte, funktion-bestellen.js Bestellseite und Statuskarte). Nicht Teil von npm test. +// Aufruf: NODE_OPTIONS=--max-old-space-size=1024 nice node test/ui/termine-fragebogen.ui.mjs (vorher free -m; es laeuft nur ein Browser) +// Screenshots: /root/reports/terminplaner-ui/fragebogen-*.png (bei Fehlern fehler-fragebogen-.png). Exit-Code 1, wenn eine Pruefung scheitert. +// +// Alles laeuft gegen die ECHTEN Router (/api/termine, /api/funktionen) hinter dem Nachbau von Appwrite und Stripe (test/ui/termineTestServer.mjs); +// nur die uebrigen Portal-Schnittstellen (/api/auth/me usw.) mockt page.route, und die Zahlungsseite (https://checkout.stripe.test/...) faengt +// page.route ab: dorthin geht kein Netzverkehr. Hosts ausser 127.0.0.1, localhost und checkout.stripe.test sind Fehler, ebenso Seitenfehler und +// Browser-Dialoge. Uhr: Server und Browser stehen auf Montag 2026-10-05 10:00 Berliner Zeit (context.clock.setFixedTime). +// NUR= fuehrt nur passende Pruefungen aus (zum Entwickeln); STACK=1 zeigt bei Fehlern den Aufrufweg. +import { mkdir, readdir, rm } from 'node:fs/promises' +import path from 'node:path' +import { startTermineUiServer, salonEingabe, FEATURE, KUNDE, PROJEKT } from './termineTestServer.mjs' + +const PW = process.env.PLAYWRIGHT_CORE || '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs' +const { chromium } = await import(PW) + +const SHOTS = '/root/reports/terminplaner-ui' +await mkdir(SHOTS, { recursive: true }) +for (const datei of await readdir(SHOTS)) if (/^(fragebogen|fehler-fragebogen)-.*\.png$/.test(datei)) await rm(path.join(SHOTS, datei)) // alte Laeufe + +const srv = await startTermineUiServer() +// erst nach dem Start des Testservers importieren (config.js liest die Umgebung beim ersten Import) +const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js') +const { aboMeldung } = await import('../../server/services/featureOrder.js') +const { leseZustimmung, nachweisTexte, texteHash, zustimmungsfassungen } = await import('../../server/services/featureConsents.js') +const REGISTER = featureConfig('termine') +const JETZT = new Date('2026-10-05T08:00:00.000Z') +const ERLAUBTE_HOSTS = new Set(['127.0.0.1', 'localhost', 'checkout.stripe.test']) +const STRIPE_SEITE = /^https:\/\/checkout\.stripe\.test\/c\/pay\/(cs_test_\w+)$/ + +// ---------------------------------------------------------------- Helfer + +function assert(cond, msg) { if (!cond) throw new Error(msg) } +function eq(ist, soll, msg) { + const a = JSON.stringify(ist) + const b = JSON.stringify(soll) + if (a !== b) throw new Error(`${msg}: ${a} statt ${b}`) +} +const pause = (ms) => new Promise((fertig) => { setTimeout(fertig, ms) }) + +/** Screenshot der ganzen Seite: das Portal scrollt im Dokument, fullPage zeigt dort nur die Kopfzeile; darum Fenster auf Seitenhoehe (hoechstens 3200 px). */ +async function shot(page, name) { + // Einblend-Animationen der Seite (kb-fade-in, kb-view-in) abwarten, sonst ist der Inhalt noch durchsichtig + await page.evaluate(() => Promise.all(document.getAnimations().filter((a) => a.effect.getComputedTiming().iterations !== Infinity).map((a) => a.finished.catch(() => {})))) + const fenster = page.viewportSize() + const hoehe = await page.evaluate(() => Math.min(Math.max(document.documentElement.scrollHeight, window.innerHeight), 3200)) + await page.setViewportSize({ width: fenster.width, height: hoehe }) + await page.screenshot({ path: `${SHOTS}/fragebogen-${name}.png` }) + await page.setViewportSize(fenster) +} +/** Kein Streifen rechts: die Seite passt in die Breite. */ +const passtInBreite = (page) => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth) + +/** Die uebrigen Portal-Schnittstellen, die dashboard.html beim Laden braucht (wie test/ui/termine-portal.ui.mjs). */ +function portalHilfe(p, json) { + if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Silke Müller', email: 'silke@salon-mueller.example' } }) + if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Salon Müller', status: 'live' }] }) + if (p === '/api/features') return json({ features: [] }) + if (p === '/api/contact') return json({ name: 'WEBklar-Team', responseTime: 'Antwort meist < 1 Werktag' }) + if (p === '/api/chat/unread-count') return json({ unread: 0 }) + return json({}) +} + +// ---------------------------------------------------------------- Ablauf der Pruefungen + +const results = [] +let browser = null +const hosts = new Set() + +/** + * Eine Pruefung in eigenem Browser-Kontext, ausgehend von einem Kunden vor der ersten Branchenwahl (srv.zuruecksetzen({ entwurf: true })). + * optionen: context (Playwright), abo (srv.abo: 'wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'), cookie ('kunde' | 'kundenansicht'). + * fn(page, t, ctx): t.anfragen sind alle Aufrufe der Seite an /api/ (Methode, Pfad, Koerper), t.stripe die Aufrufe der Zahlungsseite. + */ +async function check(name, fn, { context = {}, abo = 'wartung', cookie = 'kunde' } = {}) { + if (process.env.NUR && !name.includes(process.env.NUR)) return + await srv.zuruecksetzen({ entwurf: true }) + if (abo !== 'wartung') await srv.abo(abo) + const ctx = await browser.newContext({ viewport: { width: 1280, height: 900 }, ...context }) + await ctx.clock.setFixedTime(JETZT) + await ctx.addCookies([cookie === 'kundenansicht' ? await srv.kundenansichtCookie() : await srv.portalCookie()]) + ctx.setDefaultTimeout(8000) + const fehler = [] + const t = { anfragen: [], stripe: [] } + // jede Seite des Kontexts wird beobachtet + ctx.on('page', (seite) => { + seite.on('pageerror', (e) => fehler.push(`JavaScript-Fehler: ${e.message}`)) + seite.on('dialog', (d) => { fehler.push(`Browser-Dialog ${d.type()}: ${d.message()}`); d.dismiss().catch(() => {}) }) + seite.on('request', (r) => { + let u + try { u = new URL(r.url()) } catch { return } // data: u. a. + if (!u.protocol.startsWith('http')) return + hosts.add(u.hostname) + if (!ERLAUBTE_HOSTS.has(u.hostname)) fehler.push(`Anfrage an fremden Host: ${u.host}`) + if (u.pathname.startsWith('/api/')) { + let body = null + try { body = r.postDataJSON() } catch { body = r.postData() } + t.anfragen.push({ methode: r.method(), pfad: u.pathname, body }) + } + }) + }) + await ctx.route('**/api/**', async (route) => { + const p = new URL(route.request().url()).pathname + const json = (body, status = 200) => route.fulfill({ status, contentType: 'application/json', body: JSON.stringify(body) }) + if (p.startsWith('/api/funktionen') || p.startsWith('/api/termine')) return route.continue() + return portalHilfe(p, json) + }) + // die Zahlungsseite: nur eine Attrappe; festgehalten wird, wann sie aufgerufen wurde und wie viele Nachweise zu diesem Zeitpunkt schon lagen + await ctx.route('https://checkout.stripe.test/**', async (route) => { + t.stripe.push({ url: route.request().url(), at: Date.now(), nachweise: srv.aw.docs('featureConsents').size }) + return route.fulfill({ status: 200, contentType: 'text/html; charset=utf-8', body: 'Zahlungsseite (Attrappe)

Zahlungsseite

' }) + }) + const page = await ctx.newPage() + try { + await fn(page, t, ctx) + assert(fehler.length === 0, fehler.join(' | ')) + results.push([name, true]) + console.log(`ok ${name}`) + } catch (e) { + results.push([name, false]) + console.log(`FAIL ${name}: ${e.message.split('\n')[0]}`) + if (process.env.STACK) console.log(e.stack) + await page.screenshot({ path: `${SHOTS}/fehler-fragebogen-${name}.png` }).catch(() => {}) + } finally { + await ctx.close() + } +} + +// ---- Seite und Fragebogen +/** Das Portal frisch laden: ein Wechsel nur der Adresse nach # laedt die Daten nicht neu, darum erst eine leere Seite. */ +async function oeffne(page, hash) { + if (page.url().startsWith(srv.url)) await page.goto('about:blank') + await page.goto(`${srv.url}/dashboard.html${hash}`) +} +const warteFragebogen = (page) => page.locator('#view-termine.active .tm-fragebogen').waitFor() +const markierung = (page, key) => page.locator(`.tm-schritt[aria-current="step"][data-schritt="${key}"]`) +const warteSchritt = (page, key) => markierung(page, key).waitFor() +/** „Schritt n von 8“ (Text steht immer im DOM, sichtbar ist er nur am Handy). */ +const standText = (page) => page.locator('.tm-schritt-stand').textContent() +const schrittKnopf = (page, key) => page.locator(`.tm-schritt[data-schritt="${key}"] button`) +const weiterKnopf = (page) => page.locator('.tm-fb-form [data-aktion="weiter"]') +const feldIn = (page, name) => page.locator(`[data-feld="${name}"]`) +/** Der Kunde mit Entwurf kommt ueber die Kachel in Funktionen in die Ansicht (die Kachel merkt sich das Projekt). */ +async function ueberKachelStarten(page, knopfText) { + await oeffne(page, '#funktionen') + const kachel = page.locator('[data-fn-tile="termine"]') + await kachel.waitFor() + await kachel.getByRole('button', { name: knopfText, exact: true }).click() + await warteFragebogen(page) +} +const GEBUCHT = { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Auf der Seite Kontakt unter dem Text', gestaltung: '', bemerkung: '' } +/** Alle acht Schritte bestaetigt und alles ausgefuellt: der Entwurf, der direkt auf der Bestellseite landet. */ +const vollstaendig = (extra = {}) => ({ bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true, preiseGeprueft: true, einbau: GEBUCHT, ...extra }) + +// Zaehler fuer "die Version steigt nur durch PATCH": diese Aufrufe der Seite schreiben die Einstellung (und erhoehen deren Version) +const schreibt = (a) => (a.methode === 'PATCH' && a.pfad === '/api/termine/einstellungen') || (a.methode === 'POST' && a.pfad === '/api/termine/einstellungen/vorlage') +const zaehleSchreiber = (t) => t.anfragen.filter(schreibt).length +const zaehlePuts = (t) => t.anfragen.filter((a) => a.methode === 'PUT' && a.pfad === '/api/termine/fragebogen').length +const schreibaufrufe = (t) => t.anfragen.filter((a) => a.methode !== 'GET') + +// Eingaben der acht Schritte (ohne „Weiter“) und was danach gespeichert sein muss +const FELD_NAME = 'Salon Müller' +const FELD_ADRESSE = 'Hauptstr. 1, 67722 Winnweiler' +const FELD_TELEFON = '06301 1234' +const IMPRESSUM = 'https://salon-mueller.example/impressum' +const DATENSCHUTZ = 'https://salon-mueller.example/datenschutz' +const EINGABE = { + async wer(page) { + await page.locator('input[name="termine-wer-art"][value="team"]').check() + await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').fill('Anna\nBen') + }, + async leistungen(page) { + await feldIn(page, 'preiseGeprueft').check() + }, + async zeiten(page) { + await feldIn(page, 'bundesland').selectOption({ label: 'Rheinland-Pfalz' }) + }, + async regeln(page) { + await page.getByRole('button', { name: 'Alle sofort buchbar', exact: true }).click() + }, + async gastfelder() {}, + async benachrichtigung() {}, + async website(page) { + await feldIn(page, 'betrieb.telefon').fill(FELD_TELEFON) + await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM) + await feldIn(page, 'betrieb.datenschutzUrl').fill(DATENSCHUTZ) + await page.locator('input[name="termine-einbau-modus"][value="knopf"]').check() + await feldIn(page, 'einbau.knopfText').fill('Termin buchen') + }, +} +const NACH_SCHRITT = { + wer: () => eq(srv.einstellung().ressourcen.map((r) => r.name), ['Anna', 'Ben'], 'Team mit zwei Namen gespeichert'), + leistungen: () => eq(srv.fragebogen().preiseGeprueft, true, 'Preise geprueft gespeichert'), + zeiten: () => { + eq(srv.fragebogen().bundeslandGewaehlt, true, 'Bundesland gewaehlt gespeichert') + eq(srv.einstellung().bundesland, 'RP', 'Bundesland Rheinland-Pfalz') + }, + regeln: () => assert(srv.einstellung().leistungen.every((l) => l.buchungsart === 'sofort'), 'alle Leistungen sofort buchbar'), + gastfelder: () => {}, + benachrichtigung: () => {}, + website: () => { + eq(srv.einstellung().betrieb.telefon, FELD_TELEFON, 'Telefon gespeichert') + eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum gespeichert') + eq(srv.einstellung().betrieb.datenschutzUrl, DATENSCHUTZ, 'Datenschutz gespeichert') + const e = srv.fragebogen().einbau + eq([e.modus, e.knopfText], ['knopf', 'Termin buchen'], 'Einbauwunsch gespeichert') + }, +} +const FOLGE = srv.SCHRITTE + +/** + * Schritt 1 ohne Entwurf: Branche „Friseur und Salon“ waehlen, Name und Anschrift stehen aus dem Konto da, „Mit dieser Branche starten“. + * Danach gibt es Funktionsdokument (off) und Einstellung, und der Schritt ist bestaetigt. + */ +async function brancheStarten(page, t) { + await warteSchritt(page, 'branche') + eq(await standText(page), 'Schritt 1 von 8', 'Fortschritt vor dem Start') + eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Name aus dem Konto') + eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Anschrift aus dem Konto') + eq(srv.aw.docs('portalFeatures').size, 0, 'vor dem Start kein Funktionsdokument') + await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check() + const s0 = zaehleSchreiber(t) + await page.getByRole('button', { name: 'Mit dieser Branche starten', exact: true }).click() + await warteSchritt(page, 'wer') + const feature = srv.aw.docs('portalFeatures').get(FEATURE) + assert(feature, 'Funktionsdokument angelegt') + eq([feature.state, feature.featureKey, feature.customerId, feature.projectId], ['off', 'termine', KUNDE, PROJEKT], 'Funktionsdokument im Zustand off') + assert(/^[a-z2-7]{16}$/.test(feature.siteKey), 'Funktionsdokument hat einen siteKey') + assert(srv.einstellung(), 'Einstellung angelegt') + eq(srv.einstellung().vorlage, 'salon', 'Vorlage der Branche') + eq(srv.einstellung().betrieb.name, FELD_NAME, 'Name in der Einstellung') + eq(srv.einstellung().betrieb.adresse, FELD_ADRESSE, 'Anschrift in der Einstellung') + eq(srv.fragebogen().branche, 'salon', 'Branche im Fragebogen') + eq(srv.fragebogen().bestaetigt, ['branche'], 'Schritt 1 bestaetigt') + // die Version steigt durch die Vorlage (POST) und hoechstens einen Nachtrag (PATCH), nie durch den PUT + eq(srv.version(), zaehleSchreiber(t) - s0, 'Version = Zahl der schreibenden Aufrufe (POST vorlage, PATCH)') + eq(zaehlePuts(t), 1, 'ein PUT bestaetigt Schritt 1') +} + +/** + * „Weiter“ in einem Schritt mit Feldern: danach steht der Schritt in fragebogen.bestaetigt (aus termineConfig gelesen), der naechste Schritt + * ist da, es gab genau einen PUT und die Version der Einstellung ist nur um die PATCH-Aufrufe gestiegen. + */ +async function weiter(page, t, key, naechster) { + const v0 = srv.version() + const s0 = zaehleSchreiber(t) + const p0 = zaehlePuts(t) + await weiterKnopf(page).click() + await warteSchritt(page, naechster) + assert(srv.fragebogen().bestaetigt.includes(key), `${key} steht in bestaetigt: ${JSON.stringify(srv.fragebogen().bestaetigt)}`) + eq(zaehlePuts(t) - p0, 1, `${key}: genau ein PUT`) + const patches = zaehleSchreiber(t) - s0 + assert(patches <= 1, `${key}: hoechstens ein PATCH (${patches})`) + eq(srv.version() - v0, patches, `${key}: die Version steigt nur durch PATCH`) +} + +/** Schritte 2 bis bis ausfuellen und bestaetigen (Schritt 1 ist schon gestartet). */ +async function schritteAusfuellen(page, t, bis) { + const ende = FOLGE.indexOf(bis) + for (let i = 1; i <= ende; i += 1) { + const key = FOLGE[i] + await warteSchritt(page, key) + eq(await standText(page), `Schritt ${i + 1} von 8`, `Fortschritt in ${key}`) + await EINGABE[key](page) + await weiter(page, t, key, FOLGE[i + 1] ?? 'bestellen') + NACH_SCHRITT[key]() + } +} + +/** Bestellseite: alle Haken. */ +const hakenSetzen = async (page) => { + const haken = page.locator('.tm-bestellen [data-haken]') + const n = await haken.count() + assert(n > 0, 'die Bestellseite hat Haken') + for (let i = 0; i < n; i += 1) await haken.nth(i).check() + return n +} +const bestellKnopf = (page) => page.locator('.tm-bestellen [data-aktion="bestellen"]') + +async function pruefungen() { + // ============================================================ ohne Abo + await check('ohne_abo', async (page, t) => { + const aus = [['hosting', 'kein_abo'], ['keins', 'kein_abo'], ['gekuendigt', 'gekuendigt'], ['nicht_aktiv', 'nicht_aktiv']] + for (const [i, [abo, grund]] of aus.entries()) { + if (i > 0) await srv.abo(abo) + const soll = aboMeldung(REGISTER, grund) + assert(soll.includes('Wartungs-Abo'), `Hinweis des Servers nennt das Wartungs-Abo: ${soll}`) + await oeffne(page, '#funktionen') + const kachel = page.locator('[data-fn-tile="termine"]') + await kachel.waitFor() + const kachelText = await kachel.innerText() + assert(kachelText.includes(soll), `Kachel zeigt den Hinweis (${abo}): ${kachelText}`) + eq(await kachel.locator('.fn-tile-cta').count(), 0, `keine Knoepfe in der Kachel (${abo})`) + eq(await kachel.locator('.fn-pill').innerText(), 'Aus', `Pille Aus (${abo})`) + if (grund === 'kein_abo') assert(kachelText.includes('Zu Verträge'), 'Link zu den Vertraegen bei kein_abo') + // die Ansicht zeigt denselben Hinweis, keinen Fragebogen und keine Vorlagenauswahl + await oeffne(page, '#termine') + const hinweis = page.locator('#view-termine.active [data-hinweis="abo"]') + await hinweis.waitFor() + assert((await hinweis.innerText()).includes(soll), `Ansicht zeigt den Hinweis (${abo}): ${await hinweis.innerText()}`) + eq(await page.locator('.tm-fragebogen, .tm-vorlagen, .tm-bestellen, .tm-reiterleiste').count(), 0, `kein Fragebogen (${abo})`) + if (i === 0) await shot(page, 'ohne-abo') + } + eq(srv.aw.docs('portalFeatures').size, 0, 'portalFeatures bleibt leer') + eq(srv.aw.docs('termineConfig').size, 0, 'termineConfig bleibt leer') + eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf') + }, { abo: 'hosting' }) + + // ============================================================ Gesundheitsbereich blockt + await check('gesundheit_blockt', async (page, t) => { + await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €') + await warteSchritt(page, 'branche') + await page.locator('label.tm-vorlage', { hasText: 'Gesundheitsbereich' }).locator('input').check() + const hinweis = page.locator('[data-hinweis="gesundheit"]') + await hinweis.waitFor() + const text = await hinweis.innerText() + assert(text.includes('Gesundheitsbereich') && text.includes('nicht verfügbar'), `Erklaertext: ${text}`) + eq(await page.getByRole('button', { name: /Weiter/ }).count(), 0, 'kein „Weiter“') + eq(await page.locator('[data-aktion="branche-starten"], .tm-fb-form [type="submit"]').count(), 0, 'kein Knopf zum Starten') + await shot(page, 'gesundheit') + // auch ein erzwungenes Absenden (Enter im Feld, requestSubmit) sendet nichts + await feldIn(page, 'betrieb.name').press('Enter') + await page.evaluate(() => document.querySelector('.tm-fb-form').requestSubmit()) + await pause(500) + eq(schreibaufrufe(t), [], 'kein einziger Schreibaufruf') + for (const sammlung of ['portalFeatures', 'termineConfig', 'featureConsents']) eq(srv.aw.docs(sammlung).size, 0, `kein Dokument in ${sammlung}`) + // ein anderer Branchenwunsch hebt die Sperre wieder auf: der Knopf kommt zurueck (nichts gesendet) + await page.locator('label.tm-vorlage', { hasText: 'Friseur und Salon' }).locator('input').check() + await page.locator('[data-aktion="branche-starten"]').waitFor() + eq(await hinweis.count(), 0, 'Erklaertext weg bei anderer Branche') + eq(schreibaufrufe(t), [], 'weiterhin kein Schreibaufruf') + }) + + // ============================================================ komplett bis zum Bestellklick + await check('fragebogen_komplett_bis_bestellklick', async (page, t) => { + await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €') + await shot(page, 'schritt-1-start') + await brancheStarten(page, t) + await schritteAusfuellen(page, t, 'website') // 2 bis 8; nach dem letzten „Weiter“ steht die Bestellseite da + await warteSchritt(page, 'bestellen') + eq(srv.fragebogen().bestaetigt, FOLGE, 'alle acht Schritte bestaetigt') + eq(await standText(page), 'Prüfen und bestellen', 'Kopfzeile der Bestellseite') + + // ---- Bestellseite + const seite = page.locator('.tm-bestellen') + await seite.waitFor() + eq(await seite.locator('.tm-bestellen-offen').count(), 0, 'keine offenen Punkte') + const knopf = bestellKnopf(page) + assert(await knopf.isDisabled(), 'Knopf gesperrt, solange Haken fehlen') + const knopfText = (await knopf.innerText()).trim() + eq(knopfText, REGISTER.consent.knopf, 'Knopf traegt die Beschriftung des Registers') + const belehrung = await featureHaken('termine').belehrung() + const region = seite.locator('.tm-bestellen-belehrung .tm-rechtstext') + assert(await region.isVisible(), 'Widerrufsbelehrung ist sichtbar (nicht eingeklappt)') + const belehrungImDom = (await region.textContent()).trim() + eq(belehrungImDom, belehrung.trim(), 'Widerrufsbelehrung mit Muster-Formular wie im Register (bis F7: Platzhalter)') + const reihenfolge = await page.evaluate(() => { + const b = document.querySelector('.tm-bestellen-belehrung .tm-rechtstext') + const k = document.querySelector('.tm-bestellen [data-aktion="bestellen"]') + return { vorKnopf: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_FOLLOWING), umgekehrt: Boolean(b.compareDocumentPosition(k) & Node.DOCUMENT_POSITION_PRECEDING) } + }) + eq(reihenfolge, { vorKnopf: true, umgekehrt: false }, 'Belehrung steht im DOM vor dem Knopf') + // die gezeigten Texte der Zustimmungen: genau sie muessen im Nachweis stehen + const gezeigt = await page.evaluate(() => [...document.querySelectorAll('.tm-bestellen-haken > *')].map((el) => ({ + haken: el.matches('label') ? el.querySelector('input').getAttribute('data-haken') : null, + text: el.textContent.trim(), + }))) + assert(gezeigt.length > 0, 'Zustimmungen sind zu sehen') + await shot(page, 'bestellseite-gesperrt') + const hakenZahl = await hakenSetzen(page) + assert(hakenZahl > 0 && (await knopf.isEnabled()), 'Knopf aktiv nach allen Haken') + await shot(page, 'bestellseite-bereit') + // ein einzelner Haken weniger sperrt wieder + await page.locator('.tm-bestellen [data-haken]').first().uncheck() + assert(await knopf.isDisabled(), 'Knopf wieder gesperrt bei fehlendem Haken') + await page.locator('.tm-bestellen [data-haken]').first().check() + + // ---- Klick + const vorKlick = Date.now() + const checkoutAnfrage = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout') + await knopf.click() + const body = (await checkoutAnfrage).postDataJSON() + await page.waitForURL(STRIPE_SEITE) + const sitzungId = STRIPE_SEITE.exec(page.url())[1] + eq(t.stripe.length, 1, 'die Zahlungsseite wurde genau einmal aufgerufen') + const aufruf = t.stripe[0] + + // der Koerper der Bestellung: Projekt, Fassung und die IDs der Haken (nie Texte) + eq(Object.keys(body).sort(), ['projectId', 'zustimmung'], 'Koerper der Bestellung') + eq(body.projectId, PROJEKT, 'Projekt der Bestellung') + eq(body.zustimmung.version, REGISTER.consent.version, 'Fassung der Zustimmung') + eq(body.zustimmung.haken, REGISTER.consent.items.filter((i) => i.haken).map((i) => i.id), 'IDs der Haken in Registerreihenfolge') + + // Funktionsdokument ordered mit der Sitzung + const feature = srv.aw.docs('portalFeatures').get(FEATURE) + eq([feature.state, feature.stripeSessionId], ['ordered', sitzungId], 'Funktionsdokument ordered mit stripeSessionId') + // genau eine Checkout-Sitzung bei Stripe, fuer den Terminplaner und das Projekt + const sitzungen = srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions') + eq(sitzungen.length, 1, 'genau ein POST /v1/checkout/sessions') + eq([sitzungen[0].body['metadata[featureKey]'], sitzungen[0].body['metadata[projectId]']], ['termine', PROJEKT], 'Metadaten der Sitzung') + + // genau ein Nachweis mit den gezeigten Texten + const nachweise = [...srv.aw.docs('featureConsents').values()] + eq(nachweise.length, 1, 'genau ein featureConsents-Dokument') + const n = leseZustimmung(nachweise[0]) + eq([n.customerId, n.projectId, n.featureKey, n.stripeSessionId], [KUNDE, PROJEKT, 'termine', sitzungId], 'Nachweis gehoert zu dieser Sitzung') + eq(n.fassungen, zustimmungsfassungen(REGISTER), 'Fassungen der Unterlagen im Nachweis') + for (const eintrag of n.texte.filter((x) => x.haken !== undefined && x.id !== 'belehrung' && x.id !== 'knopf')) { + const sichtbar = gezeigt.find((g) => (eintrag.haken ? g.haken === eintrag.id : g.haken === null) && g.text === eintrag.text) + assert(sichtbar, `Text im Nachweis wurde so gezeigt: ${eintrag.id}`) + } + eq(n.texte, nachweisTexte(REGISTER, { belehrung }), 'Texte im Nachweis = Texte des Registers samt Belehrung und Knopf, in Anzeigereihenfolge') + eq(n.texte.find((x) => x.id === 'belehrung').text.trim(), belehrungImDom, 'Belehrung im Nachweis = die gezeigte Belehrung') + const gespeicherterKnopf = n.texte.find((x) => x.id === 'knopf').text + eq([gespeicherterKnopf, REGISTER.consent.knopf, knopfText], [knopfText, knopfText, knopfText], 'Knopf: gespeichert = Register = Beschriftung des geklickten Knopfes') + eq(nachweise[0].textHash, texteHash(n.texte), 'Pruefsumme der Texte') + // Zeit des Klicks: nach dem Mausklick des Skripts, und gespeichert, BEVOR die Zahlungsseite aufgerufen wurde + const zeit = Date.parse(n.acceptedAt) + assert(Number.isFinite(zeit) && zeit >= vorKlick - 1000, `Zeit des Klicks liegt nach dem Start des Skripts: ${n.acceptedAt}`) + assert(zeit < aufruf.at, `Nachweis (${n.acceptedAt}) kleiner als der Aufruf der Zahlungsseite (${new Date(aufruf.at).toISOString()})`) + assert(Date.parse(nachweise[0].$createdAt) <= aufruf.at, 'Nachweis war angelegt, bevor die Zahlungsseite aufgerufen wurde') + eq(aufruf.nachweise, 1, 'beim Aufruf der Zahlungsseite lag der Nachweis schon in der Datenbank') + }) + + // ============================================================ offene Punkte sperren + await check('offene_punkte_sperren', async (page) => { + // (a) Impressum fehlt + await srv.entwurf({ + eingabe: salonEingabe({ betrieb: { ...salonEingabe().betrieb, impressumUrl: '' } }), + fragebogen: vollstaendig(), + }) + await oeffne(page, '#termine') + await warteFragebogen(page) + await warteSchritt(page, 'bestellen') + const offen = page.locator('.tm-bestellen-offen') + await offen.waitFor() + const punkt = offen.locator('li', { hasText: 'Impressum' }) + eq(await punkt.count(), 1, 'genau ein Punkt zum Impressum') + eq(await offen.locator('li').count(), 1, 'sonst nichts offen') + eq(await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).innerText(), 'Zum Schritt', 'Knopf „Zum Schritt“') + await hakenSetzen(page) + assert(await bestellKnopf(page).isDisabled(), 'Knopf bleibt trotz aller Haken gesperrt') + assert((await page.locator('[data-hinweis="bestellen"]').innerText()).includes('offenen Punkte'), 'Hinweis am Knopf nennt die offenen Punkte') + await shot(page, 'offen-impressum') + await punkt.getByRole('button', { name: 'Zum Schritt „Website“' }).click() + await warteSchritt(page, 'website') + eq(await page.locator('.tm-fb-schritt').getAttribute('data-schritt'), 'website', 'Schritt 8 geoeffnet') + eq(await standText(page), 'Schritt 8 von 8', 'Fortschritt Schritt 8') + eq(await feldIn(page, 'betrieb.impressumUrl').inputValue(), '', 'Impressum-Feld ist leer') + // nachtragen: danach ist der Punkt weg, der Knopf nach den Haken aktiv + await feldIn(page, 'betrieb.impressumUrl').fill(IMPRESSUM) + await weiterKnopf(page).click() + await warteSchritt(page, 'bestellen') + eq(await page.locator('.tm-bestellen-offen').count(), 0, 'Punkt erledigt') + await hakenSetzen(page) + assert(await bestellKnopf(page).isEnabled(), 'Knopf aktiv, wenn nichts mehr offen ist') + + // (b) Bundesland nie gewaehlt, obwohl der Server RP gespeichert hat + await srv.entwurf({ fragebogen: vollstaendig({ bundeslandGewaehlt: false }) }) + eq(srv.einstellung().bundesland, 'RP', 'der Serverstandard RP ist gespeichert') + await oeffne(page, '#termine') + await warteFragebogen(page) + await warteSchritt(page, 'bestellen') + const offenLand = page.locator('.tm-bestellen-offen') + await offenLand.waitFor() + eq(await offenLand.locator('li').count(), 1, 'nur ein Punkt offen') + const landPunkt = offenLand.locator('li', { hasText: 'Bundesland' }) + eq(await landPunkt.count(), 1, 'Punkt zum Bundesland') + await hakenSetzen(page) + assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt ohne Bundesland') + await shot(page, 'offen-bundesland') + await landPunkt.getByRole('button', { name: 'Zum Schritt „Zeiten“' }).click() + await warteSchritt(page, 'zeiten') + eq(await standText(page), 'Schritt 4 von 8', 'Punkt fuehrt in den Schritt Zeiten') + eq(await feldIn(page, 'bundesland').inputValue(), '', 'Bundesland ist nicht vorgewaehlt (RP ist keine Wahl)') + }) + + // ============================================================ Rueckkehr und Zahlung fortsetzen + await check('rueckkehr_und_zahlung_fortsetzen', async (page, t) => { + await srv.entwurf({ fragebogen: vollstaendig() }) + const sitzung = await srv.bestellt() + const sitzungsUrl = `https://checkout.stripe.test/c/pay/${sitzung}` + + // ---- Rueckkehr nach der Zahlung: Statuskarte mit „Danke!“, Adresse bereinigt, Einstellungen gesperrt + await oeffne(page, '#termine?bestellt=1&projekt=p1') + const karte = page.locator('.tm-statuskarte') + await karte.waitFor() + eq(await karte.getAttribute('data-state'), 'ordered', 'Statuskarte im Zustand ordered') + assert((await karte.innerText()).includes('Zahlung offen'), `Karte: ${await karte.innerText()}`) + assert((await karte.locator('[data-danke]').innerText()).startsWith('Danke!'), 'Danke! nach der Rueckkehr') + eq(await page.evaluate(() => window.location.hash), '#termine', 'Adresse bereinigt') + const reiter = await page.locator('[data-reiter]').evaluateAll((els) => els.map((el) => el.dataset.reiter)) + eq(reiter, ['leistungen', 'team', 'einstellungen'], 'Reiter ohne „Termine“') + await page.locator('[data-reiter="einstellungen"]').click() + await page.locator('#tm-panel .tm-abschnitt').first().waitFor() + const felder = await page.locator('#tm-panel').evaluate((panel) => { + const alle = [...panel.querySelectorAll('input, select, textarea')] + return { alle: alle.length, offen: alle.filter((el) => !el.disabled && !el.matches(':disabled')).length } + }) + assert(felder.alle > 0, 'Einstellungen zeigen Felder') + eq(felder.offen, 0, 'alle Felder der Einstellungen sind gesperrt') + await shot(page, 'rueckkehr-zahlung-offen') + + // ---- Zahlung fortsetzen: dieselbe Sitzung, kein zweiter Nachweis + const fortsetzen = page.waitForRequest((r) => r.method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout') + await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click() + eq((await fortsetzen).postDataJSON(), { projectId: PROJEKT, fortsetzen: true }, 'Koerper von „Zahlung fortsetzen“') + await page.waitForURL(sitzungsUrl) + eq(t.stripe.map((s) => s.url), [sitzungsUrl], 'Navigation zur selben Sitzung') + eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung') + eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine neue Sitzung angelegt') + eq(srv.aw.docs('featureConsents').size, 1, 'weiterhin genau ein featureConsents-Dokument') + + // ---- Abbruch bei Stripe: Rueckkehr ohne „Danke!“ + await oeffne(page, '#termine?projekt=p1') + await karte.waitFor() + assert((await karte.innerText()).includes('Zahlung offen'), 'Abbruch: Karte Zahlung offen') + eq(await page.locator('[data-danke]').count(), 0, 'Abbruch: kein „Danke!“') + eq(await page.evaluate(() => window.location.hash), '#termine', 'Abbruch: Adresse bereinigt') + + // ---- ein PATCH direkt am Router ist gesperrt + const patch = await page.evaluate(async (version) => { + const res = await fetch('/api/termine/einstellungen', { method: 'PATCH', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ projectId: 'p1', version, einstellung: {} }) }) + return { status: res.status, code: (await res.json()).code } + }, srv.version()) + eq(patch, { status: 409, code: 'gesperrt' }, 'PATCH bei ordered ist 409 gesperrt') + + // ---- ohne Nachweis gibt es keine Weiterleitung: das Bestellformular kommt wieder + await srv.loescheZustimmungen() + await oeffne(page, '#termine') + await karte.waitFor() + const antwort = page.waitForResponse((r) => r.request().method() === 'POST' && new URL(r.url()).pathname === '/api/funktionen/termine/checkout') + const naviVorher = t.stripe.length + await karte.getByRole('button', { name: 'Zahlung fortsetzen', exact: true }).click() + const res = await antwort + eq(res.status(), 409, 'ohne Nachweis: 409') + eq((await res.json()).code, 'zustimmung_fehlt', 'ohne Nachweis: zustimmung_fehlt') + await page.locator('.tm-bestellen').waitFor() + eq(await page.locator('.tm-statuskarte').count(), 0, 'die Karte ist durch das Bestellformular ersetzt') + assert((await page.locator('#view-termine').innerText()).includes('Bitte stimme der Erklärung zu'), 'Meldung zur fehlenden Zustimmung') + assert(page.url().startsWith(srv.url), `keine Navigation: ${page.url()}`) + eq(t.stripe.length, naviVorher, 'die Zahlungsseite wurde nicht aufgerufen') + eq(srv.st.checkoutSessions.size, 1, 'weiterhin genau eine Checkout-Sitzung') + eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis ohne Zustimmung des Kunden') + await shot(page, 'zahlung-fortsetzen-ohne-nachweis') + + // ---- Zustand setup: Karte mit Ticketnummer und Rechnungslink + await srv.einrichtung() + await oeffne(page, '#termine') + await karte.waitFor() + eq(await karte.getAttribute('data-state'), 'setup', 'Karte im Zustand setup') + const text = await karte.innerText() + assert(text.includes('In Einrichtung') && text.includes('#100342'), `Karte mit Ticketnummer: ${text}`) + const rechnung = karte.getByRole('link', { name: 'Rechnung ansehen' }) + eq(await rechnung.getAttribute('href'), 'https://invoice.stripe.test/i/in_ui1', 'Rechnungslink') + eq(await rechnung.getAttribute('rel'), 'noopener noreferrer', 'Rechnungslink mit rel') + eq(await page.getByRole('button', { name: 'Zahlung fortsetzen' }).count(), 0, 'in der Einrichtung gibt es kein „Zahlung fortsetzen“') + await shot(page, 'in-einrichtung') + }) + + // ============================================================ Entwurf bleibt beim Neuladen + await check('entwurf_bleibt_beim_neuladen', async (page, t) => { + await ueberKachelStarten(page, 'Terminplaner einrichten · 149 €') + await brancheStarten(page, t) + await schritteAusfuellen(page, t, 'zeiten') // Schritte 2 bis 4 + await warteSchritt(page, 'regeln') + + // ---- neu laden: Start bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern + await page.reload() + await warteFragebogen(page) + await warteSchritt(page, 'regeln') + eq(await standText(page), 'Schritt 5 von 8', 'nach dem Neuladen Schritt 5') + assert((await page.locator('.tm-schritt[data-erledigt="true"]').count()) === 4, 'vier Schritte als erledigt markiert') + await schrittKnopf(page, 'branche').click() + await warteSchritt(page, 'branche') + eq(await feldIn(page, 'betrieb.name').inputValue(), FELD_NAME, 'Schritt 1: Name') + eq(await feldIn(page, 'betrieb.adresse').inputValue(), FELD_ADRESSE, 'Schritt 1: Anschrift') + assert((await page.locator('.tm-fb-branche').innerText()).includes('Friseur und Salon'), 'Schritt 1: Branche') + await schrittKnopf(page, 'wer').click() + await warteSchritt(page, 'wer') + assert(await page.locator('input[name="termine-wer-art"][value="team"]').isChecked(), 'Schritt 2: Team gewaehlt') + eq(await page.locator('.tm-fb-schritt[data-schritt="wer"] textarea').inputValue(), 'Anna\nBen', 'Schritt 2: Namen') + await schrittKnopf(page, 'leistungen').click() + await warteSchritt(page, 'leistungen') + assert(await feldIn(page, 'preiseGeprueft').isChecked(), 'Schritt 3: Preise geprueft') + await schrittKnopf(page, 'zeiten').click() + await warteSchritt(page, 'zeiten') + eq(await feldIn(page, 'bundesland').inputValue(), 'RP', 'Schritt 4: Bundesland') + await shot(page, 'nach-neuladen') + + // ---- Schritt 8 vorab ausfuellen und bestaetigen: der Einbauwunsch muss den Wechsel der Branche ueberstehen + await schrittKnopf(page, 'website').click() + await warteSchritt(page, 'website') + await EINGABE.website(page) + await feldIn(page, 'einbau.stelle').fill('Auf der Seite Kontakt unter dem Text') + await weiter(page, t, 'website', 'bestellen') + NACH_SCHRITT.website() + eq(srv.fragebogen().bestaetigt, ['branche', 'wer', 'leistungen', 'zeiten', 'website'], 'fuenf Schritte bestaetigt') + const einbau = srv.fragebogen().einbau + eq(einbau.stelle, 'Auf der Seite Kontakt unter dem Text', 'Einbauwunsch mit Stelle gespeichert') + + // ---- Branche wechseln mit Rueckfrage + await schrittKnopf(page, 'branche').click() + await warteSchritt(page, 'branche') + await page.locator('[data-aktion="branche-wechseln"]').click() + const rueckfrage = page.locator('.tm-frage-box') + await rueckfrage.waitFor() + assert((await rueckfrage.innerText()).includes('Alle Bestätigungen gehen zurück'), 'Rueckfrage warnt vor dem Zuruecksetzen') + eq(srv.fragebogen().branche, 'salon', 'vor der Bestaetigung ist nichts geaendert') + await rueckfrage.locator('label.tm-vorlage', { hasText: 'Kosmetik und Nagelstudio' }).locator('input').check() + await shot(page, 'branche-wechseln-rueckfrage') + const v0 = srv.version() + await rueckfrage.getByRole('button', { name: 'Ja, Branche wechseln', exact: true }).click() + await page.locator('.tm-fb-branche', { hasText: 'Kosmetik und Nagelstudio' }).waitFor() + eq(srv.fragebogen().branche, 'kosmetik', 'neue Branche gespeichert') + eq(srv.fragebogen().bestaetigt, [], 'Bestaetigungen zurueckgesetzt') + eq(srv.fragebogen().bundeslandGewaehlt, false, 'Bundesland muss neu gewaehlt werden') + eq(srv.fragebogen().preiseGeprueft, false, 'Preise muessen neu geprueft werden') + eq(srv.fragebogen().einbau, einbau, 'der Einbauwunsch bleibt') + eq(srv.einstellung().vorlage, 'kosmetik', 'Einstellung aus der neuen Vorlage') + assert(srv.version() > v0, 'Version der Einstellung gestiegen (Ersetzen ist ein Schreiben der Einstellung)') + eq(srv.einstellung().betrieb.name, FELD_NAME, 'Betriebsname bleibt') + eq(srv.einstellung().betrieb.impressumUrl, IMPRESSUM, 'Impressum bleibt') + eq(await page.locator('.tm-schritt[data-erledigt="true"]').count(), 0, 'kein Schritt mehr als erledigt markiert') + await shot(page, 'nach-branchenwechsel') + }) + + // ============================================================ Kundenansicht + await check('kundenansicht', async (page, t) => { + await srv.entwurf({ fragebogen: vollstaendig({ bestaetigt: ['branche'] }) }) + await oeffne(page, '#termine') + await warteFragebogen(page) + await warteSchritt(page, 'wer') + const hinweis = page.locator('.tm-fragebogen .tm-box-info') + assert((await hinweis.innerText()).includes('Kundenansicht'), 'Hinweis auf die Kundenansicht') + // alle Schritte lassen sich lesen und mit „Weiter“ durchklicken, bestaetigt wird nichts + for (const [i, key] of FOLGE.slice(1).entries()) { + await warteSchritt(page, key) + eq(await standText(page), `Schritt ${i + 2} von 8`, `Fortschritt in ${key}`) + await weiterKnopf(page).click() + await warteSchritt(page, FOLGE[i + 2] ?? 'bestellen') + eq(srv.fragebogen().bestaetigt, ['branche'], `nach „Weiter“ in ${key} bleibt bestaetigt unveraendert`) + } + eq(zaehlePuts(t), 0, 'die Seite sendet in der Kundenansicht keinen PUT') + // Bestellseite: gesperrt mit Hinweis (hier zusaetzlich offene Punkte, weil nichts bestaetigt wurde) + assert((await page.locator('.tm-bestellen-offen li').count()) >= 7, 'sieben unbestaetigte Schritte stehen als offene Punkte da') + await hakenSetzen(page) + assert(await bestellKnopf(page).isDisabled(), 'Bestellknopf gesperrt') + eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf') + await shot(page, 'kundenansicht-bestellseite') + + // ---- nichts offen, trotzdem gesperrt: nur die Kundenansicht haelt den Knopf zu + await srv.entwurf({ fragebogen: vollstaendig() }) + await page.reload() + await warteFragebogen(page) + await warteSchritt(page, 'bestellen') + eq(await page.locator('.tm-bestellen-offen').count(), 0, 'nichts offen') + await hakenSetzen(page) + assert(await bestellKnopf(page).isDisabled(), 'Knopf gesperrt, obwohl nichts offen ist und alle Haken gesetzt sind') + eq((await page.locator('[data-hinweis="bestellen"]').innerText()).trim(), 'In der Kundenansicht kann nicht bestellt werden.', 'Hinweis am Knopf (nichts offen)') + + // ---- die Router selbst: PUT /fragebogen und der Checkout sind 403 + const vorher = srv.fragebogen() + const direkt = await page.evaluate(async ({ fragebogen }) => { + const sende = async (url, method, body) => { + const res = await fetch(url, { method, headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }) + return { status: res.status, body: await res.json() } + } + return { + put: await sende('/api/termine/fragebogen', 'PUT', { projectId: 'p1', fragebogen: { ...fragebogen, bestaetigt: [] } }), + checkout: await sende('/api/funktionen/termine/checkout', 'POST', { projectId: 'p1', zustimmung: { version: 'x', haken: [] } }), + } + }, { fragebogen: vorher }) + eq(direkt.put.status, 403, 'PUT /fragebogen in der Kundenansicht') + eq(direkt.checkout.status, 403, 'Checkout in der Kundenansicht') + eq(srv.fragebogen(), vorher, 'der Fragebogen blieb unveraendert') + eq(srv.st.calls.filter((c) => c.method === 'POST' && c.path === '/v1/checkout/sessions').length, 0, 'keine Stripe-Sitzung') + eq(srv.aw.docs('featureConsents').size, 0, 'kein Nachweis') + }, { cookie: 'kundenansicht' }) + + // ============================================================ Handy 390 px + await check('handy_390', async (page) => { + const pruefe = async (name, soll) => { + assert(await passtInBreite(page), `${name}: seitliche Rollleiste (scrollWidth ${await page.evaluate(() => document.documentElement.scrollWidth)})`) + const stand = page.locator('.tm-schritt-stand') + assert(await stand.isVisible(), `${name}: Fortschritt ist am Handy sichtbar`) + eq((await stand.innerText()).trim(), soll, `${name}: Fortschritt`) + } + // Schritt 1 ohne Entwurf + await oeffne(page, '#termine') + await warteFragebogen(page) + await warteSchritt(page, 'branche') + await pruefe('Schritt 1 (Start)', 'Schritt 1 von 8') + await shot(page, '390-1-start') + // mit Entwurf: Bestellseite, Schritt 4, Schritt 8, Schritt 1 + await srv.entwurf({ fragebogen: vollstaendig() }) + await page.reload() + await warteFragebogen(page) + await warteSchritt(page, 'bestellen') + assert(await passtInBreite(page), 'Bestellseite: seitliche Rollleiste') + await hakenSetzen(page) + assert(await passtInBreite(page), 'Bestellseite mit Haken: seitliche Rollleiste') + await shot(page, '390-2-bestellseite') + await schrittKnopf(page, 'zeiten').click() + await warteSchritt(page, 'zeiten') + await pruefe('Schritt 4', 'Schritt 4 von 8') + await shot(page, '390-3-zeiten') + await schrittKnopf(page, 'website').click() + await warteSchritt(page, 'website') + await pruefe('Schritt 8', 'Schritt 8 von 8') + await shot(page, '390-4-website') + await schrittKnopf(page, 'branche').click() + await warteSchritt(page, 'branche') + await pruefe('Schritt 1 (Entwurf)', 'Schritt 1 von 8') + await shot(page, '390-5-branche-entwurf') + }, { context: { viewport: { width: 390, height: 844 }, deviceScaleFactor: 2, isMobile: true, hasTouch: true } }) +} + +// ---------------------------------------------------------------- Lauf + +let abbruch = null +try { + browser = await chromium.launch() + await pruefungen() + const fremd = [...hosts].filter((h) => !ERLAUBTE_HOSTS.has(h)) + results.push(['nur_eigene_herkunft', fremd.length === 0]) + console.log(fremd.length ? `FAIL nur_eigene_herkunft: Anfragen an fremde Hosts: ${fremd.join(', ')}` : 'ok nur_eigene_herkunft') +} catch (e) { + abbruch = e + console.log(`ABBRUCH: ${e.stack || e.message}`) +} finally { + if (browser) await browser.close().catch(() => {}) + await srv.stop().catch((e) => console.log(`Server beenden: ${e.message}`)) +} + +const fehlgeschlagen = results.filter(([, ok]) => !ok) +console.log(`${results.length - fehlgeschlagen.length}/${results.length} Checks bestanden`) +process.exit(abbruch || fehlgeschlagen.length ? 1 : 0) diff --git a/test/ui/termineTestServer.mjs b/test/ui/termineTestServer.mjs index db125e3..d56ac41 100644 --- a/test/ui/termineTestServer.mjs +++ b/test/ui/termineTestServer.mjs @@ -21,6 +21,14 @@ // startet ohne Einstellung (Vorlagenauswahl); der Server stellt auch dashboard.html und /api/funktionen bereit, die uebrigen // Portal-Schnittstellen (/api/auth/me usw.) mockt das Skript per page.route. // +// Entwurf und Bestellung (TP2, Fragebogen bis Bestellklick): srv.zuruecksetzen({ entwurf: true }) startet wie ein Kunde vor der ersten Branchenwahl: +// kein Funktionsdokument, keine Einstellung, aber ein laufendes Wartungs-Abo (wartungDoc) fuer c1 und ein Konto mit Firma und Anschrift +// (Vorschlag fuer die Betriebsangaben). srv.abo(zustand) tauscht das Abo ('wartung' | 'hosting' | 'keins' | 'gekuendigt' | 'nicht_aktiv'), +// srv.entwurf({ eingabe, fragebogen }) legt Funktionsdokument (off), Einstellung und Fragebogen an (Abkuerzung statt aller Klicks), +// srv.bestellt({ mitZustimmung }) macht daraus eine Bestellung im Zustand ordered mit offener Stripe-Sitzung (und dem Nachweis dieser Sitzung), +// srv.kundenansichtCookie() ist die Sitzung eines Mitarbeiters in der Kundenansicht (der Waechter haengt hier nicht: geprueft wird die +// Absicherung der Router selbst). srv.fragebogen() / srv.version() lesen den gespeicherten Stand. +// // Seitenherkunft: srv.url ist http://127.0.0.1:PORT, srv.pageOrigin ist http://localhost:PORT (derselbe Server, anderer Host). // Mit { crossOrigin: true } gilt pageOrigin als Herkunft des Portals (config.publicBaseUrl): Seiten von pageOrigin mit // data-api=srv.url sind dann echte Cross-Origin-Anfragen (Preflight fuer POST), die der Server erlaubt. @@ -118,6 +126,8 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, termineClaims: schemaKeys('termineClaims'), termineOutbox: schemaKeys('termineOutbox'), customerViewLog: schemaKeys('customerViewLog'), + subscriptions: schemaKeys('subscriptions'), + featureConsents: schemaKeys('featureConsents'), }, datetimes: TERMINE_DATETIMES, defaultLimit: 25, @@ -128,14 +138,19 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, const { config } = await import('../../server/config.js') const { startTestApp } = await import('../support/testApp.js') const { setMailTransportForTests } = await import('../../server/services/mailer.js') - const { deleteDocument } = await import('../../server/services/appwriteAdmin.js') + const { deleteDocument, updateDocument } = await import('../../server/services/appwriteAdmin.js') const { default: publicTermineRoutes, setzeUhrFuerTests: setzeUhrPublic, resetPublicTermineForTests } = await import('../../server/routes/publicTermine.js') const { default: termineRoutes, setzeUhrFuerTests: setzeUhrKunde, resetTermineRoutesForTests } = await import('../../server/routes/termine.js') const { default: funktionenRoutes } = await import('../../server/routes/funktionen.js') const { normalisiereEinstellung } = await import('../../server/services/termine/einstellungen.js') - const { speichereEinstellung, resetKontextForTests } = await import('../../server/services/termine/kontext.js') + const { speichereEinstellung, speichereFragebogen, resetKontextForTests } = await import('../../server/services/termine/kontext.js') const { resetOutboxForTests, warteAufVersand } = await import('../../server/services/termine/outbox.js') const { resetTermineLimitsForTests } = await import('../../server/services/termine/limits.js') + const { SCHRITTE, leerFragebogen } = await import('../../server/services/termine/fragebogen.js') + const { featureConfig, featureHaken } = await import('../../server/services/featureRegistry.js') + const { hakenTexte, markOrdered } = await import('../../server/services/siteFeatures.js') + const { nachweisTexte, speichereZustimmung, zustimmungsfassungen } = await import('../../server/services/featureConsents.js') + const { hostingDoc, wartungDoc } = await import('../support/fixtures.js') // Testseiten (HTML aus dem Skript) kommen vom Server selbst: Chromium lehnt Anfragen von Seiten, die Playwright per // route.fulfill liefert, an 127.0.0.1 ab (Local Network Access); Seiten desselben Servers duerfen. @@ -233,15 +248,123 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url } }, + /** + * Wartungs-Abo des Kunden c1 tauschen: 'wartung' (laeuft), 'hosting' (nur Hosting-Abo, Grund kein_abo), 'keins' (gar kein Abo, kein_abo), + * 'gekuendigt' (Kuendigung vorgemerkt, Grund gekuendigt) oder 'nicht_aktiv' (Zahlung offen, Grund nicht_aktiv). + */ + async abo(zustand = 'wartung') { + for (const id of [...aw.docs('subscriptions').keys()]) await deleteDocument('subscriptions', id) + const docs = { + wartung: [wartungDoc], + hosting: [hostingDoc], + keins: [], + gekuendigt: [{ ...wartungDoc, cancelAtPeriodEnd: true }], + nicht_aktiv: [{ ...wartungDoc, status: 'past_due' }], + }[zustand] + if (!docs) throw new Error(`Unbekanntes Abo: ${zustand}`) + for (const doc of docs) aw.seed('subscriptions', doc) + }, + + /** Cookie einer Kundenansicht (Mitarbeiter Andrej sieht das Portal von c1) fuer page.context().addCookies([...]): { name, value, url }. */ + async kundenansichtCookie({ customerId = KUNDE } = {}) { + const { kundenansichtCookie } = await import('../support/fixtures.js') + const roh = kundenansichtCookie({ customerId }) + const i = roh.indexOf('=') + return { name: roh.slice(0, i), value: roh.slice(i + 1), url: app.url } + }, + + /** + * Entwurf wie nach der Branchenwahl, ohne alle Klicks: Funktionsdokument im Zustand off (mit siteKey), Einstellung (Vorgabe: salonEingabe, + * gespeichert als Entwurf, also auch ohne Pflichtangaben fuer Gaeste) und Fragebogen (Vorgabe: Branche salon, nichts bestaetigt; fragebogen + * ueberschreibt einzelne Felder, z. B. { bestaetigt: srv.SCHRITTE, bundeslandGewaehlt: true }). + */ + async entwurf({ eingabe = salonEingabe(), fragebogen = {} } = {}) { + const erg = normalisiereEinstellung(eingabe, { now: uhr(), entwurf: true }) + if (erg.fehler) throw new Error(`Einstellung ungueltig: ${JSON.stringify(erg.fehler)}`) + resetKontextForTests() + aw.seed('portalFeatures', srv.featureDoc('off')) + const doc = aw.docs('termineConfig').get(FEATURE) + await speichereEinstellung(srv.featureDoc('off'), erg.einstellung, { version: doc ? doc.version : 0, now: uhr() }) + await speichereFragebogen(srv.featureDoc('off'), () => ({ ...leerFragebogen(), branche: 'salon', ...fragebogen }), { by: `kunde:${KUNDE}`, now: uhr() }) + }, + + /** Gespeicherter Fragebogen (Objekt) oder null; Version der gespeicherten Einstellung (0 ohne Dokument). */ + fragebogen() { + const doc = aw.docs('termineConfig').get(FEATURE) + return doc?.fragebogen ? JSON.parse(doc.fragebogen) : null + }, + version() { return aw.docs('termineConfig').get(FEATURE)?.version ?? 0 }, + SCHRITTE, + + /** + * Bestellung im Zustand ordered, wie sie der Checkout hinterlaesst: eine offene Stripe-Sitzung im Nachbau, das Funktionsdokument ordered + * (markOrdered) und, mit mitZustimmung, der Zustimmungsnachweis dieser Sitzung (Texte des Registers samt Belehrung, Fassungen, Zeit). + * Setzt einen Entwurf voraus (srv.entwurf). Liefert die ID der Sitzung. + */ + async bestellt({ sitzungId = 'cs_test_ui_bestellt', mitZustimmung = true } = {}) { + const register = featureConfig('termine') + st.checkoutSessions.set(sitzungId, { + id: sitzungId, + object: 'checkout.session', + url: `https://checkout.stripe.test/c/pay/${sitzungId}`, + mode: 'payment', + customer: null, + client_reference_id: KUNDE, + subscription: null, + metadata: { customerId: KUNDE, projectId: PROJEKT, featureKey: 'termine' }, + status: 'open', + payment_status: 'unpaid', + invoice: null, + }) + const klickAt = uhr().toISOString() + await markOrdered({ customerId: KUNDE, projectId: PROJEKT, key: 'termine', sessionId: sitzungId, consent: { text: hakenTexte(register), at: klickAt, ip: '127.0.0.1', userAgent: 'ui-test' } }) + if (mitZustimmung) { + await speichereZustimmung({ + customerId: KUNDE, + projectId: PROJEKT, + featureKey: 'termine', + stripeSessionId: sitzungId, + fassungen: zustimmungsfassungen(register), + texte: nachweisTexte(register, { belehrung: (await featureHaken('termine').belehrung?.()) ?? null }), + ip: '127.0.0.1', + userAgent: 'ui-test', + konto: 'Silke Müller ', + at: klickAt, + }) + } + resetKontextForTests() + return sitzungId + }, + + /** + * Bestellung bezahlt, Einrichtungs-Ticket angelegt: das Funktionsdokument im Zustand setup mit Ticketnummer (Ticket im Nachbau, noch nicht + * geschlossen, sonst schaltete GET /api/funktionen frei) und Rechnungslink. Setzt ein Funktionsdokument voraus (srv.entwurf, srv.bestellt). + */ + async einrichtung({ woid = 100342, invoiceUrl = 'https://invoice.stripe.test/i/in_ui1' } = {}) { + aw.seed('workorders', { $id: 't_ui1', woid, status: 'In Progress' }) + await updateDocument('portalFeatures', FEATURE, { state: 'setup', enabled: false, purchaseStatus: 'paid', ticketId: 't_ui1', invoiceUrl }) + resetKontextForTests() + }, + + /** Zustimmungsnachweise loeschen (ein ordered ohne Nachweis: der Zustand nach einer abgelaufenen Sitzung oder einem Fehler). */ + async loescheZustimmungen() { + for (const id of [...aw.docs('featureConsents').keys()]) await deleteDocument('featureConsents', id) + }, + /** * Alles auf Anfang: Dokumente loeschen, Zaehler und Zwischenspeicher leeren, neue Senke, Einstellung und Funktion neu anlegen. * ohneEinstellung: die Funktion ist aktiv, aber es gibt noch keine Einstellung (erstes Oeffnen im Portal: Vorlage waehlen). + * entwurf: ein Kunde vor der ersten Branchenwahl (TP2): kein Funktionsdokument, keine Einstellung, ein laufendes Wartungs-Abo fuer c1 und ein + * Konto mit Firma und Anschrift; die Stripe-Sitzungen und Aufrufe des Nachbaus sind leer. */ - async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false } = {}) { + async zuruecksetzen({ eingabe, uhrNeu = true, ohneEinstellung = false, entwurf = false } = {}) { await warteAufVersand() - for (const collection of ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects']) { + const sammlungen = ['portalFeatures', 'termineConfig', 'termineBookings', 'termineClaims', 'termineOutbox', 'customers', 'websiteProjects', 'subscriptions', 'featureConsents', 'workorders'] + for (const collection of sammlungen) { for (const id of [...aw.docs(collection).keys()]) await deleteDocument(collection, id) } + st.checkoutSessions.clear() + st.calls.length = 0 resetKontextForTests() resetOutboxForTests() resetTermineLimitsForTests() @@ -259,7 +382,16 @@ export async function startTermineUiServer({ jetzt = JETZT, crossOrigin = false, srv.sink = mailSink() setMailTransportForTests(srv.sink) aw.seed('websiteProjects', { $id: PROJEKT, customerId: KUNDE, projectName: 'Salon Müller', subdomain: 'salon', liveDomain: 'salon-mueller.example' }) - aw.seed('customers', { $id: KUNDE, name: 'Silke Müller', email: 'silke@salon-mueller.example' }) + aw.seed('customers', { + $id: KUNDE, + name: 'Silke Müller', + email: 'silke@salon-mueller.example', + ...(entwurf ? { companyName: 'Salon Müller', street: 'Hauptstr. 1', postalCode: '67722', city: 'Winnweiler' } : {}), + }) + if (entwurf) { + await srv.abo('wartung') + return + } aw.seed('portalFeatures', srv.featureDoc()) if (!ohneEinstellung) await srv.speichere(anpassen(eingabe ?? salonEingabe())) }, From 06ebcd1b0aeab26cce988fd006b455067eb690de Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:47:58 +0000 Subject: [PATCH 04/28] Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein datenschutzAbsatz(einstellung, anrede) liefert den Absatz "Online-Terminbuchung" fuer die Datenschutzerklaerung der Betriebs-Website als reinen Text (Sie- und Du-Fassung, fuenf bedingte Saetze: Telefon-Feld, Nachricht/Zusatzfelder, Erinnerung, Widerruf ueber zeigtWiderruf, Telefon des Betriebs). RECHTSTEXTE_ENTWURF = true. Test pinnt den Wortlaut gegen die Markdown-Quelle in allen 32 Bedingungskombinationen. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/rechtstexte.js | 196 ++++++++++++++++++++++- test/termineRechtstexte.test.js | 212 ++++++++++++++++++++++++- 2 files changed, 403 insertions(+), 5 deletions(-) diff --git a/server/services/termine/rechtstexte.js b/server/services/termine/rechtstexte.js index 8b404ab..378d8b5 100644 --- a/server/services/termine/rechtstexte.js +++ b/server/services/termine/rechtstexte.js @@ -2,8 +2,9 @@ * ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen. * * Rechtstext-Bausteine des Terminplaners fuer Gaeste (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 11). - * Reine Funktionen und Konstanten, ohne Appwrite und ohne config. Die Texte sind Rechtstexte und stehen deshalb immer - * in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem Formular folgt der Anrede des Betriebs. + * Reine Funktionen und Konstanten, ohne Appwrite; sie nutzen nur Hilfsfunktionen aus einstellungen.js (zeigtWiderruf). Die Texte + * sind Rechtstexte und stehen deshalb immer in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem + * Formular und der Datenschutz-Absatz folgen der Anrede. * * widerrufsbelehrung: Muster der Anlage 1 und Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB * (docs/rechtliches/quellen/, abgerufen 2026-10-02), Gestaltungshinweise fuer einen Dienstleistungsvertrag angewendet: @@ -13,11 +14,20 @@ * Absatz zum elektronischen Webformular (es gibt nur die Widerrufsfunktion). test/termineRechtstexte.test.js prueft * den Wortlaut gegen die Quelldateien. * + * datenschutzAbsatz: Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs (Betrieb = Verantwortlicher). + * Der Wortlaut steht auch in docs/rechtliches/termine-datenschutz-absatz.md (Fassung Sie / Fassung Du); test/termineRechtstexte.test.js + * haelt beide gleich. Aenderungen am Text immer in Markdown und Funktion gemeinsam. + * * Die Rueckgabe ist reiner Text mit Zeilenumbruechen und NICHT HTML-maskiert: Die Mailvorlage maskiert beim Einsetzen. */ +import { zeigtWiderruf } from './einstellungen.js' + export const RECHTSTEXTE_VERSION = '2026-10-02' +/** true, solange die Rechtstexte als ENTWURF gelten (Kopf KOPF der Dokumente in docs/rechtliches/); Block F setzt false zusammen mit den Koepfen. */ +export const RECHTSTEXTE_ENTWURF = true + const ZEILENUMBRUCH = '\n' const ABSATZ = '\n\n' @@ -123,6 +133,13 @@ const LOESCHFRIST_ABGESAGT_TAGE = 30 const LOESCHFRIST_UNBESTAETIGT_STUNDEN = 48 const AUFBEWAHRUNG_VORGABE_TAGE = 90 +/** + * Aufbewahrung vergangener Termine in Tagen; ein nicht ganzzahliger Wert ist die Vorgabe (90). Den Bereich 14 bis 365 stellt die + * Einstellung sicher (normalisiereEinstellung); der Loeschlauf (jobs.js) prueft ihn zusaetzlich beim Lesen. + */ +const aufbewahrungNachTermin = (einstellung) => + Number.isInteger(einstellung?.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE + /** * Kurzer Datenschutz-Hinweis (Art. 13 DSGVO gestuft) vor dem Absenden; Sie oder Du nach einstellung.anrede. * Die Loeschfristen entsprechen dem Loeschlauf: nicht bestaetigt 48 Stunden nach Ablauf der Bestaetigungsfrist, abgesagt @@ -132,7 +149,7 @@ const AUFBEWAHRUNG_VORGABE_TAGE = 90 export function formularHinweis(einstellung) { const daten = betriebsdaten(einstellung?.betrieb) const du = einstellung.anrede === 'du' - const tage = Number.isInteger(einstellung.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE + const tage = aufbewahrungNachTermin(einstellung) const verantwortlich = verbinde([daten.name, daten.adresse]) const frist = `bis ${tage} Tage nach dem Termin, abgesagte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage, ` + `nicht bestätigte Buchungen ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf der Bestätigungsfrist.` @@ -147,6 +164,179 @@ export function formularHinweis(einstellung) { + `Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: ${verantwortlich}.` } +// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (recherche-recht.md 11.1) + +const DS_PROTOKOLL_TAGE = 14 // Server-Protokolle (Datenschutzerklaerung von WEBklar, Abschnitt 3) +const DS_EMAIL_WARTESCHLANGE = + 'Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, ' + + 'nicht zustellbare E-Mails nach 48 Stunden.' + +/** Auftragsverarbeiter und Server: in beiden Anreden derselbe Text. */ +const DS_AUFTRAGSVERARBEITER = + 'Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, ' + + 'als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, ' + + '91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. ' + + 'Eine Übermittlung in Drittländer findet nicht statt.' + +const wenn = (bedingung, text) => (bedingung ? text : '') +/** Sätze eines Absatzes, durch ein Leerzeichen getrennt; ein weggelassener Satz (leer) hinterlässt keine Lücke. */ +const saetze = (...liste) => liste.filter(Boolean).join(' ') + +/** + * Die fuenf bedingten Stellen des Absatzes (in der Markdown-Quelle „[[wenn …: …]]“): Telefon-Feld, Nachricht oder Zusatzfelder, + * Erinnerung, Widerruf (wie der Widerrufsknopf im Widget: zeigtWiderruf), Telefonnummer des Betriebs. + */ +function datenschutzBedingungen(einstellung, daten) { + const gast = einstellung.gastFelder || {} + return { + telefonFeld: gast.telefon !== 'aus', + freitext: gast.nachricht !== 'aus' || (Array.isArray(gast.zusatz) && gast.zusatz.length > 0), + erinnerung: Boolean(einstellung.erinnerung?.anbieten), + widerruf: zeigtWiderruf(einstellung), + telefon: daten.telefon !== '', + } +} + +// Absaetze der Sie-Fassung, je Absatz eine kleine Funktion; d = { firma, verantwortlich, email, telefon, tage, ...Bedingungen } +const DS_SIE = [ + () => 'Online-Terminbuchung', + () => 'Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:', + (d) => + 'Welche Daten? Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz' + + wenn(d.telefonFeld, ', auf Wunsch Ihre Telefonnummer') + + wenn(d.freitext, ' und die Angaben, die Sie uns im Formular mitteilen') + + '. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben ' + + 'und welche Zustimmungen Sie erteilt haben' + + wenn(d.erinnerung, ', zum Beispiel ob Sie eine Erinnerung gewünscht haben') + + '.', + (d) => + saetze( + 'Wofür und auf welcher Grundlage?', + 'Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren.', + 'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage).', + wenn( + d.erinnerung, + 'Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. ' + + 'Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen.', + ), + wenn( + d.widerruf, + 'Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit ' + + 'und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).', + ), + 'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse ' + + 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).', + ), + (d) => + 'E-Mails. Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung' + + wenn(d.erinnerung, ' und, wenn Sie das angekreuzt haben, eine Erinnerung') + + `. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. ` + + `Absender ist ${d.firma}, Ihre Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`, + () => DS_AUFTRAGSVERARBEITER, + () => + 'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), ' + + 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. ' + + `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). ` + + 'Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.', + (d) => + `Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. ` + + `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. ` + + 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. ' + + 'Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. ' + + 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.', + (d) => + saetze( + 'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.', + wenn(d.telefon, `Sie können uns auch telefonisch erreichen: ${d.telefonnummer}.`), + 'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.', + ), + (d) => + 'Ihre Rechte. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, ' + + 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. ' + + `Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`, +] + +// Absaetze der Du-Fassung, gleicher Aufbau wie DS_SIE +const DS_DU = [ + () => 'Online-Terminbuchung', + () => 'Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:', + (d) => + 'Welche Daten? Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz' + + wenn(d.telefonFeld, ', auf Wunsch deine Telefonnummer') + + wenn(d.freitext, ' und die Angaben, die du uns im Formular mitteilst') + + '. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast ' + + 'und welche Zustimmungen du erteilt hast' + + wenn(d.erinnerung, ', zum Beispiel ob du eine Erinnerung gewünscht hast') + + '.', + (d) => + saetze( + 'Wofür und auf welcher Grundlage?', + 'Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren.', + 'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage).', + wenn( + d.erinnerung, + 'Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. ' + + 'Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst.', + ), + wenn( + d.widerruf, + 'Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit ' + + 'und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).', + ), + 'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse ' + + 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).', + ), + (d) => + 'E-Mails. Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung' + + wenn(d.erinnerung, ' und, wenn du das angekreuzt hast, eine Erinnerung') + + `. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. ` + + `Absender ist ${d.firma}, deine Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`, + () => DS_AUFTRAGSVERARBEITER, + () => + 'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), ' + + 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. ' + + `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). ` + + 'Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.', + (d) => + `Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. ` + + `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. ` + + 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. ' + + 'Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. ' + + 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.', + (d) => + saetze( + 'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.', + wenn(d.telefon, `Du kannst uns auch telefonisch erreichen: ${d.telefonnummer}.`), + 'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.', + ), + (d) => + 'Deine Rechte. Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, ' + + 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. ' + + `Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`, +] + +/** + * Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs, als reiner Text (nicht HTML-maskiert): + * Absaetze durch eine Leerzeile, erste Zeile „Online-Terminbuchung“. anrede === 'du' ist die Du-Fassung, alles andere die Sie-Fassung + * (wie formularHinweis); die Anrede kommt aus dem Parameter, nicht aus einstellung.anrede. Sätze, die zu abgeschalteten Funktionen + * gehören, fehlen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs, siehe datenschutzBedingungen). + * Die Aufbewahrung nach dem Termin ist einstellung.aufbewahrungTage (ungültig: 90). Fehlt der Betriebsname oder die E-Mail-Adresse: + * TypeError (wie bei den anderen Rechtstexten); eine fehlende Anschrift steht einfach nicht im Text. + */ +export function datenschutzAbsatz(einstellung, anrede) { + const daten = betriebsdaten(einstellung?.betrieb) + const absaetze = anrede === 'du' ? DS_DU : DS_SIE + const d = { + ...datenschutzBedingungen(einstellung, daten), + firma: daten.name, + verantwortlich: verbinde([daten.name, daten.adresse]), + email: daten.email, + telefonnummer: daten.telefon, + tage: aufbewahrungNachTermin(einstellung), + } + return absaetze.map((absatz) => absatz(d)).join(ABSATZ) +} + // ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben /** diff --git a/test/termineRechtstexte.test.js b/test/termineRechtstexte.test.js index 88355bb..b5c3e28 100644 --- a/test/termineRechtstexte.test.js +++ b/test/termineRechtstexte.test.js @@ -3,8 +3,10 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import { RECHTSTEXTE_VERSION, + RECHTSTEXTE_ENTWURF, widerrufsbelehrung, formularHinweis, + datenschutzAbsatz, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, @@ -19,6 +21,10 @@ const KOPF = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen' const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8') const zeilen = (text) => text.split('\n').map((z) => z.trim()) +// Wortlisten fuer die Anredepruefung: Du-Woerter duerfen in der Sie-Fassung nicht stehen und umgekehrt +const DU_WOERTER = /\bdein|\bdu\b|\bdir\b|\bdich\b/i +const SIE_WOERTER = /\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/ + const BETRIEB = { name: 'Salon Müller & Söhne', adresse: 'Hauptstraße 1, 67722 Winnweiler', telefon: '06301 1234', email: 'info@salon-mueller.de' } const KONTAKT = 'Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, Telefon: 06301 1234, E-Mail: info@salon-mueller.de' // Platzhalter-Betrieb: so steht die Belehrung in docs/rechtliches/termine-widerrufsbelehrung-muster.md @@ -214,6 +220,208 @@ test('Formular-Hinweis: Sie ist die Vorgabe, Aufbewahrung 90 Tage ohne gültigen assert.throws(() => formularHinweis(undefined), TypeError) }) +// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (Quelle der Einbauanleitung und der Doku) + +test('Rechtstexte sind Entwurf, bis Block F die ENTWURF-Köpfe und diese Konstante zusammen entfernt', () => { + assert.equal(RECHTSTEXTE_ENTWURF, true) +}) + +const DS_SCHALTER = ['telefonFeld', 'freitext', 'erinnerung', 'widerruf', 'telefon'] +// Bedingungstext der Quelle ([[wenn …: …]]) -> Schalter; eine unbekannte Bedingung in der Quelle lässt den Test scheitern +const DS_BEDINGUNGEN = [ + [/^Telefon-Feld aktiv$/, 'telefonFeld'], + [/^Nachricht oder Zusatzfelder aktiv$/, 'freitext'], + [/^Erinnerung angeboten$/, 'erinnerung'], + [/^Gäste Verbraucher sein können$/, 'widerruf'], + [/^Telefon hinterlegt$/, 'telefon'], +] +// Satz bzw. Satzteil, den jeder Schalter einschaltet (Sie- und Du-Fassung) +const DS_MARKER = { + telefonFeld: /Telefonnummer/, + freitext: /im Formular mitteil/, + erinnerung: /Erinnerung/, + widerruf: /Vertrag widerrufen/, + telefon: /telefonisch erreichen/, +} +const DS_ALLE_AN = Object.fromEntries(DS_SCHALTER.map((k) => [k, true])) +const DS_ALLE_AUS = Object.fromEntries(DS_SCHALTER.map((k) => [k, false])) + +/** Echte, normalisierte Einstellung; jeder Schalter steuert genau die Einstellung, aus der die Funktion ihn ableitet. */ +function dsEinstellung(zustand, extra = {}) { + return { + anrede: 'sie', + aufbewahrungTage: 90, + betrieb: { ...BETRIEB, telefon: zustand.telefon ? BETRIEB.telefon : '' }, + gastFelder: { telefon: zustand.telefonFeld ? 'optional' : 'aus', nachricht: zustand.freitext ? 'optional' : 'aus', zusatz: [] }, + erinnerung: { anbieten: zustand.erinnerung, stundenVorher: 24 }, + recht: { gaesteSind: zustand.widerruf ? 'verbraucher' : 'unternehmer', freizeit: false }, + ressourcen: [{ id: 'r1', aktiv: true }], + leistungen: [{ id: 'l1', aktiv: true, ressourcen: ['r1'] }], + ...extra, + } +} + +/** Absätze durch Leerzeile, innerhalb eines Absatzes jeder Leerraum ein Leerzeichen. */ +const normalisiere = (text) => text.split(/\n[ \t]*\n/).map((a) => a.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n\n') + +/** + * Datenschutz-Absatz aus der Markdown-Quelle: Blockquote der Fassung („Sie“ | „Du“), `> `, `**` und `###` entfernt, + * [[wenn …: X]] durch X (Schalter an) oder nichts (Schalter aus) ersetzt, Platzhalter mit den Testwerten gefüllt. + * Ein Satzteil, der mit einem Wort beginnt („und die Angaben …“), steht in der Quelle ohne Leerzeichen am Vordertext + * („Platz[[…: und die Angaben …]]“); er wird hier mit Leerzeichen angefügt (Satzteile mit Komma bleiben unmittelbar dran). + */ +function datenschutzQuelle(fassung, zustand, { tage = 90 } = {}) { + const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '') + const teil = md.split(/^## Fassung /m).slice(1).find((t) => t.startsWith(fassung)) + assert.ok(teil, `Fassung ${fassung} fehlt in der Quelle`) + const zitat = teil.split('\n').filter((z) => z.startsWith('>')).map((z) => z.replace(/^>\s?/, '').replace(/^### /, '')) + assert.ok(zitat.length > 10, 'Blockquote der Quelle nicht gefunden') + const roh = zitat.join('\n').replace(/\*\*/g, '') + const bedingt = roh.replace(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g, (_, bedingung, satz, offset, ganz) => { + const eintrag = DS_BEDINGUNGEN.find(([muster]) => muster.test(bedingung)) + assert.ok(eintrag, `unbekannte Bedingung in der Quelle: ${bedingung}`) + if (!zustand[eintrag[1]]) return '' + return /^[A-Za-zÄÖÜäöüß]/.test(satz) && offset > 0 && !/\s/.test(ganz[offset - 1]) ? ` ${satz}` : satz + }) + const werte = { Firma: BETRIEB.name, Anschrift: BETRIEB.adresse, 'E-Mail': BETRIEB.email, Telefon: BETRIEB.telefon, Tage: String(tage) } + const gefuellt = bedingt.replace(/\{\{([^}]+)\}\}/g, (_, name) => { + assert.ok(Object.hasOwn(werte, name), `unbekannter Platzhalter in der Quelle: ${name}`) + return werte[name] + }) + assert.ok(!/\[\[|\]\]|\{\{|\}\}/.test(gefuellt), 'offene Klammer nach dem Ersetzen') + return normalisiere(gefuellt) +} + +const alleZustaende = () => + Array.from({ length: 2 ** DS_SCHALTER.length }, (_, maske) => Object.fromEntries(DS_SCHALTER.map((k, i) => [k, Boolean(maske & (1 << i))]))) + +test('Datenschutz-Absatz: Wortlaut entspricht der Markdown-Quelle, beide Anreden, alle 32 Bedingungskombinationen', () => { + for (const [fassung, anrede] of [['Sie', 'sie'], ['Du', 'du']]) { + for (const zustand of alleZustaende()) { + assert.equal( + normalisiere(datenschutzAbsatz(dsEinstellung(zustand), anrede)), + datenschutzQuelle(fassung, zustand), + `${fassung} ${JSON.stringify(zustand)}`, + ) + } + } +}) + +test('Datenschutz-Absatz: Ausgabe ist schon normalisiert (Absätze durch Leerzeile, keine doppelten Leerzeichen, kein Platzhalterrest)', () => { + for (const anrede of ['sie', 'du']) { + for (const zustand of alleZustaende()) { + const text = datenschutzAbsatz(dsEinstellung(zustand), anrede) + assert.equal(text, normalisiere(text), `${anrede} ${JSON.stringify(zustand)}`) + assert.ok(!/ {2}| [,.;:]|\[\[|\]\]|\{\{|\}\}|undefined|null|NaN/.test(text), `${anrede} ${JSON.stringify(zustand)}`) + assert.ok(text.startsWith('Online-Terminbuchung\n\n')) + assert.equal(text.split('\n\n')[0], 'Online-Terminbuchung') + } + } +}) + +test('Datenschutz-Absatz: gleiche Quelle auch mit anderer Aufbewahrung (Pin mit 365 Tagen)', () => { + const einstellung = dsEinstellung(DS_ALLE_AN, { aufbewahrungTage: 365 }) + assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'sie')), datenschutzQuelle('Sie', DS_ALLE_AN, { tage: 365 })) + assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'du')), datenschutzQuelle('Du', DS_ALLE_AN, { tage: 365 })) +}) + +test('Datenschutz-Absatz: jede Bedingung schaltet genau ihren Satz', () => { + for (const anrede of ['sie', 'du']) { + for (const schalter of DS_SCHALTER) { + const aus = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AN, [schalter]: false }), anrede) + const an = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AUS, [schalter]: true }), anrede) + for (const [name, muster] of Object.entries(DS_MARKER)) { + assert.equal(muster.test(aus), name !== schalter, `${anrede}: ${schalter} aus, Marker ${name}`) + assert.equal(muster.test(an), name === schalter, `${anrede}: nur ${schalter} an, Marker ${name}`) + } + } + } +}) + +test('Datenschutz-Absatz: die Bedingungen lesen die Einstellung wie das Widget', () => { + const alleAn = dsEinstellung(DS_ALLE_AN) + const text = (e, anrede = 'sie') => datenschutzAbsatz(e, anrede) + // Telefon-Feld: nur „aus“ blendet aus, optional und Pflicht zählen + assert.ok(DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'pflicht', nachricht: 'aus', zusatz: [] } }))) + assert.ok(!DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] } }))) + // Nachricht oder Zusatzfelder: eines von beiden genügt + const aus = { telefon: 'aus', nachricht: 'aus' } + assert.ok(!DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [] } }))) + assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, nachricht: 'pflicht', zusatz: [] } }))) + assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [{ key: 'wunsch', label: 'Wunsch', typ: 'text', optionen: [], pflicht: false }] } }))) + // Widerruf: genau die Bedingung des Widerrufsknopfs (Verbraucher-Gäste und mindestens eine sichtbare Leistung) + assert.ok(DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'gemischt', freizeit: false } }))) + assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'unternehmer', freizeit: false } }))) + assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'verbraucher', freizeit: true } }))) + assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, leistungen: [{ id: 'l1', aktiv: false, ressourcen: ['r1'] }] }))) + assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, ressourcen: [{ id: 'r1', aktiv: false }] }))) + // Telefon des Betriebs: Leerraum zählt als nicht hinterlegt + assert.ok(!DS_MARKER.telefon.test(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' ' } }))) + assert.ok(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' 06301\n1234 ' } }).includes('telefonisch erreichen: 06301 1234.')) +}) + +test('Datenschutz-Absatz: Aufbewahrung nach dem Termin, ungültiger Wert wird zu 90 Tagen (wie der Formular-Hinweis)', () => { + for (const anrede of ['sie', 'du']) { + const mit = (aufbewahrungTage) => datenschutzAbsatz(dsEinstellung(DS_ALLE_AN, { aufbewahrungTage }), anrede) + assert.ok(mit(365).includes('Bestätigte Termine löschen wir 365 Tage nach dem Termin,')) + assert.ok(mit(14).includes('löschen wir 14 Tage nach dem Termin,')) + for (const ungueltig of ['viel', undefined, null, 14.5, NaN]) { + assert.ok(mit(ungueltig).includes('löschen wir 90 Tage nach dem Termin,'), `${anrede}: ${String(ungueltig)}`) + } + } +}) + +test('Datenschutz-Absatz: Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter', () => { + for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) { + const sie = datenschutzAbsatz(dsEinstellung(zustand), 'sie') + const du = datenschutzAbsatz(dsEinstellung(zustand), 'du') + assert.ok(!DU_WOERTER.test(sie), 'Du-Form in der Sie-Fassung') + assert.ok(!SIE_WOERTER.test(du), 'Sie-Form in der Du-Fassung') + assert.ok(/Sie|Ihr/.test(sie) && /\bdu\b|\bdein/.test(du), 'Fassungen sind nicht zu unterscheiden') + assert.ok(sie.includes('Auf unserer Website können Sie Termine online buchen.')) + assert.ok(du.includes('Auf unserer Website kannst du Termine online buchen.')) + } +}) + +test('Datenschutz-Absatz: die Anrede kommt aus dem Parameter, alles außer „du“ ist die Sie-Fassung', () => { + const einstellung = dsEinstellung(DS_ALLE_AN, { anrede: 'du' }) + assert.ok(datenschutzAbsatz(einstellung, 'sie').includes('können Sie Termine online buchen')) + assert.ok(datenschutzAbsatz(einstellung, 'du').includes('kannst du Termine online buchen')) + for (const anrede of [undefined, 'unbekannt', 'Du', null]) { + assert.equal(datenschutzAbsatz(einstellung, anrede), datenschutzAbsatz(einstellung, 'sie'), String(anrede)) + } +}) + +test('Datenschutz-Absatz: Name und E-Mail sind Pflicht (TypeError), eine leere Anschrift lässt kein leeres Komma stehen', () => { + const ohne = (feld) => ({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, [feld]: undefined } }) + assert.throws(() => datenschutzAbsatz(ohne('name'), 'sie'), TypeError) + assert.throws(() => datenschutzAbsatz(ohne('email'), 'du'), TypeError) + assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, name: ' \n ' } }, 'sie'), TypeError) + assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, email: '' } }, 'sie'), TypeError) + assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: undefined }, 'sie'), TypeError) + assert.throws(() => datenschutzAbsatz({}, 'sie'), TypeError) + assert.throws(() => datenschutzAbsatz(undefined, 'sie'), TypeError) + + for (const adresse of ['', ' ', undefined]) { + for (const anrede of ['sie', 'du']) { + const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, adresse } }, anrede) + assert.ok(text.endsWith('Verantwortlich ist Salon Müller & Söhne, E-Mail info@salon-mueller.de.'), `${anrede}: ${String(adresse)}`) + assert.ok(!/, ,|,,|, E-Mail , /.test(text)) + } + } + const voll = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), 'sie') + assert.ok(voll.endsWith('Verantwortlich ist Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, E-Mail info@salon-mueller.de.')) +}) + +test('Datenschutz-Absatz: reiner Text, nicht HTML-maskiert, Betriebsangaben unverändert eingesetzt (kein Ersetzungsmuster)', () => { + const betrieb = { name: "Frisör $& $1 'Anna' & Co", adresse: 'Weg 1,\n67722 Winnweiler', telefon: '06301 $` 1234', email: 'a&b@salon.de' } + const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb }, 'sie') + assert.ok(text.includes("Absender ist Frisör $& $1 'Anna' & Co, Ihre Antworten")) + assert.ok(text.includes("Verantwortlich ist Frisör $& $1 'Anna' & Co, Weg 1, 67722 Winnweiler, E-Mail a&b@salon.de.")) + assert.ok(text.includes('telefonisch erreichen: 06301 $` 1234.')) + assert.ok(!text.includes('&') && !text.includes('<')) +}) + // ---------------------------------------------------------------- Dokumente const DOKUMENTE = [ @@ -260,8 +468,8 @@ test('Datenschutz-Absatz: Löschfristen der Vorgaben, Sie- und Du-Fassung, Auftr assert.ok(f.includes('Art. 6 Abs. 1 lit. b DSGVO') && f.includes('Art. 6 Abs. 1 lit. a DSGVO')) } assert.ok(fassungen[0].startsWith('Sie') && fassungen[1].startsWith('Du')) - assert.ok(!/\bdein|\bdu\b|\bdir\b|\bdich\b/i.test(fassungen[0]), 'Du-Form in der Sie-Fassung') - assert.ok(!/\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/.test(fassungen[1]), 'Sie-Form in der Du-Fassung') + assert.ok(!DU_WOERTER.test(fassungen[0]), 'Du-Form in der Sie-Fassung') + assert.ok(!SIE_WOERTER.test(fassungen[1]), 'Sie-Form in der Du-Fassung') }) test('6c-Entwurf: nur als Entwurfsdatei, public/datenschutz.html bleibt unverändert (ohne Abschnitt 6c)', () => { From ee454017b0d0e243e6e2625764492bb416b527e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 23:55:42 +0000 Subject: [PATCH 05/28] Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben einbauSnippet und esc wandern aus routes/termine.js nach services/termine/einbauanleitung.js (Parameter statt ctx/config). Neu: Modus knopf mit data-knopf (nur bei gesetztem Text, hoechstens 40 Zeichen, Zeilenumbrueche zu Leerzeichen, maskiert; unbekannter Modus gilt als inline), WCAG- Helfer luminanz/kontrast/textFarbeAuf 1:1 aus dem Widget und cssVorschlag(farbe) mit Warnung, wenn der Akzent gegen Weiss unter 3 : 1 liegt. VORGABE_FARBE wird exportiert. Die Kundenansicht bleibt unveraendert (Modus inline). Farbparitaet Widget/Server ueber alle 4096 Farben im 17er-Raster getestet. Co-Authored-By: Claude Sonnet 5.5 --- server/routes/termine.js | 17 +- server/services/termine/einbauanleitung.js | 121 ++++++++++++ server/services/termine/einstellungen.js | 2 +- test/termineEinbauanleitung.test.js | 206 +++++++++++++++++++++ test/termineEmbedStatic.test.js | 19 ++ 5 files changed, 349 insertions(+), 16 deletions(-) create mode 100644 server/services/termine/einbauanleitung.js create mode 100644 test/termineEinbauanleitung.test.js diff --git a/server/routes/termine.js b/server/routes/termine.js index 40878c5..28ea5dd 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -12,6 +12,7 @@ import { createWriteLimiter } from '../services/writeLimiter.js' import { verschiebeZeiten } from '../services/termine/ablauf.js' import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js' import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' +import { einbauSnippet } from '../services/termine/einbauanleitung.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' import { loeschTag, loeschzeit } from '../services/termine/frist.js' import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js' @@ -278,20 +279,6 @@ function feedLinks(ctx) { return links } -const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z]) - -/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */ -function einbauSnippet(ctx) { - const telefon = ctx.einstellung?.betrieb?.telefon - const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.' - return [ - `
`, - `

${ausweich}

`, - '
', - ``, - ].join('\n') -} - router.use((_req, res, next) => { res.set('Cache-Control', 'no-store') next() @@ -341,7 +328,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou bestellung: bestellbar ? await bestellungAnsicht(register) : null, feeds: e && state === 'active' ? feedLinks(ctx) : {}, siteKey: ctx.feature.siteKey || null, - einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {}, + einbau: laeuft ? { snippet: einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }) } : {}, anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0, fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar }) diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js new file mode 100644 index 0000000..d13541b --- /dev/null +++ b/server/services/termine/einbauanleitung.js @@ -0,0 +1,121 @@ +import { VORGABE_FARBE } from './einstellungen.js' + +/** + * Einbau des Terminplaners auf der Website des Kunden: Bausteine fuer Einbau-Code und Farbvorschlag + * (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt Einbauanleitung; Anleitung fuer das Team: + * docs/funktionen/termine-einbau.md, Abschnitte 1 und 3). + * + * Reine Funktionen ohne Appwrite und ohne config: die Adresse des Portals (`apiBase`) und alle Werte kommen als Parameter. + * Der Einbau-Code stand bis TP2 in routes/termine.js; GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert). + * + * Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende), + * bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js + * haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit. + */ + +const FARBE_RE = /^#[0-9a-fA-F]{6}$/ +const KNOPF_MAX = 40 +const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe + +// ---------------------------------------------------------------- Text + +/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */ +export function esc(text) { + return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z]) +} + +/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */ +function knopfTextBereinigt(roh) { + if (typeof roh !== 'string') return '' + const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim() + return Array.from(eine).slice(0, KNOPF_MAX).join('') +} + +// ---------------------------------------------------------------- Einbau-Code + +/** + * Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript. + * modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt + * als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen". + */ +export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) { + const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : '' + const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.' + const attribute = [ + `data-webklar-termine="${esc(siteKey)}"`, + `data-api="${esc(apiBase)}"`, + `data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`, + ...(knopf ? [`data-knopf="${esc(knopf)}"`] : []), + ] + return [ + `
`, + `

${ausweich}

`, + '
', + ``, + ].join('\n') +} + +// ---------------------------------------------------------------- Farben + +/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */ +export function luminanz(farbe) { + const rgb = [1, 3, 5].map((i) => { + const c = parseInt(farbe.slice(i, i + 2), 16) / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + }) + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] +} + +/** Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21), wie kontrast() im Widget. */ +const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + +/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */ +export function kontrast(hexA, hexB) { + return verhaeltnis(luminanz(hexA), luminanz(hexB)) +} + +/** + * Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1 + * (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe). 1:1 aus dem Widget. + */ +export function textFarbeAuf(farbe) { + const l = luminanz(farbe) + const weiss = verhaeltnis(l, 1) + const dunkel = verhaeltnis(l, luminanz('#111827')) + const beste = Math.max(weiss, dunkel) + if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000' + return weiss >= dunkel ? '#ffffff' : '#111827' +} + +/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */ +const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',') + +/** + * CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus + * `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen; + * alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe. + * Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift), + * kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der + * Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund. + */ +export function cssVorschlag(farbe) { + const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE + const akzentText = textFarbeAuf(akzent) + const textKontrast = kontrast(akzent, akzentText) + const seitenKontrast = kontrast(akzent, '#ffffff') + const hinweise = [] + if (seitenKontrast < MIN_RAHMEN) { + hinweise.push( + `Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` + + `mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.` + ) + } + const css = [ + `/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`, + '[data-webklar-termine]{', + ` --wkt-akzent:${akzent};`, + ` --wkt-akzent-text:${akzentText};`, + '}', + ].join('\n') + return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise } +} diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index 5478dae..1796bec 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -12,7 +12,7 @@ import { heute, zuUtc } from './zeit.js' * Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal). */ -const VORGABE_FARBE = '#7c3aed' +export const VORGABE_FARBE = '#7c3aed' const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 } const RASTER = [5, 10, 15, 20, 30, 60] const ERINNERUNG_STUNDEN = [2, 12, 24, 48] diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js new file mode 100644 index 0000000..005fc79 --- /dev/null +++ b/test/termineEinbauanleitung.test.js @@ -0,0 +1,206 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { VORGABE_FARBE } from '../server/services/termine/einstellungen.js' +import { cssVorschlag, einbauSnippet, esc, kontrast, luminanz, textFarbeAuf } from '../server/services/termine/einbauanleitung.js' + +// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config. +// Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js. + +const SITE_KEY = 'abcdefghijklmnop' +const API = 'https://project.webklar.com' + +/** Das Snippet aus Abschnitt 1 von docs/funktionen/termine-einbau.md: SITEKEY ersetzt, der Erklaer-Kommentar am Zeilenende entfernt. */ +function snippetAusDoku() { + const doku = readFileSync(new URL('../docs/funktionen/termine-einbau.md', import.meta.url), 'utf8') + const abschnitt = doku.split('## 1. Das Snippet')[1] + assert.ok(abschnitt, 'Abschnitt 1 fehlt in termine-einbau.md') + const block = /```html\n([\s\S]*?)\n```/.exec(abschnitt) + assert.ok(block, 'Code-Block in Abschnitt 1 fehlt') + return block[1] + .replace('SITEKEY', SITE_KEY) + .split('\n') + .map((zeile) => zeile.replace(/\s*\s*$/, '')) + .join('\n') +} + +const basis = { siteKey: SITE_KEY, apiBase: API, telefon: '06301 1234' } + +// ---------------------------------------------------------------- esc + +test('esc maskiert & < > " und macht aus allem anderen Text', () => { + assert.equal(esc('a & b < c > d "e"'), 'a & b < c > d "e"') + assert.equal(esc(42), '42') + assert.equal(esc(undefined), '') + assert.equal(esc(null), '') +}) + +// ---------------------------------------------------------------- Snippet + +test('Snippet inline ist zeichengleich zu Abschnitt 1 von termine-einbau.md', () => { + assert.equal(einbauSnippet(basis), snippetAusDoku()) + assert.equal(einbauSnippet({ ...basis, modus: 'inline' }), snippetAusDoku()) +}) + +test('Snippet: Telefon wird maskiert, ohne Telefon steht der allgemeine Ausweichtext', () => { + const mit = einbauSnippet({ ...basis, telefon: '06301 <12>' }) + assert.ok(mit.includes('

Termine telefonisch unter 06301 <12>.

'), mit) + assert.ok(!mit.includes('<12>')) + for (const telefon of [undefined, null, '']) { + assert.ok(einbauSnippet({ siteKey: SITE_KEY, apiBase: API, telefon }).includes('

Termine telefonisch vereinbaren.

'), String(telefon)) + } +}) + +test('Snippet: siteKey und Adresse sind maskiert (kein Ausbrechen aus dem Attribut)', () => { + const s = einbauSnippet({ siteKey: 'a">'), s) + assert.equal((s.match(/`)) +}) + +test('Snippet im Modus knopf ohne Text: kein data-knopf (das Widget nimmt "Termin buchen")', () => { + for (const knopfText of [undefined, null, '', ' ', '\n\t']) { + const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText }) + assert.ok(s.includes('data-modus="knopf">'), JSON.stringify(knopfText)) + assert.ok(!s.includes('data-knopf'), JSON.stringify(knopfText)) + } +}) + +test('Snippet: der Knopf-Text ist hoechstens 40 Zeichen lang, Zeilenumbrueche werden zu Leerzeichen', () => { + const lang = `${'a'.repeat(30)}\n${'b'.repeat(29)}` + assert.equal(lang.length, 60) + const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: lang }) + const wert = /data-knopf="([^"]*)"/.exec(s)[1] + assert.equal(wert.length, 40) + assert.equal(wert, `${'a'.repeat(30)} ${'b'.repeat(9)}`) + assert.ok(!/[\r\n]/.test(wert)) + for (const umbruch of ['\r\n', '\r', '\u2028']) { + const t = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `Jetzt${umbruch}buchen` }) + assert.ok(t.includes('data-knopf="Jetzt'), t) + assert.ok(!/data-knopf="[^"]*[\r\n\u2028]/.test(t), JSON.stringify(umbruch)) + } + assert.equal(/data-knopf="([^"]*)"/.exec(einbauSnippet({ ...basis, modus: 'knopf', knopfText: 'Jetzt\nbuchen' }))[1], 'Jetzt buchen') + // gekuerzt wird der Rohtext, nicht der maskierte: 40 Anfuehrungszeichen sind 40 Zeichen, nicht 240 + const ang = einbauSnippet({ ...basis, modus: 'knopf', knopfText: '"'.repeat(60) }) + assert.equal(/data-knopf="([^"]*)"/.exec(ang)[1], '"'.repeat(40)) +}) + +test('Snippet: der Text kuerzt nie mitten in einem Zeichen aus zwei Code-Einheiten', () => { + const s = einbauSnippet({ ...basis, modus: 'knopf', knopfText: `${'a'.repeat(39)}😀😀` }) + const wert = /data-knopf="([^"]*)"/.exec(s)[1] + assert.equal(wert, `${'a'.repeat(39)}😀`) +}) + +test('Snippet: ein Knopf-Text im Modus inline und ein unbekannter Modus ergeben das Inline-Snippet', () => { + assert.equal(einbauSnippet({ ...basis, modus: 'inline', knopfText: 'Buchen' }), snippetAusDoku()) + for (const modus of ['dialog', 'KNOPF', '', null, 42, {}]) { + assert.equal(einbauSnippet({ ...basis, modus, knopfText: 'Buchen' }), snippetAusDoku(), JSON.stringify(modus)) + } +}) + +// ---------------------------------------------------------------- Farben + +test('luminanz und kontrast: Eckwerte', () => { + assert.equal(luminanz('#ffffff'), 1) + assert.equal(luminanz('#000000'), 0) + assert.equal(kontrast('#000000', '#ffffff'), 21) + assert.equal(kontrast('#ffffff', '#000000'), 21, 'Reihenfolge egal') + assert.equal(kontrast('#7c3aed', '#7c3aed'), 1) +}) + +test('textFarbeAuf: Weiss auf dunklen, dunkles Grau auf hellen, Schwarz nur wo beides unter 4,5 : 1 bleibt', () => { + assert.equal(textFarbeAuf('#0f766e'), '#ffffff') + assert.equal(textFarbeAuf('#f5c542'), '#111827') + assert.equal(textFarbeAuf('#000000'), '#ffffff') + assert.equal(textFarbeAuf('#ffffff'), '#111827') + assert.equal(textFarbeAuf('#9d4b6f'), '#ffffff') + // mittlere Toene, bei denen weder Weiss noch das dunkle Grau 4,5 : 1 erreicht + assert.equal(textFarbeAuf('#808080'), '#000000') + assert.ok(kontrast('#808080', '#ffffff') < 4.5 && kontrast('#808080', '#111827') < 4.5) + assert.equal(textFarbeAuf('#6b7280'), '#ffffff') +}) + +/** Alle 4096 Farben mit Kanalwerten in 17er-Schritten (0, 17, ... 255). */ +function alleFarben() { + const stufen = Array.from({ length: 16 }, (_, i) => i * 17) + const hex = (c) => c.toString(16).padStart(2, '0') + const liste = [] + for (const r of stufen) for (const g of stufen) for (const b of stufen) liste.push(`#${hex(r)}${hex(g)}${hex(b)}`) + return liste +} + +test('textFarbeAuf: jede der 4096 Farben erreicht mit ihrer Schrift mindestens 4,5 : 1', () => { + const farben = alleFarben() + assert.equal(farben.length, 4096) + for (const farbe of farben) { + const text = textFarbeAuf(farbe) + assert.ok(['#ffffff', '#111827', '#000000'].includes(text), `${farbe}: ${text}`) + assert.ok(kontrast(farbe, text) >= 4.5, `${farbe}: ${text} nur ${kontrast(farbe, text).toFixed(2)} : 1`) + } +}) + +// ---------------------------------------------------------------- CSS-Vorschlag + +test('cssVorschlag: Kommentar, Selektor und beide Variablen', () => { + const v = cssVorschlag('#9d4b6f') + assert.ok(v.css.includes('--wkt-akzent:#9d4b6f;'), v.css) + assert.ok(v.css.includes('--wkt-akzent-text:#ffffff;'), v.css) + assert.ok(v.css.includes('[data-webklar-termine]{'), v.css) + assert.match(v.css, /^\/\*[^*]*\*\/\n/, 'beginnt mit einem Kommentar') + assert.equal(v.akzent, '#9d4b6f') + assert.equal(v.akzentText, '#ffffff') + assert.equal(v.kontrastText, kontrast('#9d4b6f', '#ffffff')) + assert.equal(v.kontrastSeite, kontrast('#9d4b6f', '#ffffff')) + assert.deepEqual(v.hinweise, [], 'ein dunkler Akzent braucht keine Warnung') + // heller Akzent: dunkle Schrift + const hell = cssVorschlag('#f5c542') + assert.equal(hell.akzentText, '#111827') + assert.ok(hell.css.includes('--wkt-akzent-text:#111827;')) + assert.equal(hell.kontrastText, kontrast('#f5c542', '#111827')) + assert.ok(hell.kontrastText >= 4.5) +}) + +test('cssVorschlag: nur gueltige #rrggbb, sonst die Vorgabefarbe und der Rohtext steht nirgends', () => { + assert.equal(VORGABE_FARBE, '#7c3aed') + for (const roh of ['red', '#fff', undefined, null, 42, '#9d4b6f;}', '#12345g', ' #9d4b6f', '#9d4b6f\n']) { + const v = cssVorschlag(roh) + assert.equal(v.akzent, '#7c3aed', String(roh)) + assert.ok(v.css.includes('--wkt-akzent:#7c3aed;'), String(roh)) + assert.ok(v.css.includes(`--wkt-akzent-text:${textFarbeAuf('#7c3aed')};`), String(roh)) + const ausgabe = JSON.stringify(v) + for (const stueck of ['red', '', '12345g', '#9d4b6f', '#fff;']) { + if (typeof roh === 'string' && roh.includes(stueck)) assert.ok(!ausgabe.includes(stueck), `${JSON.stringify(roh)}: ${stueck} steht in der Ausgabe`) + } + assert.ok(!/[<>]/.test(v.css), 'kein HTML im CSS') + } + // Grossbuchstaben sind gueltig und werden klein geschrieben + assert.equal(cssVorschlag('#9D4B6F').akzent, '#9d4b6f') +}) + +test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent unter 3 : 1 liegt', () => { + const v = cssVorschlag('#f5c542') + assert.ok(v.kontrastSeite < 3, String(v.kontrastSeite)) + assert.equal(v.hinweise.length, 1) + assert.match(v.hinweise[0], /Weiß/) + assert.match(v.hinweise[0], /1,6 : 1/, 'Kontrast mit deutschem Komma') + assert.match(v.hinweise[0], /3 : 1/) + assert.match(v.hinweise[0], /Rahmen/) + // knapp ueber der Grenze: keine Warnung + const gut = cssVorschlag('#0f766e') + assert.ok(gut.kontrastSeite >= 3) + assert.deepEqual(gut.hinweise, []) + // Grenzfall: genau an 3 : 1 warnt nicht, knapp darunter schon + for (const farbe of alleFarben()) { + const k = kontrast(farbe, '#ffffff') + assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`) + } +}) diff --git a/test/termineEmbedStatic.test.js b/test/termineEmbedStatic.test.js index d1b0524..c4baff9 100644 --- a/test/termineEmbedStatic.test.js +++ b/test/termineEmbedStatic.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import vm from 'node:vm' +import { luminanz as serverLuminanz, textFarbeAuf as serverTextFarbeAuf } from '../server/services/termine/einbauanleitung.js' // Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4): // - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen @@ -196,6 +197,24 @@ test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raste } }) +test('Farbparitaet: Widget und Farbvorschlag des Servers (einbauanleitung.js) waehlen fuer alle 4096 Farben dieselbe Schrift', () => { + const { textFarbeAuf, luminanz } = farbFunktionen() + const stufen = Array.from({ length: 16 }, (_, i) => i * 17) // 0, 17, ... 255 + const hex = (c) => c.toString(16).padStart(2, '0') + let anzahl = 0 + for (const r of stufen) { + for (const g of stufen) { + for (const b of stufen) { + const farbe = `#${hex(r)}${hex(g)}${hex(b)}` + assert.equal(serverTextFarbeAuf(farbe), textFarbeAuf(farbe), `${farbe}: Schriftfarbe weicht ab`) + assert.equal(serverLuminanz(farbe), luminanz(farbe), `${farbe}: Leuchtdichte weicht ab`) + anzahl += 1 + } + } + } + assert.equal(anzahl, 4096) +}) + // ---------------------------------------------------------------- Demo und Einbauanleitung test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => { From 13bbadc729d2535cbe7f8304980ec03871a6572d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:07:38 +0000 Subject: [PATCH 06/28] Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen) einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt, kunde, entwurf }) setzt die ersten sechs Abschnitte der Team-Anleitung zusammen (modell, leistungen, ressourcen, regeln, gastfelder, benachrichtigungen, feste Reihenfolge, nicht kopierbar) plus stand { version, updatedAt } und den Volltext. Rein: kein I/O, config nur beim Aufruf (Portal-Adresse, Haltezeit); gleiche Eingabe und gleiches now ergeben gleiche Ausgabe, die Eingabe bleibt unveraendert. Feldweissliste statt Objektkopie: nie feedToken, keine Betriebs-E-Mail, keine Leistungsbeschreibung, keine unbekannten Zusatzfelder. Kundentexte stehen unmaskiert als Text, einzeilige Werte werden auf eine Zeile gebracht (keine vorgetaeuschte Ueberschrift). Abwesenheiten und Sonderoeffnungen nur ab heute (Berliner Kalendertag), Listen ueber 20 Eintraege enden mit "und N weitere". Hilfen wochenplanText und kuerzeListe sind exportiert. Warnungen und Kurzfassung bleiben leer (kommen mit D3b und D4); ohne Einstellung entstehen keine Abschnitte. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/einbauanleitung.js | 324 +++++++++++++- test/termineEinbauanleitung.test.js | 484 ++++++++++++++++++++- 2 files changed, 800 insertions(+), 8 deletions(-) diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js index d13541b..fa91905 100644 --- a/server/services/termine/einbauanleitung.js +++ b/server/services/termine/einbauanleitung.js @@ -1,12 +1,18 @@ -import { VORGABE_FARBE } from './einstellungen.js' +import { config } from '../../config.js' +import { VORGABE_FARBE, preisHinweis, preisText, sichtbareLeistungen, zahlungText, zeigtWiderruf } from './einstellungen.js' +import { RECHTSTEXTE_ENTWURF } from './rechtstexte.js' +import { datumKurz, heute } from './zeit.js' /** - * Einbau des Terminplaners auf der Website des Kunden: Bausteine fuer Einbau-Code und Farbvorschlag - * (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt Einbauanleitung; Anleitung fuer das Team: - * docs/funktionen/termine-einbau.md, Abschnitte 1 und 3). + * Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt + * Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md). * - * Reine Funktionen ohne Appwrite und ohne config: die Adresse des Portals (`apiBase`) und alle Werte kommen als Parameter. - * Der Einbau-Code stand bis TP2 in routes/termine.js; GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert). + * Zwei Teile in einer Datei: + * - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse + * des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js; + * GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert). + * - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen. + * Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit). * * Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende), * bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js @@ -119,3 +125,309 @@ export function cssVorschlag(farbe) { ].join('\n') return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise } } + +// ================================================================ Generator einbauanleitung() +// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b haengt Einbau bis Abrechnung, die +// Warnungen und den vollen Volltext an, Task D4 die Kurzfassung (`kurz`). +// +// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender- +// Abo der Ressource), die Betriebs-E-Mail (sie steht nur im Datenschutz-Absatz, D3b), Beschreibungen der Leistungen und alles, +// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text) stehen unmaskiert als Text; wer sie anzeigt, setzt +// sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, damit ein Name keine Ueberschrift vortaeuschen kann. + +const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] +const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ +const MAX_ABWESENHEITEN = 20 +const MAX_SONDEROEFFNUNGEN = 20 +const NICHT_ANGEGEBEN = 'nicht angegeben' +const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' } +const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' } +const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' } + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const liste = (wert) => (Array.isArray(wert) ? wert : []) +const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}` +const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe) + +/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */ +const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '') + +/** '2026-10-05' => '05.10.2026'. */ +const tagText = (tag) => tag.split('-').reverse().join('.') + +/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */ +function fensterText(fenster) { + return liste(fenster) + .filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string') + .map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`) + .join(', ') +} + +/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */ +export function wochenplanText(plan) { + const aus = [] + for (let tag = 1; tag <= 7; tag += 1) { + const text = fensterText(istObjekt(plan) ? plan[tag] : null) + if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`) + } + return aus +} + +/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */ +export function kuerzeListe(eintraege, max) { + if (eintraege.length <= max) return [...eintraege] + return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`] +} + +const neuerAbschnitt = (id, titel, text) => ({ id, titel, text, kopierbar: false }) + +/** Zeilen als Aufzaehlung: '- Eintrag'. */ +const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`) + +// ---------------------------------------------------------------- Abschnitt Modell + +function standText(d) { + if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version' + return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}` +} + +function abschnittModell(d) { + const { e, betrieb: b } = d + const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length + const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN + const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '') + return neuerAbschnitt('modell', 'Modell', [ + `Erzeugt am ${datumKurz(d.now)}`, + `Stand der Einstellungen: ${standText(d)}`, + ...(d.kunde ? [`Kunde: ${d.kunde}`] : []), + ...(d.projekt ? [`Projekt: ${d.projekt}`] : []), + `Betrieb: ${angabe(b.name)}`, + `Anschrift: ${angabe(b.adresse)}`, + `Telefon: ${angabe(b.telefon)}`, + `Impressum: ${angabe(b.impressumUrl)}`, + `Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`, + ...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []), + ...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []), + `Farbe: ${d.farbe}`, + `Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`, + `Gäste: ${gaeste}`, + `Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), ` + + `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`, + `Portal-Adresse (data-api): ${d.portal}`, + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Leistungen + +/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */ +function zuordnungText(ids, nachId) { + const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean) + if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet' + const plaetze = treffer.filter((r) => r.typ === 'platz').length + const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze' + return `${label}: ${treffer.map((r) => r.name).join(', ')}` +} + +function leistungsZeile(l, nachId) { + const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt' + const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : '' + return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, ` + + `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}` +} + +function abschnittLeistungen(d) { + if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.') + const aktiv = d.leistungen.filter((l) => l.aktiv).length + return neuerAbschnitt('leistungen', 'Leistungen', [ + `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`, + ...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Ressourcen + +function ressourcenBlock(r) { + const plan = wochenplanText(r.plan) + return [ + `- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`, + ...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`), + ].join('\n') +} + +/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */ +function abwesenheitsZeilen(d) { + const eintraege = liste(d.e.abwesenheiten) + .filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag) + .sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0)) + .map((a) => { + const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}` + const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle' + return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})` + }) + return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN)) +} + +/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */ +function sonderoeffnungsZeilen(d) { + const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {} + const eintraege = Object.keys(roh) + .filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag) + .sort() + .map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`) + return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN)) +} + +function abschnittRessourcen(d) { + const abwesenheiten = abwesenheitsZeilen(d) + const sonder = sonderoeffnungsZeilen(d) + return neuerAbschnitt('ressourcen', 'Personen und Plätze', [ + d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.', + ...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []), + ...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []), + ].join('\n\n')) +} + +// ---------------------------------------------------------------- Abschnitt Regeln + +/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */ +function vorlaufText(minuten) { + if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN + if (minuten === 0) return 'kein Vorlauf' + if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage') + if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden') + return anzahl(minuten, 'Minute', 'Minuten') +} + +function stornoZeilen(storno) { + const text = typeof storno?.text === 'string' ? storno.text.trim() : '' + if (!text) return ['Stornobedingungen: keine hinterlegt'] + return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)] +} + +function abschnittRegeln(d) { + const { e } = d + const r = istObjekt(e.regeln) ? e.regeln : {} + const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN) + return neuerAbschnitt('regeln', 'Regeln', [ + `Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`, + `Vorlauf: ${vorlaufText(r.vorlaufMin)}`, + `Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`, + `Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`, + `Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`, + `Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`, + `Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`, + zahlungText(e), + `Preishinweis: ${preisHinweis(e)}`, + zeigtWiderruf(e) + ? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt' + : 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)', + ...stornoZeilen(e.storno), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Gastfelder + +function abschnittGastfelder(d) { + const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {} + const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => { + const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text' + return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})` + }) + return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [ + 'Name: Pflicht', + 'E-Mail-Adresse: Pflicht', + `Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`, + `Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`, + ...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Benachrichtigungen + +function abschnittBenachrichtigungen(d) { + const { e } = d + const sichtbar = sichtbareLeistungen(e) + const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage') + const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage') + const widerruf = zeigtWiderruf(e) + const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher) + const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : '' + return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [ + `Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), ` + + 'Antworten gehen an den Betrieb.', + ['Mails an Gäste:', ...aufzaehlung([ + `Bestätigungslink zur E-Mail-Adresse${haltezeit}`, + ...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []), + ...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []), + ...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []), + 'Absage und Verschiebung', + ...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []), + ])].join('\n'), + ['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([ + ...(sofort ? ['Neue Buchung'] : []), + ...(anfrage ? ['Neue Terminanfrage'] : []), + 'Absage oder Verschiebung durch den Gast', + ...(widerruf ? ['Widerruf'] : []), + ])].join('\n'), + 'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.', + ].join('\n\n')) +} + +// ---------------------------------------------------------------- Zusammenbau + +const ABSCHNITTE = [abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen] + +/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */ +const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n') + +/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */ +function projektText(project) { + const name = zeile(project?.projectName) || zeile(project?.subdomain) + const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain) + return name && domain && name !== domain ? `${name} (${domain})` : name || domain +} + +/** + * Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now` + * ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen. + * + * @param {object} p + * @param {object} p.feature portalFeatures-Dokument der Funktion (D3b: siteKey, unlockedByPurchase; der Registereintrag + * config.features.termine wird nicht gebraucht) + * @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl) + * @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine + * @param {object|null} [p.fragebogen] geparster Fragebogen oder null (D3b: Kundenwuensche zum Einbau) + * @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert) + * @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError + * @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen + * @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name + * @param {boolean} [p.entwurf] D3b: Rechtstexte sind noch ENTWURF (Warnung); Standard RECHTSTEXTE_ENTWURF + * @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}} + * D3a: warnungen [] und kurz '' (kommen mit D3b und D4); ohne Einstellung keine Abschnitte. Die Anrede der Gaeste kommt aus einstellung.anrede. + */ +export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt') + const stand = { version, updatedAt } + if (!istObjekt(einstellung)) return { stand, warnungen: [], abschnitte: [], text: '', kurz: '' } + + const ressourcen = liste(einstellung.ressourcen).filter(istObjekt) + const geaendert = updatedAt ? new Date(updatedAt) : null + const daten = { + e: einstellung, + now, + heuteTag: heute(now), + version, + geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '', + betrieb: istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}, + kunde: zeile(kunde?.name), + projekt: projektText(project), + farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE, + portal: zeile(config.publicBaseUrl), + haltezeit: config.termine?.holdMinutes, + ressourcen, + leistungen: liste(einstellung.leistungen).filter(istObjekt), + nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])), + } + const abschnitte = ABSCHNITTE.map((baue) => baue(daten)) + return { stand, warnungen: [], abschnitte, text: volltext(abschnitte), kurz: '' } +} diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js index 005fc79..115c8c2 100644 --- a/test/termineEinbauanleitung.test.js +++ b/test/termineEinbauanleitung.test.js @@ -1,11 +1,16 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import { VORGABE_FARBE } from '../server/services/termine/einstellungen.js' -import { cssVorschlag, einbauSnippet, esc, kontrast, luminanz, textFarbeAuf } from '../server/services/termine/einbauanleitung.js' +import { config } from '../server/config.js' +import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einstellungen.js' +import { ausVorlage } from '../server/services/termine/vorlagen.js' +import { + cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText, +} from '../server/services/termine/einbauanleitung.js' // Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config. // Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js. +// Der Generator einbauanleitung() (Task D3a, ganz unten) liest config nur beim Aufruf (Portal-Adresse), sonst ist auch er rein. const SITE_KEY = 'abcdefghijklmnop' const API = 'https://project.webklar.com' @@ -204,3 +209,478 @@ test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`) } }) + +// ================================================================ Generator einbauanleitung() 1 (Task D3a) +// Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Rein: kein I/O, nur ausdruecklich benannte Felder. + +const NOW = new Date('2026-10-05T10:00:00Z') // Montag, 12:00 Berliner Zeit +const SPAETER = new Date('2026-10-06T10:00:00Z') // einen Tag spaeter: nur die "Erzeugt am"-Zeile aendert sich +const GEHEIM = 'XYZ-GEHEIM' +const BETRIEB = { + name: 'Salon Muster', + email: 'inhaber-intern@salon-muster.example', + telefon: '0631 123456', + adresse: 'Hauptstraße 1, 67655 Kaiserslautern', + kleinunternehmer: false, + impressumUrl: 'https://salon-muster.example/impressum', + datenschutzUrl: 'https://salon-muster.example/datenschutz', + bundesland: 'RP', +} + +/** Salon-Vorlage mit Farbe, zwei Personen und einem Platz, Wochenplan, Abwesenheiten, Sonderoeffnungen und einer Anfrage-Leistung. */ +function fixtureEinstellung() { + const e = ausVorlage('salon', BETRIEB, NOW) + e.farbe = '#9d4b6f' + const [anna] = e.ressourcen + anna.name = 'Anna' + anna.plan = { 1: [['09:00', '12:30'], ['14:00', '18:00']], 3: [['09:00', '12:30']], 7: [['10:00', '12:00']] } + e.ressourcen.push( + { id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() }, + { id: 'r_stuhl001', typ: 'platz', name: 'Stuhl 2', aktiv: false, plan: {}, feedToken: neuerFeedToken() }, + ) + e.leistungen[0].ressourcen = [anna.id, 'r_ben00001'] + e.leistungen[1].aktiv = false // Damenschnitt + e.leistungen[2].buchungsart = 'anfrage' + e.leistungen[2].preisArt = 'keiner' + e.leistungen[2].preisCent = 0 + e.abwesenheiten = [ + { id: 'a_alt00001', ressourceId: anna.id, von: '2026-09-28', bis: '2026-10-02', titel: 'Urlaub vorbei' }, + { id: 'a_jetzt001', ressourceId: 'r_ben00001', von: '2026-10-05', bis: '2026-10-05', titel: 'Fortbildung' }, + { id: 'a_laeuft01', ressourceId: anna.id, von: '2026-10-01', bis: '2026-10-07', titel: 'Kur' }, + { id: 'a_zukunft1', ressourceId: null, von: '2026-12-24', bis: '2026-12-31', titel: 'Betriebsferien' }, + ] + e.sonderoeffnungen = { '2026-10-03': [['09:00', '12:00']], '2026-12-24': [], '2026-11-07': [['10:00', '14:00']] } + e.gastFelder = { + telefon: 'pflicht', + nachricht: 'optional', + zusatz: [ + { key: 'haare', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true }, + { key: 'wunsch', label: 'Wunschfrisur', typ: 'text', optionen: [], pflicht: false }, + ], + } + return e +} + +const PROJEKT = { projectName: 'Salon Muster Website', subdomain: 'salon-muster', liveDomain: 'salon-muster.example', previewUrl: 'https://salon-muster.project.webklar.com' } + +/** Standard-Aufruf des Generators; Abweichungen ueber `zusatz`. */ +function erzeuge(zusatz = {}) { + return einbauanleitung({ + feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup' }, + project: PROJEKT, + einstellung: fixtureEinstellung(), + fragebogen: null, + version: 3, + updatedAt: '2026-10-03T08:15:00.000Z', + now: NOW, + kunde: { name: 'Salon Muster GbR' }, + ...zusatz, + }) +} + +const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id) +const zeilen = (text) => text.split('\n') + +test('D3a: Ergebnisform, die sechs Abschnitte in fester Reihenfolge, keiner kopierbar', () => { + const r = erzeuge() + assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen']) + assert.deepEqual(r.abschnitte.map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen']) + for (const a of r.abschnitte) { + assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id) + assert.equal(a.kopierbar, false, a.id) + assert.ok(a.titel && typeof a.titel === 'string', a.id) + assert.ok(a.text && typeof a.text === 'string', a.id) + } + assert.deepEqual(r.warnungen, [], 'Warnungen kommen mit D3b') + assert.equal(r.kurz, '', 'die Kurzfassung kommt mit D4') + // der Volltext fuehrt jeden Abschnitt mit Ueberschrift + assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), r.abschnitte.map((a) => `## ${a.titel}`)) + for (const a of r.abschnitte) assert.ok(r.text.includes(a.text), a.id) +}) + +test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => { + assert.deepEqual(erzeuge().stand, { version: 3, updatedAt: '2026-10-03T08:15:00.000Z' }) + assert.deepEqual(erzeuge({ updatedAt: undefined }).stand, { version: 3, updatedAt: null }) + assert.deepEqual(erzeuge({ version: 0, updatedAt: undefined }).stand, { version: 0, updatedAt: null }) +}) + +test('D3a Modell: Betrieb, Kontakt, Adressen, Farbe, Anrede, Portal-Adresse, Erzeugt-am und Stand der Einstellungen', () => { + const modell = abschnitt(erzeuge(), 'modell').text + for (const erwartet of [ + 'Erzeugt am 05.10.2026', + 'Stand der Einstellungen: Version 3, zuletzt geändert 03.10.2026', + 'Kunde: Salon Muster GbR', + 'Projekt: Salon Muster Website (salon-muster.example)', + 'Betrieb: Salon Muster', + 'Anschrift: Hauptstraße 1, 67655 Kaiserslautern', + 'Telefon: 0631 123456', + 'Impressum: https://salon-muster.example/impressum', + 'Datenschutzerklärung: https://salon-muster.example/datenschutz', + 'Farbe: #9d4b6f', + 'Anrede der Gäste: Sie', + 'Gäste: Privatpersonen (Verbraucher)', + `Portal-Adresse (data-api): ${config.publicBaseUrl}`, + 'Umfang: 3 Personen und Plätze (2 aktiv), 4 Leistungen (3 aktiv)', + ]) assert.ok(zeilen(modell).includes(erwartet), `${erwartet}\n---\n${modell}`) +}) + +test('D3a Modell: fehlende Angaben stehen als nicht angegeben, Du-Anrede, Projekt ohne Domain, Version 0, keine Kundenzeile', () => { + const e = fixtureEinstellung() + e.betrieb.telefon = '' + e.betrieb.adresse = '' + e.betrieb.impressumUrl = null + e.betrieb.datenschutzUrl = null + e.anrede = 'du' + e.recht = { gaesteSind: 'gemischt', freizeit: true } + const modell = abschnitt(erzeuge({ einstellung: e, project: { projectName: 'Nur Name' }, kunde: null, version: 0, updatedAt: null }), 'modell').text + const z = zeilen(modell) + for (const erwartet of [ + 'Betrieb: Salon Muster', + 'Anschrift: nicht angegeben', + 'Telefon: nicht angegeben', + 'Impressum: nicht angegeben', + 'Datenschutzerklärung: nicht angegeben', + 'Anrede der Gäste: Du', + 'Gäste: Privatpersonen und Unternehmen, Freizeitleistung', + 'Projekt: Nur Name', + 'Stand der Einstellungen: keine gespeicherte Version', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${modell}`) + assert.ok(!z.some((x) => x.startsWith('Kunde:')), 'ohne Kunde keine Kundenzeile') +}) + +test('D3a Modell: AGB- und Website-Adresse nur, wenn vorhanden; die Portal-Adresse wird beim Aufruf gelesen', () => { + const ohne = abschnitt(erzeuge(), 'modell').text + assert.ok(!/AGB|Website:/.test(ohne), ohne) + const e = fixtureEinstellung() + e.betrieb.agbUrl = 'https://salon-muster.example/agb' + e.betrieb.websiteUrl = 'https://salon-muster.example/' + const mit = zeilen(abschnitt(erzeuge({ einstellung: e }), 'modell').text) + assert.ok(mit.includes('AGB: https://salon-muster.example/agb')) + assert.ok(mit.includes('Website: https://salon-muster.example/')) + const vorher = config.publicBaseUrl + try { + config.publicBaseUrl = 'https://portal.test.example' + assert.ok(zeilen(abschnitt(erzeuge(), 'modell').text).includes('Portal-Adresse (data-api): https://portal.test.example')) + } finally { + config.publicBaseUrl = vorher + } +}) + +test('D3a Leistungen: Name, Dauer, Preis, Buchungsart, Personen; inaktive und Anfrage-Leistungen markiert', () => { + const text = abschnitt(erzeuge(), 'leistungen').text + const z = zeilen(text) + assert.equal(z[0], '4 Leistungen (3 aktiv):') + assert.ok(z.includes('- Herrenschnitt: 30 Min. (+ 5 Min. Pause danach), 28,00 €, sofort buchbar, Personen: Anna, Ben'), text) + assert.ok(z.includes('- Damenschnitt (inaktiv): 45 Min. (+ 5 Min. Pause danach), ab 45,00 €, sofort buchbar, Personen: Anna'), text) + assert.ok(z.includes('- Kinderschnitt: 30 Min. (+ 5 Min. Pause danach), kein Preis, nur als Anfrage, Personen: Anna'), text) + assert.ok(z.includes('- Färben: 120 Min. (+ 10 Min. Pause danach), ab 75,00 €, sofort buchbar, Personen: Anna'), text) + assert.equal(z.filter((x) => x.includes('(inaktiv)')).length, 1, 'nur die inaktive Leistung ist markiert') +}) + +test('D3a Leistungen: Plaetze heissen Plaetze, gemischt Personen und Plaetze, ohne Pause keine Klammer, unbekannte Zuordnung faellt weg', () => { + const e = fixtureEinstellung() + e.leistungen[0].ressourcen = ['r_stuhl001'] + e.leistungen[1].ressourcen = [e.ressourcen[0].id, 'r_stuhl001', 'r_gibtsnicht'] + e.leistungen[3].pufferNach = 0 + e.leistungen[3].ressourcen = [] + const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'leistungen').text) + assert.ok(z.some((x) => x.startsWith('- Herrenschnitt:') && x.endsWith('Plätze: Stuhl 2')), z.join('\n')) + assert.ok(z.some((x) => x.startsWith('- Damenschnitt (inaktiv):') && x.endsWith('Personen und Plätze: Anna, Stuhl 2')), z.join('\n')) + assert.ok(z.includes('- Färben: 120 Min., ab 75,00 €, sofort buchbar, keine Person und kein Platz zugeordnet'), z.join('\n')) +}) + +test('D3a Ressourcen: Wochenplan je Tag, nur kuenftige Abwesenheiten, Sonderoeffnungen ab heute', () => { + const text = abschnitt(erzeuge(), 'ressourcen').text + const z = zeilen(text) + assert.ok(z.includes('- Anna (Person)'), text) + assert.ok(z.includes(' Mo 09:00–12:30, 14:00–18:00'), text) + assert.ok(z.includes(' Mi 09:00–12:30'), text) + assert.ok(z.includes(' So 10:00–12:00'), text) + assert.ok(z.includes('- Ben (Person)') && z.includes(' Di 08:00–16:00'), text) + assert.ok(z.includes('- Stuhl 2 (Platz, inaktiv)'), text) + assert.ok(z.includes(' keine Zeiten eingetragen'), text) + // Abwesenheiten: abgelaufene fehlen, heute endende und laufende bleiben, nach Beginn sortiert + const ab = z.slice(z.indexOf('Abwesenheiten (ab heute):') + 1).filter((x) => x.startsWith('- ') && /Fortbildung|Kur|Betriebsferien|Urlaub/.test(x)) + assert.deepEqual(ab, [ + '- 01.10.2026–07.10.2026: Kur (Anna)', + '- 05.10.2026: Fortbildung (Ben)', + '- 24.12.2026–31.12.2026: Betriebsferien (alle)', + ]) + assert.ok(!text.includes('Urlaub vorbei'), 'die abgelaufene Abwesenheit steht nirgends') + // Sonderoeffnungen: der 03.10. liegt vor heute und faellt weg, der 07.11. kommt vor dem 24.12. + const so = z.slice(z.indexOf('Sonderöffnungszeiten (ab heute, für alle):') + 1).filter((x) => x.startsWith('- ')) + assert.deepEqual(so.slice(-2), ['- 07.11.2026: 10:00–14:00', '- 24.12.2026: geschlossen']) + assert.ok(!text.includes('03.10.2026')) +}) + +test('D3a Ressourcen: ohne Abwesenheiten und Sonderoeffnungen entfallen die Ueberschriften; ohne Ressourcen steht ein Hinweis', () => { + const e = fixtureEinstellung() + e.abwesenheiten = [{ id: 'a_alt00001', ressourceId: null, von: '2026-01-01', bis: '2026-01-02', titel: 'alt' }] + e.sonderoeffnungen = { '2026-01-01': [] } + const text = abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text + assert.ok(!text.includes('Abwesenheiten'), text) + assert.ok(!text.includes('Sonderöffnungszeiten'), text) + e.ressourcen = [] + assert.equal(abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text, 'Keine Personen oder Plätze angelegt.') +}) + +test('D3a Regeln: Raster, Vorlauf, Horizont, Absagefrist, Obergrenze, Storno-Text mit Fassung, Zahlung', () => { + const e = fixtureEinstellung() + const text = abschnitt(erzeuge({ einstellung: e }), 'regeln').text + const z = zeilen(text) + for (const erwartet of [ + 'Raster: 15 Minuten', + 'Vorlauf: 2 Stunden', + 'Buchungszeitraum: 60 Tage im Voraus', + 'Absage durch Gäste: bis 24 Stunden vor dem Termin', + 'Offene Termine je E-Mail-Adresse: höchstens 3', + 'Person wählbar: ja', + 'Feiertage: geschlossen (Bundesland RP)', + 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte', + 'Preishinweis: inkl. USt', + 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt', + `Stornobedingungen (Fassung ${e.storno.version}):`, + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + assert.match(e.storno.version, /^\d{4}-\d{2}-\d{2}-\d+$/) + assert.ok(text.includes(` ${e.storno.text}`), 'der Storno-Text steht vollstaendig, eingerueckt') +}) + +test('D3a Regeln: andere Werte, kein Storno-Text, Unternehmen ohne Widerruf, Kleinunternehmer, keine Zahlarten', () => { + const e = fixtureEinstellung() + e.regeln = { raster: 30, vorlaufMin: 0, horizontTage: 14, absageBisStd: 0, maxOffeneJeEmail: 1, personWaehlbar: false } + e.feiertageGeschlossen = false + e.storno = { text: '', version: '2026-10-01-2' } + e.recht = { gaesteSind: 'unternehmer', freizeit: false } + e.betrieb.kleinunternehmer = true + e.zahlung = { modus: 'vorort', vorOrtArten: [] } + const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text) + for (const erwartet of [ + 'Raster: 30 Minuten', + 'Vorlauf: kein Vorlauf', + 'Buchungszeitraum: 14 Tage im Voraus', + 'Absage durch Gäste: bis zum Terminbeginn', + 'Offene Termine je E-Mail-Adresse: höchstens 1', + 'Person wählbar: nein', + 'Feiertage: geöffnet wie im Wochenplan (Bundesland RP)', + 'Bezahlung vor Ort', + 'Preishinweis: kein Ausweis der USt nach § 19 UStG', + 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)', + 'Stornobedingungen: keine hinterlegt', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${z.join('\n')}`) + assert.ok(!z.join('\n').includes('2026-10-01-2'), 'ohne Text keine Fassung im Text') +}) + +test('D3a Regeln: Vorlauf in Minuten, Stunden und Tagen, Absagefrist in Tagen', () => { + const vorlauf = (minuten) => { + const e = fixtureEinstellung() + e.regeln.vorlaufMin = minuten + return zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text).find((x) => x.startsWith('Vorlauf:')) + } + assert.equal(vorlauf(45), 'Vorlauf: 45 Minuten') + assert.equal(vorlauf(60), 'Vorlauf: 1 Stunde') + assert.equal(vorlauf(90), 'Vorlauf: 90 Minuten') + assert.equal(vorlauf(1440), 'Vorlauf: 1 Tag') + assert.equal(vorlauf(20160), 'Vorlauf: 14 Tage') +}) + +test('D3a Gastfelder: Name und E-Mail immer Pflicht, Telefon und Nachricht nach Einstellung, Zusatzfelder mit Auswahl', () => { + const text = abschnitt(erzeuge(), 'gastfelder').text + const z = zeilen(text) + for (const erwartet of [ + 'Name: Pflicht', + 'E-Mail-Adresse: Pflicht', + 'Telefon: Pflicht', + 'Nachricht: optional', + 'Zusatzfelder (2):', + '- Haarlänge (Auswahl: kurz, mittel, lang; Pflicht)', + '- Wunschfrisur (Text; optional)', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + const e = fixtureEinstellung() + e.gastFelder = { telefon: 'aus', nachricht: 'aus', zusatz: [] } + const leer = zeilen(abschnitt(erzeuge({ einstellung: e }), 'gastfelder').text) + assert.ok(leer.includes('Telefon: aus') && leer.includes('Nachricht: aus') && leer.includes('Zusatzfelder: keine'), leer.join('\n')) +}) + +test('D3a Benachrichtigungen: Mails an Gaeste nach Konfiguration, Mails an den Betrieb ohne Adresse, Kalender-Abo ohne Link', () => { + const text = abschnitt(erzeuge(), 'benachrichtigungen').text + const z = zeilen(text) + for (const erwartet of [ + 'Gäste werden mit „Sie“ angesprochen. Absender ist der Betriebsname (Salon Muster), Antworten gehen an den Betrieb.', + 'Mails an Gäste:', + '- Bestätigungslink zur E-Mail-Adresse (gültig 30 Minuten)', + '- Terminbestätigung (sofort buchbare Leistungen)', + '- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', + '- Erinnerung 24 Stunden vor dem Termin (nur wenn der Gast sie ankreuzt)', + '- Absage und Verschiebung', + '- Eingangsbestätigung des Widerrufs', + 'Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', + '- Neue Buchung', + '- Neue Terminanfrage', + '- Absage oder Verschiebung durch den Gast', + '- Widerruf', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + assert.ok(text.includes('Kalender-Abo (ICS)') && text.includes('im Portal'), text) + assert.ok(!text.includes(BETRIEB.email), 'die Betriebs-E-Mail steht nicht in der Anleitung') + // die Gueltigkeit des Bestaetigungslinks wird beim Aufruf aus der Konfiguration gelesen + const vorher = config.termine.holdMinutes + try { + config.termine.holdMinutes = 45 + assert.ok(zeilen(abschnitt(erzeuge(), 'benachrichtigungen').text).includes('- Bestätigungslink zur E-Mail-Adresse (gültig 45 Minuten)')) + } finally { + config.termine.holdMinutes = vorher + } +}) + +test('D3a Benachrichtigungen: ohne Erinnerung, ohne Anfrage-Leistung, ohne Widerruf und mit Du-Anrede entfallen die Zeilen', () => { + const e = fixtureEinstellung() + e.anrede = 'du' + e.erinnerung = { anbieten: false, stundenVorher: 24 } + e.leistungen[2].buchungsart = 'sofort' + e.recht = { gaesteSind: 'unternehmer', freizeit: false } + const text = abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text + const z = zeilen(text) + assert.ok(text.startsWith('Gäste werden mit „Du“ angesprochen.'), text) + for (const fehlt of ['- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', '- Neue Terminanfrage', '- Eingangsbestätigung des Widerrufs', '- Widerruf']) { + assert.ok(!z.includes(fehlt), `${fehlt}\n---\n${text}`) + } + assert.ok(!z.some((x) => x.startsWith('- Erinnerung')), text) + assert.ok(z.includes('- Neue Buchung') && z.includes('- Absage oder Verschiebung durch den Gast'), text) + // nur Anfrage-Leistungen: keine Terminbestaetigung und keine "Neue Buchung" + e.leistungen.forEach((l) => { l.buchungsart = 'anfrage' }) + const nurAnfrage = zeilen(abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text) + assert.ok(!nurAnfrage.includes('- Terminbestätigung (sofort buchbare Leistungen)') && !nurAnfrage.includes('- Neue Buchung')) + assert.ok(nurAnfrage.includes('- Neue Terminanfrage')) + // inaktive Leistungen zaehlen nicht + const f = fixtureEinstellung() + f.leistungen[2].aktiv = false + assert.ok(!zeilen(abschnitt(erzeuge({ einstellung: f }), 'benachrichtigungen').text).includes('- Neue Terminanfrage')) +}) + +test('D3a feedToken: jede Ressource hat einen echten Token, das Ergebnis enthaelt weder einen Token noch das Wort feedToken', () => { + const e = fixtureEinstellung() + assert.ok(e.ressourcen.length >= 3) + for (const r of e.ressourcen) assert.match(r.feedToken, /^[A-Za-z0-9_-]{43}$/) + const json = JSON.stringify(erzeuge({ einstellung: e })) + assert.ok(!json.includes('feedToken')) + assert.ok(!json.toLowerCase().includes('feedtoken')) + for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken), `Token von ${r.name} steht im Ergebnis`) +}) + +test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und Kopf tauchen nirgends auf', () => { + const e = fixtureEinstellung() + e.geheim = GEHEIM + e.betrieb.geheim = GEHEIM + e.regeln.geheim = GEHEIM + e.gastFelder.geheim = GEHEIM + e.gastFelder.zusatz[0].geheim = GEHEIM + e.storno.geheim = GEHEIM + e.ressourcen[0].geheim = GEHEIM + e.ressourcen[0].notiz = 'NOTIZ-INTERN' + e.leistungen[0].geheim = GEHEIM + e.leistungen[0].notiz = 'NOTIZ-INTERN' + e.leistungen[0].beschreibung = 'BESCHREIBUNG-NUR-FUER-GAESTE' + e.abwesenheiten[3].geheim = GEHEIM + const json = JSON.stringify(erzeuge({ + einstellung: e, + feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, geheim: GEHEIM, feedToken: GEHEIM, adminNote: GEHEIM }, + project: { ...PROJEKT, geheim: GEHEIM, apiKey: GEHEIM }, + kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM }, + fragebogen: { geheim: GEHEIM }, + })) + for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE', BETRIEB.email]) assert.ok(!json.includes(verboten), verboten) +}) + +test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => { + const e = fixtureEinstellung() + e.betrieb.name = 'Evil\n## Regeln\nRaster: 1' + e.ressourcen[0].name = ' & "Anna"' + e.leistungen[0].name = 'Schnitt\r\n## Gastfelder' + e.abwesenheiten[3].titel = 'Ferien\n## Modell' + const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } }) + assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 6, r.text) + assert.ok(r.text.includes(' & "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein') + assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text) + assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1) +}) + +test('D3a ist deterministisch und veraendert die Eingabe nicht', () => { + const e = fixtureEinstellung() + // Abwesenheiten absichtlich unsortiert: der Generator sortiert, darf dabei die Eingabe aber nicht umordnen + e.abwesenheiten.reverse() + const vorher = structuredClone(e) + const a = erzeuge({ einstellung: e }) + assert.deepEqual(e, vorher, 'nach dem ersten Aufruf: die Einstellung bleibt unveraendert (kein Sortieren oder Filtern in place)') + const b = erzeuge({ einstellung: e }) + assert.deepEqual(e, vorher, 'nach dem zweiten Aufruf ebenso') + assert.deepEqual(a, b) +}) + +test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung vergangener Dinge', () => { + const normal = (r) => ({ ...r, text: r.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT'), abschnitte: r.abschnitte.map((a) => ({ ...a, text: a.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT') })) }) + // einen Tag spaeter, ohne dass etwas ablaeuft (die Fortbildung liegt hier erst am 20.10.): nur die Erzeugt-am-Zeile unterscheidet + const spaeterEnde = fixtureEinstellung() + spaeterEnde.abwesenheiten[1].von = '2026-10-20' + spaeterEnde.abwesenheiten[1].bis = '2026-10-20' + const eins = erzeuge({ einstellung: spaeterEnde }) + const zwei = erzeuge({ now: SPAETER, einstellung: spaeterEnde }) + assert.notDeepEqual(eins, zwei) + assert.ok(abschnitt(eins, 'modell').text.includes('Erzeugt am 05.10.2026')) + assert.ok(abschnitt(zwei, 'modell').text.includes('Erzeugt am 06.10.2026')) + assert.deepEqual(normal(eins), normal(zwei), 'sonst bleibt alles gleich') + // mit der Fixture-Abwesenheit vom 05.10. faellt sie am Folgetag weg (bis < heute) + const a = erzeuge() + const b = erzeuge({ now: SPAETER }) + assert.ok(abschnitt(a, 'ressourcen').text.includes('Fortbildung')) + assert.ok(!abschnitt(b, 'ressourcen').text.includes('Fortbildung')) + assert.ok(abschnitt(b, 'ressourcen').text.includes('Kur'), 'die laufende Kur bleibt') + // zwei Monate spaeter: vergangene Abwesenheiten und Sonderoeffnungen fehlen, sonst bleibt alles gleich + const spaet = erzeuge({ now: new Date('2026-12-20T10:00:00Z') }) + const ressourcen = abschnitt(spaet, 'ressourcen').text + assert.ok(!ressourcen.includes('Kur') && !ressourcen.includes('Fortbildung') && !ressourcen.includes('07.11.2026'), ressourcen) + assert.ok(ressourcen.includes('Betriebsferien') && ressourcen.includes('24.12.2026: geschlossen'), ressourcen) + for (const id of ['leistungen', 'regeln', 'gastfelder', 'benachrichtigungen']) assert.equal(abschnitt(spaet, id).text, abschnitt(a, id).text, id) + // ein Abwesenheitstag, der in Berlin schon der naechste Tag ist, zaehlt nach Berliner Kalender (23:30 UTC = 01:30 Berlin am 06.10.) + const nachtgrenze = erzeuge({ now: new Date('2026-10-05T23:30:00Z') }) + assert.ok(!abschnitt(nachtgrenze, 'ressourcen').text.includes('Fortbildung'), 'der 05.10. ist in Berlin schon vorbei') + assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026')) +}) + +test('D3a ohne Einstellung wirft nichts und liefert keine Abschnitte; ein ungueltiges now wirft', () => { + const r = erzeuge({ einstellung: null, version: 0, updatedAt: null }) + assert.deepEqual(r.abschnitte, []) + assert.equal(r.text, '') + assert.deepEqual(r.stand, { version: 0, updatedAt: null }) + for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) { + assert.throws(() => erzeuge({ now }), TypeError, String(now)) + } +}) + +test('D3a Hilfen: wochenplanText und kuerzeListe', () => { + assert.deepEqual(wochenplanText({ 1: [['09:00', '12:30'], ['14:00', '18:00']], 5: [['09:00', '13:00']] }), ['Mo 09:00–12:30, 14:00–18:00', 'Fr 09:00–13:00']) + assert.deepEqual(wochenplanText({ 7: [['10:00', '12:00']], 2: [] }), ['So 10:00–12:00'], 'leere Tage fehlen, Reihenfolge Montag bis Sonntag') + assert.deepEqual(wochenplanText({}), []) + assert.deepEqual(wochenplanText(undefined), []) + assert.deepEqual(wochenplanText({ 1: 'kaputt', 8: [['09:00', '10:00']], 3: [['09:00']] }), [], 'Unbrauchbares wird uebersprungen') + assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 3), ['a', 'b', 'c']) + assert.deepEqual(kuerzeListe(['a', 'b', 'c', 'd', 'e'], 2), ['a', 'b', '… und 3 weitere']) + assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 2), ['a', 'b', '… und 1 weitere']) + assert.deepEqual(kuerzeListe([], 5), []) +}) + +test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N weitere, Leistungen stehen vollstaendig', () => { + const e = fixtureEinstellung() + e.abwesenheiten = Array.from({ length: 30 }, (_, i) => ({ + id: `a_x${String(i).padStart(7, '0')}`, ressourceId: null, von: `2027-01-${String(i + 1).padStart(2, '0')}`, bis: `2027-01-${String(i + 1).padStart(2, '0')}`, titel: `Tag ${i + 1}`, + })) + e.sonderoeffnungen = Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`2027-02-${String(i + 1).padStart(2, '0')}`, [['10:00', '12:00']]])) + e.leistungen = Array.from({ length: 50 }, (_, i) => ({ ...e.leistungen[0], id: `s_l${String(i).padStart(6, '0')}`, name: `Leistung ${i + 1}` })) + const r = erzeuge({ einstellung: e }) + const z = zeilen(abschnitt(r, 'ressourcen').text) + assert.equal(z.filter((x) => /^- \d\d\.01\.2027/.test(x)).length, 20) + assert.ok(z.includes('- … und 10 weitere'), z.join('\n')) + assert.equal(z.filter((x) => /^- \d\d\.02\.2027/.test(x)).length, 20) + assert.ok(z.includes('- … und 5 weitere'), z.join('\n')) + const l = zeilen(abschnitt(r, 'leistungen').text) + assert.equal(l[0], '50 Leistungen (50 aktiv):') + assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50) +}) From 90692b646b3ccc34a9421447d3ed8a80e5794a1e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:22:04 +0000 Subject: [PATCH 07/28] Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen) Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/einbauanleitung.js | 262 ++++++++++- test/termineEinbauanleitung.test.js | 477 ++++++++++++++++++++- 2 files changed, 706 insertions(+), 33 deletions(-) diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js index fa91905..b8ec150 100644 --- a/server/services/termine/einbauanleitung.js +++ b/server/services/termine/einbauanleitung.js @@ -1,6 +1,10 @@ import { config } from '../../config.js' -import { VORGABE_FARBE, preisHinweis, preisText, sichtbareLeistungen, zahlungText, zeigtWiderruf } from './einstellungen.js' -import { RECHTSTEXTE_ENTWURF } from './rechtstexte.js' +import { anleitungsLink } from '../featureRegistry.js' +import { gesundheitsWarnungen } from './fragebogen.js' +import { + VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf, +} from './einstellungen.js' +import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from './rechtstexte.js' import { datumKurz, heute } from './zeit.js' /** @@ -12,7 +16,8 @@ import { datumKurz, heute } from './zeit.js' * des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js; * GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert). * - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen. - * Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit). + * Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit, WOMS-Adresse, + * Preis). Er nutzt die reinen Hilfen gesundheitsWarnungen (fragebogen.js) und datenschutzAbsatz (rechtstexte.js). * * Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende), * bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js @@ -127,13 +132,18 @@ export function cssVorschlag(farbe) { } // ================================================================ Generator einbauanleitung() -// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b haengt Einbau bis Abrechnung, die -// Warnungen und den vollen Volltext an, Task D4 die Kurzfassung (`kurz`). +// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze, +// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4 haengt die Kurzfassung (`kurz`) an. // // Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender- -// Abo der Ressource), die Betriebs-E-Mail (sie steht nur im Datenschutz-Absatz, D3b), Beschreibungen der Leistungen und alles, -// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text) stehen unmaskiert als Text; wer sie anzeigt, setzt -// sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, damit ein Name keine Ueberschrift vortaeuschen kann. +// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles, +// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text, Wuensche zum Einbau) stehen unmaskiert als Text; wer sie +// anzeigt, setzt sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, mehrzeilige eingerueckt, damit ein Kundentext +// keine Ueberschrift vortaeuschen kann. Wuensche des Kunden stehen nie im Snippet und nie im CSS (nur der Knopf-Text, maskiert, +// als data-knopf). + +/** Kennzeichnung der Gaestetexte als Entwurf; erste Warnung, solange `entwurf` gilt (Standard RECHTSTEXTE_ENTWURF). */ +export const ENTWURF_BANNER = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen' const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ @@ -179,7 +189,7 @@ export function kuerzeListe(eintraege, max) { return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`] } -const neuerAbschnitt = (id, titel, text) => ({ id, titel, text, kopierbar: false }) +const neuerAbschnitt = (id, titel, text, kopierbar = false) => ({ id, titel, text, kopierbar }) /** Zeilen als Aufzaehlung: '- Eintrag'. */ const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`) @@ -373,9 +383,197 @@ function abschnittBenachrichtigungen(d) { ].join('\n\n')) } +// ---------------------------------------------------------------- Wuensche des Kunden (Fragebogen, Schritt Einbau) + +const EINBAU_MODI = ['inline', 'knopf'] +const MODUS_TEXT = { + inline: 'inline – die Buchungsstrecke steht direkt in der Seite', + knopf: 'knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog', +} +// Grenzen wie in fragebogen.js (TEXTGRENZEN): hier erneut angewandt, die Anleitung traut dem gespeicherten Fragebogen nicht blind +const WUNSCH_GRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 } +const WUNSCH_MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung']) + +/** Modus (ohne Fragebogen oder ohne gueltige Wahl: inline) und die vier Wunschtexte, bereinigt, gekuerzt und wohlgeformt. */ +function kundenwunsch(fragebogen) { + const einbau = istObjekt(fragebogen) && istObjekt(fragebogen.einbau) ? fragebogen.einbau : {} + const modusGewaehlt = EINBAU_MODI.includes(einbau.modus) + const wunsch = { modus: modusGewaehlt ? einbau.modus : 'inline', modusGewaehlt } + for (const [feld, max] of Object.entries(WUNSCH_GRENZEN)) { + wunsch[feld] = bereinigterText(einbau[feld], max, { mehrzeilig: WUNSCH_MEHRZEILIG.has(feld) }).toWellFormed() + } + return wunsch +} + +/** ' Stelle: erste Zeile' und weitere Zeilen um vier Leerzeichen eingerueckt (nie am Zeilenanfang, also nie eine Ueberschrift). */ +function wunschZeilen(bezeichnung, wert) { + if (!wert) return [] + const [erste, ...weitere] = wert.split('\n') + return [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => (z === '' ? '' : ` ${z}`))] +} + +function kundenwunschBlock(wunsch) { + const zeilen = [ + ...wunschZeilen('Knopf-Text', wunsch.knopfText), + ...wunschZeilen('Stelle', wunsch.stelle), + ...wunschZeilen('Gestaltung', wunsch.gestaltung), + ...wunschZeilen('Bemerkung', wunsch.bemerkung), + ] + return zeilen.length ? ['Kundenwunsch (Text des Kunden):', ...zeilen].join('\n') : 'Kundenwunsch (Text des Kunden): keine Angaben im Fragebogen' +} + +// ---------------------------------------------------------------- Abschnitte Einbau, Snippet, CSS + +function abschnittEinbau(d) { + const w = d.wunsch + const quelle = w.modusGewaehlt ? 'Wahl des Kunden im Fragebogen' : 'Standard, der Fragebogen nennt keinen Modus' + const telefon = zeile(d.betrieb.telefon) || NICHT_ANGEGEBEN + const impressum = zeile(d.betrieb.impressumUrl) || NICHT_ANGEGEBEN + const datenschutz = zeile(d.betrieb.datenschutzUrl) || NICHT_ANGEGEBEN + return neuerAbschnitt('einbau', 'Einbau', [ + `siteKey: ${d.siteKey || NICHT_ANGEGEBEN}`, + `Modus: ${MODUS_TEXT[w.modus]} (${quelle})`, + ['So wird eingebaut:', ...aufzaehlung([ + 'Platzhalter und Skript aus dem Abschnitt „Einbau-Code (Snippet)“ in die Seite setzen: den Platzhalter an die Stelle, die der Kunde nennt ' + + '(siehe Kundenwunsch), das Skript einmal pro Seite, am besten vor dem schließenden body-Tag. Das Skript hat keine Abhängigkeiten, ' + + 'setzt keine Cookies und lädt nichts von Dritten.', + 'Der Ausweichtext im Platzhalter gehört der Website: Er bleibt ohne JavaScript, bei falschem siteKey, abgeschalteter Funktion, fehlenden ' + + `Pflichtangaben und Netzfehlern stehen. Telefon des Betriebs: ${telefon}. Gestalte ihn im Stil der Seite.`, + 'Aussehen: den Abschnitt „Aussehen (CSS)“ in die CSS der Website übernehmen. Weitere Variablen stehen in docs/funktionen/termine-einbau.md, Abschnitt 3.', + 'Datenschutz: den Absatz „Online-Terminbuchung“ (Abschnitte „Datenschutz-Absatz für die Website“) in die Datenschutzerklärung der Website aufnehmen, ' + + 'bevor das Widget freigeschaltet wird.', + `Impressum und Datenschutzerklärung: Die URLs stehen in den Betriebsangaben im Portal (Impressum: ${impressum}; Datenschutzerklärung: ${datenschutz}), ` + + 'das Widget verlinkt sie im Fuß.', + ])].join('\n'), + kundenwunschBlock(w), + ].join('\n\n')) +} + +/** Einbau-Code aus den Angaben des Betriebs und dem Modus des Fragebogens; Wuensche ausser dem Knopf-Text kommen nie hinein. */ +const baueSnippet = (d) => + einbauSnippet({ siteKey: d.siteKey, apiBase: d.portal, telefon: d.telefon, modus: d.wunsch.modus, knopfText: d.wunsch.knopfText }) + +const abschnittSnippet = (d) => neuerAbschnitt('snippet', 'Einbau-Code (Snippet)', baueSnippet(d), true) + +const abschnittCss = (d) => neuerAbschnitt('css', 'Aussehen (CSS)', cssVorschlag(d.farbe).css, true) + +// ---------------------------------------------------------------- Abschnitte Datenschutz-Absatz (Sie, Du) + +const DATENSCHUTZ_HINWEIS = + 'Der Datenschutz-Absatz konnte nicht erzeugt werden: In den Einstellungen fehlt der Name oder die E-Mail-Adresse des Betriebs. ' + + 'Beides im Portal eintragen lassen und die Anleitung neu laden.' + +/** { sie, du } oder null, wenn Name oder E-Mail-Adresse des Betriebs fehlen (datenschutzAbsatz wirft dann einen TypeError). */ +function datenschutzTexte(einstellung) { + try { + return { sie: datenschutzAbsatz(einstellung, 'sie'), du: datenschutzAbsatz(einstellung, 'du') } + } catch (err) { + if (err instanceof TypeError) return null + throw err + } +} + +/** Nur ein echter Absatz ist kopierbar; der Hinweis, dass er fehlt, nie (er gehoert nicht in eine Datenschutzerklaerung). */ +const abschnittDatenschutz = (anrede) => (d) => { + const id = `datenschutz_${anrede}` + const titel = `Datenschutz-Absatz für die Website (${anrede === 'du' ? 'Du' : 'Sie'}-Form)` + const absatz = d.datenschutz?.[anrede] + return absatz ? neuerAbschnitt(id, titel, absatz, true) : neuerAbschnitt(id, titel, DATENSCHUTZ_HINWEIS) +} + +// ---------------------------------------------------------------- Abschnitt Abnahme-Checkliste + +// Zwischenregel bis Block F (F10b2 ersetzt sie durch die Endregel aus F4: WEBklar fuegt den Absatz ein, wenn die Website von WEBklar betreut wird) +const DATENSCHUTZ_ZWISCHENREGEL = 'liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden' + +function abschnittCheckliste(d) { + const { e } = d + const widerruf = zeigtWiderruf(e) + const preis = sichtbareLeistungen(e).some(hatPreis) + const knopf = d.wunsch.modus === 'knopf' + const impressum = zeile(d.betrieb.impressumUrl) || 'fehlt' + const datenschutz = zeile(d.betrieb.datenschutzUrl) || 'fehlt' + const wo = d.anleitungsLink ? ` (Kunde → Reiter Funktionen, Zeile Terminplaner: ${d.anleitungsLink})` : '' + const punkte = [ + // vor der Freischaltung + 'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.', + ...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []), + 'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.', + `Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${DATENSCHUTZ_ZWISCHENREGEL}).`, + `URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`, + `„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`, + // am sichtbaren Widget + 'Probebuchung bis „Fast geschafft“ durchgespielt: Die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. ' + + 'Danach den Testtermin stornieren (Verwaltungslink aus der E-Mail oder im Portal).', + ...(widerruf ? ['Widerrufsbelehrung ist im Widget aufklappbar (Verbraucher-Betrieb, auch bei nur Anfrage-Leistungen).'] : []), + ...(widerruf && preis ? ['Der Haken für den vorzeitigen Beginn erscheint bei Leistungen mit Preis für Termine in den nächsten 14 Tagen.'] : []), + ...(widerruf ? ['Der Knopf „Vertrag widerrufen“ steht im Fuß, das Formular funktioniert.'] : []), + ...(widerruf && knopf + ? ['Modus knopf: Widerrufsknopf dauerhaft neben dem Buchungsknopf (nicht erst im Dialog); er öffnet den Dialog gleich beim Widerrufsformular.'] + : []), + 'Kontrast geprüft: Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1; globale Regeln des Themes (button, input) stören nicht.', + 'Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, der Dialog passt in den Bildschirm; Zoom 200 %: nichts wird abgeschnitten.', + 'Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog.', + ] + return neuerAbschnitt('checkliste', 'Abnahme-Checkliste', punkte.map((p) => `- [ ] ${p}`).join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Abrechnung + +const RECHNUNG_RE = /^https:\/\/\S+$/i + +/** Preis der Einrichtung aus dem Register (config.features.termine.priceLabel), sonst „149 €“. */ +function einrichtungsPreis() { + const preis = zeile(config.features?.termine?.priceLabel) + return preis || '149 €' +} + +function abschnittAbrechnung(d) { + if (d.feature?.unlockedByPurchase !== true) { + return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.') + } + const rechnung = zeile(d.feature.invoiceUrl) + return neuerAbschnitt('abrechnung', 'Abrechnung', [ + `Abrechnung: Einrichtung bezahlt (${einrichtungsPreis()}, Rechnung ${RECHNUNG_RE.test(rechnung) ? rechnung : 'folgt im Portal'}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden.`, + 'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.', + ].join('\n')) +} + +// ---------------------------------------------------------------- Warnungen + +const WARNUNG_PFLICHT = { + 'betrieb.telefon': 'Telefonnummer des Betriebs fehlt: Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.', + 'betrieb.adresse': + 'Anschrift des Betriebs fehlt (sie steht in der Widerrufsbelehrung): Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.', +} +const WARNUNG_OHNE_URL = 'fehlt: Im Fuß des Widgets fehlt der Link.' +const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder E-Mail-Adresse des Betriebs fehlt in den Einstellungen.' + +/** + * Meldungen aus gesundheitsWarnungen (Block C) tragen Namen des Kunden: gekuerzt in UTF-16-Einheiten (ein halbes Zeichenpaar ist moeglich) + * und ungemaskiert. Hier werden sie wohlgeformt und einzeilig; sie bleiben Klartext, der Anzeiger setzt sie als Text ein (nie als HTML). + */ +const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '') + +/** Warnungen fuer das Team in fester Reihenfolge: Banner, Pflichtangaben, Impressum, Datenschutz-URL, Gesundheit, Kontrast, Datenschutz-Absatz. */ +function warnungenAus(d) { + const warnungen = [] + if (d.entwurf) warnungen.push(ENTWURF_BANNER) + for (const feld of fehlendePflichtangaben(d.e)) warnungen.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`) + if (!zeile(d.betrieb.impressumUrl)) warnungen.push(`Impressum-URL ${WARNUNG_OHNE_URL}`) + if (!zeile(d.betrieb.datenschutzUrl)) warnungen.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`) + for (const meldung of gesundheitsWarnungen(d.e)) warnungen.push(meldungText(meldung)) + warnungen.push(...cssVorschlag(d.farbe).hinweise) + if (!d.datenschutz) warnungen.push(WARNUNG_OHNE_DATENSCHUTZ) + return warnungen +} + // ---------------------------------------------------------------- Zusammenbau -const ABSCHNITTE = [abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen] +const ABSCHNITTE = [ + abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen, + abschnittEinbau, abschnittSnippet, abschnittCss, abschnittDatenschutz('sie'), abschnittDatenschutz('du'), abschnittCheckliste, abschnittAbrechnung, +] /** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */ const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n') @@ -392,42 +590,68 @@ function projektText(project) { * ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen. * * @param {object} p - * @param {object} p.feature portalFeatures-Dokument der Funktion (D3b: siteKey, unlockedByPurchase; der Registereintrag - * config.features.termine wird nicht gebraucht) + * @param {object} [p.feature] portalFeatures-Dokument der Funktion (gelesen: siteKey, customerId, projectId, unlockedByPurchase, + * invoiceUrl; der Registereintrag config.features.termine wird nicht gebraucht) * @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl) * @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine - * @param {object|null} [p.fragebogen] geparster Fragebogen oder null (D3b: Kundenwuensche zum Einbau) + * @param {object|null} [p.fragebogen] geparster Fragebogen oder null (gelesen: einbau.modus, knopfText, stelle, gestaltung, bemerkung) * @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert) * @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError * @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen * @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name - * @param {boolean} [p.entwurf] D3b: Rechtstexte sind noch ENTWURF (Warnung); Standard RECHTSTEXTE_ENTWURF + * @param {boolean} [p.entwurf] Rechtstexte sind noch ENTWURF (erste Warnung ENTWURF_BANNER); Standard RECHTSTEXTE_ENTWURF * @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}} - * D3a: warnungen [] und kurz '' (kommen mit D3b und D4); ohne Einstellung keine Abschnitte. Die Anrede der Gaeste kommt aus einstellung.anrede. + * abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze. + * text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie). + * Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert". `kurz` ist '' (kommt mit D4). */ export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) { if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt') const stand = { version, updatedAt } - if (!istObjekt(einstellung)) return { stand, warnungen: [], abschnitte: [], text: '', kurz: '' } + const banner = entwurf ? [ENTWURF_BANNER] : [] + const gemeinsam = { + feature, + siteKey: zeile(feature?.siteKey), + portal: zeile(config.publicBaseUrl), + wunsch: kundenwunsch(fragebogen), + anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '', + } + + if (!istObjekt(einstellung)) { + const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })] + return { stand, warnungen: [...banner, 'Keine Einstellungen gespeichert'], abschnitte, text: volltext(abschnitte), kurz: '' } + } const ressourcen = liste(einstellung.ressourcen).filter(istObjekt) const geaendert = updatedAt ? new Date(updatedAt) : null + const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {} const daten = { + ...gemeinsam, e: einstellung, now, + entwurf: Boolean(entwurf), heuteTag: heute(now), version, geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '', - betrieb: istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}, + betrieb, + telefon: zeile(betrieb.telefon), kunde: zeile(kunde?.name), projekt: projektText(project), farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE, - portal: zeile(config.publicBaseUrl), haltezeit: config.termine?.holdMinutes, ressourcen, leistungen: liste(einstellung.leistungen).filter(istObjekt), nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])), + datenschutz: datenschutzTexte(einstellung), } const abschnitte = ABSCHNITTE.map((baue) => baue(daten)) - return { stand, warnungen: [], abschnitte, text: volltext(abschnitte), kurz: '' } + // der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt + const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du' + return { + stand, + warnungen: warnungenAus(daten), + abschnitte, + text: volltext(abschnitte.filter((a) => a.id !== andere)), + kurz: '', + } } diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js index 115c8c2..3c45c51 100644 --- a/test/termineEinbauanleitung.test.js +++ b/test/termineEinbauanleitung.test.js @@ -4,8 +4,9 @@ import { readFileSync } from 'node:fs' import { config } from '../server/config.js' import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einstellungen.js' import { ausVorlage } from '../server/services/termine/vorlagen.js' +import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from '../server/services/termine/rechtstexte.js' import { - cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText, + ENTWURF_BANNER, cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText, } from '../server/services/termine/einbauanleitung.js' // Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config. @@ -281,21 +282,22 @@ function erzeuge(zusatz = {}) { const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id) const zeilen = (text) => text.split('\n') -test('D3a: Ergebnisform, die sechs Abschnitte in fester Reihenfolge, keiner kopierbar', () => { - const r = erzeuge() +test('D3a: Ergebnisform, die ersten sechs Abschnitte in fester Reihenfolge, keiner davon kopierbar', () => { + const r = erzeuge({ entwurf: false }) assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen']) - assert.deepEqual(r.abschnitte.map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen']) + assert.deepEqual(r.abschnitte.slice(0, 6).map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen']) for (const a of r.abschnitte) { assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id) - assert.equal(a.kopierbar, false, a.id) assert.ok(a.titel && typeof a.titel === 'string', a.id) assert.ok(a.text && typeof a.text === 'string', a.id) } - assert.deepEqual(r.warnungen, [], 'Warnungen kommen mit D3b') + for (const a of r.abschnitte.slice(0, 6)) assert.equal(a.kopierbar, false, a.id) + assert.deepEqual(r.warnungen, [], 'ein vollstaendiger Betrieb ohne Entwurf-Kennzeichnung hat keine Warnungen') assert.equal(r.kurz, '', 'die Kurzfassung kommt mit D4') - // der Volltext fuehrt jeden Abschnitt mit Ueberschrift - assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), r.abschnitte.map((a) => `## ${a.titel}`)) - for (const a of r.abschnitte) assert.ok(r.text.includes(a.text), a.id) + // der Volltext fuehrt jeden Abschnitt mit Ueberschrift, die Datenschutz-Fassung der anderen Anrede fehlt (D3b) + const ohneDu = r.abschnitte.filter((a) => a.id !== 'datenschutz_du') + assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), ohneDu.map((a) => `## ${a.titel}`)) + for (const a of ohneDu) assert.ok(r.text.includes(a.text), a.id) }) test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => { @@ -587,7 +589,10 @@ test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und K kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM }, fragebogen: { geheim: GEHEIM }, })) - for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE', BETRIEB.email]) assert.ok(!json.includes(verboten), verboten) + for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE']) assert.ok(!json.includes(verboten), verboten) + // die Betriebs-E-Mail steht nur in den beiden Datenschutz-Absaetzen (Verantwortlicher), sonst nirgends + const r = erzeuge({ einstellung: e }) + for (const a of r.abschnitte) assert.equal(a.text.includes(BETRIEB.email), a.id.startsWith('datenschutz_'), a.id) }) test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => { @@ -597,7 +602,7 @@ test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts- e.leistungen[0].name = 'Schnitt\r\n## Gastfelder' e.abwesenheiten[3].titel = 'Ferien\n## Modell' const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } }) - assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 6, r.text) + assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12, r.text) // 13 Abschnitte, nur eine Datenschutz-Fassung assert.ok(r.text.includes(' & "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein') assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text) assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1) @@ -645,10 +650,9 @@ test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung ver assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026')) }) -test('D3a ohne Einstellung wirft nichts und liefert keine Abschnitte; ein ungueltiges now wirft', () => { +test('D3a ohne Einstellung wirft nichts; ein ungueltiges now wirft', () => { const r = erzeuge({ einstellung: null, version: 0, updatedAt: null }) - assert.deepEqual(r.abschnitte, []) - assert.equal(r.text, '') + assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'], 'D3b: nur der Einbau-Code') assert.deepEqual(r.stand, { version: 0, updatedAt: null }) for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) { assert.throws(() => erzeuge({ now }), TypeError, String(now)) @@ -684,3 +688,448 @@ test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N assert.equal(l[0], '50 Leistungen (50 aktiv):') assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50) }) + +// ================================================================ Generator einbauanleitung() 2 (Task D3b) +// Einbau, Snippet, CSS, Datenschutz-Absaetze, Checkliste, Abrechnung, Warnungen, Volltext. + +const ALLE_IDS = [ + 'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen', + 'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung', +] +const KOPIERBAR = ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du'] +const KNOPF_FRAGEBOGEN = { + einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht, wie die Seite', bemerkung: '' }, +} +const PAKET_FEATURE = { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup', unlockedByPurchase: true } +const hatText = (ergebnis, ...teile) => teile.every((t) => ergebnis.text.includes(t)) + +test('D3b: die 13 Abschnitt-IDs in fester Reihenfolge, kopierbar nur Snippet, CSS und beide Datenschutz-Absaetze', () => { + const r = erzeuge() + assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS) + for (const a of r.abschnitte) { + assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id) + assert.equal(a.kopierbar, KOPIERBAR.includes(a.id), a.id) + assert.ok(a.titel && a.text, a.id) + } + assert.equal(new Set(r.abschnitte.map((a) => a.titel)).size, 13, 'jede Ueberschrift kommt einmal vor') +}) + +test('D3b: ENTWURF_BANNER hat den Wortlaut aus dem Brief', () => { + assert.equal(ENTWURF_BANNER, 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen') +}) + +test('D3b Volltext: genau eine Datenschutz-Fassung, die zur Anrede passt; abschnitte enthalten beide', () => { + const sie = fixtureEinstellung() + const du = fixtureEinstellung() + du.anrede = 'du' + const textSie = datenschutzAbsatz(sie, 'sie') + const textDu = datenschutzAbsatz(sie, 'du') + assert.notEqual(textSie, textDu) + + const a = erzeuge({ einstellung: sie }) + assert.equal(abschnitt(a, 'datenschutz_sie').text, textSie) + assert.equal(abschnitt(a, 'datenschutz_du').text, datenschutzAbsatz(sie, 'du')) + assert.ok(a.text.includes(textSie) && !a.text.includes(textDu), 'Sie-Betrieb: nur die Sie-Fassung') + assert.ok(a.text.includes(`## ${abschnitt(a, 'datenschutz_sie').titel}`) && !a.text.includes(`## ${abschnitt(a, 'datenschutz_du').titel}`)) + + const b = erzeuge({ einstellung: du }) + assert.deepEqual(b.abschnitte.map((x) => x.id), ALLE_IDS, 'die Abschnitte bleiben gleich, nur der Volltext waehlt') + assert.ok(b.text.includes(datenschutzAbsatz(du, 'du')) && !b.text.includes(datenschutzAbsatz(du, 'sie')), 'Du-Betrieb: nur die Du-Fassung') + assert.equal(abschnitt(b, 'datenschutz_sie').text, datenschutzAbsatz(du, 'sie')) + // jeder andere Abschnitt steht im Volltext, in der Reihenfolge der Abschnitte + const ids = b.abschnitte.filter((x) => x.id !== 'datenschutz_sie').map((x) => `## ${x.titel}`) + assert.deepEqual(zeilen(b.text).filter((z) => z.startsWith('## ')), ids) + // eine unbekannte Anrede gilt wie in D3a als Sie + const seltsam = fixtureEinstellung() + seltsam.anrede = 'Herr' + assert.ok(erzeuge({ einstellung: seltsam }).text.includes(datenschutzAbsatz(seltsam, 'sie'))) +}) + +test('D3b Datenschutz-Absatz: kein ENTWURF-Banner im kopierbaren Text, die Betriebs-E-Mail steht nur dort', () => { + const r = erzeuge({ entwurf: true }) + for (const id of ['datenschutz_sie', 'datenschutz_du']) { + const t = abschnitt(r, id).text + assert.ok(!t.includes('ENTWURF'), id) + assert.ok(t.startsWith('Online-Terminbuchung'), id) + assert.ok(t.includes(BETRIEB.email), id) + } +}) + +test('D3b Snippet und CSS: genau das, was die Bausteine liefern, ohne Fragebogen im Modus inline', () => { + const r = erzeuge() + const erwartet = einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'inline' }) + assert.equal(abschnitt(r, 'snippet').text, erwartet) + assert.ok(erwartet.includes('data-modus="inline"') && !erwartet.includes('data-knopf')) + assert.ok(erwartet.includes(`data-webklar-termine="${SITE_KEY}"`) && erwartet.includes(`data-api="${config.publicBaseUrl}"`)) + assert.ok(erwartet.includes('

Termine telefonisch unter 0631 123456.

'), 'der Ausweichtext nennt die Telefonnummer des Betriebs') + assert.equal(abschnitt(r, 'css').text, cssVorschlag('#9d4b6f').css) + assert.ok(abschnitt(r, 'css').text.includes('--wkt-akzent:#9d4b6f;')) + // Fragebogen ohne Modus oder mit unbekanntem Modus: inline + for (const fragebogen of [{ einbau: { modus: null } }, { einbau: { modus: 'dialog' } }, { einbau: 'kaputt' }, {}, 'text']) { + assert.equal(abschnitt(erzeuge({ fragebogen }), 'snippet').text, erwartet, JSON.stringify(fragebogen)) + } + // die Portal-Adresse wird beim Aufruf gelesen + const vorher = config.publicBaseUrl + try { + config.publicBaseUrl = 'https://portal.test.example' + assert.ok(abschnitt(erzeuge(), 'snippet').text.includes('data-api="https://portal.test.example"')) + } finally { + config.publicBaseUrl = vorher + } +}) + +test('D3b Snippet: Telefonnummer und siteKey sind maskiert (kein Ausbrechen aus dem Platzhalter)', () => { + const e = fixtureEinstellung() + e.betrieb.telefon = '0631 12' + const r = erzeuge({ einstellung: e, feature: { ...PAKET_FEATURE, siteKey: 'a">']) { + assert.ok(einbau.includes(wunsch), `${wunsch}\n---\n${einbau}`) + } + assert.equal((einbau.match(/Kundenwunsch \(Text des Kunden\)/g) || []).length, 1, 'ein einziger Block') + // Stelle, Gestaltung und Bemerkung stehen in keinem anderen Abschnitt (der Knopf-Text nur zusaetzlich im Snippet) + for (const a of r.abschnitte.filter((x) => x.id !== 'einbau')) { + for (const wunsch of ['Startseite unter dem Hero', 'Schlicht, wie die Seite', 'alert(1)']) assert.ok(!a.text.includes(wunsch), `${wunsch} in ${a.id}`) + } + assert.ok(!snippet.includes('alert(1)') && !abschnitt(r, 'css').text.includes('alert(1)')) + assert.equal((snippet.match(/', + ]) + assert.ok(mit.kurz.includes('data-modus="knopf" data-knopf="Termin sichern"')) + assert.equal(mit.kurz.split('alert(1)').length - 1, 1, 'der Wunsch steht nur im Kundenwunsch-Block, nie im Snippet') + + const ohne = absatzMit(erzeuge({ fragebogen: null }).kurz, 'Kundenwunsch').split('\n') + assert.deepEqual(ohne, ['Kundenwunsch (Text des Kunden):', ' Modus: inline (Standard)', ' Sonst keine Angaben im Fragebogen']) + + // mehrzeilig: weitere Zeilen eingerueckt, Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext), keine Ueberschrift, kein falscher Stand + const mehrKurz = erzeuge({ fragebogen: { einbau: { modus: 'inline', bemerkung: 'Eins\n\n\nZwei\nKonfigurationsstand: Version 99' } } }).kurz + assert.deepEqual(absatzMit(mehrKurz, 'Kundenwunsch').split('\n').slice(2), [' Bemerkung: Eins', ' Zwei', ' Konfigurationsstand: Version 99']) + assert.ok(!/^Konfigurationsstand: Version 99/m.test(mehrKurz), 'der Wunsch kann den Stand nicht faelschen') + assert.equal(ticketStand(mehrKurz), 3) + assert.equal(absaetze(mehrKurz).length, absaetze(erzeuge().kurz).length, 'die Absatzzahl aendert sich durch den Wunsch nicht') +}) + +test('D4 kurz Zaehlungen: Anzahl, aktive und die ersten 8 Namen, danach "und N weitere"', () => { + const umfang = absatzMit(erzeuge().kurz, 'Umfang:').split('\n') + assert.deepEqual(umfang, [ + 'Umfang:', + '- 4 Leistungen (3 aktiv): Herrenschnitt, Damenschnitt, Kinderschnitt, Färben', + '- 3 Personen und Plätze (2 aktiv): Anna, Ben, Stuhl 2', + '- 2 Zusatzfelder: Haarlänge, Wunschfrisur', + ]) + const e = fixtureEinstellung() + e.leistungen = Array.from({ length: 12 }, (_, i) => ({ ...e.leistungen[0], id: `s_z${i}`, name: `Leistung ${i + 1}`, aktiv: i !== 3 })) + const zeile12 = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1] + assert.equal(zeile12, '- 12 Leistungen (11 aktiv): Leistung 1, Leistung 2, Leistung 3, Leistung 4, Leistung 5, Leistung 6, Leistung 7, Leistung 8, … und 4 weitere') + e.leistungen = e.leistungen.slice(0, 8) + assert.ok(!absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').includes('weitere'), 'genau 8: nichts uebrig') + e.leistungen = e.leistungen.slice(0, 1) + e.ressourcen = [] + e.gastFelder = { zusatz: [] } + assert.deepEqual(absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n').slice(1), ['- 1 Leistung (1 aktiv): Leistung 1', '- 0 Personen und Plätze', '- 0 Zusatzfelder']) + // sehr lange Namen: je Name hoechstens 60 Zeichen, Umbrueche werden zu Leerzeichen + e.leistungen = [{ ...e.leistungen[0], name: `${'N'.repeat(100)}\nzwei` }] + const lang = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1] + assert.ok(lang.endsWith(`${'N'.repeat(59)}…`), lang) + assert.ok(!lang.includes('zwei') && !lang.includes('N'.repeat(60))) +}) + +test('D4 kurz Warnungen: hoechstens 5 in der Reihenfolge von warnungen, das Banner nur einmal, ohne Warnung kein Absatz', () => { + const r = erzeuge({ einstellung: mitSechsWarnungen(), entwurf: true }) + assert.equal(r.warnungen.length, 6, r.warnungen.join('\n')) + const block = absatzMit(r.kurz, 'Warnungen:').split('\n') + assert.deepEqual(block, ['Warnungen:', ...r.warnungen.slice(0, 5).map((w) => `- ${w}`)], 'die ersten fuenf, die sechste (Kontrast) bleibt dem Panel') + assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1, 'der Banner-Text steht genau einmal im Ticket') + assert.equal(block[1], `- ${ENTWURF_BANNER}`) + const ohne = erzeuge({ entwurf: false }) + assert.deepEqual(ohne.warnungen, []) + assert.ok(!ohne.kurz.includes('Warnungen:')) + assert.equal(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).warnungen.length, 5) + assert.equal(absatzMit(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).kurz, 'Warnungen:').split('\n').length, 6) +}) + +test('D4 kurz Gesundheit: eigener Absatz, zaehlt nicht zu den 5 Warnungen, wohlgeformt, einzeilig und als Klartext', () => { + const e = mitSechsWarnungen() + e.leistungen[0].name = 'Physiotherapie' + e.leistungen[1].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}` + e.leistungen[1].beschreibung = 'Therapie' + e.leistungen[2].name = 'Zeile1\n## Zeile2 x' + e.leistungen[2].beschreibung = 'Physiotherapie' + const r = erzeuge({ einstellung: e, entwurf: true }) + const gesundheit = absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n') + const meldungen = gesundheitsMeldungen(e) + assert.equal(meldungen.length, 3, meldungen.join('\n')) + assert.deepEqual(gesundheit, [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)]) + assert.equal(absatzMit(r.kurz, 'Warnungen:').split('\n').length, 6, 'die fuenf Warnungen bleiben voll, die Gesundheit kommt dazu') + for (const m of meldungen) { + assert.ok(m.isWellFormed() && !/[\r\n]/.test(m), JSON.stringify(m)) + assert.ok(!r.warnungen.every((w) => w !== m), 'dieselben Meldungen stehen auch in warnungen') + } + assert.ok(meldungen.some((m) => m.includes('x')), 'Klartext, nichts wird maskiert') + assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz)) + // keine Treffer: kein Absatz + assert.ok(!erzeuge().kurz.includes(GESUNDHEIT_KOPF)) + // Reihenfolge der Absaetze + const stellen = ['Funktion:', 'Kundenwunsch', 'Umfang:', 'Warnungen:', GESUNDHEIT_KOPF, 'Einbau-Code (Snippet):', 'Schritte:', 'Verweis:'].map((a) => absaetze(erzeuge({ einstellung: e, entwurf: true, fragebogen: KNOPF_FRAGEBOGEN }).kurz).findIndex((x) => x.startsWith(a))) + assert.ok(stellen.every((i) => i >= 0), JSON.stringify(stellen)) + assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b)) +}) + +test('D4 gesundheitsMeldungen: ohne Treffer und ohne Einstellung leer, sonst die bereinigten Meldungen der Pruefung aus C', () => { + assert.deepEqual(gesundheitsMeldungen(null), []) + assert.deepEqual(gesundheitsMeldungen(undefined), []) + assert.deepEqual(gesundheitsMeldungen(fixtureEinstellung()), []) + const e = fixtureEinstellung() + e.leistungen[0].name = 'Physiotherapie' + assert.deepEqual(gesundheitsMeldungen(e), ['Leistungsname enthält „Physiotherapie“']) + const halb = fixtureEinstellung() + halb.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}` + halb.leistungen[0].beschreibung = 'Physiotherapie' + for (const m of gesundheitsMeldungen(halb)) assert.ok(m.isWellFormed(), JSON.stringify(m)) +}) + +test('D4 kurz ohne Einstellungen: Kopf mit Version 0, Warnung, Snippet, Schritte und Verweis', () => { + const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: true }) + const [kopf] = absaetze(r.kurz) + assert.ok(kopf.endsWith('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), kopf) + assert.ok(r.kurz.includes('noch keine Einstellungen')) + assert.deepEqual(absatzMit(r.kurz, 'Warnungen:').split('\n'), ['Warnungen:', `- ${ENTWURF_BANNER}`, '- Keine Einstellungen gespeichert']) + assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${r.abschnitte[0].text}`)) + assert.ok(!r.kurz.includes('Umfang:') && !r.kurz.includes(GESUNDHEIT_KOPF)) + assert.equal(absatzMit(r.kurz, 'Schritte:').split('\n').length, 7) + assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`)) + assert.ok(r.kurz.length <= TICKET_DETAILS_MAX) + // der Wunsch des Kunden (Fragebogen) steht auch ohne Einstellungen im Ticket + assert.ok(erzeuge({ einstellung: null, version: 0, fragebogen: KNOPF_FRAGEBOGEN }).kurz.includes(' Knopf-Text: Termin sichern')) +}) + +test('D4 kurz Maximalbefuellung: hoechstens TICKET_DETAILS_MAX, Kopf, Snippet, Verweis, Stundenkontingent und alle 10 Gesundheitswarnungen bleiben', () => { + const e = maximalEinstellung() + const meldungen = gesundheitsMeldungen(e) + assert.equal(meldungen.length, 10, 'die Pruefung aus C liefert hoechstens 10') + const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true }) + assert.ok(r.kurz.length <= TICKET_DETAILS_MAX, `kurz ${r.kurz.length}`) + assert.ok(r.kurz.length > 3000, 'es steht etwas Sinnvolles da, nicht nur ein Rumpf') + assert.equal(absaetze(r.kurz)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026') + assert.ok(r.kurz.includes(abschnitt(r, 'snippet').text)) + assert.ok(r.kurz.includes('Nicht auf das Stundenkontingent anrechnen')) + assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`)) + assert.deepEqual(absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n'), [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)]) + assert.ok(!r.kurz.includes('Online-Terminbuchung')) + for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken)) + assert.ok(r.kurz.isWellFormed()) + assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1) +}) + +test('D4 kurz Platzdruck: erst fallen die Zaehlungen, dann die Warnungen, dann der Kundenwunsch weg; Pflichtteile und Gesundheit nie', () => { + const gesehen = new Set() + let zuletzt = null + // das Snippet waechst mit dem (hier absichtlich nicht normalisierten) Telefon und treibt die Pflichtteile nach oben + for (let laenge = 1; laenge <= 3200; laenge += 8) { + const e = maximalEinstellung() + e.betrieb.telefon = '0'.repeat(laenge) + e.betrieb.adresse = '' + const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true }) + const k = r.kurz + assert.ok(k.length <= TICKET_DETAILS_MAX, `Telefon ${laenge}: kurz ${k.length}`) + // Pflichtteile + assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026') + assert.ok(k.includes(abschnitt(r, 'snippet').text), `Telefon ${laenge}: Snippet`) + assert.ok(k.includes('Nicht auf das Stundenkontingent anrechnen') && k.endsWith(`Verweis: ${LINK}`)) + assert.deepEqual(absatzMit(k, GESUNDHEIT_KOPF).split('\n').slice(1), gesundheitsMeldungen(e).map((m) => `- ${m}`), `Telefon ${laenge}: Gesundheit`) + // Stufen + const umfang = k.includes('\n\nUmfang:') + const warnungen = k.includes('\n\nWarnungen:') + const wunsch = k.includes('\n\nKundenwunsch (Text des Kunden):') + if (umfang) assert.ok(warnungen && wunsch, `Telefon ${laenge}: Zaehlungen nur mit Warnungen und Wunsch`) + if (warnungen) assert.ok(wunsch, `Telefon ${laenge}: Warnungen nur mit Wunsch`) + if (warnungen) { + const anzahl = absatzMit(k, 'Warnungen:').split('\n').length - 1 + assert.deepEqual(absatzMit(k, 'Warnungen:').split('\n').slice(1), r.warnungen.filter((w) => !gesundheitsMeldungen(e).includes(w)).slice(0, anzahl).map((w) => `- ${w}`), 'es fallen die letzten Warnungen zuerst weg') + } + gesehen.add(`${umfang ? 'Umfang' : '-'}/${warnungen ? 'Warnungen' : '-'}/${wunsch ? 'Wunsch' : '-'}`) + zuletzt = k + } + assert.ok(gesehen.has('Umfang/Warnungen/Wunsch'), [...gesehen].join(' ')) + assert.ok(gesehen.has('-/Warnungen/Wunsch'), [...gesehen].join(' ')) + assert.ok(gesehen.has('-/-/Wunsch'), [...gesehen].join(' ')) + assert.ok(gesehen.has('-/-/-'), [...gesehen].join(' ')) + assert.ok(zuletzt.length > 0) +}) + +test('D4 kurz: selbst ein riesiges Snippet sprengt die Grenze nicht, die Gesundheit steht vor dem Schnitt', () => { + const e = maximalEinstellung() + e.betrieb.telefon = '9'.repeat(20000) + const k = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, entwurf: true }).kurz + assert.ok(k.length <= TICKET_DETAILS_MAX, `kurz ${k.length}`) + assert.ok(k.isWellFormed() && !HALBES_PAAR.test(k)) + assert.ok(k.includes(GESUNDHEIT_KOPF)) + assert.ok(k.endsWith('… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen')) + assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026') +}) + +test('D4 kurz ist wohlgeformt: halbe Zeichenpaare in Namen, Wuenschen, Projekt und Kunde werden ersetzt, Emojis bleiben', () => { + const e = fixtureEinstellung() + e.leistungen[0].name = `Schnitt \uD83D${'x'.repeat(10)}😀` + e.ressourcen[0].name = `Anna\uDE00` + const fb = { einbau: { modus: 'inline', bemerkung: `halb \uD83D und ganz 😀`, stelle: '\uDE00' } } + const r = erzeuge({ einstellung: e, fragebogen: fb, project: { projectName: `Salon \uD83D`, liveDomain: 'x.de' }, kunde: { name: `K\uDE00unde` } }) + assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz), JSON.stringify(r.kurz.match(HALBES_PAAR))) + assert.ok(r.kurz.includes('😀'), 'ganze Zeichenpaare bleiben') + assert.ok(JSON.parse(JSON.stringify(r.kurz)) === r.kurz) +}) + +test('D4 kurz ist deterministisch, unabhaengig von now und aendert die Eingaben nicht', () => { + const e = maximalEinstellung() + const fb = structuredClone(MAX_FRAGEBOGEN) + const vorher = structuredClone({ e, fb }) + const a = erzeuge({ einstellung: e, fragebogen: fb }) + assert.equal(erzeuge({ einstellung: e, fragebogen: fb }).kurz, a.kurz) + assert.equal(erzeuge({ einstellung: e, fragebogen: fb, now: SPAETER }).kurz, a.kurz, 'in der Kurzfassung steht kein Erzeugungsdatum') + assert.deepEqual(structuredClone({ e, fb }), vorher) +}) + +// ---------------------------------------------------------------- ticketStand, driftWarnung + +test('D4 ticketStand liest Konfigurationsstand: Version N am Zeilenanfang, sonst null', () => { + assert.equal(ticketStand('Funktion: Terminplaner\nKonfigurationsstand: Version 7, 03.10.2026\n\nrest'), 7) + assert.equal(ticketStand('Konfigurationsstand: Version 7, 03.10.2026'), 7) + assert.equal(ticketStand('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), 0) + assert.equal(ticketStand('x\r\nKonfigurationsstand: Version 12'), 12) + assert.equal(ticketStand('Konfigurationsstand: Version 999999999'), 999999999, 'neun Stellen sind erlaubt') + assert.equal(ticketStand('Konfigurationsstand: Version 99999999999'), null, 'elf Stellen: kein Stand') + assert.equal(ticketStand('Konfigurationsstand: Version 1234567890'), null, 'zehn Stellen: kein Stand') + for (const ohne of ['', 'Funktion: Terminplaner', 'Konfigurationsstand: Version', 'Konfigurationsstand: Version x', ' Konfigurationsstand: Version 7', 'Der Konfigurationsstand: Version 7', 'konfigurationsstand: Version 7', 'Konfigurationsstand: Version -7']) { + assert.equal(ticketStand(ohne), null, JSON.stringify(ohne)) + } + for (const nichtText of [undefined, null, 7, {}, ['Konfigurationsstand: Version 7']]) assert.equal(ticketStand(nichtText), null, String(nichtText)) +}) + +test('D4 driftWarnung: nur wenn die Einstellungen jetzt eine hoehere Version haben als das Ticket', () => { + assert.equal(driftWarnung(7, 9), 'Einstellungen seit der Bestellung geändert (Ticket Version 7, jetzt 9).') + assert.equal(driftWarnung(0, 1), 'Einstellungen seit der Bestellung geändert (Ticket Version 0, jetzt 1).', 'Version 0 im Ticket ist ein gueltiger Stand') + assert.equal(driftWarnung(9, 9), null) + assert.equal(driftWarnung(9, 7), null) + assert.equal(driftWarnung(null, 9), null) + assert.equal(driftWarnung(7, null), null) + assert.equal(driftWarnung(undefined, 9), null) + assert.equal(driftWarnung('7', 9), null) + assert.equal(driftWarnung(7.5, 9), null) +}) diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js index 011815c..b030530 100644 --- a/test/termineKontext.test.js +++ b/test/termineKontext.test.js @@ -31,7 +31,7 @@ const { leerFragebogen, normalisiereFragebogen, FRAGEBOGEN_MAX } = await import( const { activate } = await import('../server/services/siteFeatures.js') const K = await import('../server/services/termine/kontext.js') const M = await import('../server/services/termine/mails.js') -const { ladeEinstellung, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K +const { ladeEinstellung, ladeStand, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K const { BETREFFE, werteFuer, anGast, anBetrieb } = M after(() => setMailTransportForTests(null)) @@ -266,6 +266,78 @@ test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); feh }) }) +// ---------------------------------------------------------------- ladeStand (TP2 D4: Stand fuer das Einrichtungs-Ticket) + +test('ladeStand: ohne termineConfig-Dokument null, mit Dokument Einstellung, Version, updatedAt und der Fragebogen als gespeicherte Angaben', async () => { + assert.equal(await ladeStand(FEATURE), null, 'noch nichts gespeichert') + const e = einstellung() + const fb = frage({ branche: 'salon', bestaetigt: ['branche'], einbau: { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Startseite' } }) + await speichereEinstellung(feature(), e, { version: 0, now: JETZT, fragebogen: fb }) + const stand = await ladeStand(FEATURE) + assert.deepEqual(Object.keys(stand).sort(), ['einstellung', 'fragebogen', 'updatedAt', 'version']) + assert.deepEqual(stand.einstellung, e) + assert.equal(stand.version, 1) + assert.equal(new Date(stand.updatedAt).getTime(), JETZT.getTime()) + assert.deepEqual(stand.fragebogen, JSON.parse(gespeichert().fragebogen)) + assert.equal(stand.fragebogen.einbau.knopfText, 'Termin buchen') + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Salon Neu' }) }), { version: 1, now: SEKUNDEN(60) }) + const zweit = await ladeStand(FEATURE) + assert.equal(zweit.version, 2) + assert.equal(zweit.einstellung.betrieb.name, 'Salon Neu') + assert.equal(new Date(zweit.updatedAt).getTime(), SEKUNDEN(60).getTime()) + assert.deepEqual(zweit.fragebogen, stand.fragebogen, 'der Fragebogen bleibt, wenn nur die Einstellung gespeichert wird') + assert.equal(log.error.mock.callCount(), 0, 'lesbare Dokumente: keine Logzeile') +}) + +test('ladeStand: kaputtes fragebogen-JSON gibt fragebogen null bei gueltiger Einstellung (mit Log), kaputte Einstellung gibt einstellung null mit der Version', async () => { + const e = einstellung() + aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 2, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: '{kaputt' }) + const stand = await ladeStand(FEATURE) + assert.deepEqual(stand.einstellung, e) + assert.equal(stand.fragebogen, null) + assert.equal(stand.version, 2) + assert.equal(log.error.mock.callCount(), 1) + assert.match(String(log.error.mock.calls[0].arguments[0]), /Fragebogen f_p_projekt01_termine/) + + const fb = frage({ branche: 'salon' }) + await deleteDocument('termineConfig', FEATURE) + aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: JSON.stringify(fb) }) + const kaputt = await ladeStand(FEATURE) + assert.equal(kaputt.einstellung, null) + assert.equal(kaputt.version, 3, 'die Version des Dokuments bleibt') + assert.deepEqual(kaputt.fragebogen, fb) + assert.equal(log.error.mock.callCount(), 2) +}) + +test('ladeStand: fehlendes oder leeres fragebogen-Attribut, ein Nicht-Objekt und fehlende Zeit ergeben null; Version ohne Zahl ist 0', async () => { + const e = einstellung() + const doc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 1, updatedAt: JETZT.toISOString(), updatedBy: '' } + for (const fragebogen of [undefined, null, '']) { + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + aw.seed('termineConfig', { ...doc, ...(fragebogen === undefined ? {} : { fragebogen }) }) + assert.equal((await ladeStand(FEATURE)).fragebogen, null, String(fragebogen)) + } + assert.equal(log.error.mock.callCount(), 0, 'nichts gespeichert ist kein Fehler') + for (const roh of ['[1,2]', '"text"', '42', 'null']) { + await deleteDocument('termineConfig', FEATURE) + aw.seed('termineConfig', { ...doc, fragebogen: roh }) + assert.equal((await ladeStand(FEATURE)).fragebogen, null, roh) + } + await deleteDocument('termineConfig', FEATURE) + aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e) }) + const ohneZeit = await ladeStand(FEATURE) + assert.equal(ohneZeit.updatedAt, null) + assert.equal(ohneZeit.version, 0) + assert.deepEqual(ohneZeit.einstellung, e) +}) + +test('ladeStand: Eingabepruefung wie ladeEinstellung, fehlende Collection ist ein Fehler und kein "nichts"', async () => { + for (const id of [undefined, null, '', 7]) await assert.rejects(() => ladeStand(id), TypeError, String(id)) + await mitFehlenderCollection('termineConfig', async () => { + await assert.rejects(() => ladeStand(FEATURE), (err) => err.status === 404 && err.type === 'collection_not_found') + }) +}) + test('R40 speichereEinstellung mit Funktion: laeuft nach der Versionspruefung, bekommt die bisherige Einstellung (erst null), Ergebnis wird gespeichert', async () => { const aufrufe = [] const erste = einstellung({ betrieb: betriebMit({ name: 'Erste' }) }) diff --git a/test/termineTicket.test.js b/test/termineTicket.test.js new file mode 100644 index 0000000..8773269 --- /dev/null +++ b/test/termineTicket.test.js @@ -0,0 +1,288 @@ +import { test, after, mock } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' +import { TERMINE_DATETIMES } from '../server/services/termine/schema.js' + +// Kurzfassung der Einbauanleitung im Einrichtungs-Ticket (TP2 Task D4): termineTicketDetails ist der Inhalt von +// config.features.termine.ticket.details. Es wirft nie ("bezahlt ist bezahlt, das Ticket entsteht immer"): bei Lesefehlern +// stehen Kopfzeilen, der Hinweis und der Verweis im Ticket. websiteProjects, workorders und customers legt das Setup-Skript nicht +// an: ohne Schema im Nachbau. +const aw = await startAppwriteMock({ + schema: { portalFeatures: schemaKeys('portalFeatures'), termineConfig: schemaKeys('termineConfig') }, + datetimes: TERMINE_DATETIMES, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { ausVorlage } = await import('../server/services/termine/vorlagen.js') +const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { termineTicketDetails } = await import('../server/services/termine/ticketDetails.js') +const { ticketStand, driftWarnung, gesundheitsMeldungen } = await import('../server/services/termine/einbauanleitung.js') + +aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', location: 'Berlin', email: 'kunde1@example.test' }) + +const NOW = new Date('2026-10-05T10:00:00Z') +const GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen' +const HALBES_PAAR = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? { + const e = ausVorlage('salon', BETRIEB, NOW) + e.farbe = '#9d4b6f' + return e +} + +/** Maximalbefuellung wie im Plan: 50 Leistungen mit 100 Zeichen, 20 Personen, 5 Zusatzfelder, 10 Gesundheitsmeldungen, 6 Warnungen. */ +function maximalEinstellung() { + const e = einstellung() + e.betrieb.telefon = '' + e.betrieb.adresse = '' + e.betrieb.impressumUrl = '' + e.betrieb.datenschutzUrl = '' + e.farbe = '#ffff00' + const vorlage = e.leistungen[0] + e.leistungen = Array.from({ length: 50 }, (_, i) => ({ + ...vorlage, + id: `s_max${String(i).padStart(5, '0')}`, + name: `${String(i + 1).padStart(2, '0')} ${'L'.repeat(97)}`, + aktiv: true, + ressourcen: [], + beschreibung: i < 10 ? 'Physiotherapie' : '', + })) + e.leistungen[0].name = 'Physiotherapie' + e.ressourcen = Array.from({ length: 20 }, (_, i) => ({ + id: `r_max${String(i).padStart(5, '0')}`, typ: 'person', name: `Person ${i + 1} ${'P'.repeat(80)}`, aktiv: true, plan: { 1: [['09:00', '12:00']] }, feedToken: neuerFeedToken(), + })) + e.gastFelder = { + telefon: 'pflicht', + nachricht: 'optional', + zusatz: Array.from({ length: 5 }, (_, i) => ({ key: `z${i}`, label: `Zusatzfeld ${i + 1} ${'Z'.repeat(90)}`, typ: 'text', optionen: [], pflicht: false })), + } + return e +} +const MAX_FRAGEBOGEN = { einbau: { modus: 'knopf', knopfText: 'K'.repeat(40), stelle: 'S'.repeat(300), gestaltung: 'G'.repeat(1000), bemerkung: 'B'.repeat(1000) } } + +let zaehler = 0 +/** Projekt (Kunde c1) anlegen und den Terminplaner bestellen; mit e (und fb) liegt ein termineConfig-Dokument unter der Funktions-ID. */ +async function bestelle({ e = null, fb = null, version = 1, projekt = PROJEKT } = {}) { + zaehler += 1 + const projectId = `tt${zaehler}` + const sessionId = `cs_tt${zaehler}` + aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', ...projekt }) + const doc = await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} }) + if (e) { + aw.seed('termineConfig', { + $id: doc.$id, customerId: 'c1', projectId, einstellung: JSON.stringify(e), version, updatedAt: UPDATED_AT, updatedBy: 'kunde:c1', + ...(fb ? { fragebogen: JSON.stringify(fb) } : {}), + }) + } + return { projectId, sessionId, doc } +} + +/** ctx genau in der Form, die markPaid uebergibt: { feature: Register-Eintrag, doc, project, customer }. */ +const ctxFuer = (doc, project = PROJEKT, customer = { name: 'Kunde Eins' }) => ({ feature: reg.featureConfig('termine'), doc, project, customer }) +const absaetze = (text) => text.split('\n\n') +const stumm = () => mock.method(console, 'error', () => {}) + +// ------------------------------------------------------------------------------ termineTicketDetails --- +test('termineTicketDetails mit Einstellungen: Kopf mit Konfigurationsstand: Version 1, Snippet mit siteKey, Verweis, Stundenkontingent', async () => { + const { projectId, doc } = await bestelle({ e: einstellung() }) + const text = await termineTicketDetails(ctxFuer(doc)) + const [kopf] = absaetze(text) + assert.deepEqual(kopf.split('\n'), [ + 'Funktion: Terminplaner', + 'Projekt: Salon Haarlinie', + 'Domain: haarlinie.example', + 'Kunde: Kunde Eins', + `siteKey: ${doc.siteKey}`, + 'Konfigurationsstand: Version 1, 03.10.2026', + ]) + assert.ok(text.includes('Konfigurationsstand: Version 1')) + assert.equal(ticketStand(text), 1) + assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`)) + assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`)) + assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen')) + assert.ok(text.includes('Umfang:\n- 4 Leistungen'), text) + assert.ok(!text.includes('Online-Terminbuchung') && !text.includes(BETRIEB.email), 'weder Datenschutz-Absatz noch Betriebs-E-Mail') + assert.ok(text.length <= MAX) + assert.ok(!text.includes('bezahlt'), 'hier wurde nur bestellt: keine Behauptung, die Einrichtung sei bezahlt') +}) + +test('termineTicketDetails: der Wunsch des Kunden aus dem Fragebogen steht im Ticket, der Modus im Snippet', async () => { + const fb = { einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht', bemerkung: 'Bitte vor dem 20.10.' } } + const { doc } = await bestelle({ e: einstellung(), fb }) + const text = await termineTicketDetails(ctxFuer(doc)) + assert.ok(text.includes(' Modus: knopf\n Knopf-Text: Termin sichern\n Stelle: Startseite unter dem Hero\n Gestaltung: Schlicht\n Bemerkung: Bitte vor dem 20.10.'), text) + assert.ok(text.includes('data-modus="knopf" data-knopf="Termin sichern"')) +}) + +test('termineTicketDetails ohne termineConfig: noch keine Einstellungen, Snippet, Verweis', async () => { + const { projectId, doc } = await bestelle() + const text = await termineTicketDetails(ctxFuer(doc)) + assert.ok(text.includes('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), text) + assert.ok(text.includes('noch keine Einstellungen')) + assert.equal(ticketStand(text), 0) + assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`)) + assert.ok(text.endsWith(`Verweis: ${reg.anleitungsLink('c1', projectId)}`)) + assert.ok(text.includes('Keine Einstellungen gespeichert')) +}) + +test('termineTicketDetails bei Lesefehler: loest auf, ohne Wurf, mit Kopfzeilen, Hinweis und Link', async () => { + const { projectId, doc } = await bestelle({ e: einstellung() }) + const fehler = stumm() + try { + aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 }) + const text = await termineTicketDetails(ctxFuer(doc)) + const link = reg.anleitungsLink('c1', projectId) + for (const teil of ['Projekt: Salon Haarlinie', 'Domain: haarlinie.example', 'Kunde: Kunde Eins', `siteKey: ${doc.siteKey}`, `Verweis: ${link}`, 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen', 'Nicht auf das Stundenkontingent anrechnen']) { + assert.ok(text.includes(teil), `${teil}\n---\n${text}`) + } + assert.equal(ticketStand(text), null, 'ohne Konfigurationsstand-Zeile kein Vergleich') + assert.equal(fehler.mock.callCount(), 1) + assert.ok(!String(fehler.mock.calls[0].arguments.join(' ')).includes(doc.siteKey), 'das Log nennt keine Daten des Betriebs') + } finally { + fehler.mock.restore() + } + // danach funktioniert es wieder (der Fehler war einmalig) + assert.ok((await termineTicketDetails(ctxFuer(doc))).includes('Konfigurationsstand: Version 1')) +}) + +test('termineTicketDetails wirft nie: auch nicht ohne Dokument, ohne ctx oder bei fehlender Collection', async () => { + const fehler = stumm() + try { + for (const ctx of [undefined, null, {}, { doc: null }, { doc: {} }, { doc: { $id: '' } }, { doc: { $id: 7 } }, ctxFuer({ $id: 'f_gibt_es_nicht', customerId: 'c1', projectId: 'p0', siteKey: SITE_KEY })]) { + const text = await termineTicketDetails(ctx) + assert.equal(typeof text, 'string', JSON.stringify(ctx)) + assert.ok(text.includes('Terminplaner'), JSON.stringify(ctx)) + assert.ok(text.length <= MAX && text.isWellFormed()) + } + } finally { + fehler.mock.restore() + } +}) + +test('termineTicketDetails: Namen mit Markup bleiben Text, halbe Zeichenpaare werden ersetzt', async () => { + const e = einstellung() + e.leistungen[0].name = `Schnitt x \uD83D${'x'.repeat(8)}😀` + const projekt = { projectName: `Salon \uD83D x`, liveDomain: 'haarlinie.example' } + const { doc } = await bestelle({ e, projekt }) + const text = await termineTicketDetails(ctxFuer(doc, projekt, { name: `Kunde \uDE00 & Co` })) + assert.ok(text.isWellFormed() && !HALBES_PAAR.test(text)) + assert.ok(text.includes('x') && text.includes('x'), 'Klartext, kein HTML') + assert.ok(text.includes('😀')) + // ohne Kunde: die Kunden-ID wie in den Notfall-Details + assert.ok((await termineTicketDetails(ctxFuer(doc, projekt, null))).includes('Kunde: c1')) +}) + +test('termineTicketDetails: Gesundheit (Physiotherapie) und Maximalbefuellung: enthaelt Physio und bleibt hoechstens 7000 Zeichen', async () => { + const e = maximalEinstellung() + const meldungen = gesundheitsMeldungen(e) + assert.equal(meldungen.length, 10) + const { doc } = await bestelle({ e, fb: MAX_FRAGEBOGEN, version: 12 }) + const text = await termineTicketDetails(ctxFuer(doc)) + assert.ok(text.length <= MAX, `kurz ${text.length}`) + assert.ok(text.includes('Physio')) + assert.ok(text.includes(`Gesundheitsbereich prüfen:\n${meldungen.map((m) => `- ${m}`).join('\n')}`), 'alle zehn Meldungen') + assert.ok(text.includes(`data-webklar-termine="${doc.siteKey}"`)) + assert.ok(text.includes('Nicht auf das Stundenkontingent anrechnen')) + assert.equal(ticketStand(text), 12) + assert.ok(!text.includes('Online-Terminbuchung')) + for (const r of e.ressourcen) assert.ok(!text.includes(r.feedToken)) + assert.ok(!text.includes(GEKUERZT), 'die Budgetliste laesst Teile weg und schneidet nicht ab') + // ticketTexte kuerzt nichts mehr + const texte = await reg.ticketTexte(ctxFuer(doc)) + assert.equal(texte.details, text) +}) + +// ------------------------------------------------------------------------------------- Registry --- +test('Registry: featureConfig("termine").ticket.details(ctx) liefert den Text von termineTicketDetails, siteKey und $id aus ctx.doc', async () => { + const { doc } = await bestelle({ e: einstellung() }) + const feature = reg.featureConfig('termine') + assert.equal(feature.ticket.topic(PROJEKT), 'Terminplaner einbauen – Salon Haarlinie') + // das Register-Objekt traegt keinen siteKey: der Text darf nur aus ctx.doc kommen + const ctx = { feature: { ...feature, siteKey: 'FALSCH_AUS_REGISTER', $id: 'f_falsch' }, doc, project: PROJEKT, customer: { name: 'Kunde Eins' } } + const ueberRegister = await feature.ticket.details(ctx) + assert.equal(ueberRegister, await termineTicketDetails(ctx)) + assert.equal(ueberRegister, await termineTicketDetails(ctxFuer(doc))) + assert.ok(ueberRegister.includes(doc.siteKey) && !ueberRegister.includes('FALSCH_AUS_REGISTER')) + assert.ok(ueberRegister.includes('Konfigurationsstand: Version 1'), 'gelesen wurde das Dokument der Funktion (doc.$id)') +}) + +// ----------------------------------------------------------------------- Integration (Review Focus 1) --- +const workorder = (doc) => aw.docs('workorders').get(doc.ticketId) + +test('markOrdered + markPaid: das Ticket traegt die Kurzfassung (hoechstens 7000, mit Snippet), die Stand-Zeile und der Vergleich meldet Aenderungen', async () => { + const { sessionId, doc: bestellt } = await bestelle({ e: maximalEinstellung(), fb: MAX_FRAGEBOGEN }) + const { doc, created, fall } = await sf.markPaid({ sessionId }) + assert.deepEqual([created, fall, doc.state], [true, null, 'setup']) + const ticket = workorder(doc) + assert.ok(ticket.details.length <= MAX, `details ${ticket.details.length}`) + assert.ok(ticket.details.includes(`data-webklar-termine="${bestellt.siteKey}"`)) + assert.ok(ticket.details.includes('Einrichtung bezahlt, 149 €'), 'markPaid uebergibt das bezahlte Dokument (unlockedByPurchase)') + assert.ok(ticket.details.includes('Physio')) + assert.ok(!ticket.details.includes(GEKUERZT)) + assert.equal(ticket.topic, 'Terminplaner einbauen – Salon Haarlinie') + // Ticket Version 1; die Einstellungen aendern sich spaeter: der Vergleich (Team-Route, D8) meldet es + const ticketVersion = ticketStand(ticket.details) + assert.equal(ticketVersion, 1) + assert.equal(driftWarnung(ticketVersion, 1), null) + assert.equal(driftWarnung(ticketVersion, 4), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 4).') +}) + +test('markPaid mit kaputtem termineConfig-Lesen legt das Ticket trotzdem an: created true, Notfall-Details mit Kopf, Hinweis und Link', async () => { + const { projectId, sessionId } = await bestelle({ e: einstellung() }) + const fehler = stumm() + try { + aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 }) + const { doc, created, fall } = await sf.markPaid({ sessionId }) + assert.deepEqual([created, fall, doc.state], [true, null, 'setup']) + assert.ok(doc.ticketId, 'Zahlung nie ohne Ticket') + const ticket = workorder(doc) + assert.ok(ticket.details.includes(`siteKey: ${doc.siteKey}`)) + assert.ok(ticket.details.includes('Anleitung konnte nicht erzeugt werden, im WOMS öffnen')) + assert.ok(ticket.details.includes(reg.anleitungsLink('c1', projectId))) + assert.ok(ticket.details.length <= MAX) + } finally { + fehler.mock.restore() + } +}) + +test('Webhook-Wiederholung nach einem Lesefehler: ohne Ticket bleibt setup fortsetzbar, die Kurzfassung entsteht beim zweiten Versuch', async () => { + const { sessionId } = await bestelle({ e: einstellung() }) + const fehler = stumm() + try { + // das Anlegen des Tickets scheitert (nicht das Lesen): setup ohne ticketId + aw.failNext({ collection: 'workorders', method: 'POST', status: 500 }) + await assert.rejects(() => sf.markPaid({ sessionId })) + const { doc, created } = await sf.markPaid({ sessionId }) + assert.equal(created, true) + assert.ok(workorder(doc).details.includes('Konfigurationsstand: Version 1')) + } finally { + fehler.mock.restore() + } +}) + +test('config.features.termine.ticket.details ruft die Datei lazy auf (config.js bleibt ohne statischen Dienst-Import)', async () => { + const { readFileSync } = await import('node:fs') + const src = readFileSync(new URL('../server/config.js', import.meta.url), 'utf8') + assert.match(src, /import\('\.\/services\/termine\/ticketDetails\.js'\)/) + assert.ok(!/^import .*ticketDetails/m.test(src)) + assert.equal(typeof config.features.termine.ticket.details, 'function') +}) From 8fe14c2fe636223ba0318409e4152137515bb29a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:56:35 +0000 Subject: [PATCH 09/28] Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert freischaltHindernis (Abo zuerst, dann Einstellung; Abo-Lesefehler = abo_unbekannt) und meldeFreigeschaltet (K16, Fehler geloggt, mit attachments) in termine/freischaltung.js. Der Standardhaken freischaltbar fuer termine delegiert an freischaltHindernis, runActivation an meldeFreigeschaltet. Co-Authored-By: Claude Sonnet 5.5 --- server/services/featureRegistry.js | 13 +- server/services/siteFeatures.js | 23 +- server/services/termine/freischaltung.js | 57 ++++ test/termineFreischaltung.test.js | 353 +++++++++++++++++++++++ 4 files changed, 425 insertions(+), 21 deletions(-) create mode 100644 server/services/termine/freischaltung.js create mode 100644 test/termineFreischaltung.test.js diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 893050d..b8dca68 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -27,14 +27,15 @@ async function termineBestellpruefung({ customerId, projectId, feature }) { /** * Freischaltpruefung des Terminplaners (Spec 4.5: die Aktivierung bleibt gesperrt, bis ein Abo laeuft): die Einrichtung - * ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Fail-closed: ein Lesefehler - * (Abo, Einstellung) wirft, activateClosedTickets haelt das Dokument dann in 'setup'. Der Aufrufer liest das Dokument - * unter der Sperre frisch und uebergibt es; auch das Abo wird hier frisch gelesen, nie aus einem Zwischenspeicher. + * ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Das entscheidet + * freischaltHindernis (termine/freischaltung.js, auch die Gruende dort); dieser Haken gibt nur bei null frei. Fail-closed: + * ein nicht lesbares Abo ('abo_unbekannt') gibt nie frei, ein Lesefehler der Einstellung wirft, activateClosedTickets haelt + * das Dokument dann in 'setup'. Der Aufrufer liest das Dokument unter der Sperre frisch und uebergibt es; auch das Abo wird + * hier frisch gelesen (freischaltHindernis ohne eingereichten Zwischenspeicher), nie aus einem Zwischenspeicher. */ async function termineFreischaltbar(doc) { - const [{ ladeEinstellung }, { laeuft }] = await Promise.all([import('./termine/kontext.js'), import('./aboStatus.js')]) - if (!(await laeuft(doc.customerId))) return false - return (await ladeEinstellung(doc.$id)) !== null + const { freischaltHindernis } = await import('./termine/freischaltung.js') + return (await freischaltHindernis(doc)) === null } /** diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 508866b..af28dfb 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -1010,22 +1010,15 @@ export async function handleFeatureCheckoutEvent(event) { } } -/** Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden. */ +/** + * Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active', K16 an jeden betroffenen Kunden (meldeFreigeschaltet: Fehler + * werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf). termine/freischaltung.js importiert diese + * Datei, darum wird es hier erst im Aufruf geladen. + */ export async function runActivation({ now = new Date(), customerId = null } = {}) { const activated = await activateClosedTickets({ now, customerId }) - for (const doc of activated) { - try { - const customer = await customerOf(doc.customerId) - if (!customer?.email) continue - const feature = featureConfig(doc.featureKey) - if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) - await sendCustomerMail( - customer.email, - bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) }) - ) - } catch (err) { - console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) - } - } + if (activated.length === 0) return activated + const { meldeFreigeschaltet } = await import('./termine/freischaltung.js') + for (const doc of activated) await meldeFreigeschaltet(doc) return activated } diff --git a/server/services/termine/freischaltung.js b/server/services/termine/freischaltung.js new file mode 100644 index 0000000..126660f --- /dev/null +++ b/server/services/termine/freischaltung.js @@ -0,0 +1,57 @@ +import { aboMemo } from '../aboStatus.js' +import { featureConfig } from '../featureRegistry.js' +import { sendCustomerMail } from '../mailer.js' +import { bereitMail, customerOf, projectName } from '../siteFeatures.js' +import { ladeEinstellung } from './kontext.js' + +// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (TP2 D6, aus B11 herausgeloest). Beide Funktionen stehen hier, +// damit der Standardhaken freischaltbar (featureRegistry.js), der Freischalt-Job (siteFeatures.runActivation) und die +// Staff-Aktivierung (F13a) dieselbe Antwort und denselben Mailweg nutzen. siteFeatures.js und featureRegistry.js laden diese +// Datei erst im Aufruf (dynamisches import()), weil sie selbst siteFeatures.js importiert. + +/** + * Was hindert die Freischaltung dieses Termine-Dokuments? null = nichts; sonst der Grund: + * 'kein_abo' kein laufendes Wartungs-Abo (inkl. beendet, Wechsel-Widerruf E1; past_due und vorgemerkte Kuendigung laufen noch) + * 'keine_einstellung' Abo laeuft, aber es ist noch keine Einstellung gespeichert + * 'abo_unbekannt' das Abo war nicht lesbar: nie freischalten, wenn etwas nicht lesbar war (der Fehler wird geloggt) + * Reihenfolge: Abo zuerst, dann Einstellung; ohne Abo wird die Einstellung gar nicht gelesen. Ein Lesefehler bei der + * Einstellung wirft (anders als beim Abo steht dafuer kein Grundcode bereit): der Aufrufer, activateClosedTickets, haelt das + * Dokument dann in 'setup'. + * abo: Fragen an das Abo, { laeuft(customerId) }; Standard aboMemo() je Aufruf, also frisch gelesen. Wer viele Dokumente + * im Listenlauf prueft, uebergibt einen gemeinsamen aboMemo(); Pruefungen unter einer Sperre lesen frisch (Standard). + * @param {{ $id: string, customerId: string, featureKey?: string }} doc portalFeatures-Dokument + * @returns {Promise} + */ +export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) { + let laeuft + try { + laeuft = await abo.laeuft(doc.customerId) + } catch (err) { + console.error(`[funktionen] Freischaltung ${doc.featureKey || 'termine'} (${doc.$id}) nicht prüfbar:`, err.message) + return 'abo_unbekannt' + } + if (!laeuft) return 'kein_abo' + if ((await ladeEinstellung(doc.$id)) === null) return 'keine_einstellung' + return null +} + +/** + * K16 (Funktion bereit) an den Kunden eines freigeschalteten Dokuments, Werte aus dem Register-Eintrag der Funktion (bereitMail). + * Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail, + * die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler. + * attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a). + * @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument + * @param {{ attachments?: object[] }} [optionen] + */ +export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) { + try { + const customer = await customerOf(doc.customerId) + if (!customer?.email) return + const feature = featureConfig(doc.featureKey) + if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) + const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) }) + await sendCustomerMail(customer.email, { ...mail, attachments }) + } catch (err) { + console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) + } +} diff --git a/test/termineFreischaltung.test.js b/test/termineFreischaltung.test.js new file mode 100644 index 0000000..7bff585 --- /dev/null +++ b/test/termineFreischaltung.test.js @@ -0,0 +1,353 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (Task D6, aus B11 herausgeloest): +// freischaltHindernis(doc, { abo }) => null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt' +// (Abo zuerst, dann Einstellung; ein Abo-Lesefehler gibt nie frei), +// meldeFreigeschaltet(doc, { attachments }) = K16 an den Kunden (Fehler geloggt, nie geworfen), +// der Standardhaken freischaltbar(doc) ist (await freischaltHindernis(doc)) === null, +// runActivation ruft meldeFreigeschaltet fuer jedes aktivierte Dokument. +// Die Tests von B11 (featureFreischaltung.test.js) laufen unveraendert weiter. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + termineConfig: schemaKeys('termineConfig'), + contractEvents: schemaKeys('contractEvents'), + }, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') +const { aboMemo } = await import('../server/services/aboStatus.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { freischaltHindernis, meldeFreigeschaltet } = await import('../server/services/termine/freischaltung.js') + +const mails = mailSink() +setMailTransportForTests(mails) +after(() => setMailTransportForTests(null)) + +const BASE = 'https://project.webklar.com' +const JETZT = new Date('2026-10-05T08:00:00.000Z') + +// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo +const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) +const nurHosting = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) + +let nr = 0 +/** Ein portalFeatures-Dokument im Zustand 'setup' mit Projekt und geschlossenem Ticket; mit einstellung: true dazu die gespeicherte Einstellung. */ +function einrichtung(key = 'termine', { customerId = 'c1', einstellung = false } = {}) { + nr += 1 + const id = `f_${key}_${nr}` + const projectId = `p_${key}_${nr}` + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}` }) + aw.seed('workorders', { $id: `t_${id}`, woid: `WO-${nr}`, status: 'Closed', topic: 'x' }) + aw.seed('portalFeatures', { + $id: id, + customerId, + projectId, + featureKey: key, + state: 'setup', + enabled: false, + siteKey: `siteke${String(nr).padStart(10, 'a')}`.slice(0, 16), + ticketId: `t_${id}`, + purchaseStatus: 'paid', + unlockedByPurchase: true, + }) + if (einstellung) speichere(id, customerId, projectId) + return aw.docs('portalFeatures').get(id) +} + +function speichere(featureId, customerId = 'c1', projectId = 'p') { + aw.seed('termineConfig', { + $id: featureId, + customerId, + projectId, + einstellung: JSON.stringify({ betrieb: { name: 'Salon' } }), + version: 1, + updatedAt: JETZT.toISOString(), + updatedBy: 'test', + }) +} + +const k16 = (subject) => mails.sent.filter((m) => m.subject === subject) +const geloggt = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') +const STUMM = (t) => { + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'warn', () => {}) +} + +beforeEach(async () => { + for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects', 'contractEvents', 'customers']) { + for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + } + seedFixtures(aw) + wartung() + mails.sent.length = 0 + config.termine.mode = 'pilot' + config.termine.pilotEmails = ['*'] +}) + +// ------------------------------------------------------------------------------ freischaltHindernis --- +test('freischaltHindernis: Wartungs-Abo da + Einstellung da => null', async () => { + const doc = einrichtung('termine', { einstellung: true }) + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: kein Wartungs-Abo => kein_abo, auch ohne Einstellung (Abo zuerst)', async () => { + nurHosting() + const mit = einrichtung('termine', { einstellung: true }) + const ohne = einrichtung('termine') + assert.equal(await freischaltHindernis(mit), 'kein_abo') + assert.equal(await freischaltHindernis(ohne), 'kein_abo', 'Abo zuerst: ohne Abo und ohne Einstellung steht kein_abo da') + // c3 hat gar kein Abo + const c3 = einrichtung('termine', { customerId: 'c3' }) + assert.equal(await freischaltHindernis(c3), 'kein_abo') +}) + +test('freischaltHindernis: Abo da, keine Einstellung => keine_einstellung', async () => { + const doc = einrichtung('termine') + assert.equal(await freischaltHindernis(doc), 'keine_einstellung') + speichere(doc.$id, 'c1', doc.projectId) + assert.equal(await freischaltHindernis(doc), null, 'mit gespeicherter Einstellung frei') +}) + +test('freischaltHindernis: Abo-Lesefehler (failNext auf subscriptions) => abo_unbekannt, geloggt, nie null', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'subscriptions', status: 500, message: 'Nachbau: Abo kaputt' }) + assert.equal(await freischaltHindernis(doc), 'abo_unbekannt') + assert.match(geloggt(), new RegExp(`Freischaltung termine \\(${doc.$id}\\) nicht prüfbar: .*Nachbau: Abo kaputt`)) + // der Fehler war einmalig: die naechste Frage liest wieder und gibt frei + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: Abo past_due zaehlt als laufend => null', async () => { + wartung({ status: 'past_due' }) + const doc = einrichtung('termine', { einstellung: true }) + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: Wechsel-Widerruf (E1: Spiegel Wartung active, juengstes wechsel-Ereignis widerrufen) => kein_abo; ein neueres erledigtes gibt frei', async () => { + const wechsel = (n, status) => + aw.seed('contractEvents', { + $id: `ev_w${n}`, + $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, n)).toISOString(), + customerId: 'c1', + subscriptionId: 'sub_h', + type: 'wechsel', + productKey: 'wartung', + details: '{}', + status, + }) + const doc = einrichtung('termine', { einstellung: true }) + wechsel(1, 'widerrufen') + assert.equal(await freischaltHindernis(doc), 'kein_abo') + wechsel(2, 'erledigt') + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: Abo beendet (canceled) => kein_abo', async () => { + wartung({ status: 'canceled' }) + const doc = einrichtung('termine', { einstellung: true }) + assert.equal(await freischaltHindernis(doc), 'kein_abo') +}) + +test('freischaltHindernis: ohne Abo wird die Einstellung gar nicht gelesen (ihr Lesefehler stoert dann nicht)', async () => { + nurHosting() + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'termineConfig', status: 500 }) + assert.equal(await freischaltHindernis(doc), 'kein_abo') + // der eingeplante Fehler ist noch offen (nichts hat termineConfig gelesen); er trifft jetzt die Einstellungs-Pruefung + wartung() + await assert.rejects(() => freischaltHindernis(doc)) +}) + +test('freischaltHindernis: Lesefehler bei der Einstellung wirft (kein Freigeben, der Aufrufer faengt es)', async () => { + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'termineConfig', status: 500 }) + await assert.rejects(() => freischaltHindernis(doc)) +}) + +test('freischaltHindernis: fragt das Abo des Kunden des Dokuments; ein eingereichtes abo ersetzt den Standard', async () => { + const doc = einrichtung('termine', { customerId: 'c2', einstellung: true }) + const gefragt = [] + const stub = (antwort) => ({ + laeuft: async (customerId) => { + gefragt.push(customerId) + if (antwort instanceof Error) throw antwort + return antwort + }, + }) + assert.equal(await freischaltHindernis(doc, { abo: stub(true) }), null) + assert.equal(await freischaltHindernis(doc, { abo: stub(false) }), 'kein_abo') + assert.deepEqual(gefragt, ['c2', 'c2']) +}) + +test('freischaltHindernis: abo.laeuft wirft => abo_unbekannt (auch bei einem eingereichten abo)', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + const abo = { + laeuft: async () => { + throw new Error('Memo: Ablehnung bleibt') + }, + } + assert.equal(await freischaltHindernis(doc, { abo }), 'abo_unbekannt') + assert.match(geloggt(), /Memo: Ablehnung bleibt/) +}) + +test('freischaltHindernis: ein gemeinsamer aboMemo() liest das Abo je Kunde nur einmal; ohne abo liest jeder Aufruf frisch', async () => { + const doc = einrichtung('termine', { einstellung: true }) + aw.resetListCalls() + const memo = aboMemo() + assert.equal(await freischaltHindernis(doc, { abo: memo }), null) + assert.equal(await freischaltHindernis(doc, { abo: memo }), null) + assert.equal(aw.listCalls('subscriptions').length, 1, 'Memo: ein Lesen') + + // ohne abo: frisch gelesen, ein Abo, das dazwischen endet, gibt nicht mehr frei + assert.equal(await freischaltHindernis(doc), null) + wartung({ status: 'canceled' }) + assert.equal(await freischaltHindernis(doc), 'kein_abo') +}) + +// ---------------------------------------------------------------------------- Delegation des Standardhakens --- +test('Standardhaken freischaltbar: true nur bei null, false bei kein_abo / keine_einstellung / abo_unbekannt', async (t) => { + STUMM(t) + const { freischaltbar } = reg.featureHaken('termine') + assert.equal(typeof freischaltbar, 'function') + const doc = einrichtung('termine') + assert.equal(await freischaltbar(doc), false, 'keine_einstellung') + speichere(doc.$id, 'c1', doc.projectId) + assert.equal(await freischaltbar(doc), true, 'null') + aw.failNext({ collection: 'subscriptions', status: 500 }) + assert.equal(await freischaltbar(doc), false, 'abo_unbekannt: fail-closed, kein Wurf') + wartung({ status: 'canceled' }) + assert.equal(await freischaltbar(doc), false, 'kein_abo') +}) + +// ------------------------------------------------------------------------------------ meldeFreigeschaltet --- +test('meldeFreigeschaltet: K16 mit "Terminplaner" im Betreff und Link #termine, genau eine Mail an den Kunden', async () => { + const doc = einrichtung('termine', { einstellung: true }) + await meldeFreigeschaltet(doc) + assert.equal(mails.sent.length, 1, mails.sent.map((m) => m.subject).join(' / ')) + const [mail] = k16('Dein Terminplaner ist bereit') + assert.ok(mail, mails.sent.map((m) => m.subject).join(' / ')) + assert.match(mail.subject, /Terminplaner/) + assert.deepEqual(mail.to.map((t) => t.address), ['k@example.invalid']) + assert.match(mail.text, /Hallo Kunde Eins,/) + assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Projekt \d+/) + assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#termine"/) + assert.deepEqual(mail.attachments ?? [], [], 'ohne attachments kein Anhang') +}) + +test('meldeFreigeschaltet: attachments (die PDFs der Staff-Aktivierung) stehen in der gesendeten Mail', async () => { + const doc = einrichtung('termine', { einstellung: true }) + const pdf = (filename) => ({ filename, content: Buffer.from(`%PDF-1.4\n% ${filename}\n`), contentType: 'application/pdf' }) + await meldeFreigeschaltet(doc, { attachments: [pdf('Leistungsbeschreibung.pdf'), pdf('AVV.pdf')] }) + assert.equal(mails.sent.length, 1) + const [mail] = mails.sent + assert.equal(mail.subject, 'Dein Terminplaner ist bereit') + assert.deepEqual(mail.attachments.map((a) => [a.filename, a.contentType]), [ + ['Leistungsbeschreibung.pdf', 'application/pdf'], + ['AVV.pdf', 'application/pdf'], + ]) + for (const a of mail.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF') +}) + +test('meldeFreigeschaltet gilt fuer jede Funktion des Registers: News => K16 der News-Pinnwand mit Link #news', async () => { + const doc = einrichtung('news') + await meldeFreigeschaltet(doc) + const [mail] = k16('Deine News-Pinnwand ist bereit') + assert.ok(mail, mails.sent.map((m) => m.subject).join(' / ')) + assert.ok(mail.text.includes(`${BASE}/dashboard.html#news`), mail.text) + assert.equal(mails.sent.length, 1) +}) + +test('meldeFreigeschaltet: Versandfehler => kein Wurf, console.error mit Kunde und Grund', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + setMailTransportForTests({ + sendMail: async () => { + throw new Error('SMTP: Postfach voll') + }, + }) + try { + await assert.doesNotReject(() => meldeFreigeschaltet(doc)) + } finally { + setMailTransportForTests(mails) + } + assert.equal(console.error.mock.calls.length, 1) + assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen/) + assert.match(geloggt(), /SMTP: Postfach voll/) + assert.equal(mails.sent.length, 0) +}) + +test('meldeFreigeschaltet: Kunde ohne E-Mail oder unbekannt => keine Mail, kein Wurf, kein Fehler im Log', async (t) => { + STUMM(t) + aw.seed('customers', { $id: 'c_leer', appwriteUserId: 'u_leer', email: '', name: 'Ohne Adresse', status: 'active' }) + const ohneMail = einrichtung('termine', { customerId: 'c_leer' }) + const unbekannt = einrichtung('termine', { customerId: 'c_gibtsnicht' }) + await meldeFreigeschaltet(ohneMail) + await meldeFreigeschaltet(unbekannt) + assert.equal(mails.sent.length, 0) + assert.equal(console.error.mock.calls.length, 0) +}) + +test('meldeFreigeschaltet: Kunde nicht lesbar oder Funktion nicht im Register => kein Wurf, geloggt, keine Mail', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' }) + await assert.doesNotReject(() => meldeFreigeschaltet(doc)) + assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*Nachbau: Kunden kaputt/) + + const fremd = einrichtung('gibtsnicht') + await assert.doesNotReject(() => meldeFreigeschaltet(fremd)) + assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*gibtsnicht nicht im Register/) + assert.equal(mails.sent.length, 0) +}) + +// -------------------------------------------------------------------------------------------- runActivation --- +test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => { + const doc = einrichtung('termine', { einstellung: true }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [doc.$id]) + assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active') + assert.equal(mails.sent.length, 1) + assert.equal(k16('Dein Terminplaner ist bereit').length, 1) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(mails.sent.length, 1) +}) + +test('runActivation: ein fehlgeschlagener K16-Versand haelt weder die Aktivierung noch die uebrigen Mails auf', async (t) => { + STUMM(t) + const erstes = einrichtung('termine', { einstellung: true }) + const zweites = einrichtung('termine', { customerId: 'c2', einstellung: true }) + aw.seed('subscriptions', subscriptionDoc('sub_h2', { ...wartungDoc, customerId: 'c2', email: 'k2@example.invalid', stripeCustomerId: 'cus_c2' })) + let n = 0 + setMailTransportForTests({ + sendMail: async (mail) => { + n += 1 + if (n === 1) throw new Error('SMTP: erster Versand scheitert') + return mails.sendMail(mail) + }, + }) + let done + try { + done = await sf.runActivation({ now: JETZT }) + } finally { + setMailTransportForTests(mails) + } + assert.deepEqual(done.map((d) => d.$id).sort(), [erstes.$id, zweites.$id].sort()) + for (const doc of [erstes, zweites]) assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active') + assert.equal(mails.sent.length, 1, 'die zweite Mail geht trotzdem raus') + assert.match(geloggt(), /K16 an Kunde c\d fehlgeschlagen.*erster Versand scheitert/) +}) From f830698dc3b67cbd19012072628e01fb87b77ce6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:08:03 +0000 Subject: [PATCH 10/28] Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team) Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16 einmal, unter feature-activate: mit Haken freischaltHindernis) und ticket (Ticket nachlegen: Zahlung bei Stripe pruefen und Webhook-Weg, geloeschtes Ticket ersetzen, vorhandenes unberuehrt). Ticketanlage aus markPaid in legeTicketAn herausgeloest. Co-Authored-By: Claude Sonnet 5.5 --- server/routes/admin/funktionenAdmin.js | 19 +- server/services/siteFeatures.js | 197 +++++++++- test/funktionenTermine.test.js | 495 ++++++++++++++++++++++++- 3 files changed, 699 insertions(+), 12 deletions(-) diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 065145e..1811c77 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -1,6 +1,7 @@ import { Router } from 'express' import { config } from '../../config.js' import { getDocument } from '../../services/appwriteAdmin.js' +import { featureConfig } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' import { FEATURE_KEYS, @@ -8,9 +9,11 @@ import { adminActivate, assertOwnProject, disable, + freischaltenNachAbnahme, getFeatureDoc, listCustomerFeatureDocs, listCustomerFeatures, + ticketNachlegen, } from '../../services/siteFeatures.js' /** @@ -69,19 +72,27 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => { } }) -// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404 +// Je Funktion (/news, /termine; jeder andere Schluessel -> 404): activate (ohne Zahlung), disable, abnahme (Freischalten nach Abnahme, in +// setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket nachlegen, in setup). abnahme und ticket gibt es nur fuer +// Funktionen mit ticketFreischaltung im Register. +const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket'] +const TICKET_ACTIONS = ['abnahme', 'ticket'] + funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { try { const { customerId, key } = req.params if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' }) const { projectId, action } = req.body || {} const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : '' - if (action !== 'activate' && action !== 'disable') { + if (!ACTIONS.includes(action)) { return res.status(400).json({ error: 'Unbekannte Aktion' }) } if (typeof projectId !== 'string' || !projectId) { return res.status(400).json({ error: 'Projekt fehlt' }) } + if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) { + return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' }) + } await assertOwnProject(customerId, projectId) const by = adminName(req) await withLocks([`feature-admin:${projectId}`], async () => { @@ -89,6 +100,10 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) if (action === 'activate') { await adminActivate(customerId, projectId, key, { by, adminNote }) + } else if (action === 'abnahme') { + await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote }) + } else if (action === 'ticket') { + await ticketNachlegen({ customerId, projectId, key, by, adminNote }) } else { if (!doc) { throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 }) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index af28dfb..aa7148b 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -358,20 +358,34 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { }) const project = await getDocument(config.collections.websiteProjects, doc.projectId) const customer = await customerOf(doc.customerId).catch(() => null) - const feature = featureConfig(doc.featureKey) - const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {} - const { ticketId } = await createFeatureTicket({ - customerId: doc.customerId, - project, - featureKey: doc.featureKey, - siteKey: doc.siteKey, - ...texte, - }) + const ticketId = await legeTicketAn(setup, project, customer) const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId }) return { doc: updated, created: true, fall: null } }) } +/** + * Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern. + * Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins + * Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail. + * @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey) + * @param {object} project websiteProjects-Dokument + * @param {object|null} customer Kundendokument, best effort + * @returns {Promise} ID des neuen Tickets + */ +async function legeTicketAn(doc, project, customer) { + const feature = featureConfig(doc.featureKey) + const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {} + const { ticketId } = await createFeatureTicket({ + customerId: doc.customerId, + project, + featureKey: doc.featureKey, + siteKey: doc.siteKey, + ...texte, + }) + return ticketId +} + /** * Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. * Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte @@ -586,6 +600,171 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul return activated } +// --------------------------------------------------------------------------- +// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen) +// --------------------------------------------------------------------------- + +const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.' +const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.' +/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */ +const ABNAHME_HINDERNIS = { + kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'], + keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'], + abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'], +} + +/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */ +async function dokumentFuerTeamAktion(customerId, projectId, key) { + assertKey(key) + const doc = await getFeatureDoc(customerId, projectId, key) + if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`) + return doc +} + +/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */ +async function frischesDokument(vorab, key) { + try { + return await getDocument(config.collections.portalFeatures, vorab.$id) + } catch (err) { + if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`) + throw err + } +} + +/** + * Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen (Terminplaner: + * laufendes Wartungs-Abo und gespeicherte Einstellung); fehlt der Haken (News), gibt es keine Pruefung und kein Abo wird gelesen. + * Die Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). + */ +async function abnahmeHindernis(doc) { + if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null + const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js') + return pruefe(doc) +} + +/** + * "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen + * wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben. + * Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate: wie der Freischalt-Job + * (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts. + * - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}". + * - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false. + * - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503. + * @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args + * @returns {Promise<{ doc: object, activated: boolean }>} + */ +export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) { + const vorab = await dokumentFuerTeamAktion(customerId, projectId, key) + const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => { + const doc = await frischesDokument(vorab, key) + if (doc.state === 'active') return { doc, activated: false } + if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP) + const grund = await abnahmeHindernis(doc) + if (grund) { + const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.'] + throw httpError(status, text) + } + const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)') + const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('active'), + activatedAt: now.toISOString(), + ...(note ? { adminNote: note } : {}), + }) + return { doc: updated, activated: true } + }) + if (ergebnis.activated) { + const { meldeFreigeschaltet } = await import('./termine/freischaltung.js') + await meldeFreigeschaltet(ergebnis.doc) + } + return ergebnis +} + +/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */ +async function ticketBesteht(ticketId) { + try { + await getDocument(config.collections.workorders, ticketId) + return true + } catch (err) { + if (err.status === 404) return false + throw err + } +} + +/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */ +async function bezahlteSitzung(doc) { + if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.') + let session + try { + session = await retrieveCheckoutSession(doc.stripeSessionId) + } catch (err) { + console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message) + throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.') + } + if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.') + return session +} + +/** + * Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie + * im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das + * Ticket und sendet nichts. Keine Sperre feature-paid: hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar). + */ +async function ticketNachZahlung(doc, note) { + const session = await bezahlteSitzung(doc) + await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } }) + const nachher = await getDocument(config.collections.portalFeatures, doc.$id) + if (!nachher.ticketId) return { doc: nachher, created: false } + if (!note) return { doc: nachher, created: true } + try { + return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true } + } catch (err) { + // Ticket und Mails sind raus; die Notiz ist nur ein Vermerk + console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message) + return { doc: nachher, created: true } + } +} + +/** + * Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim + * ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:), damit kein Stripe-Retry daneben + * schreibt; das Dokument wird darunter erneut gelesen. + */ +async function ersetzeGeloeschtesTicket(doc, note) { + return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => { + const frisch = await getDocument(config.collections.portalFeatures, doc.$id) + if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP) + if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false } + const project = await getDocument(config.collections.websiteProjects, frisch.projectId) + const customer = await customerOf(frisch.customerId).catch(() => null) + const ticketId = await legeTicketAn(frisch, project, customer) + const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) }) + return { doc: updated, created: true } + }) +} + +/** + * "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409). + * (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen, + * Ticket + K15 + T16 genau einmal (ticketNachZahlung); + * (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket); + * (c) Ticket vorhanden: nichts, created false. + * Eine Sperre feature-ticket: je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten + * (ein Ticket, created nur einmal true). + * @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args + * @returns {Promise<{ doc: object, created: boolean }>} + */ +export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) { + const vorab = await dokumentFuerTeamAktion(customerId, projectId, key) + const note = adminNote ?? byNote(by, 'Ticket nachgelegt') + return withLocks([`feature-ticket:${vorab.$id}`], async () => { + const doc = await frischesDokument(vorab, key) + if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP) + if (!doc.ticketId) return ticketNachZahlung(doc, note) + if (await ticketBesteht(doc.ticketId)) return { doc, created: false } + return ersetzeGeloeschtesTicket(doc, note) + }) +} + /** Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. */ export async function findActiveBySiteKey(siteKey, featureKey = 'news') { if (!/^[a-z2-7]{16}$/.test(String(siteKey || '')) || !FEATURE_KEYS.includes(featureKey)) return null diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 0491cd8..ee7ef4a 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -21,9 +21,17 @@ const sf = await import('../server/services/siteFeatures.js') const { loeschTag } = await import('../server/services/termine/frist.js') const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js') const { aboMeldung } = await import('../server/services/featureOrder.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes }) -after(() => Promise.all([app.close(), aw.close(), st.close()])) +// Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) seedFixtures(aw) aw.seed('employees', { $id: 'e_staff2', userId: 'u_staff2', displayName: 'Andrej', email: 'andrej@webklar.com' }) aw.seed('websiteProjects', { $id: 'pt1', customerId: 'c1', projectName: 'Salon' }) @@ -546,3 +554,488 @@ test('adminActivate (Dienst): bezahlter Terminplaner ohne Abo => Fehler 409, bei const pilot = legeKunde({ over: { unlockedByPurchase: false } }) assert.equal((await sf.adminActivate(pilot.customerId, pilot.projectId, 'termine', { by: 'test' })).state, 'active') }) + +// ------------------------------------------ Team-Aktionen Freischalten (Abnahme) und Ticket nachlegen (D7) --- +// abnahme: Terminplaner in setup freischalten, ohne dass das Einrichtungs-Ticket geschlossen wird (Ticket und Kauf bleiben, K16 einmal). +// ticket: ein fehlendes Einrichtungs-Ticket nachlegen - (a) Ticket nie angelegt: Zahlung bei Stripe pruefen und den Weg des Webhooks gehen +// (Ticket, K15, T16 genau einmal), (b) Ticket geloescht: neues Ticket, keine Mails, (c) Ticket da: nichts. +const K16_BETREFF = { termine: 'Dein Terminplaner ist bereit', news: 'Deine News-Pinnwand ist bereit' } +const K15_BETREFF = 'Deine Bestellung: Terminplaner' +const T16_BETREFF = 'Neue Funktion bestellt: Terminplaner' +const NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.' +const NUR_SETUP_TICKET = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.' +const OHNE_ABO = 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.' +const OHNE_EINSTELLUNG = 'Es sind noch keine Einstellungen gespeichert.' +const ABO_UNKLAR = 'Das Abo konnte gerade nicht geprüft werden.' +const NICHT_BEZAHLT = 'Die Zahlung ist noch nicht eingegangen.' +const KEINE_AKTION = 'Diese Aktion gibt es für diese Funktion nicht.' +const mailsAn = (email, subject) => mails.sent.filter((m) => m.subject === subject && m.to.some((t) => t.address === email)) +const seit = (n) => mails.sent.slice(n) + +let d7Nr = 0 +/** + * Kunde mit Projekt, Funktionsdokument (Zustand state), Wartungs-Abo und gespeicherter Einstellung. + * ticket: 'Open' | 'Closed' (Ticket da) | 'geloescht' (ticketId gesetzt, Ticket fehlt) | null (nie angelegt). + * session: null | 'paid' | 'unpaid' legt die Stripe-Sitzung samt Rechnung im Nachbau an und traegt sie am Dokument ein. + */ +function legeEinrichtung({ key = 'termine', abo = 'wartung', einstellung = true, ticket = 'Open', session = null, state = 'setup', over = {} } = {}) { + d7Nr += 1 + const n = d7Nr + const customerId = `cd${n}` + const projectId = `cdp${n}` + const docId = `f_${projectId}_${key}` + const ticketId = `t_cd${n}` + const sessionId = session ? `cs_cd${n}` : '' + const email = `cd${n}@example.invalid` + aw.seed('customers', { $id: customerId, name: `Salon ${n}`, email, status: 'active' }) + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Salon ${n}` }) + if (abo) aw.seed('subscriptions', subscriptionDoc(`sub_cd${n}`, { ...(abo === 'wartung' ? wartungDoc : hostingDoc), customerId })) + if (ticket === 'Open' || ticket === 'Closed') { + aw.seed('workorders', { $id: ticketId, woid: 7000 + n, status: ticket, topic: `Terminplaner einbauen – Salon ${n}`, details: 'Original-Ticket' }) + } + if (session) { + st.invoices.set(`in_cd${n}`, { id: `in_cd${n}`, object: 'invoice', hosted_invoice_url: `https://invoice.stripe.test/in_cd${n}` }) + st.checkoutSessions.set(sessionId, { + id: sessionId, object: 'checkout.session', mode: 'payment', status: 'complete', payment_status: session, invoice: `in_cd${n}`, + metadata: { customerId, projectId, featureKey: key }, + }) + } + aw.seed('portalFeatures', { + $id: docId, customerId, projectId, featureKey: key, siteKey: 'abcdefghijklmnop', state, enabled: state === 'active', + unlockedByPurchase: true, purchaseStatus: 'paid', orderedAt: '2026-10-01T10:00:00.000Z', stripeSessionId: sessionId, + ...(ticket ? { ticketId } : {}), adminNote: '', ...over, + }) + if (einstellung) { + aw.seed('termineConfig', { + $id: docId, customerId, projectId, einstellung: JSON.stringify({ betrieb: { name: `Salon ${n}` } }), version: 1, updatedAt: '2026-10-03T08:00:00.000Z', updatedBy: 'test', + }) + } + return { customerId, projectId, docId, ticketId, sessionId, email } +} +const abnahme = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'abnahme' }, opts) +const nachlegen = (k, key = 'termine', opts) => team(k.customerId, key, { projectId: k.projectId, action: 'ticket' }, opts) +const ticketDoc = (id) => aw.docs('workorders').get(id) +const dienst = (k, fn, key = 'termine') => sf[fn]({ customerId: k.customerId, projectId: k.projectId, key, by: 'Andrej', adminNote: `x Andrej: ${fn}` }) + +// ----------------------------------------------------------------------------- abnahme --- +test('abnahme auf setup mit Einstellung und Abo: active, Ticket (Open) und Kauf bleiben, adminNote, K16 genau einmal', async () => { + const k = legeEinrichtung() + const res = await abnahme(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.projects.map((p) => p.projectId), [k.projectId]) + const doc = docById(k.docId) + assert.deepEqual([doc.state, doc.enabled], ['active', true]) + assert.ok(Math.abs(Date.parse(doc.activatedAt) - Date.now()) < 5000, 'activatedAt gesetzt') + assert.equal(featuresOf(res, k.projectId).termine.state, 'active') + assert.equal(featuresOf(res, k.projectId).termine.activatedAt, doc.activatedAt) + assert.equal(doc.ticketId, k.ticketId, 'ticketId bleibt') + assert.equal(ticketDoc(k.ticketId).status, 'Open', 'das Ticket wird nicht geschlossen') + assert.equal(doc.unlockedByPurchase, true) + assert.equal(doc.purchaseStatus, 'paid') + assert.match(doc.adminNote, /^\d{4}-\d\d-\d\dT[\d:.]+Z Andrej: abnahme$/) + const k16 = mailsAn(k.email, K16_BETREFF.termine) + assert.equal(k16.length, 1, 'K16 genau einmal') + assert.match(k16[0].text, /dashboard\.html#termine/) +}) + +test('abnahme: zweiter Klick => 200 ohne zweite Mail und ohne Schreibzugriff; Dienst meldet activated true, dann false', async () => { + const k = legeEinrichtung() + assert.equal((await abnahme(k)).status, 200) + const vorher = { ...docById(k.docId) } + const nochmal = await abnahme(k) + assert.equal(nochmal.status, 200) + assert.equal(featuresOf(nochmal, k.projectId).termine.state, 'active') + assert.deepEqual(docById(k.docId), vorher, 'nichts geschrieben: activatedAt und Notiz bleiben') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1) + + const k2 = legeEinrichtung() + const erster = await dienst(k2, 'freischaltenNachAbnahme') + assert.equal(erster.activated, true) + assert.equal(erster.doc.state, 'active') + const zweiter = await dienst(k2, 'freischaltenNachAbnahme') + assert.equal(zweiter.activated, false) + assert.equal(zweiter.doc.state, 'active') + assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1) +}) + +test('abnahme: zwei parallele Klicks => eine Mail, ein Zustandswechsel', async () => { + const k = legeEinrichtung() + const [a, b] = await Promise.all([abnahme(k), abnahme(k)]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(docById(k.docId).state, 'active') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1) + // auch ohne die Sperre der Route: zwei gleichzeitige Dienstaufrufe aktivieren nur einmal + const k2 = legeEinrichtung() + const [x, y] = await Promise.all([dienst(k2, 'freischaltenNachAbnahme'), dienst(k2, 'freischaltenNachAbnahme')]) + assert.deepEqual([x.activated, y.activated].sort(), [false, true]) + assert.equal(mailsAn(k2.email, K16_BETREFF.termine).length, 1) +}) + +test('abnahme und der Freischalt-Job (runActivation) bei geschlossenem Ticket gleichzeitig => eine Mail insgesamt, in beiden Reihenfolgen', async () => { + for (const abnahmeZuerst of [true, false]) { + const k = legeEinrichtung({ ticket: 'Closed' }) + const start = [() => abnahme(k), () => sf.runActivation({ customerId: k.customerId })] + if (!abnahmeZuerst) start.reverse() + await Promise.all(start.map((f) => f())) + assert.equal(docById(k.docId).state, 'active', `abnahmeZuerst ${abnahmeZuerst}`) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 1, `abnahmeZuerst ${abnahmeZuerst}`) + } +}) + +test('abnahme: bereits aktive Funktion (Pilot, Kulanz) => 200 ohne Mail und ohne Schreibzugriff', async () => { + const k = legeEinrichtung({ state: 'active', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '', activatedAt: '2026-10-02T10:00:00.000Z' } }) + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(docById(k.docId), vorher) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) +}) + +test('abnahme: off, ordered und disabled => 409 mit Text, nichts geschrieben, keine Mail', async () => { + const faelle = [ + legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }), + legeEinrichtung({ state: 'ordered', ticket: null, over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }), + legeEinrichtung({ state: 'disabled', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }), + ] + for (const k of faelle) { + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 409, vorher.state) + assert.equal(res.body.error, NUR_SETUP, vorher.state) + assert.deepEqual(docById(k.docId), vorher, vorher.state) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0, vorher.state) + } +}) + +test('abnahme ohne laufendes Wartungs-Abo (kein Abo, nur Hosting, beendet) => 409, setup bleibt, keine Mail', async () => { + const faelle = [ + legeEinrichtung({ abo: null }), + legeEinrichtung({ abo: 'hosting' }), + legeEinrichtung({ abo: 'wartung' }), + ] + // beendetes Wartungs-Abo: das dritte Abo auf canceled stellen + aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: faelle[2].customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' })) + for (const k of faelle) { + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 409, k.customerId) + assert.equal(res.body.error, OHNE_ABO) + assert.deepEqual(docById(k.docId), vorher, 'setup bleibt, nichts geschrieben') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) + } +}) + +test('abnahme: eine vorgemerkte Kuendigung und past_due laufen noch => 200', async () => { + for (const aboOver of [{ cancelAtPeriodEnd: true }, { status: 'past_due' }]) { + const k = legeEinrichtung() + aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: k.customerId, ...aboOver })) + assert.equal((await abnahme(k)).status, 200, JSON.stringify(aboOver)) + assert.equal(docById(k.docId).state, 'active') + } +}) + +test('abnahme ohne gespeicherte Einstellung => 409, setup bleibt', async () => { + const k = legeEinrichtung({ einstellung: false }) + const vorher = { ...docById(k.docId) } + const res = await abnahme(k) + assert.equal(res.status, 409) + assert.equal(res.body.error, OHNE_EINSTELLUNG) + assert.deepEqual(docById(k.docId), vorher) + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) +}) + +test('abnahme: Abo nicht lesbar => 503 mit Text (geloggt), setup bleibt; danach geht es', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const k = legeEinrichtung() + const vorher = { ...docById(k.docId) } + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' }) + const res = await abnahme(k) + assert.equal(res.status, 503) + assert.equal(res.body.error, ABO_UNKLAR) + assert.deepEqual(docById(k.docId), vorher, 'nie freischalten, wenn etwas nicht lesbar war') + assert.equal(mailsAn(k.email, K16_BETREFF.termine).length, 0) + assert.ok(log.mock.calls.some((c) => /nicht prüfbar/.test(c.arguments.join(' '))), 'der Fehler steht im Log') + assert.equal((await abnahme(k)).status, 200) + assert.equal(docById(k.docId).state, 'active') +}) + +test('abnahme: Projekt eines anderen Kunden => 403, Kundensitzung statt JWT => 401/403, ohne Dokument => 409 und nichts angelegt', async () => { + const k = legeEinrichtung() + const vorher = { ...docById(k.docId) } + assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403, 'pt3 gehoert c2') + assert.equal((await team(k.customerId, 'termine', { projectId: 'pt3', action: 'abnahme' })).status, 403) + assert.equal((await team('c1', 'termine', { projectId: 'gibtsnicht', action: 'abnahme' })).status, 403) + const alsKunde = await abnahme(k, 'termine', { cookie: customerCookie(k.customerId) }) + assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`) + assert.deepEqual(docById(k.docId), vorher) + const docs = aw.docs('portalFeatures').size + aw.seed('websiteProjects', { $id: 'pt_d7_leer', customerId: 'c1', projectName: 'Ohne Dokument D7' }) + const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer', action: 'abnahme' }) + assert.equal(ohneDoc.status, 409) + assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner') + assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden') + assert.equal(mails.sent.filter((m) => m.subject === K16_BETREFF.termine && m.to.some((to) => to.address === 'k@example.invalid')).length, 0) +}) + +test('abnahme bei News funktioniert ohne Abo-Pruefung (es gibt keinen Haken freischaltbar): active, K16 News, Abo nicht gelesen', async () => { + const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false }) + const abfragen = aw.listCalls('subscriptions').length + const res = await abnahme(k, 'news') + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(featuresOf(res, k.projectId).news.state, 'active') + assert.equal(docById(k.docId).state, 'active') + assert.equal(docById(k.docId).ticketId, k.ticketId) + assert.equal(aw.listCalls('subscriptions').length, abfragen, 'keine Abo-Abfrage') + assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 1) +}) + +test('abnahme und ticket bei analyse und unbekannten Schluesseln => 404, nichts angelegt', async () => { + const k = legeEinrichtung() + const docs = aw.docs('portalFeatures').size + for (const action of ['abnahme', 'ticket']) { + for (const key of ['analyse', 'kalender']) { + const res = await team(k.customerId, key, { projectId: k.projectId, action }) + assert.equal(res.status, 404, `${key} ${action}`) + } + } + assert.equal(aw.docs('portalFeatures').size, docs) +}) + +test('abnahme und ticket brauchen ticketFreischaltung im Register, sonst 409 ohne Schreibzugriff', async () => { + const k = legeEinrichtung({ key: 'news' }) + const vorher = { ...docById(k.docId) } + const alt = config.features.news.ticketFreischaltung + config.features.news.ticketFreischaltung = false + try { + for (const action of ['abnahme', 'ticket']) { + const res = await team(k.customerId, 'news', { projectId: k.projectId, action }) + assert.equal(res.status, 409, action) + assert.equal(res.body.error, KEINE_AKTION, action) + } + } finally { + config.features.news.ticketFreischaltung = alt + } + assert.deepEqual(docById(k.docId), vorher) + assert.equal(mailsAn(k.email, K16_BETREFF.news).length, 0) + // activate und disable gehoeren nicht dazu und gehen weiter + assert.equal((await team(k.customerId, 'news', { projectId: k.projectId, action: 'disable' })).status, 200) +}) + +test('abnahme: scheitert der Versand von K16, ist die Funktion trotzdem freigeschaltet (Fehler im Log)', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const k = legeEinrichtung() + setMailTransportForTests({ sendMail: async () => { throw new Error('SMTP weg') } }) + t.after(() => setMailTransportForTests(mails)) + const res = await abnahme(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(docById(k.docId).state, 'active') + assert.ok(log.mock.calls.some((c) => /K16 an Kunde .* fehlgeschlagen/.test(c.arguments.join(' ')))) +}) + +// ----------------------------------------------------------------------------- ticket --- +test('ticket (a): setup ohne ticketId, Sitzung bezahlt => Ticket mit Kurzfassung, K15 und T16 je einmal; zweiter Klick und Webhook-Wiederholung aendern nichts', async () => { + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const res = await nachlegen(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = docById(k.docId) + assert.ok(doc.ticketId, 'ticketId gesetzt') + assert.equal(workorders(), tickets + 1) + const ticket = ticketDoc(doc.ticketId) + assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/) + assert.ok(ticket.details.includes('Konfigurationsstand: Version 1'), ticket.details) + assert.equal(ticket.status, 'Open') + assert.deepEqual([doc.state, doc.unlockedByPurchase], ['setup', true]) + assert.match(doc.adminNote, /Andrej: ticket/) + assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false) + // K15 an den Kunden, T16 ans Team, je einmal + assert.equal(mailsAn(k.email, K15_BETREFF).length, 1) + const neu = seit(mailsVorher) + assert.equal(neu.filter((m) => m.subject === T16_BETREFF).length, 1) + assert.match(neu.find((m) => m.subject === T16_BETREFF).text, new RegExp(String(ticket.woid))) + assert.equal(neu.length, 2, 'sonst nichts') + + // zweiter Klick: Ticket vorhanden => nichts Neues + const nochmal = await nachlegen(k) + assert.equal(nochmal.status, 200) + assert.equal(workorders(), tickets + 1) + assert.equal(docById(k.docId).ticketId, doc.ticketId) + assert.equal(seit(mailsVorher).length, 2, 'keine weiteren Mails') + + // ein folgender Webhook mit derselben Sitzung (Stripe-Retry) sendet und legt nichts doppelt an + await sf.handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: st.checkoutSessions.get(k.sessionId) } }) + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2, 'Webhook-Wiederholung: keine weiteren Mails') + assert.equal(docById(k.docId).ticketId, doc.ticketId) +}) + +test('ticket (a), Dienst: created true beim Anlegen, false danach; zwei gleichzeitige Aufrufe legen ein Ticket und senden K15/T16 einmal', async () => { + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const [a, b] = await Promise.all([dienst(k, 'ticketNachlegen'), dienst(k, 'ticketNachlegen')]) + assert.deepEqual([a.created, b.created].sort(), [false, true]) + assert.equal(a.doc.ticketId, b.doc.ticketId) + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2, 'K15 und T16 je einmal') + const spaeter = await dienst(k, 'ticketNachlegen') + assert.equal(spaeter.created, false) + assert.equal(workorders(), tickets + 1) +}) + +test('ticket (a): zwei parallele Klicks => ein Ticket, K15 und T16 je einmal', async () => { + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2) + assert.equal(mailsAn(k.email, K15_BETREFF).length, 1) +}) + +test('ticket (a): Sitzung nicht bezahlt => 409, kein Ticket, keine Mail; Stripe-Ausfall => 503, danach geht es', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const unbezahlt = legeEinrichtung({ ticket: null, session: 'unpaid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const vorher = { ...docById(unbezahlt.docId) } + const res = await nachlegen(unbezahlt) + assert.equal(res.status, 409) + assert.equal(res.body.error, NICHT_BEZAHLT) + assert.deepEqual(docById(unbezahlt.docId), vorher) + assert.equal(workorders(), tickets) + + const k = legeEinrichtung({ ticket: null, session: 'paid' }) + const vorherK = { ...docById(k.docId) } + st.failNext({ method: 'GET', path: /^\/v1\/checkout\/sessions\//, status: 400, message: 'Nachbau: Stripe kaputt' }) + const aus = await nachlegen(k) + assert.equal(aus.status, 503) + assert.equal(typeof aus.body.error, 'string') + assert.ok(!JSON.stringify(aus.body).includes('Nachbau'), 'nichts Technisches nach aussen') + assert.deepEqual(docById(k.docId), vorherK) + assert.equal(workorders(), tickets) + assert.equal(seit(mailsVorher).length, 0) + assert.ok(log.mock.calls.some((c) => /Nachbau: Stripe kaputt/.test(c.arguments.join(' '))), 'Ursache im Log') + + assert.equal((await nachlegen(k)).status, 200, 'Stripe wieder da') + assert.equal(workorders(), tickets + 1) + assert.equal(seit(mailsVorher).length, 2) +}) + +test('ticket (a): ohne vermerkte Sitzung => 409, Stripe wird nicht gefragt', async () => { + const k = legeEinrichtung({ ticket: null }) + const aufrufe = st.calls.length + const res = await nachlegen(k) + assert.equal(res.status, 409) + assert.equal(res.body.error, 'Zu dieser Funktion ist keine Zahlung vermerkt.') + assert.equal(st.calls.length, aufrufe) +}) + +test('ticket (b): Ticket geloescht => neues Ticket mit neuer ticketId, keine Mail, Stripe nicht gefragt', async () => { + const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const aufrufe = st.calls.length + assert.equal(ticketDoc(k.ticketId), undefined, 'Ausgangslage: Ticket fehlt') + assert.equal(featuresOf({ body: await get(`/api/team/funktionen/${k.customerId}`, STAFF) }, k.projectId).termine.ticketMissing, true) + const res = await nachlegen(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = docById(k.docId) + assert.ok(doc.ticketId && doc.ticketId !== k.ticketId, 'neue ticketId') + assert.equal(workorders(), tickets + 1) + const ticket = ticketDoc(doc.ticketId) + assert.match(ticket.topic, /^Terminplaner einbauen – Salon \d+$/) + assert.ok(ticket.details.includes('Konfigurationsstand: Version 1')) + assert.equal(ticket.status, 'Open') + assert.equal(doc.state, 'setup') + assert.match(doc.adminNote, /Andrej: ticket/) + assert.equal(featuresOf(res, k.projectId).termine.ticketMissing, false) + assert.equal(seit(mailsVorher).length, 0, 'keine Mails: K15/T16 gingen beim ersten Mal raus') + assert.equal(st.calls.length, aufrufe, 'Stripe nicht gefragt') +}) + +test('ticket (b): zwei parallele Klicks und zwei gleichzeitige Dienstaufrufe => genau ein neues Ticket', async () => { + const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const tickets = workorders() + const [a, b] = await Promise.all([nachlegen(k), nachlegen(k)]) + assert.deepEqual([a.status, b.status], [200, 200]) + assert.equal(workorders(), tickets + 1) + + const k2 = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const tickets2 = workorders() + const [x, y] = await Promise.all([dienst(k2, 'ticketNachlegen'), dienst(k2, 'ticketNachlegen')]) + assert.deepEqual([x.created, y.created].sort(), [false, true]) + assert.equal(workorders(), tickets2 + 1) + assert.equal(x.doc.ticketId, y.doc.ticketId) +}) + +test('ticket (c): Ticket vorhanden (offen oder geschlossen) => no-op, created false, nichts geschrieben, Stripe nicht gefragt', async () => { + for (const ticket of ['Open', 'Closed']) { + const k = legeEinrichtung({ ticket, session: 'paid' }) + const tickets = workorders() + const mailsVorher = mails.sent.length + const aufrufe = st.calls.length + const vorher = { ...docById(k.docId) } + const res = await nachlegen(k) + assert.equal(res.status, 200, ticket) + assert.deepEqual(docById(k.docId), vorher, ticket) + assert.equal(workorders(), tickets, ticket) + assert.equal(seit(mailsVorher).length, 0, ticket) + assert.equal(st.calls.length, aufrufe, ticket) + const dienstErgebnis = await dienst(k, 'ticketNachlegen') + assert.equal(dienstErgebnis.created, false, ticket) + assert.equal(dienstErgebnis.doc.ticketId, k.ticketId, ticket) + } +}) + +test('ticket: nur in setup - off, ordered, active und disabled => 409; ohne Dokument => 409', async () => { + const faelle = [ + legeEinrichtung({ state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }), + legeEinrichtung({ state: 'ordered', ticket: null, session: 'paid', over: { purchaseStatus: 'ordered', unlockedByPurchase: false } }), + legeEinrichtung({ state: 'active', ticket: 'geloescht', session: 'paid' }), + legeEinrichtung({ state: 'disabled', ticket: null, session: 'paid', over: { disabledReason: 'team', disabledAt: '2026-10-01T10:00:00.000Z' } }), + ] + const tickets = workorders() + const mailsVorher = mails.sent.length + for (const k of faelle) { + const vorher = { ...docById(k.docId) } + const res = await nachlegen(k) + assert.equal(res.status, 409, vorher.state) + assert.equal(res.body.error, NUR_SETUP_TICKET, vorher.state) + assert.deepEqual(docById(k.docId), vorher, vorher.state) + } + assert.equal(workorders(), tickets) + assert.equal(seit(mailsVorher).length, 0) + aw.seed('websiteProjects', { $id: 'pt_d7_leer2', customerId: 'c1', projectName: 'Ohne Dokument D7b' }) + const docs = aw.docs('portalFeatures').size + const ohneDoc = await team('c1', 'termine', { projectId: 'pt_d7_leer2', action: 'ticket' }) + assert.equal(ohneDoc.status, 409) + assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner') + assert.equal(aw.docs('portalFeatures').size, docs) +}) + +test('ticket: fremdes Projekt => 403, Kundensitzung statt JWT => 401/403, nichts geschrieben', async () => { + const k = legeEinrichtung({ ticket: 'geloescht', session: 'paid' }) + const vorher = { ...docById(k.docId) } + const tickets = workorders() + assert.equal((await team('c1', 'termine', { projectId: 'pt3', action: 'ticket' })).status, 403) + const alsKunde = await nachlegen(k, 'termine', { cookie: customerCookie(k.customerId) }) + assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`) + assert.deepEqual(docById(k.docId), vorher) + assert.equal(workorders(), tickets) +}) + +test('ticket bei News (a): derselbe Weg des Webhooks, K15 und T16 der News-Pinnwand', async () => { + const k = legeEinrichtung({ key: 'news', abo: null, einstellung: false, ticket: null, session: 'paid' }) + const mailsVorher = mails.sent.length + const res = await nachlegen(k, 'news') + assert.equal(res.status, 200, JSON.stringify(res.body)) + const doc = docById(k.docId) + assert.ok(doc.ticketId) + assert.equal(doc.state, 'setup') + const neu = seit(mailsVorher) + assert.equal(neu.filter((m) => m.subject === 'Neue Funktion bestellt: News-Pinnwand').length, 1) + assert.equal(neu.filter((m) => m.to.some((to) => to.address === k.email)).length, 1, 'K15 an den Kunden') +}) From 938b7099ddee70fd09be81f9515685910eb8babb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 01:18:30 +0000 Subject: [PATCH 11/28] Funktionen: Team-Route fuer die Terminplaner-Einbauanleitung GET /api/team/funktionen/:customerId/termine/anleitung?projectId= liefert die volle Einbauanleitung (ohne kurz) fuer das WOMS-Panel: stand, warnungen mit Drift-Warnung gegen die Version im Ticket, abschnitte, text, state, ticketWoid, ticketVersion und hindernis (nur in setup). Nur lesend, in jedem Zustand und Modus, ohne customerViewLog. Die Teamansicht GET /:customerId traegt hindernis je Terminplaner in setup, mit einem Abo-Lauf je Anfrage und ohne portalFeatures erneut zu lesen. Die Einbau-Doku nennt die Anleitung je Kunde im WOMS-Panel und die Kurzfassung im Ticket. Co-Authored-By: Claude Sonnet 5.5 --- docs/funktionen/termine-einbau.md | 2 +- server/routes/admin/funktionenAdmin.js | 83 ++++++- test/funktionenTermine.test.js | 321 +++++++++++++++++++++++++ 3 files changed, 399 insertions(+), 7 deletions(-) diff --git a/docs/funktionen/termine-einbau.md b/docs/funktionen/termine-einbau.md index 1cc00bb..4f892b8 100644 --- a/docs/funktionen/termine-einbau.md +++ b/docs/funktionen/termine-einbau.md @@ -1,6 +1,6 @@ # Terminplaner auf der Kundenwebsite einbauen -Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` (16 Zeichen, a–z und 2–7) und den fertigen Einbau-Code findest Du im Portal: in der Kundenansicht des Kunden unter Terminplaner → Einstellungen, Abschnitt „Einbau auf deiner Website“ (mit Knopf „Code kopieren“). Den `siteKey` zeigt auch das Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Terminplaner, mit Kopierknopf. Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite. +Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` (16 Zeichen, a–z und 2–7) und den fertigen Einbau-Code findest Du im Portal: in der Kundenansicht des Kunden unter Terminplaner → Einstellungen, Abschnitt „Einbau auf deiner Website“ (mit Knopf „Code kopieren“). Den `siteKey` zeigt auch das Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Terminplaner, mit Kopierknopf. Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite. Die Anleitung für einen bestimmten Kunden erzeugt das Ticketsystem (WOMS) selbst: Kunde → Reiter „Funktionen“ → Anleitung des Projekts, mit den Einstellungen des Betriebs, dem fertigen Einbau-Code, dem CSS-Vorschlag, den Datenschutz-Absätzen, der Abnahme-Checkliste und den Warnungen. Im Einrichtungs-Ticket steht davon die Kurzfassung (Stand der Einstellungen, Einbau-Code, Schritte) mit dem Link auf diese Anleitung. Dieses Dokument ist die allgemeine Referenz dahinter: Es erklärt Variablen, Stile, Datenschutz und API, die für jeden Betrieb gelten. ## 1. Das Snippet diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 1811c77..7baa559 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -1,5 +1,6 @@ import { Router } from 'express' import { config } from '../../config.js' +import { aboMemo } from '../../services/aboStatus.js' import { getDocument } from '../../services/appwriteAdmin.js' import { featureConfig } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' @@ -8,6 +9,7 @@ import { FEATURE_LABELS, adminActivate, assertOwnProject, + customerOf, disable, freischaltenNachAbnahme, getFeatureDoc, @@ -15,6 +17,9 @@ import { listCustomerFeatures, ticketNachlegen, } from '../../services/siteFeatures.js' +import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js' +import { freischaltHindernis } from '../../services/termine/freischaltung.js' +import { ladeStand } from '../../services/termine/kontext.js' /** * Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen"). @@ -38,27 +43,44 @@ export function composeAdminNote(previous, { by, action, note }, now = new Date( return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry } -async function ticketExists(ticketId) { - if (!ticketId) return false +/** Das Ticket oder null (keine ticketId, Ticket geloescht); jeder andere Lesefehler wird weitergereicht. */ +async function readTicket(ticketId) { + if (!ticketId) return null try { - await getDocument(config.collections.workorders, ticketId) - return true + return await getDocument(config.collections.workorders, ticketId) } catch (err) { - if (err.status === 404) return false + if (err.status === 404) return null throw err } } -// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet +/** Name des Kunden fuer die Anleitung; wie im Ticket (termineTicketDetails) die Kunden-ID, wenn er fehlt oder nicht lesbar ist (nur Kosmetik, kein Fehler). */ +async function customerName(customerId) { + try { + return (await customerOf(customerId))?.name || customerId + } catch (err) { + console.error(`[funktionen-admin] Kunde ${customerId} fuer die Anleitung nicht lesbar:`, err.message) + return customerId + } +} + +const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null + +// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet. +// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert +// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird +// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut. async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) const projects = await listCustomerFeatures(customerId, { docs }) + const abo = aboMemo() for (const p of projects) { for (const key of FEATURE_KEYS) { const view = p.features[key] const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key) view.adminNote = doc?.adminNote || '' view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId))) + view.hindernis = key === 'termine' && doc?.state === 'setup' ? await freischaltHindernis(doc, { abo }) : null } } return projects @@ -72,6 +94,55 @@ funktionenAdminRouter.get('/:customerId', async (req, res) => { } }) +/** + * Einbauanleitung des Terminplaners eines Projekts fuer das WOMS-Panel (Task D8): die volle Fassung des Generators ohne `kurz` (die geht nur + * ins Ticket), nur lesend. Gilt in jedem Zustand und bei jedem Modus (kein TERMINE_ENABLED-Gate) und ist kein Kundenzugriff (kein + * customerViewLog, kein Pfad im Kundenrouter). Rechnung und Preis kommen live aus dem Dokument. + * state: Zustand des Dokuments. ticketWoid, ticketVersion: Ticket des Dokuments und der Stand, auf dem es beruht (null ohne Ticket oder + * ohne Standzeile). hindernis: nur in setup, was die Freischaltung noch hindert (siehe adminView), sonst null. + * Ein gespeicherter Stand, der neuer ist als das Ticket, haengt eine Warnung ans Ende von warnungen (driftWarnung); ein geloeschtes + * Ticket (404) ist keine Warnung und kein Fehler, jeder andere Lesefehler ist einer (keine stille Luecke). + */ +funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) => { + try { + const { customerId } = req.params + const { projectId } = req.query + if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' }) + const project = await assertOwnProject(customerId, projectId) + const doc = await getFeatureDoc(customerId, projectId, 'termine') + if (!doc) { + throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.termine.keine}`), { status: 409 }) + } + const stand = await ladeStand(doc.$id) + const ticket = await readTicket(doc.ticketId) + const ticketVersion = ticket ? ticketStand(ticket.details) : null + const { stand: anleitungsStand, warnungen, abschnitte, text } = einbauanleitung({ + feature: doc, + project, + einstellung: stand?.einstellung ?? null, + fragebogen: stand?.fragebogen ?? null, + version: stand?.version ?? 0, + updatedAt: stand?.updatedAt ?? null, + kunde: { name: await customerName(customerId) }, + now: new Date(), + }) + const drift = driftWarnung(ticketVersion, anleitungsStand.version) + // Felder einzeln benannt: `kurz` (nur fuers Ticket) und alles, was der Generator spaeter dazubekommt, geht nie unbesehen nach aussen + res.json({ + stand: anleitungsStand, + warnungen: drift ? [...warnungen, drift] : warnungen, + abschnitte, + text, + state: doc.state, + ticketWoid: ticket?.woid || null, + ticketVersion, + hindernis: doc.state === 'setup' ? await freischaltHindernis(doc) : null, + }) + } catch (err) { + sendError(res, err) + } +}) + // Je Funktion (/news, /termine; jeder andere Schluessel -> 404): activate (ohne Zahlung), disable, abnahme (Freischalten nach Abnahme, in // setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket nachlegen, in setup). abnahme und ticket gibt es nur fuer // Funktionen mit ticketFreischaltung im Register. diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index ee7ef4a..7fc0c52 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -23,6 +23,9 @@ const { deleteCustomerWithPortalAccess } = await import('../server/services/cust const { aboMeldung } = await import('../server/services/featureOrder.js') const { setMailTransportForTests } = await import('../server/services/mailer.js') const { mailSink } = await import('./support/mailSink.js') +const { ausVorlage } = await import('../server/services/termine/vorlagen.js') +const { neuerFeedToken } = await import('../server/services/termine/einstellungen.js') +const { updateDocument } = await import('../server/services/appwriteAdmin.js') const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes }) // Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke @@ -1039,3 +1042,321 @@ test('ticket bei News (a): derselbe Weg des Webhooks, K15 und T16 der News-Pinnw assert.equal(neu.filter((m) => m.subject === 'Neue Funktion bestellt: News-Pinnwand').length, 1) assert.equal(neu.filter((m) => m.to.some((to) => to.address === k.email)).length, 1, 'K15 an den Kunden') }) + +// ------------------------------------------- Team-Route Einbauanleitung (D8) --- +// GET /api/team/funktionen/:customerId/termine/anleitung?projectId=: die volle Anleitung (ohne `kurz`) fuer das WOMS-Panel, nur lesend, +// in jedem Zustand und bei jedem Modus. Dazu hindernis je setup-Dokument in der Kundenansicht des Teams (GET /:customerId). +const ANLEITUNG_IDS = [ + 'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen', + 'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung', +] +const ANLEITUNG_KEYS = ['abschnitte', 'hindernis', 'stand', 'state', 'text', 'ticketVersion', 'ticketWoid', 'warnungen'] // sortiert +const D8_BETRIEB = { + name: 'Salon Muster', email: 'inhaber@salon-muster.example', telefon: '0631 123456', adresse: 'Hauptstraße 1, 67655 Kaiserslautern', + kleinunternehmer: false, impressumUrl: 'https://salon-muster.example/impressum', datenschutzUrl: 'https://salon-muster.example/datenschutz', bundesland: 'RP', +} +/** Salon-Vorlage mit einer zweiten Person; beide Personen tragen einen feedToken (nie in die Antwort). */ +function d8Einstellung() { + const e = ausVorlage('salon', D8_BETRIEB, new Date('2026-10-05T10:00:00Z')) + e.ressourcen.push({ id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() }) + return e +} +const TICKET_MIT_STAND = (version) => + `Funktion: Terminplaner\nProjekt: Salon\nDomain: salon.example\nKunde: Salon\nsiteKey: abcdefghijklmnop\nKonfigurationsstand: Version ${version}, 03.10.2026\n\nSchritte:\n1. WOMS` + +/** + * Funktionsdokument wie legeEinrichtung (Abo, Ticket), aber mit einer vollen Einstellung in termineConfig. + * einstellung: Objekt | false (kein termineConfig-Dokument) | string (roh gespeichert); ticketDetails ersetzt die Details eines vorhandenen Tickets. + */ +function legeAnleitung({ einstellung = d8Einstellung(), version = 1, ticketDetails, ...rest } = {}) { + const k = legeEinrichtung({ ...rest, einstellung: false }) + if (einstellung !== false) { + aw.seed('termineConfig', { + $id: k.docId, customerId: k.customerId, projectId: k.projectId, + einstellung: typeof einstellung === 'string' ? einstellung : JSON.stringify(einstellung), version, updatedAt: '2026-10-03T08:15:00.000Z', updatedBy: 'test', + }) + } + const ticket = ticketDoc(k.ticketId) + if (ticketDetails !== undefined && ticket) { + aw.seed('workorders', { $id: k.ticketId, woid: ticket.woid, status: ticket.status, topic: ticket.topic, details: ticketDetails }) + } + return k +} +const anleitungPfad = (k, query = `projectId=${k.projectId}`) => `/api/team/funktionen/${k.customerId}/termine/anleitung${query ? `?${query}` : ''}` +const anleitung = (k, opts = STAFF, query) => rawGet(anleitungPfad(k, query), opts) +const warnungDrift = (ticketVersion, jetzt) => `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).` + +/** Fuehrt fn aus und zaehlt die schreibenden Anfragen (alles ausser GET) an den Appwrite-Nachbau. */ +async function zaehleSchreibzugriffe(fn) { + const echt = globalThis.fetch + const schreibend = [] + globalThis.fetch = async (url, optionen) => { + const methode = optionen?.method ?? 'GET' + if (methode !== 'GET' && String(url).startsWith(aw.url)) schreibend.push(`${methode} ${String(url).replace(/\?.*/, '')}`) + return echt(url, optionen) + } + try { + return { ergebnis: await fn(), schreibend } + } finally { + globalThis.fetch = echt + } +} +const modusMerken = (t) => { + const vorher = { mode: config.termine.mode, pilotEmails: [...config.termine.pilotEmails] } + t.after(() => termineMit(vorher.mode, vorher.pilotEmails)) +} + +test('Anleitung: ohne JWT 401, mit Kundensitzung statt JWT 401/403, die Route gibt es im Kundenrouter nicht (404)', async () => { + const k = legeAnleitung() + const ohne = await anleitung(k, {}) + assert.equal(ohne.status, 401) + const alsKunde = await anleitung(k, { cookie: customerCookie(k.customerId) }) + assert.ok(alsKunde.status === 401 || alsKunde.status === 403, `Status ${alsKunde.status}`) + assert.ok(!JSON.stringify(alsKunde.body).includes('abschnitte'), 'keine Anleitung fuer den Kunden') + // kein Pfad unter /api/funktionen (Kundenrouter), auch nicht mit gueltiger Kundensitzung + const kundenpfad = await rawGet(`/api/funktionen/termine/anleitung?projectId=${k.projectId}`, { cookie: customerCookie(k.customerId) }) + assert.equal(kundenpfad.status, 404) + assert.equal((await rawGet('/api/funktionen/termine/anleitung', { cookie: customerCookie(k.customerId) })).status, 404) +}) + +test('Anleitung: projectId fehlt oder ist leer oder doppelt => 400; fremdes oder unbekanntes Projekt => 403; ohne Dokument => 409 ohne Dokument anzulegen', async () => { + const k = legeAnleitung() + for (const query of ['', 'projectId=', 'projectId=a&projectId=b', 'projekt=x']) { + const res = await anleitung(k, STAFF, query) + assert.equal(res.status, 400, query) + assert.equal(typeof res.body.error, 'string', query) + } + // pt3 gehoert c2, nicht dem Kunden in der URL + assert.equal((await anleitung(k, STAFF, 'projectId=pt3')).status, 403) + assert.equal((await rawGet(`/api/team/funktionen/c1/termine/anleitung?projectId=pt3`, STAFF)).status, 403, 'Projekt von c2 fuer c1') + assert.equal((await anleitung(k, STAFF, 'projectId=gibtsnicht')).status, 403) + const docs = aw.docs('portalFeatures').size + aw.seed('websiteProjects', { $id: 'pt_d8_leer', customerId: 'c1', projectName: 'Ohne Dokument D8' }) + const ohneDoc = await rawGet('/api/team/funktionen/c1/termine/anleitung?projectId=pt_d8_leer', STAFF) + assert.equal(ohneDoc.status, 409) + assert.equal(ohneDoc.body.error, 'Für dieses Projekt gibt es noch keinen Terminplaner') + assert.equal(ohneDoc.body.error, `Für dieses Projekt gibt es noch ${sf.FEATURE_LABELS.termine.keine}`) + assert.equal(aw.docs('portalFeatures').size, docs, 'kein Dokument entstanden') + // ein Dokument der News-Pinnwand ist kein Terminplaner + const news = legeEinrichtung({ key: 'news', einstellung: false }) + assert.equal((await rawGet(`/api/team/funktionen/${news.customerId}/termine/anleitung?projectId=${news.projectId}`, STAFF)).status, 409) +}) + +test('Anleitung: 200 in jedem Zustand und bei Modus off, nur lesend, ohne Kundenansicht-Protokoll, mit state und ohne kurz', async (t) => { + modusMerken(t) + termineMit('off') + const faelle = { + off: { state: 'off', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }, + ordered: { state: 'ordered', ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } }, + setup: { state: 'setup' }, + active: { state: 'active' }, + disabled: { state: 'disabled', over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } }, + } + const protokoll = aw.docs('customerViewLog').size + for (const [state, optionen] of Object.entries(faelle)) { + const k = legeAnleitung(optionen) + const { ergebnis: res, schreibend } = await zaehleSchreibzugriffe(() => anleitung(k)) + assert.equal(res.status, 200, `${state}: ${JSON.stringify(res.body)}`) + assert.equal(res.body.state, state) + assert.deepEqual(Object.keys(res.body).sort(), ANLEITUNG_KEYS, state) + assert.ok(!('kurz' in res.body), `${state}: kurz bleibt im Ticket`) + assert.deepEqual(schreibend, [], `${state}: die Route schreibt nichts`) + } + assert.equal(aw.docs('customerViewLog').size, protokoll, 'kein Eintrag im Protokoll der Kundenansicht') +}) + +test('Anleitung: die Antwort enthaelt keinen feedToken der Einstellung', async () => { + const einstellung = d8Einstellung() + const tokens = einstellung.ressourcen.map((r) => r.feedToken) + assert.equal(tokens.length, 2) + assert.ok(tokens.every((tk) => typeof tk === 'string' && tk.length >= 20), 'Ausgangslage: echte Token') + const k = legeAnleitung({ einstellung }) + const res = await anleitung(k) + assert.equal(res.status, 200) + const json = JSON.stringify(res.body) + for (const tk of tokens) assert.ok(!json.includes(tk), 'feedToken in der Antwort') + assert.ok(!json.includes('feedToken')) + assert.ok(!('kurz' in res.body)) + assert.ok(!json.includes('Konfigurationsstand:'), 'die Kurzfassung des Tickets (mit ihrer Standzeile) gehoert nicht in die Antwort') +}) + +test('Anleitung mit Einstellung: 13 Abschnitte wie der Generator, stand.version 1, Volltext, Warnungen als Liste', async () => { + const k = legeAnleitung() + const res = await anleitung(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.abschnitte.map((a) => a.id), ANLEITUNG_IDS) + for (const a of res.body.abschnitte) assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id) + assert.deepEqual(res.body.abschnitte.filter((a) => a.kopierbar).map((a) => a.id), ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du']) + assert.deepEqual(res.body.stand, { version: 1, updatedAt: '2026-10-03T08:15:00.000Z' }) + assert.equal(typeof res.body.text, 'string') + assert.ok(res.body.text.includes(res.body.abschnitte[0].titel)) + assert.ok(Array.isArray(res.body.warnungen) && res.body.warnungen.every((w) => typeof w === 'string')) + const snippet = res.body.abschnitte.find((a) => a.id === 'snippet').text + assert.ok(snippet.includes('data-webklar-termine="abcdefghijklmnop"'), 'siteKey des Dokuments im Einbau-Code') + // eine hoehere Version der Einstellung kommt an + const v4 = legeAnleitung({ version: 4 }) + assert.equal((await anleitung(v4)).body.stand.version, 4) +}) + +test('Anleitung ohne gespeicherte Einstellung: nur der Einbau-Code und die Warnung, stand.version 0; unlesbare Einstellung ebenso mit der Version des Dokuments', async (t) => { + t.mock.method(console, 'error', () => {}) + const k = legeAnleitung({ einstellung: false }) + const res = await anleitung(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.deepEqual(res.body.abschnitte.map((a) => a.id), ['snippet']) + assert.ok(res.body.warnungen.includes('Keine Einstellungen gespeichert'), res.body.warnungen.join(' | ')) + assert.deepEqual(res.body.stand, { version: 0, updatedAt: null }) + assert.ok(res.body.abschnitte[0].text.includes('abcdefghijklmnop')) + // kaputtes JSON im Dokument: wie "keine Einstellung", die Version bleibt sichtbar + const kaputt = legeAnleitung({ einstellung: '{kaputt', version: 4 }) + const r2 = await anleitung(kaputt) + assert.equal(r2.status, 200, JSON.stringify(r2.body)) + assert.deepEqual(r2.body.abschnitte.map((a) => a.id), ['snippet']) + assert.ok(r2.body.warnungen.includes('Keine Einstellungen gespeichert')) + assert.equal(r2.body.stand.version, 4) +}) + +test('Anleitung, Drift: Ticket auf Version 1, Einstellung jetzt Version 3 => Warnung am Ende von warnungen, ticketVersion und ticketWoid', async () => { + const k = legeAnleitung({ version: 3, ticketDetails: TICKET_MIT_STAND(1) }) + const res = await anleitung(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(warnungDrift(1, 3), 'Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).') + assert.equal(res.body.warnungen.at(-1), warnungDrift(1, 3)) + assert.ok(res.body.warnungen.length > 1, 'sie steht hinter den uebrigen Warnungen') + assert.equal(res.body.warnungen.filter((w) => w.startsWith('Einstellungen seit der Bestellung')).length, 1) + assert.equal(res.body.ticketVersion, 1) + assert.equal(res.body.ticketWoid, ticketDoc(k.ticketId).woid) + assert.deepEqual(res.body.stand.version, 3) +}) + +test('Anleitung, Drift: gleiche oder neuere Version im Ticket, Ticket ohne Standzeile, Ticket geloescht, Funktion ohne ticketId => keine Warnung und kein Fehler', async () => { + const faelle = { + 'gleiche Version': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(3) }, ticketVersion: 3, hatTicket: true }, + 'Ticket neuer als die Einstellung': { opts: { version: 3, ticketDetails: TICKET_MIT_STAND(5) }, ticketVersion: 5, hatTicket: true }, + 'Ticket ohne Standzeile': { opts: { version: 3 }, ticketVersion: null, hatTicket: true }, + 'Ticket geloescht': { opts: { version: 3, ticket: 'geloescht' }, ticketVersion: null, hatTicket: false }, + 'ohne ticketId': { opts: { version: 3, ticket: null }, ticketVersion: null, hatTicket: false }, + } + for (const [name, { opts, ticketVersion, hatTicket }] of Object.entries(faelle)) { + const k = legeAnleitung(opts) + const res = await anleitung(k) + assert.equal(res.status, 200, `${name}: ${JSON.stringify(res.body)}`) + assert.ok(!res.body.warnungen.some((w) => /seit der Bestellung/.test(w)), `${name}: ${res.body.warnungen.join(' | ')}`) + assert.equal(res.body.ticketVersion, ticketVersion, name) + assert.equal(res.body.ticketWoid, hatTicket ? ticketDoc(k.ticketId).woid : null, name) + } + // Ticket aus der Zeit ohne Einstellung (Version 0), inzwischen gespeichert (Version 1) + const nullStand = legeAnleitung({ version: 1, ticketDetails: 'Funktion: Terminplaner\nKonfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)' }) + const r0 = await anleitung(nullStand) + assert.equal(r0.body.ticketVersion, 0) + assert.equal(r0.body.warnungen.at(-1), warnungDrift(0, 1)) +}) + +test('Anleitung, Drift: ein nicht lesbares Ticket (5xx) ist ein Fehler, keine stille Luecke; ein Aufruf spaeter geht', async (t) => { + t.mock.method(console, 'error', () => {}) + const k = legeAnleitung({ version: 3, ticketDetails: TICKET_MIT_STAND(1) }) + aw.failNext({ collection: 'workorders', method: 'GET', status: 500, message: 'Nachbau: Ticket kaputt' }) + const aus = await anleitung(k) + assert.ok(aus.status >= 500 && aus.status < 600, `Status ${aus.status}`) + assert.equal(typeof aus.body.error, 'string') + assert.equal((await anleitung(k)).status, 200) +}) + +test('Anleitung, hindernis: nur in setup - ohne Abo kein_abo, mit Abo und Einstellung null, ohne Einstellung keine_einstellung; sonst null und das Abo bleibt ungelesen', async () => { + const setupOhneAbo = legeAnleitung({ abo: null }) + assert.equal((await anleitung(setupOhneAbo)).body.hindernis, 'kein_abo') + assert.equal((await anleitung(legeAnleitung({ abo: 'hosting' }))).body.hindernis, 'kein_abo', 'Hosting ist kein Wartungs-Abo') + const beendet = legeAnleitung({ abo: 'wartung' }) + aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: beendet.customerId, status: 'canceled', endedAt: '2026-10-01T10:00:00.000Z' })) + assert.equal((await anleitung(beendet)).body.hindernis, 'kein_abo', 'beendetes Abo') + const frei = await anleitung(legeAnleitung()) + assert.equal(frei.body.hindernis, null) + assert.equal(frei.body.state, 'setup') + assert.equal((await anleitung(legeAnleitung({ einstellung: false }))).body.hindernis, 'keine_einstellung') + // eine vorgemerkte Kuendigung und past_due laufen noch + const gekuendigt = legeAnleitung() + aw.seed('subscriptions', subscriptionDoc(`sub_cd${d7Nr}`, { ...wartungDoc, customerId: gekuendigt.customerId, cancelAtPeriodEnd: true })) + assert.equal((await anleitung(gekuendigt)).body.hindernis, null) + + // active, disabled, ordered und off: null, auch ohne Abo, und das Abo wird gar nicht erst gelesen + const vorher = aw.listCalls('subscriptions').length + for (const [state, optionen] of Object.entries({ + active: { state: 'active', abo: null }, + disabled: { state: 'disabled', abo: null, over: { disabledReason: 'abo_ende', disabledAt: '2026-10-01T10:00:00.000Z' } }, + ordered: { state: 'ordered', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: 'ordered' } }, + off: { state: 'off', abo: null, ticket: null, over: { unlockedByPurchase: false, purchaseStatus: '' } }, + })) { + const res = await anleitung(legeAnleitung(optionen)) + assert.equal(res.status, 200, state) + assert.equal(res.body.hindernis, null, state) + } + assert.equal(aw.listCalls('subscriptions').length, vorher, 'ausserhalb von setup keine Abo-Abfrage') +}) + +test('Anleitung, hindernis: Abo nicht lesbar => 200 mit abo_unbekannt (geloggt), nie ein stilles null', async (t) => { + const log = t.mock.method(console, 'error', () => {}) + const k = legeAnleitung() + aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500, message: 'Nachbau: Abo kaputt' }) + const res = await anleitung(k) + assert.equal(res.status, 200, JSON.stringify(res.body)) + assert.equal(res.body.hindernis, 'abo_unbekannt') + assert.ok(log.mock.calls.length >= 1, 'der Fehler steht im Log') + assert.equal((await anleitung(k)).body.hindernis, null, 'der naechste Aufruf liest wieder') +}) + +test('Anleitung, Abrechnung: der Rechnungs-Link erscheint, sobald invoiceUrl im Dokument steht (auch bei altem Ticket); Preis aus dem Register', async () => { + const k = legeAnleitung({ ticketDetails: TICKET_MIT_STAND(1) }) + const abrechnung = async () => (await anleitung(k)).body.abschnitte.find((a) => a.id === 'abrechnung').text + const davor = await abrechnung() + assert.ok(davor.includes('Rechnung folgt im Portal'), davor) + assert.ok(davor.includes(config.features.termine.priceLabel), davor) + const ticketVorher = { ...ticketDoc(k.ticketId) } + // backfillInvoiceLinks traegt den Link spaeter nach: die Anleitung zeigt ihn sofort, das Ticket bleibt, wie es war + await updateDocument(config.collections.portalFeatures, k.docId, { invoiceUrl: 'https://invoice.stripe.test/in_d8' }) + const danach = await abrechnung() + assert.ok(danach.includes('https://invoice.stripe.test/in_d8'), danach) + assert.ok(!danach.includes('folgt im Portal'), danach) + assert.deepEqual(ticketDoc(k.ticketId), ticketVorher, 'das Ticket wird nicht angefasst') +}) + +// ---- Teamansicht GET /:customerId: hindernis je setup-Dokument + +test('Teamansicht: hindernis je Terminplaner in setup, null in jedem anderen Zustand und bei News; die Funktionsdokumente werden einmal gelesen', async (t) => { + const kunde = legeAnleitung() // Abo und Einstellung vorhanden + // zweites Projekt desselben Kunden in setup, ohne Einstellung + aw.seed('websiteProjects', { $id: 'cdp_zwei', customerId: kunde.customerId, projectName: 'Zweites Projekt' }) + aw.seed('portalFeatures', { $id: 'f_cdp_zwei_termine', customerId: kunde.customerId, projectId: 'cdp_zwei', featureKey: 'termine', siteKey: 'bcdefghijklmnopq', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' }) + // drittes Projekt: aktiv, viertes: News in setup + aw.seed('websiteProjects', { $id: 'cdp_drei', customerId: kunde.customerId, projectName: 'Drittes Projekt' }) + aw.seed('portalFeatures', { $id: 'f_cdp_drei_termine', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'termine', siteKey: 'cdefghijklmnopqr', state: 'active', enabled: true }) + aw.seed('portalFeatures', { $id: 'f_cdp_drei_news', customerId: kunde.customerId, projectId: 'cdp_drei', featureKey: 'news', siteKey: 'defghijklmnopqrs', state: 'setup', enabled: false, unlockedByPurchase: true, purchaseStatus: 'paid' }) + + const echt = globalThis.fetch + const abfragen = [] + globalThis.fetch = async (url, optionen) => { + if (/\/collections\/portalFeatures\/documents/.test(String(url))) abfragen.push(`${optionen?.method ?? 'GET'} ${String(url).replace(/\?.*/, '')}`) + return echt(url, optionen) + } + t.after(() => (globalThis.fetch = echt)) + const abosVorher = aw.listCalls('subscriptions').length + const res = await get(`/api/team/funktionen/${kunde.customerId}`, STAFF) + globalThis.fetch = echt + + assert.equal(abfragen.length, 1, abfragen.join(' | ')) + const features = (projectId) => res.projects.find((p) => p.projectId === projectId).features + assert.equal(features(kunde.projectId).termine.hindernis, null, 'setup mit Abo und Einstellung') + assert.equal(features('cdp_zwei').termine.hindernis, 'keine_einstellung', 'setup ohne Einstellung') + assert.equal(features('cdp_drei').termine.hindernis, null, 'active') + assert.equal(features('cdp_drei').news.hindernis, null, 'News in setup hat keine Freischalt-Hindernisse') + assert.equal(features(kunde.projectId).news.hindernis, null, 'kein Dokument') + for (const p of res.projects) for (const view of Object.values(p.features)) assert.ok('hindernis' in view, `${p.projectId}: Feld hindernis fehlt`) + assert.equal(aw.listCalls('subscriptions').length - abosVorher, 1, 'zwei setup-Dokumente, ein Abo-Lauf (aboMemo je Anfrage)') + + // ohne Abo: kein_abo, und die Ansicht der uebrigen Felder bleibt wie bisher + const ohneAbo = legeAnleitung({ abo: null }) + const r2 = await get(`/api/team/funktionen/${ohneAbo.customerId}`, STAFF) + const view = r2.projects.find((p) => p.projectId === ohneAbo.projectId).features.termine + assert.equal(view.hindernis, 'kein_abo') + assert.equal(view.state, 'setup') + assert.equal(view.ticketMissing, false) +}) From b20d0464c43c999940200e7b02f61cad27fa982a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:24:42 +0000 Subject: [PATCH 12/28] Rechtstexte: Dokument-Registry und Fassungskonstante des Terminplaners Co-Authored-By: Claude Sonnet 5.5 --- server/config.js | 39 +++++- server/index.js | 6 +- server/services/analyseLegal.js | 55 +------- server/services/legalDocs.js | 127 +++++++++++++++++ test/legalDocs.test.js | 237 ++++++++++++++++++++++++++++++++ test/termineSchalter.test.js | 3 + 6 files changed, 414 insertions(+), 53 deletions(-) create mode 100644 server/services/legalDocs.js create mode 100644 test/legalDocs.test.js diff --git a/server/config.js b/server/config.js index 3a16081..138887d 100644 --- a/server/config.js +++ b/server/config.js @@ -145,6 +145,25 @@ const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG' const ticketProjektName = (project) => project?.projectName || project?.subdomain || '' +/** + * Rechtsunterlage des Terminplaners mit Fassung und Adresse aus config.termine.avvVersion (bei jedem Zugriff gelesen). + * Tests duerfen sie ueberschreiben (Zuweisung); wird der aktuelle Standardwert zurueckgeschrieben (Wiederherstellen nach + * dem Test) oder null/undefined zugewiesen, gilt wieder der Standard und die Unterlage folgt der Konstante. + */ +function termineRechtsunterlage(slug) { + let ueberschrieben = null + const standard = () => ({ version: config.termine.avvVersion, url: `/rechtliches/${slug}-${config.termine.avvVersion}.html` }) + return { + get: () => ueberschrieben ?? standard(), + set(wert) { + const std = standard() + ueberschrieben = wert == null || (wert.version === std.version && wert.url === std.url) ? null : wert + }, + } +} +const termineLb = termineRechtsunterlage('leistungsbeschreibung-terminplaner') +const termineAvv = termineRechtsunterlage('avv-terminplaner') + /** * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen, * Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig, @@ -234,8 +253,20 @@ const features = { }, ], }, - leistungsbeschreibung: { version: 'ENTWURF', url: '' }, - avv: { version: 'ENTWURF', url: '' }, + // Fassung und Adresse folgen config.termine.avvVersion bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js + // importiert keine Dienste, test/legalDocs.test.js gleicht beide ab) + get leistungsbeschreibung() { + return termineLb.get() + }, + set leistungsbeschreibung(wert) { + termineLb.set(wert) + }, + get avv() { + return termineAvv.get() + }, + set avv(wert) { + termineAvv.set(wert) + }, widerruf: null, returnHash: 'termine', returnProjekt: true, @@ -527,6 +558,10 @@ export const config = { .split(',') .map((s) => s.trim().toLowerCase()) .filter(Boolean), + // Fassung der Terminplaner-Bestellunterlagen (Leistungsbeschreibung, AVV, Widerrufsbelehrung, Bestelltexte, News- + // Zustimmung): Berliner Kalendertag des Fassungs-Commits (JJJJ-MM-TT). Eine ausgelieferte Fassung wird nie + // ueberschrieben: nach Uebergabe an die Kanzlei oder Livegang heisst jede Textaenderung neues Datum und neue Dateien. + avvVersion: '2026-10-05', holdMinutes: 30, zelleMin: 5, mailsProStunde: 200, diff --git a/server/index.js b/server/index.js index f1582fc..cdfa158 100644 --- a/server/index.js +++ b/server/index.js @@ -38,7 +38,7 @@ import newsRoutes from './routes/news.js' import publicNewsRoutes from './routes/publicNews.js' import publicTermineRoutes from './routes/publicTermine.js' import adminAnalyseRoutes from './routes/admin/analyse.js' -import { analyseLegalRouter } from './services/analyseLegal.js' +import { legalRouter } from './services/legalDocs.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' import teamRoutes from './routes/team/index.js' import legacyAdminRedirect from './routes/legacyAdminRedirect.js' @@ -110,8 +110,8 @@ app.use('/embed/', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*') next() }) -// Adressen ohne Fassung: aktuelle geprüfte Fassung der Analyse-Rechtstexte (vor express.static) -app.use('/rechtliches', analyseLegalRouter()) +// Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static) +app.use('/rechtliches', legalRouter()) // Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem app.use('/admin.html', legacyAdminRedirect.admin) app.use('/kalkulation.html', legacyAdminRedirect.kalkulation) diff --git a/server/services/analyseLegal.js b/server/services/analyseLegal.js index ee23143..22fe836 100644 --- a/server/services/analyseLegal.js +++ b/server/services/analyseLegal.js @@ -1,54 +1,13 @@ -// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/) -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import { Router } from 'express' -import { config } from '../config.js' +// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/): +// duenner Wrapper um die Dokument-Registry legalDocs.js (Satz 'analyse') +import { legalPaths as registryPaths, legalPdfAttachments as registryAttachments, legalPdfFiles as registryFiles, legalRouter } from './legalDocs.js' -const publicDir = fileURLToPath(new URL('../../public/', import.meta.url)) -const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/ +export const legalPaths = (version) => registryPaths('analyse', version) -function checkVersion(version) { - if (!VERSION_RE.test(String(version))) throw new Error('Analyse: ungültige Fassung') - return String(version) -} - -export function legalPaths(version) { - const v = checkVersion(version) - return { - avv: `/rechtliches/avv-website-analyse-${v}.html`, - lb: `/rechtliches/leistungsbeschreibung-website-analyse-${v}.html`, - } -} - -export function legalPdfFiles(version) { - const v = checkVersion(version) - return { - avv: { filename: `AVV-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `avv-website-analyse-${v}.pdf`) }, - lb: { filename: `Leistungsbeschreibung-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `leistungsbeschreibung-website-analyse-${v}.pdf`) }, - } -} +export const legalPdfFiles = (version) => registryFiles('analyse', version) /** Beide PDFs als Mail-Anhaenge; wirft, wenn eine Datei fehlt oder nicht lesbar ist. */ -export async function legalPdfAttachments(version) { - const files = legalPdfFiles(version) - return Promise.all( - [files.avv, files.lb].map(async (f) => ({ - filename: f.filename, - content: await readFile(f.path), - contentType: 'application/pdf', - })) - ) -} +export const legalPdfAttachments = (version) => registryAttachments('analyse', version) /** Adressen ohne Fassung liefern immer die aktuelle geprüfte Fassung (config.analyse.avvVersion). */ -export function analyseLegalRouter() { - const router = Router() - const send = (key) => (_req, res, next) => { - const file = join(publicDir, legalPaths(config.analyse.avvVersion)[key]) - res.sendFile(file, (err) => err && next(err.code === 'ENOENT' ? undefined : err)) - } - router.get('/avv-website-analyse.html', send('avv')) - router.get('/leistungsbeschreibung-website-analyse.html', send('lb')) - return router -} +export const analyseLegalRouter = () => legalRouter({ saetze: ['analyse'] }) diff --git a/server/services/legalDocs.js b/server/services/legalDocs.js new file mode 100644 index 0000000..e7a7642 --- /dev/null +++ b/server/services/legalDocs.js @@ -0,0 +1,127 @@ +// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/): +// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und +// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum. +// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben. +import { access, readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { Router } from 'express' +import { config } from '../config.js' + +const publicDir = fileURLToPath(new URL('../../public/', import.meta.url)) +const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Dokumente je Satz in Anhangsreihenfolge: slug = Dateiname der HTML-Seite und des PDFs (-.html|pdf), + * pdf = Dateiname des Mail-Anhangs (-.pdf). config.js baut die Adressen des Terminplaners aus denselben + * Slugs (config.js darf diese Datei nicht importieren); test/legalDocs.test.js gleicht beide ab. + */ +const SAETZE = { + analyse: { + avv: { slug: 'avv-website-analyse', pdf: 'AVV-Website-Analyse' }, + lb: { slug: 'leistungsbeschreibung-website-analyse', pdf: 'Leistungsbeschreibung-Website-Analyse' }, + }, + termine: { + lb: { slug: 'leistungsbeschreibung-terminplaner', pdf: 'Leistungsbeschreibung-Terminplaner' }, + avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' }, + widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' }, + }, +} + +/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */ +const FUNKTION_SATZ = { news: null, termine: 'termine' } + +/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */ +const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion) + +function satzDokumente(satz) { + if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz') + return SAETZE[satz] +} + +function pruefeFassung(version) { + if (!VERSION_RE.test(String(version))) throw new Error('Rechtstexte: ungültige Fassung') + return String(version) +} + +/** Adressen der versionierten HTML-Seiten: { [doc]: '/rechtliches/-.html' }. */ +export function legalPaths(satz, version) { + const dokumente = satzDokumente(satz) + const v = pruefeFassung(version) + return Object.fromEntries(Object.entries(dokumente).map(([doc, d]) => [doc, `/rechtliches/${d.slug}-${v}.html`])) +} + +/** PDF je Dokument: { [doc]: { filename (Anhangsname), path (Datei unter public/rechtliches/) } }. */ +export function legalPdfFiles(satz, version) { + const dokumente = satzDokumente(satz) + const v = pruefeFassung(version) + return Object.fromEntries( + Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(publicDir, 'rechtliches', `${d.slug}-${v}.pdf`) }]) + ) +} + +/** + * PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine + * Datei fehlt oder nicht lesbar ist. ohne: Dokumente, die nicht angehaengt werden (sie muessen nicht vorhanden sein). + */ +export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) { + const dateien = legalPdfFiles(satz, version) + for (const doc of ohne) { + if (!Object.hasOwn(dateien, doc)) throw new Error('Rechtstexte: unbekanntes Dokument') + } + return Promise.all( + Object.entries(dateien) + .filter(([doc]) => !ohne.includes(doc)) + .map(async ([, f]) => ({ filename: f.filename, content: await readFile(f.path), contentType: 'application/pdf' })) + ) +} + +function funktionSatz(featureKey) { + if (typeof featureKey !== 'string' || !Object.hasOwn(FUNKTION_SATZ, featureKey)) throw new Error('Rechtstexte: unbekannte Funktion') + return FUNKTION_SATZ[featureKey] +} + +/** + * Anhaenge der Bestellmail einer Funktion in der aktuellen Fassung: termine => Satz 'termine', news => []. Wirft bei + * unbekannter Funktion und bei fehlender Datei (der Aufrufer entscheidet, was dann geschieht). + */ +export async function featureLegalAttachments(featureKey, opts) { + const satz = funktionSatz(featureKey) + return satz ? legalPdfAttachments(satz, aktuelleFassung(satz), opts) : [] +} + +/** + * true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite + * verlinkt) und PDFs (die Mail anhaengt). Unbekannte Funktion und jeder Lesefehler: false (fail-closed). + */ +export async function featureLegalReady(featureKey) { + try { + const satz = funktionSatz(featureKey) + if (!satz) return true + const v = aktuelleFassung(satz) + await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(publicDir, adresse)))) + await legalPdfAttachments(satz, v) + return true + } catch { + return false + } +} + +/** + * Adressen ohne Fassung (/rechtliches/.html) liefern die Datei der aktuellen Fassung aus config. Nur fuer + * Menschen und die WOMS-Karten: keine Mail und kein Zustimmungstext darf sie verlinken, denn sie aendern sich mit der + * Fassung. Gehoert vor express.static; fehlt die Datei, geht die Anfrage weiter (404). saetze: welche Saetze bedient + * werden, dir: Verzeichnis der Dateien (Standard public/rechtliches, Tests uebergeben ein eigenes). + */ +export function legalRouter({ saetze = Object.keys(SAETZE), dir = join(publicDir, 'rechtliches') } = {}) { + const router = Router() + for (const satz of saetze) { + for (const d of Object.values(satzDokumente(satz))) { + router.get(`/${d.slug}.html`, (_req, res, next) => { + const datei = join(dir, `${d.slug}-${pruefeFassung(aktuelleFassung(satz))}.html`) + res.sendFile(datei, (err) => err && next(err.code === 'ENOENT' ? undefined : err)) + }) + } + } + return router +} diff --git a/test/legalDocs.test.js b/test/legalDocs.test.js new file mode 100644 index 0000000..5c5db8f --- /dev/null +++ b/test/legalDocs.test.js @@ -0,0 +1,237 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Router } from 'express' +import { useTestEnv } from './support/testEnv.js' + +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { startTestApp } = await import('./support/testApp.js') +const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, legalRouter } = await import( + '../server/services/legalDocs.js' +) +const analyse = await import('../server/services/analyseLegal.js') +const { featureConfig } = await import('../server/services/featureRegistry.js') + +const V = config.termine.avvVersion +const ALT = '2026-10-02' // eingefrorene Altfassung der Gaestetexte und der Analyse-Rechtstexte +const root = (rel) => new URL(`../${rel}`, import.meta.url) + +// Router mit eigenem Verzeichnis: die Adressen ohne Fassung liefern die Datei der Fassung aus config +const dir = mkdtempSync(join(tmpdir(), 'legaldocs-')) +const inhalt = (name, version) => `

${name} ${version}

` +const DATEIEN = ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen'] +for (const slug of DATEIEN) writeFileSync(join(dir, `${slug}-${V}.html`), inhalt(slug, V)) +writeFileSync(join(dir, `avv-website-analyse-${ALT}.html`), inhalt('avv-website-analyse', ALT)) +const rechtliches = Router() +rechtliches.use(legalRouter({ dir })) +const app = await startTestApp({ '/rechtliches': rechtliches }) +after(() => { + app.close() + rmSync(dir, { recursive: true, force: true }) +}) + +const get = async (path) => { + const r = await fetch(app.url + path) + return { status: r.status, text: await r.text() } +} + +/** Konstante und Register fuer einen Test setzen; stellt sie danach wieder her. */ +async function mitFassung(fassung, fn) { + const alt = config.termine.avvVersion + config.termine.avvVersion = fassung + try { + return await fn() + } finally { + config.termine.avvVersion = alt + } +} + +test('legalPaths("termine", V) nennt drei versionierte Adressen', () => { + assert.deepEqual(legalPaths('termine', V), { + lb: `/rechtliches/leistungsbeschreibung-terminplaner-${V}.html`, + avv: `/rechtliches/avv-terminplaner-${V}.html`, + widerruf: `/rechtliches/widerrufsbelehrung-funktionen-${V}.html`, + }) + assert.deepEqual(Object.keys(legalPaths('termine', V)), ['lb', 'avv', 'widerruf']) + assert.deepEqual(legalPaths('analyse', ALT), { + avv: `/rechtliches/avv-website-analyse-${ALT}.html`, + lb: `/rechtliches/leistungsbeschreibung-website-analyse-${ALT}.html`, + }) +}) + +test('legalPaths: ungültige Fassung und unbekannter Satz werfen', () => { + for (const satz of ['termine', 'analyse']) { + for (const v of ['../x', '2026-10-5', '2026-10-05x', 'ENTWURF', '', undefined, null, '2026-10-05/../x']) { + assert.throws(() => legalPaths(satz, v), /Fassung/, `${satz}: ${String(v)}`) + assert.throws(() => legalPdfFiles(satz, v), /Fassung/) + } + } + for (const satz of ['news', 'unbekannt', '__proto__', 'toString', '', undefined]) { + assert.throws(() => legalPaths(satz, V), /Satz/, String(satz)) + assert.throws(() => legalPdfFiles(satz, V), /Satz/) + } +}) + +test('legalPdfFiles: Dateinamen und Pfade je Satz und Fassung', () => { + const t = legalPdfFiles('termine', V) + assert.deepEqual(Object.keys(t), ['lb', 'avv', 'widerruf']) + assert.equal(t.lb.filename, `Leistungsbeschreibung-Terminplaner-${V}.pdf`) + assert.equal(t.avv.filename, `AVV-Terminplaner-${V}.pdf`) + assert.equal(t.widerruf.filename, `Widerrufsbelehrung-Muster-Widerrufsformular-${V}.pdf`) + assert.equal(t.lb.path, new URL(`../public/rechtliches/leistungsbeschreibung-terminplaner-${V}.pdf`, import.meta.url).pathname) + assert.equal(t.avv.path, new URL(`../public/rechtliches/avv-terminplaner-${V}.pdf`, import.meta.url).pathname) + assert.equal(t.widerruf.path, new URL(`../public/rechtliches/widerrufsbelehrung-funktionen-${V}.pdf`, import.meta.url).pathname) + const a = legalPdfFiles('analyse', ALT) + assert.deepEqual(Object.keys(a), ['avv', 'lb']) + assert.equal(a.avv.filename, `AVV-Website-Analyse-${ALT}.pdf`) + assert.equal(a.lb.filename, `Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`) +}) + +test('legalPdfAttachments: fehlende Datei wirft, Analyse liefert avv und lb, ohne lässt Dokumente weg', async () => { + await assert.rejects(legalPdfAttachments('termine', '1999-01-01')) + await assert.rejects(legalPdfAttachments('analyse', '1999-01-01')) + await assert.rejects(legalPdfAttachments('unbekannt', V), /Satz/) + const alle = await legalPdfAttachments('analyse', ALT) + assert.deepEqual(alle.map((x) => x.filename), [`AVV-Website-Analyse-${ALT}.pdf`, `Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`]) + for (const x of alle) { + assert.equal(x.contentType, 'application/pdf') + assert.equal(x.content.subarray(0, 4).toString(), '%PDF') + } + const ohneAvv = await legalPdfAttachments('analyse', ALT, { ohne: ['avv'] }) + assert.deepEqual(ohneAvv.map((x) => x.filename), [`Leistungsbeschreibung-Website-Analyse-${ALT}.pdf`]) + // eine ausgelassene Datei muss nicht vorhanden sein + assert.deepEqual(await legalPdfAttachments('termine', '1999-01-01', { ohne: ['lb', 'avv', 'widerruf'] }), []) + await assert.rejects(legalPdfAttachments('analyse', ALT, { ohne: ['gibtsnicht'] }), /Dokument/) +}) + +test('featureLegalAttachments / featureLegalReady: news ohne Dokumente, unbekannte Funktion nie bereit', async () => { + assert.deepEqual(await featureLegalAttachments('news'), []) + assert.deepEqual(await featureLegalAttachments('news', { ohne: ['lb'] }), []) + assert.equal(await featureLegalReady('news'), true) + await assert.rejects(featureLegalAttachments('gibtsnicht'), /Funktion/) + await assert.rejects(featureLegalAttachments('analyse'), /Funktion/) + await assert.rejects(featureLegalAttachments('__proto__'), /Funktion/) + assert.equal(await featureLegalReady('gibtsnicht'), false) + assert.equal(await featureLegalReady('analyse'), false) +}) + +test('featureLegalAttachments("termine") liest die Fassung aus config.termine.avvVersion; fehlt eine Datei, ist die Funktion nicht bereit', async () => { + await mitFassung('1999-01-01', async () => { + await assert.rejects(featureLegalAttachments('termine')) + assert.equal(await featureLegalReady('termine'), false) + }) +}) + +test('config.termine.avvVersion: Datumsmuster, nach der Altfassung der Gästetexte, echter Tag', () => { + assert.match(config.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/) + assert.ok(config.termine.avvVersion > ALT, `${config.termine.avvVersion} liegt nicht nach ${ALT}`) + assert.ok(config.termine.avvVersion >= '2026-10-03') + assert.ok(!Number.isNaN(Date.parse(`${config.termine.avvVersion}T00:00:00Z`))) + assert.equal(new Date(`${config.termine.avvVersion}T00:00:00Z`).toISOString().slice(0, 10), config.termine.avvVersion) +}) + +test('config.ustHinweis bleibt wörtlich', () => { + assert.equal(config.ustHinweis, 'ohne Umsatzsteuer gemäß § 19 UStG') +}) + +test('Register: leistungsbeschreibung und avv des Terminplaners folgen der Konstante (Getter, Drift-Wächter)', async () => { + const t = featureConfig('termine') + assert.deepEqual(t.leistungsbeschreibung, { version: V, url: legalPaths('termine', V).lb }) + assert.deepEqual(t.avv, { version: V, url: legalPaths('termine', V).avv }) + assert.doesNotMatch(JSON.stringify([t.leistungsbeschreibung, t.avv]), /ENTWURF/) + // zur Laufzeit geaendert: der Getter folgt + await mitFassung('2099-01-02', async () => { + assert.deepEqual(t.leistungsbeschreibung, { version: '2099-01-02', url: legalPaths('termine', '2099-01-02').lb }) + assert.deepEqual(t.avv, { version: '2099-01-02', url: legalPaths('termine', '2099-01-02').avv }) + }) + assert.deepEqual(t.leistungsbeschreibung, { version: V, url: legalPaths('termine', V).lb }) + // ein Test darf den Eintrag ueberschreiben (siehe termineFragebogen.test.js) + const merke = t.leistungsbeschreibung + t.leistungsbeschreibung = { version: 'T1', url: '/x' } + assert.deepEqual(t.leistungsbeschreibung, { version: 'T1', url: '/x' }) + t.leistungsbeschreibung = merke + assert.deepEqual(t.leistungsbeschreibung, merke) +}) + +test('News-Rechtsdokumente bleiben unberührt', () => { + const n = featureConfig('news') + assert.deepEqual(n.leistungsbeschreibung, { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }) + assert.equal(n.avv, null) + assert.deepEqual(analyse.legalPaths(ALT), legalPaths('analyse', ALT)) + assert.equal(Object.getOwnPropertyDescriptor(n, 'leistungsbeschreibung').get, undefined) +}) + +test('config.js importiert legalDocs.js nicht (Adresse aus Slug und Version gebaut)', () => { + const src = readFileSync(root('server/config.js'), 'utf8') + assert.doesNotMatch(src, /^\s*(import\b[^\n]*|.*\bimport\()['"`][^'"`]*legalDocs/m) + assert.doesNotMatch(src, /^import .* from '\.\/services\//m) +}) + +test('analyseLegal.js ist ein dünner Wrapper um die Registry', async () => { + assert.deepEqual(analyse.legalPaths(ALT), legalPaths('analyse', ALT)) + assert.deepEqual(analyse.legalPdfFiles(ALT), legalPdfFiles('analyse', ALT)) + assert.deepEqual(await analyse.legalPdfAttachments(ALT), await legalPdfAttachments('analyse', ALT)) + assert.throws(() => analyse.legalPaths('../x'), /Fassung/) + await assert.rejects(analyse.legalPdfAttachments('1999-01-01')) + assert.equal(typeof analyse.analyseLegalRouter, 'function') +}) + +test('Router: Adressen ohne Fassung liefern die Fassung aus config (Terminplaner)', async () => { + for (const slug of DATEIEN) { + const r = await get(`/rechtliches/${slug}.html`) + assert.equal(r.status, 200, slug) + assert.equal(r.text, inhalt(slug, V)) + } + // die Fassung folgt der Konstante zur Laufzeit: fuer eine andere Fassung gibt es keine Datei => 404 + await mitFassung('2099-01-02', async () => { + assert.equal((await get('/rechtliches/avv-terminplaner.html')).status, 404) + }) + assert.equal((await get('/rechtliches/avv-terminplaner.html')).status, 200) +}) + +test('Router: Adressen ohne Fassung der Analyse liefern config.analyse.avvVersion', async () => { + assert.equal(config.analyse.avvVersion, ALT) + const r = await get('/rechtliches/avv-website-analyse.html') + assert.equal(r.status, 200) + assert.equal(r.text, inhalt('avv-website-analyse', ALT)) + // die zweite Analyse-Datei fehlt im Testverzeichnis: 404, kein 500 + assert.equal((await get('/rechtliches/leistungsbeschreibung-website-analyse.html')).status, 404) +}) + +test('Router: nur die festen Adressen, versionierte und fremde Pfade fallen durch', async () => { + for (const p of [ + `/rechtliches/avv-terminplaner-${V}.html`, // versionierte Adressen liefert express.static in index.js + '/rechtliches/avv-terminplaner.pdf', + '/rechtliches/../config.js', + '/rechtliches/gibtsnicht.html', + '/rechtliches/', + ]) { + assert.equal((await get(p)).status, 404, p) + } + const post = await fetch(`${app.url}/rechtliches/avv-terminplaner.html`, { method: 'POST' }) + assert.equal(post.status, 404) +}) + +test('Router: legalRouter() ohne Verzeichnis liefert die Analyse-Adressen aus public/rechtliches', async () => { + const echt = Router() + echt.use(legalRouter()) + const a = await startTestApp({ '/rechtliches': echt }) + try { + const r = await fetch(`${a.url}/rechtliches/avv-website-analyse.html`) + assert.equal(r.status, 200) + assert.deepEqual(Buffer.from(await r.arrayBuffer()), readFileSync(root(`public/rechtliches/avv-website-analyse-${ALT}.html`))) + } finally { + a.close() + } +}) + +test('server/index.js hängt den gemeinsamen Router vor express.static ein', () => { + const index = readFileSync(root('server/index.js'), 'utf8') + assert.match(index, /import \{ legalRouter \} from '\.\/services\/legalDocs\.js'/) + assert.ok(index.includes("app.use('/rechtliches', legalRouter())")) + assert.ok(!index.includes('analyseLegalRouter')) + assert.ok(index.indexOf("app.use('/rechtliches', legalRouter())") < index.indexOf('app.use(express.static(publicDir))')) +}) diff --git a/test/termineSchalter.test.js b/test/termineSchalter.test.js index c0d08ee..9f49cee 100644 --- a/test/termineSchalter.test.js +++ b/test/termineSchalter.test.js @@ -41,9 +41,12 @@ async function freshConfig(env = {}) { test('Konfiguration: Vorgaben ohne Umgebungsvariablen', async () => { const fresh = await freshConfig() + // avvVersion: Fassung der Bestellunterlagen (Berliner Tag des Fassungs-Commits), hier nur das Datumsmuster + assert.match(fresh.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/) assert.deepEqual(fresh.termine, { mode: 'off', pilotEmails: [], + avvVersion: fresh.termine.avvVersion, holdMinutes: 30, zelleMin: 5, mailsProStunde: 200, From 3073fffcf5d72a22fd0e8da46f380c6eeba331d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:30:38 +0000 Subject: [PATCH 13/28] =?UTF-8?q?News:=20Zitat=20=C2=A7=20356=20Abs.=205?= =?UTF-8?q?=20Nr.=202,=20Wertersatz-Satz=20und=20=C2=A7=2019=20UStG=20stat?= =?UTF-8?q?t=20brutto?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- .../2026-09-30-funktionen-news-design.md | 13 +- public/funktionen.js | 9 +- .../funktionen-leistungsbeschreibung.html | 10 +- server/config.js | 21 +- test/featureMails.test.js | 4 +- test/featureRegistry.test.js | 7 +- test/funktionenRecht.test.js | 228 ++++++++++++++++++ test/funktionenTiles.test.js | 4 +- test/legalDocs.test.js | 4 +- test/legalTexts.test.js | 3 +- 10 files changed, 277 insertions(+), 26 deletions(-) create mode 100644 test/funktionenRecht.test.js diff --git a/docs/superpowers/specs/2026-09-30-funktionen-news-design.md b/docs/superpowers/specs/2026-09-30-funktionen-news-design.md index 6d349ef..d7222a8 100644 --- a/docs/superpowers/specs/2026-09-30-funktionen-news-design.md +++ b/docs/superpowers/specs/2026-09-30-funktionen-news-design.md @@ -69,13 +69,18 @@ auf `off`). - Grobe Zeiteinschätzung: „meist innerhalb von 1–2 Wochen“ (Text in `config`, kein Versprechen). - Projektauswahl bei mehreren Websites. - Pflicht-Checkbox (Widerruf, analog Abo-Bestellung): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf - der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald - der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).“ Einwilligung wird mit Zeitstempel und - Textfassung am Feature-Dokument gespeichert (wie die Abo-Consent-Metadaten). + der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen + Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, + schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Einwilligung + wird mit Zeitstempel und Textfassung am Feature-Dokument gespeichert (wie die Abo-Consent-Metadaten). + *Korrekturvermerk 2026-10-05 (Terminplaner TP2, Task F2): Ursprünglich stand hier „… erlischt, sobald der Einbau + vollständig erbracht ist (§ 356 Abs. 4 BGB).“ Zitat und Wertersatz-Satz sind korrigiert bzw. ergänzt (Entwurf bis + zum Befund der Kanzlei, Kanzlei-Paket Frage Q3). Bestehende Bestellungen behalten den damals gespeicherten Text + und die damalige Fassung.* - Knopf „**Zahlungspflichtig bestellen · 30 €**“ ⇒ `POST /api/funktionen/news/checkout`. **Zahlung**: Stripe Checkout `mode: 'payment'`, einmaliger Preis über `ensurePrice('news_setup')` -(Katalog in `config`/`services/products.js`, 30,00 € brutto), `invoice_creation.enabled = true`, +(Katalog in `config`/`services/products.js`, 30,00 €; *Korrekturvermerk 2026-10-05: ohne Umsatzsteuer gemäß § 19 UStG, nicht „brutto“*), `invoice_creation.enabled = true`, Zahlarten wie beim Abo (Karte, SEPA). Metadaten `{ customerId, projectId, featureKey: 'news' }`. **Stripe-Webhook** `checkout.session.completed` (bzw. `checkout.session.async_payment_succeeded` bei SEPA): diff --git a/public/funktionen.js b/public/funktionen.js index d4a67d6..a8e6889 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -8,6 +8,8 @@ import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' import { ZU_VERTRAEGE } from './vertraege-link.js' const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt +// Rueckfall fuer den Preishinweis, wenn die Antwort von /api/funktionen kein ustHinweis hat (gleicher Wortlaut wie config.ustHinweis) +const UST_HINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG' const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um const PHOTO_MAX_BYTES = 10 * 1024 * 1024 @@ -266,7 +268,7 @@ export async function initFunktionen(root) { export async function initNews(root, { projectId } = {}) { if (!root) return if (projectId) selection.projectId = projectId - const { projects = [], setupEstimate = '', consentText = '', features } = await request('/api/funktionen') + const { projects = [], setupEstimate = '', consentText = '', features, ustHinweis } = await request('/api/funktionen') if (!projects.length) { root.innerHTML = '
Noch keine Website-Projekte zugewiesen.
' return @@ -301,7 +303,7 @@ export async function initNews(root, { projectId } = {}) { if (feature.state === 'active') renderEditor(stage, project) else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate) else if (feature.state === 'disabled') stage.innerHTML = '

News-Pinnwand ist abgeschaltet

Für diese Website ist die Pinnwand im Moment nicht aktiv. , dann klären wir das.

' - else renderBooking(stage, project, { setupEstimate, offer: newsOffer(features, consentText), thanks }) + else renderBooking(stage, project, { setupEstimate, offer: { ...newsOffer(features, consentText), ustHinweis: textOr(ustHinweis, UST_HINWEIS) }, thanks }) } function renderBooking(stage, project, { setupEstimate, offer, thanks }) { @@ -315,7 +317,7 @@ function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
  • Beiträge jederzeit selbst schreiben, ändern und abnehmen
  • Nutzung danach kostenlos, kein Abo
  • -

    ${esc(offer.price)} einmalig, inklusive Mehrwertsteuer

    +

    ${esc(offer.price)} einmalig,

    ${setupEstimate ? `

    Einrichtung: ${esc(setupEstimate)}

    ` : ''} @@ -323,6 +325,7 @@ function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
    ` stage.querySelector('#news-consent-text').textContent = offer.consentText + stage.querySelector('#news-ust-hinweis').textContent = offer.ustHinweis const box = stage.querySelector('#news-consent') const button = stage.querySelector('#news-order') const error = stage.querySelector('#news-order-error') diff --git a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html index ba7197b..b4245cb 100644 --- a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html +++ b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html @@ -40,15 +40,15 @@
    WEBKLAR

    Leistungsbeschreibung: Funktionen – News-Pinnwand

    -

    Stand 30.09.2026

    +

    Stand 05.10.2026

    Das Wichtigste auf einen Blick - + - +
    LeistungEinbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal
    Preis30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten
    Preis30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten
    Dauer des Einbausmeist innerhalb von 1–2 Wochen (keine verbindliche Zusage)
    WiderrufVerbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)
    WiderrufVerbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 5 Nr. 2 BGB erlöschen (Abschnitt 6)
    @@ -57,7 +57,7 @@

    Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.

    2. Preis

    -

    Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.

    +

    Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG). Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.

    3. Einbau und Freischaltung

    Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau ist erfahrungsgemäß meist innerhalb von 1–2 Wochen abgeschlossen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.

    @@ -70,7 +70,7 @@

    6. Widerrufsrecht

    Sind Sie Verbraucher, können Sie diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen widerrufen. Die Frist beginnt mit dem Vertragsschluss. Zum Widerruf genügt eine eindeutige Erklärung an uns, zum Beispiel über project.webklar.com/widerrufen.html.

    -

    Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen. Das Widerrufsrecht erlischt nach § 356 Abs. 4 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.

    +

    Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen (§ 357a Abs. 2 BGB). Das Widerrufsrecht erlischt nach § 356 Abs. 5 Nr. 2 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.

    Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die Vertragsbedingungen und die Datenschutzerklärung.

    diff --git a/server/config.js b/server/config.js index 138887d..5a7e695 100644 --- a/server/config.js +++ b/server/config.js @@ -180,19 +180,30 @@ const features = { label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }, priceLabel: '30 €', setupEstimate: 'meist innerhalb von 1–2 Wochen', - // Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB) + // Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB, + // Wertersatz § 357a Abs. 2 BGB). Aufbau wie der Gaestetext ZUSTIMMUNG_VORZEITIG; Zitat und Wertersatz-Satz sind + // Entwurf bis zur Kanzlei (Kanzlei-Paket, Frage Q3). Die Fassung folgt config.termine.avvVersion (); bestehende + // Bestellungen behalten Text und Fassung, die am Dokument und im Nachweis gespeichert sind. consent: { - version: '2026-09-30', + get version() { + return config.termine.avvVersion + }, knopf: 'Zahlungspflichtig bestellen · 30 €', items: [ { id: 'beginn', haken: true, - text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).', + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', }, ], }, - leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }, + // Stand der Leistungsbeschreibung (public/vertragsunterlagen/funktionen-leistungsbeschreibung.html) = + leistungsbeschreibung: { + get version() { + return config.termine.avvVersion + }, + url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html', + }, avv: null, widerruf: null, returnHash: 'news', @@ -219,7 +230,7 @@ const features = { knopf: 'Beitrag schreiben', danach: 'Danach kannst Du Beiträge im Portal schreiben.', bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.', - preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo', + preisHinweis: `einmalig, ${ustHinweis}; die Nutzung ist kostenlos, es läuft kein Abo`, anleitung: false, }, requiresWartungsAbo: false, diff --git a/test/featureMails.test.js b/test/featureMails.test.js index 5f194de..cb6a394 100644 --- a/test/featureMails.test.js +++ b/test/featureMails.test.js @@ -145,7 +145,7 @@ test('News-Mails bleiben wörtlich: K15 mit Rechnung', async () => { '', 'Funktion: News-Pinnwand – Einrichtung', 'Projekt: Bäckerei', - 'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)', + 'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)', 'Einrichtung: meist innerhalb von 1–2 Wochen', '', 'Wie geht es weiter?', @@ -180,7 +180,7 @@ test('News-Mails bleiben wörtlich: K15 ohne Rechnung', async () => { '', 'Funktion: News-Pinnwand – Einrichtung', 'Projekt: Café', - 'Einmalig bezahlt: 30,00 € (brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)', + 'Einmalig bezahlt: 30,00 € (einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo)', 'Einrichtung: meist innerhalb von 1–2 Wochen', '', 'Wie geht es weiter?', diff --git a/test/featureRegistry.test.js b/test/featureRegistry.test.js index b820e0d..dca871f 100644 --- a/test/featureRegistry.test.js +++ b/test/featureRegistry.test.js @@ -15,10 +15,11 @@ const reg = await import('../server/services/featureRegistry.js') const sf = await import('../server/services/siteFeatures.js') const { createFeatureTicket } = await import('../server/services/ticketAdmin.js') -// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert) +// Wortlaut-Pin des News-Satzes und der Fassung (der einzige Literal-Pin auf Texte, die Block F2 ändert); die Fassung ist der +// Tag = config.termine.avvVersion, beim Anheben von ändert sich auch dieser Pin const NEWS_SATZ = - 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).' -const NEWS_FASSUNG = '2026-09-30' + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).' +const NEWS_FASSUNG = '2026-10-05' const SITE_KEY = 'abcdefghijklmnop' diff --git a/test/funktionenRecht.test.js b/test/funktionenRecht.test.js new file mode 100644 index 0000000..44e2dbf --- /dev/null +++ b/test/funktionenRecht.test.js @@ -0,0 +1,228 @@ +import { readFileSync, writeFileSync, mkdtempSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { startFakeStripe } from './support/fakeStripe.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Rechtstext-Korrekturen der News-Pinnwand (Task F2): Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz, § 19 UStG statt "brutto". +// Die Wortlaute sind hier als Literale eingefroren (Wortlaut-Pin); die Fassung folgt config.termine.avvVersion. +const aw = await startAppwriteMock({ + schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents') }, +}) +const st = await startFakeStripe() +useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url }) +const { config } = await import('../server/config.js') +const { startTestApp, get, post } = await import('./support/testApp.js') +const { customerCookie, seedFixtures } = await import('./support/fixtures.js') +const { default: funktionenRoutes } = await import('../server/routes/funktionen.js') +const sf = await import('../server/services/siteFeatures.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') + +const app = await startTestApp({ '/api/funktionen': funktionenRoutes }) +const mails = mailSink() +setMailTransportForTests(mails) +after(() => { + setMailTransportForTests(null) + return Promise.all([app.close(), aw.close(), st.close()]) +}) +seedFixtures(aw) +aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', liveDomain: 'baeckerei.de' }) +aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Café' }) +const cookie = customerCookie('c1') + +const root = (rel) => new URL(`../${rel}`, import.meta.url) +const lesen = (rel) => readFileSync(root(rel), 'utf8') + +const NEWS_SATZ = + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).' +const ALTER_SATZ = + 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).' +const USTHINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG' + +test('news_zustimmung_wortlaut: Register = Alias = exakter Text mit Abs. 5 Nr. 2 und § 357a Abs. 2, Fassung = ', () => { + const news = config.features.news + assert.deepEqual(news.consent.items, [{ id: 'beginn', haken: true, text: NEWS_SATZ }]) + assert.equal(config.funktionen.consentText, NEWS_SATZ) + assert.equal(news.consent.items[0].text, config.funktionen.consentText) + assert.ok(NEWS_SATZ.includes('Abs. 5 Nr. 2') && NEWS_SATZ.includes('§ 357a Abs. 2')) + assert.ok(!NEWS_SATZ.includes('Abs. 4')) + assert.equal(news.consent.knopf, 'Zahlungspflichtig bestellen · 30 €') + // die Fassung folgt der Konstante (zur Laufzeit), auch die der Leistungsbeschreibung, deren Stand sich mit dem Text ändert + assert.equal(news.consent.version, config.termine.avvVersion) + assert.equal(news.leistungsbeschreibung.version, config.termine.avvVersion) + assert.ok(news.consent.version > '2026-10-02') + const alt = config.termine.avvVersion + config.termine.avvVersion = '2099-01-02' + try { + assert.equal(news.consent.version, '2099-01-02') + assert.equal(news.leistungsbeschreibung.version, '2099-01-02') + } finally { + config.termine.avvVersion = alt + } + assert.equal(news.leistungsbeschreibung.url, '/vertragsunterlagen/funktionen-leistungsbeschreibung.html') + assert.equal(news.mails.preisHinweis, `einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo`) +}) + +test('waechter_zitat_und_preistext: kein altes Zitat, keine Mehrwertsteuer-Angabe, kein "brutto" in den Kundentexten', () => { + const dateien = [ + 'server/config.js', + 'public/vertragsunterlagen/funktionen-leistungsbeschreibung.html', + 'public/funktionen.js', + 'server/mail-templates/K15 Bestellung Funktion.html', + 'server/services/stripeBilling.js', + ] + for (const datei of dateien) { + const text = lesen(datei) + assert.doesNotMatch(text, /§ 356 Abs\. 4/, `${datei}: altes Zitat`) + assert.doesNotMatch(text, /inklusive (Mehr|Umsatz)steuer/i, `${datei}: inklusive Steuer`) + assert.doesNotMatch(text, /inklusive Mehrwertsteuer/i, `${datei}: inklusive Mehrwertsteuer`) + assert.doesNotMatch(text, /\bbrutto\b/i, `${datei}: brutto`) + } + const lb = lesen('public/vertragsunterlagen/funktionen-leistungsbeschreibung.html') + assert.equal(lb.split(USTHINWEIS).length - 1, 2, 'LB nennt den Hinweis in Kopf-Tabelle und Abschnitt 2') + assert.ok(lb.includes('30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten')) + assert.ok(lb.includes('Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG).')) + assert.ok(lb.includes('erlischt nach § 356 Abs. 5 Nr. 2 BGB')) + assert.ok(lb.includes('(§ 357a Abs. 2 BGB)')) + assert.ok(lb.includes(''), 'die Freigabe bleibt Sache des Inhabers') + // der Stand der LB ist der Tag der Fassung + const [j, m, t] = config.termine.avvVersion.split('-') + assert.ok(lb.includes(`

    Stand ${t}.${m}.${j}

    `), 'Stand der LB = config.termine.avvVersion') +}) + +test('bestellseite_hinweis: GET /api/funktionen liefert ustHinweis', async () => { + const body = await get('/api/funktionen', { cookie }) + assert.equal(body.ustHinweis, config.ustHinweis) + assert.equal(config.ustHinweis, USTHINWEIS) + assert.equal(body.consentText, NEWS_SATZ) + assert.equal(body.features.news.consent.items[0].text, NEWS_SATZ) + assert.equal(body.features.news.consent.version, config.termine.avvVersion) +}) + +// ---- Client: public/funktionen.js wirklich laden (absolute Importe auf die Dateien umleiten) und die Buchungsseite rendern +const dir = mkdtempSync(join(tmpdir(), 'fn-recht-')) +const fileUrl = (name) => pathToFileURL(new URL(`../public/${name}`, import.meta.url).pathname).href +const clientQuelle = lesen('public/funktionen.js') + .replace("'/analyse-view.js'", `'${fileUrl('analyse-view.js')}'`) + .replace("'/geraete.js'", `'${fileUrl('geraete.js')}'`) + .replace("'./vertraege-link.js'", `'${fileUrl('vertraege-link.js')}'`) +const clientDatei = join(dir, 'funktionen.mjs') +writeFileSync(clientDatei, clientQuelle) +const { initNews } = await import(pathToFileURL(clientDatei).href) + +function fakeEl() { + const handlers = {} + return { innerHTML: '', textContent: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] } +} +async function renderBuchung(antwort) { + const echtesFetch = globalThis.fetch + globalThis.window = { location: { hash: '#news', pathname: '/dashboard.html', search: '', href: '' }, history: { replaceState() {} } } + globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => antwort }) + const els = {} + const stage = { innerHTML: '', els, querySelector: (sel) => (els[sel] ||= fakeEl()), querySelectorAll: () => [] } + const rootEl = fakeEl() + rootEl.querySelector = (sel) => (sel === '#news-stage' ? stage : null) + try { + await initNews(rootEl, { projectId: 'p1' }) + return stage + } finally { + delete globalThis.window + globalThis.fetch = echtesFetch + } +} +const frei = { projectId: 'p1', projectName: 'Muster', features: { news: { state: 'off' } } } + +test('bestellseite_hinweis (Client): Preis mit Hinweis aus ustHinweis, per textContent; Rückfall = config.ustHinweis; kein "inklusive Mehrwertsteuer"', async () => { + const stage = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ, ustHinweis: 'Testhinweis § 19' }) + assert.match(stage.innerHTML, /30 €<\/strong>/) + assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer|inklusive|brutto/i) + assert.doesNotMatch(stage.innerHTML, /Testhinweis/, 'der Hinweis steht nie im HTML-Text, nur als textContent') + assert.equal(stage.els['#news-ust-hinweis'].textContent, 'Testhinweis § 19') + assert.match(stage.innerHTML, /einmalig, <\/span>/) + + // ein Hinweis mit Markup bleibt Text + const bose = await renderBuchung({ projects: [frei], ustHinweis: '' }) + assert.doesNotMatch(bose.innerHTML, /') + + // fehlt der Hinweis in der Antwort, gilt der Rückfall im Client: derselbe Wortlaut wie config.ustHinweis + const rueck = await renderBuchung({ projects: [frei], consentText: NEWS_SATZ }) + assert.equal(rueck.els['#news-ust-hinweis'].textContent, config.ustHinweis) + for (const leer of ['', ' ', 5, null]) { + const r = await renderBuchung({ projects: [frei], ustHinweis: leer }) + assert.equal(r.els['#news-ust-hinweis'].textContent, config.ustHinweis, String(leer)) + } +}) + +// ---- K15 (Bestellbestätigung) der News +const paidEvent = (sessionId, projectId) => ({ + id: `evt_${sessionId}`, + object: 'event', + type: 'checkout.session.completed', + data: { object: { id: sessionId, object: 'checkout.session', mode: 'payment', payment_status: 'paid', metadata: { customerId: 'c1', projectId, featureKey: 'news' } } }, +}) + +test('k15_news: Preiszeile mit § 19 UStG statt "brutto"', async () => { + aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Bistro' }) + await sf.markOrdered({ customerId: 'c1', projectId: 'p3', key: 'news', sessionId: 'cs_k15', consent: {} }) + const vor = mails.sent.length + await sf.handleFeatureCheckoutEvent(paidEvent('cs_k15', 'p3')) + const k15 = mails.sent.slice(vor).filter((m) => m.subject.startsWith('Deine Bestellung')) + assert.equal(k15.length, 1) + assert.ok(k15[0].text.includes(`Einmalig bezahlt: 30,00 € (einmalig, ${USTHINWEIS}; die Nutzung ist kostenlos, es läuft kein Abo)`), k15[0].text) + assert.doesNotMatch(k15[0].text, /brutto/i) + assert.doesNotMatch(k15[0].html, /brutto/i) + assert.ok(k15[0].html.includes(USTHINWEIS)) +}) + +// ---- bestehende Bestellungen behalten Text und Fassung; neue speichern die Register-Werte +test('alte_bestellung_unveraendert: altes Dokument bleibt, neue Bestellung speichert Register-Text und Fassung', async () => { + aw.seed('portalFeatures', { + $id: 'f_p2_news', // feste ID je (Projekt, Funktion) + enabled: true, + unlockedByPurchase: true, + customerId: 'c1', + projectId: 'p2', + featureKey: 'news', + state: 'active', + purchaseStatus: 'paid', + consentText: ALTER_SATZ, + consentAt: '2026-09-30T10:00:00.000Z', + orderedAt: '2026-09-30T10:00:00.000Z', + }) + aw.seed('featureConsents', { + $id: 'fc_alt', + customerId: 'c1', + projectId: 'p2', + featureKey: 'news', + stripeSessionId: 'cs_alt', + acceptedAt: '2026-09-30T10:00:00.000Z', + fassungen: JSON.stringify({ zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }), + texte: JSON.stringify([{ id: 'beginn', text: ALTER_SATZ, haken: true }]), + }) + const alt = await sf.getFeatureDoc('c1', 'p2', 'news') + assert.equal(alt.consentText, ALTER_SATZ) + const liste = await get('/api/funktionen', { cookie }) + assert.equal(liste.projects.find((p) => p.projectId === 'p2').features.news.state, 'active') + + const res = await post('/api/funktionen/news/checkout', { projectId: 'p1', consent: true }, { cookie }) + assert.equal(res.status, 200, JSON.stringify(res.body)) + const neu = await sf.getFeatureDoc('c1', 'p1', 'news') + assert.equal(neu.consentText, NEWS_SATZ) + const nachweise = [...aw.docs('featureConsents').values()].filter((d) => d.projectId === 'p1') + assert.equal(nachweise.length, 1) + assert.deepEqual(JSON.parse(nachweise[0].fassungen), { zustimmung: config.termine.avvVersion, leistungsbeschreibung: config.termine.avvVersion }) + const texte = JSON.parse(nachweise[0].texte) + assert.equal(texte[0].text, NEWS_SATZ) + + // das alte Dokument und sein Nachweis sind unverändert + assert.equal((await sf.getFeatureDoc('c1', 'p2', 'news')).consentText, ALTER_SATZ) + assert.deepEqual(JSON.parse(aw.docs('featureConsents').get('fc_alt').fassungen), { zustimmung: '2026-09-30', leistungsbeschreibung: '2026-09-30' }) + assert.equal(JSON.parse(aw.docs('featureConsents').get('fc_alt').texte)[0].text, ALTER_SATZ) +}) diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index b559f7e..bcdafa2 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -113,7 +113,9 @@ test('News-Buchungsseite: Preis, Knopf und Zustimmungstext aus features.news, se features: { news: register }, }) assert.match(stage.innerHTML, /31 €<\/strong>/) - assert.match(stage.innerHTML, /inklusive Mehrwertsteuer/, 'der Wortlaut bleibt bis Block F') + assert.doesNotMatch(stage.innerHTML, /Mehrwertsteuer/, 'der Preishinweis kommt aus ustHinweis (Block F)') + assert.match(stage.innerHTML, /einmalig, <\/span>/) + assert.equal(stage.els['#news-ust-hinweis'].textContent, 'ohne Umsatzsteuer gemäß § 19 UStG', 'Rückfall ohne ustHinweis in der Antwort') assert.match(stage.innerHTML, /id="news-order" disabled>Jetzt kaufen · 31 € { +test('News: Leistungsbeschreibung folgt der Fassung (Adresse unverändert, kein AVV, kein Getter am Eintrag)', () => { const n = featureConfig('news') - assert.deepEqual(n.leistungsbeschreibung, { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }) + assert.deepEqual(n.leistungsbeschreibung, { version: config.termine.avvVersion, url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }) assert.equal(n.avv, null) assert.deepEqual(analyse.legalPaths(ALT), legalPaths('analyse', ALT)) assert.equal(Object.getOwnPropertyDescriptor(n, 'leistungsbeschreibung').get, undefined) diff --git a/test/legalTexts.test.js b/test/legalTexts.test.js index 9bba9f1..5de2c11 100644 --- a/test/legalTexts.test.js +++ b/test/legalTexts.test.js @@ -482,7 +482,8 @@ test('legal_datenschutz_news: Abschnitt 6a und Leistungsbeschreibung (Entwurf)', const text = strip(lb) assert.ok(text.includes('Auf Ihren Wunsch schalten wir die Pinnwand ab') && !text.includes('Endet die Zusammenarbeit mit uns, schalten wir')) assert.ok(text.includes('abgeschlossen') && !text.includes('dauert erfahrungsgemäß')) - for (const s of ['30,00 €', '§ 356 Abs. 4 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s) + for (const s of ['30,00 €', '§ 356 Abs. 5 Nr. 2 BGB', '§ 357a Abs. 2 BGB', '1–2 Wochen', 'keine laufenden Kosten']) assert.ok(text.includes(s), s) + assert.ok(!text.includes('§ 356 Abs. 4') && !/inklusive Umsatzsteuer/.test(text), 'altes Zitat und alter Preistext sind weg') const muster = read('docs/funktionen/website-datenschutz-absatz.md') // 14 Tage Protokolle nur, solange Abschnitt 3 der WEBklar-Erklaerung dieselbe Frist nennt From 6b4cbb3204822f04d9276407fcec8f9ba0024f53 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:35:04 +0000 Subject: [PATCH 14/28] Rechtstexte: Widerrufsbelehrung der Funktionen aus dem Gesetzesmuster Co-Authored-By: Claude Sonnet 5.5 --- .../widerrufsbelehrung-funktionen.md | 54 ++++++ server/services/termine/rechtstexte.js | 42 +++- test/termineRechtstexte.test.js | 179 ++++++++++++++++++ 3 files changed, 268 insertions(+), 7 deletions(-) create mode 100644 docs/rechtliches/widerrufsbelehrung-funktionen.md diff --git a/docs/rechtliches/widerrufsbelehrung-funktionen.md b/docs/rechtliches/widerrufsbelehrung-funktionen.md new file mode 100644 index 0000000..a635114 --- /dev/null +++ b/docs/rechtliches/widerrufsbelehrung-funktionen.md @@ -0,0 +1,54 @@ +# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen) + +**Fassung 2026-10-05** + +Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular. + +## Widerrufsbelehrung + +### Widerrufsrecht + +Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. + +Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. + +Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. + +Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. + +Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. + +### Folgen des Widerrufs + +Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. + +Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. + +## Muster-Widerrufsformular + +(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) + +– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com: + +– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*) + +– Bestellt am (*)/erhalten am (*) + +– Name des/der Verbraucher(s) + +– Anschrift des/der Verbraucher(s) + +– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) + +– Datum + +(*) Unzutreffendes streichen. + +## Hinweise zum Widerruf + +Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt. + +- Das Widerrufsrecht gilt für jede Bestellung einer Funktion gesondert, also für jedes Website-Projekt, für das der Kunde eine Funktion bestellt. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss. +- Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt. +- Hat WEBklar auf ausdrücklichen Wunsch des Kunden vor Ablauf der Widerrufsfrist mit der Einrichtung begonnen, schuldet der Kunde bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung. Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung. +- Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe. diff --git a/server/services/termine/rechtstexte.js b/server/services/termine/rechtstexte.js index 378d8b5..3e04c1a 100644 --- a/server/services/termine/rechtstexte.js +++ b/server/services/termine/rechtstexte.js @@ -58,12 +58,18 @@ const verbinde = (teile) => teile.filter(Boolean).join(', ') const FRIST = 'des Vertragsabschlusses.' // Gestaltungshinweis a): Dienstleistungsvertrag // Widerrufsfunktion: Satzrahmen des Gesetzes wörtlich („Sie können Ihr Widerrufsrecht auch online unter … ausüben.“); in die -// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt ein geeigneter Hinweis statt einer Internetadresse. +// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt bei den Gästen ein geeigneter Hinweis statt einer +// Internetadresse, bei der Bestellung einer Funktion (WEBklar als Unternehmer) die Adresse der Widerrufsseite. +const EINGANGSBESTAETIGUNG = + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) ' + + 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.' + const WIDERRUFSFUNKTION = 'Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website ' + 'sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. ' - + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) ' - + 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.' + + EINGANGSBESTAETIGUNG + +const WIDERRUFSFUNKTION_WEBKLAR = `Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. ${EINGANGSBESTAETIGUNG}` // Wertersatz bei Dienstleistungen; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen. const WERTERSATZ = @@ -76,7 +82,7 @@ function kontakt({ name, adresse, telefon, email }) { return verbinde([name, adresse, `Telefon: ${telefon}`, `E-Mail: ${email}`]) } -function belehrungsAbsaetze(daten) { +function belehrungsAbsaetze(daten, widerrufsfunktion = WIDERRUFSFUNKTION) { return [ 'Widerrufsbelehrung', 'Widerrufsrecht', @@ -85,7 +91,7 @@ function belehrungsAbsaetze(daten) { `Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt(daten)}) mittels einer eindeutigen Erklärung ` + '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. ' + 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', - WIDERRUFSFUNKTION, + widerrufsfunktion, 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', 'Folgen des Widerrufs', 'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten ' @@ -98,9 +104,9 @@ function belehrungsAbsaetze(daten) { ] } -function formularAbsaetze({ name, adresse, email }) { +function formularAbsaetze({ name, adresse, email }, anZeile = `An ${verbinde([name, adresse, email])}:`) { const zeilen = [ - `An ${verbinde([name, adresse, email])}:`, + anZeile, 'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)', 'Bestellt am (*)/erhalten am (*)', 'Name des/der Verbraucher(s)', @@ -127,6 +133,28 @@ export function widerrufsbelehrung(betrieb) { return [...belehrungsAbsaetze(daten), ...formularAbsaetze(daten)].join(ABSATZ) } +/** WEBklar als Unternehmer (Bestellung einer Funktion im Kundenportal): Kontaktangaben der Widerrufsbelehrung. */ +export const WEBKLAR_KONTAKT = Object.freeze({ + name: 'WEBklar GbR', + adresse: 'Am Schwimmbad 10, 67722 Winnweiler', + telefon: '0176 23726355', + email: 'support@webklar.com', +}) + +/** + * Widerrufsbelehrung und Muster-Widerrufsformular fuer die Bestellung der Einrichtung einer Funktion (Terminplaner, News): + * WEBklar ist der Unternehmer, Sie-Form, Text mit Zeilenumbruechen wie widerrufsbelehrung. Gleiche Bausteine wie bei den + * Gaesten (Frist „des Vertragsabschlusses.“, Wertersatz fuer Dienstleistungen), aber die Widerrufsfunktion nennt die Adresse + * der Widerrufsseite (Wortlaut wie in den Abo-Bedingungen, Abschnitt Widerrufsbelehrung) und die „An“-Zeile des Formulars nennt + * die E-Mail-Adresse mit „E-Mail:“. Quelle des Dokuments docs/rechtliches/widerrufsbelehrung-funktionen.md; test/termineRechtstexte.test.js + * haelt beide gleich und gleicht die Belehrung mit public/abo-bedingungen.html ab. + */ +export function widerrufsbelehrungFunktion() { + const daten = betriebsdaten(WEBKLAR_KONTAKT, { vollstaendig: true }) + const an = `An ${verbinde([daten.name, daten.adresse, `E-Mail: ${daten.email}`])}:` + return [...belehrungsAbsaetze(daten, WIDERRUFSFUNKTION_WEBKLAR), ...formularAbsaetze(daten, an)].join(ABSATZ) +} + // ---------------------------------------------------------------- Hinweis unter dem Buchungsformular (recherche-recht.md 11.2) const LOESCHFRIST_ABGESAGT_TAGE = 30 diff --git a/test/termineRechtstexte.test.js b/test/termineRechtstexte.test.js index b5c3e28..cb9fe54 100644 --- a/test/termineRechtstexte.test.js +++ b/test/termineRechtstexte.test.js @@ -1,10 +1,14 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { config } from '../server/config.js' import { RECHTSTEXTE_VERSION, RECHTSTEXTE_ENTWURF, + WEBKLAR_KONTAKT, widerrufsbelehrung, + widerrufsbelehrungFunktion, formularHinweis, datenschutzAbsatz, ZUSTIMMUNG_VORZEITIG, @@ -481,3 +485,178 @@ test('6c-Entwurf: nur als Entwurfsdatei, public/datenschutz.html bleibt unverän const html = lies('public/datenschutz.html') assert.ok(!/6c\./.test(html) && !html.includes('id="termine"'), '6c darf vor TP2 nicht in public/datenschutz.html stehen') }) + +// ---------------------------------------------------------------- Widerrufsbelehrung der Funktionen (WEBklar als Unternehmer, Task F3) + +const KONTAKT_WEBKLAR = 'WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com' +const AN_ZEILE_WEBKLAR = 'An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:' +const WIDERRUFEN_URL = 'https://project.webklar.com/widerrufen.html' +const funktion = () => widerrufsbelehrungFunktion() + +test('Gäste-Belehrung bleibt byte-gleich (Hash der Ausgabe vor Task F3)', () => { + const text = widerrufsbelehrung(BETRIEB) + assert.equal(createHash('sha256').update(text).digest('hex'), 'a4b3549b7b4ba0bdeeca438bc823d07eede5e4aa35e21e335fb5304ff36a1ff6') + assert.equal(text.length, 2983) +}) + +test('WEBKLAR_KONTAKT: WEBklar GbR als Unternehmer', () => { + assert.deepEqual(WEBKLAR_KONTAKT, { + name: 'WEBklar GbR', + adresse: 'Am Schwimmbad 10, 67722 Winnweiler', + telefon: '0176 23726355', + email: 'support@webklar.com', + }) +}) + +test('belehrung_funktion_muster_woertlich: jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt', () => { + const text = funktion() + const muster = musterZeilen() + assert.equal(muster.length, 8) + assert.equal(fristBaustein(), 'des Vertragsabschlusses.') + for (const zeile of muster) { + const erwartet = zeile + .replace('ab dem Tag .', `ab dem Tag ${fristBaustein()}`) + .replace('uns ( )', `uns (${KONTAKT_WEBKLAR})`) + assert.ok(text.includes(erwartet), `Absatz fehlt oder ist verändert: ${erwartet}`) + } + // Fristbeginn und Kontakt stehen genau einmal + assert.equal(text.split('ab dem Tag des Vertragsabschlusses.').length - 1, 1) + assert.equal(text.split(KONTAKT_WEBKLAR).length - 1, 1) +}) + +test('belehrung_funktion_widerrufsfunktion: Satzrahmen der Anlage 1 wörtlich, die Lücke ist die Adresse der Widerrufsfunktion', () => { + const text = funktion() + const m = /„(Sie können Ihr Widerrufsrecht auch online unter) … \[[^\]]+\] (ausüben\.) (Wenn Sie diese Online-Funktion nutzen, [^“]+?)\s*“/.exec(ANLAGE_1) + assert.ok(m, 'Gestaltungshinweis zur Widerrufsfunktion fehlt oder hat sich geändert in anlage-1.txt') + const absatz = `${m[1]} ${WIDERRUFEN_URL} ${m[2]} ${m[3]}` + assert.ok(text.includes(absatz), 'Absatz zur Widerrufsfunktion fehlt oder weicht vom Satzrahmen der Anlage ab') + assert.equal(text.split(WIDERRUFEN_URL).length - 1, 1) + // kein Hinweis auf eine Schaltfläche der Gäste-Fassung + assert.ok(!text.includes('Termin verwalten') && !text.includes('Schaltfläche')) + // der Wertersatz für Dienstleistungen steht wie in der Gäste-Fassung + const w = /Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser\/Gas\/Strom\/Fernwärme \[Unzutreffendes streichen\] (während[^“]+?)\s*“/.exec(ANLAGE_1) + assert.ok(w) + assert.ok(text.includes(`Haben Sie verlangt, dass die Dienstleistungen ${w[1]}`)) +}) + +test('belehrung_funktion_formular: Muster-Widerrufsformular der Anlage 2 wörtlich, „An“-Zeile mit WEBklar', () => { + const text = funktion() + const z = zeilen(ANLAGE_2) + const start = z.indexOf('Muster-Widerrufsformular') + assert.ok(start >= 0) + const inhalt = z.slice(start).filter((s) => s && s !== '–') + let anZeile = 0 + for (const zeile of inhalt) { + if (zeile.startsWith('An [hier ist')) { + anZeile += 1 + assert.ok(text.includes(`– ${AN_ZEILE_WEBKLAR}`), '„An“-Zeile mit WEBklar fehlt') + } else { + assert.ok(text.includes(zeile), `Formularzeile fehlt oder ist verändert: ${zeile}`) + } + } + assert.equal(anZeile, 1) + assert.equal(text.split(AN_ZEILE_WEBKLAR).length - 1, 1) +}) + +test('Belehrung der Funktionen: reiner Text ohne Platzhalter, deterministisch, Sie-Form, nichts zu Waren', () => { + const text = funktion() + assert.equal(typeof text, 'string') + assert.ok(text.startsWith('Widerrufsbelehrung\n\nWiderrufsrecht\n')) + assert.ok(text.includes('\n') && !text.includes('\r')) + assert.ok(!/\{\{|\}\}|\[|\]|…|\.\.\.|\(\s*\)/.test(text), 'offener Platzhalter') + assert.ok(!/undefined|null|NaN/.test(text)) + assert.ok(!/Rücksendung|zurückzusenden|Besitz genommen|Wir holen die Waren|Sie haben die Waren|elektronisch ausfüllen|Wir können die Rückzahlung verweigern/.test(text)) + assert.ok(!DU_WOERTER.test(text)) + assert.ok(!/&|<[a-z/]/i.test(text)) + assert.equal(funktion(), funktion()) + // Gäste-Fassung und Funktionen-Fassung unterscheiden sich nur in Kontakt, Widerrufsfunktion und „An“-Zeile + const gast = widerrufsbelehrung(WEBKLAR_KONTAKT) + assert.notEqual(gast, text) + const ohneUnterschied = (t) => t.replace(/uns \([^)]*\)/, 'uns (K)').replace(/Sie können Ihr Widerrufsrecht auch online unter .*? ausüben\./, 'WF').replace(/– An [^\n]*/, '– An A') + assert.equal(ohneUnterschied(gast), ohneUnterschied(text)) +}) + +/** Text eines HTML-Ausschnitts: Tags weg, Entitäten der Seite aufgelöst, Leerraum zu einem Leerzeichen. */ +const klartext = (html) => + html + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim() + +test('belehrung_gleich_abo: Belehrung der Funktionen = Belehrung in den Abo-Bedingungen (Drift-Wächter), Muster-Formular ebenso', () => { + const html = lies('public/abo-bedingungen.html') + const von = html.indexOf('

    Widerrufsrecht

    ') + const bis = html.indexOf('Ende der Widerrufsbelehrung') + assert.ok(von > 0 && bis > von, 'Aufbau der Abo-Bedingungen hat sich geändert') + // Die Kontaktklammer wird vorher in beiden durch einen Platzhalter ersetzt: die Gäste-Bausteine setzen „Telefon: …, E-Mail: …“ mit + // Doppelpunkt, die Abo-Bedingungen ohne + const klammer = (t) => t.replace(/uns \([^)]*\)/, 'uns (KONTAKT)') + const abo = klammer(klartext(html.slice(von, bis))) + const text = funktion() + const formularKopf = text.indexOf('\n\nMuster-Widerrufsformular\n\n') + assert.ok(formularKopf > 0) + const textBelehrung = text.slice(text.indexOf('Widerrufsrecht'), formularKopf) + assert.equal(klammer(klartext(textBelehrung)), abo) + + // Muster-Widerrufsformular: Kasten der Abo-Bedingungen gegen die Formularzeilen, „An“-Zeile vorher ersetzt + const formVon = html.indexOf('

    Muster-Widerrufsformular

    ') + const formBis = html.indexOf('

    ') + assert.ok(formVon > bis && formBis > formVon) + const an = (t) => t.replace(/– An .*?:(?= – Hiermit)/, '– An ANSCHRIFT:') + const aboForm = an(klartext(html.slice(formVon, formBis)).replace(/^Muster-Widerrufsformular /, '')) + const textForm = an(klartext(text.slice(formularKopf + 2).replace(/^Muster-Widerrufsformular\n+/, ''))) + assert.equal(textForm, aboForm) + assert.ok(aboForm.includes('– An ANSCHRIFT:') && textForm.includes('– An ANSCHRIFT:')) +}) + +// ---------------------------------------------------------------- Dokument widerrufsbelehrung-funktionen.md + +const DOKU = 'docs/rechtliches/widerrufsbelehrung-funktionen.md' + +/** Absätze eines Textes: durch Leerzeilen getrennt, jede Zeile eines Absatzes für sich (Formularzeilen), Überschriften-Zeichen weg. */ +const absaetze = (text) => + text + .replace(/\r/g, '') + .split(/\n{2,}/) + .flatMap((block) => block.split('\n')) + .map((z) => z.replace(/^#+\s+/, '').trim()) + .filter(Boolean) + +test('belehrung_dokument_gepinnt: Textblock der Datei = Ausgabe von widerrufsbelehrungFunktion(), Kopf mit Fassung, keine Platzhalter', () => { + const md = lies(DOKU).replace(/\r/g, '') + const zeilenMd = md.split('\n') + assert.equal(zeilenMd[0], '# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)') + assert.equal(zeilenMd[2], `**Fassung ${config.termine.avvVersion}**`) + assert.ok(!/\[|\]|…|\{\{|\}\}/.test(md), 'offener Platzhalter') + assert.ok(!/ENTWURF|Entwurf|Prüfpunkt|TODO|*gFZpHRIr*l7C&@)w7kxAD8ALtWkYH1m`Bj)5;2uNgh%e)!a3Q>e z6a-Vh#V_FG9{hZg2CuR;VllH|`3TTPJ$g z)-3Ug?02aKI2X(91AECaJ>Rr|a>2!Pg{Pg1Q1R2L52(6+RAWGfN)yxv{-URul#^1WA; z`m#tNi)GHprrrVGK5Dj*jL`8!7+ZJF<#4|WnWs9hf}4<{FGEPpfJ&xiCc z#l%H~->15KSSmlRfHyC(?lt_&f)z7uC&hbL*K^rE&)@ifJPHK-?;a0Y)E+NpkB?jK zTG27>h%vBiUav1#TGSZCHIGba)Wv<*0iNw>VzZ;5LR9RtzE?!TMyHirU*M}V3q?&k z@;k6+Qox}|W!PsYhsaPoLMxH)wWVN4pfuiyME2T&^oIlFdoSJ8JxzaH45O&!J1>TE zt)#ua-&7vJH$QZ>X%}Q9AX$}<)z7Y53DItz-Y3f%O%qBj$ zyuyQkzk~ZVKXa*WqpBFa9-Esabn(2WnNYNTHdLy`ZW8dJ#7;7EaElZMY;-R0PORDI z%$EtxCv~+P`nZ9OUBV-#LJ-G`pL8WbWbUu<<|(n$BQL`mnCmr&A!3Eb4g*Uax<0&` zb%l_uH2qA?m~cunQJVs*tzBhj^1%}PAL99x~^KWQ=C|Qv5F^b#h=XY7Md39?H-NPnuX)#icy94 z{zf{Q`z)6)kUD-W!ElIt#*Ua~GVxJvv@fn^3OW2gqP67JNVR{V(}%Kdjkcl%I;}OJ zU{ezf{59ZGp(AOQ7}&ETI@B)Tc13!A)u3*xZZL{7iiiWk^B z2Rlf~X-|MQOedpVMG!O+zfd3kbxQZG(O6i1euC-BK+7IooZ@@DeSu^B>(J7rPn4sB z&^kcJ0G~}!WYjT5Vq-V{h47M%{Fc>9nxS>vgl{~T$mNG|Jua6Ph0>@=8(=GkfhBDq z^ znMmg<=$E;v+Z+Iw{J63!i`47^QzdZ^NX>Xa_FvfWcbc50^GKz@d+rBYJ|?tQEOssU zw+TP~m{m;#7qhnQ*6RyzuWH=IarHN| zEP+4aX5eR3))9~y+p)YJ4TW58{l6@SjY481WtyJdp_O$nA{BI@8kGgE|8Oycb9H#xU2E4(F41klUSnsW zaZwy(b8M%8-(OQx1H1 z6*=OKZ&5gyiD$}xD4gSD&6(~cH6T3r>+COa`M6DB6O{N{hMSinQHg_1WuiDnG{gX9 zy_;c*#{Nr%eAP2wYOqPv+w1rF6@6q9{pL(N@|%Q_tK8K+6pBbR zLxC(^(?y*=09mE@+aiUf4OIOrYMO;$#O})sP4>$9P(c)04f(Z6FPZPtgRpr>Z3pvK z`uM6_2Xb?;1;4_@gsS*az;|zx%68&CixO>9^?r>XqM>nM8v#-(b@_5-h67YFE*pvTw<$XPw^L+yW zZ_Lsrqf6yyT|P_#N^cIiIfBsz`8nJBYS-ONfnzuT$e%9Of#Xj)c0{fd64VM(^gWma z2g&Bb11EZ$EC^jeZvFH!q=@JuZGil3f5Ec`fJvxWQ}4Rb){pzEwG__-sTa^V9yatG zjZ@;6qu<7#iR(wdf#Jt5giDC8j)Y##oX^ON!`XDPcnZy1WL76my=M4q5FzxSdB62o zOa+)6j2-O}MfpVIrA_7>(pbn}aDnPj6ZTlilHARAUNZQrp{foBP)^xIb{r(wIq8H2 zo}9n^l@gcZ3opB`3h1{LmWTSd5@hId-rXcUD#J*f)`B-5(gs#UerVZ}CA>yUhY*AJ z(kMZRl={OH-M?TutAViuZXGvb10_HxonLXDWbaWiL9j$CexWD(h8Khhvh7*>soNXQ zL6>umZs9l&e<~{oDUj3F@1kArA;CVbv@PH2A z5~jee!6zwH!z8&{T$AJJs4x>kL`5DHfM_{D8W95NAI=JL`LN#fUTuW*=A8BU`u0=J zeiI8Z87G@B{xfeo*rf~^0|EBZo3~MjBucX4-;QkqBwjj+9W*Ft;-hENPkSs8dr0Fw4MJ;Jl)HasOnaJICb?p0@M00X(@dm6CR#0;1xOtp87(-}%F|3}UY36u5T`y!`~zgLP}N1dhq$=Q(qWhSaHrxX8So~`aU zzC%5y;`w-23t!;z)6^^L zdc7@C@U`UFnyN8PtT*8-Mdai0!%PAndLadmkjFu7^QFac(0p?Qw$5#SGYya#V=?+6 zOr7=N;>ytJk%8WSETjh+VG!ywoT8IcdL~IDPHGNHHW00@v-NCkBAZLMpl(&@!4Sbh z=WQ5c|k+3^~)RTA}0A1-E7jR_kMW1c;$)J4xXHAzK!`Yyf-SXm`40k_QXg}F|hqNwvrq^%e(0H0qmxEPfcxijj)*2D!E~m z22Bs#Zt+5*pl_y4h4%i8x8=!_&s3f$ldK}3sHZ2jw19L_AT=OJPB+1e%wTMzOh&KJ zz6I@>3^j$PhAQYU=77V;?^C#MFpNJP=F4ygf3M75^U7YET>tqx$NGk)1evw(bzyeQzueShA%gry-hgvIs&Q+13Da|;fUO4TD30^eYmSwTO=g#j!;1J4BQsY6Q7F8@9ys zKOx|?^S%cEt8w%$&k|X@#t_2Lztp>m9)ij>>JsD(;uQm zq;Kv-m0|Q-he>N`XoH4%Ejpn9jWb?qQliEQo2;6>X`o(GQ#XsJ7xE5i&uruwqdGatyeTO! zm;FTY4B6f}*Qn`^C}aCm9^X(m-~oJe1ufSweQEw>U8-~C-*<%OeB;TE;(p@}+*2O^ zk^=aXFh;zxJw;U7OvV5znW=l+yzE&YMTA1oat9nmhY*-DaWv`}B?PDMhp{?DIP9mD z8&9m51yR+Ki_8(9FLKpW=F3$#v_Y|+SwPW}odU$jFGA^Su*&^x!*U%LXc7?07D@)s zc&XMW`<|nZfGKJ|qUEOOSE|?TReRZ}JS8)rAX9MzL#&l_r{W7VNuY&q^T@Gkr-;oBeW77*2gEk0}3&Fd+WAEKj(xd}A{vfyt&cFa%mW)sUc)Y=BW-oQ$S$op%@UjfxCx>~@WGpjCgl zJU#?slZiK)0DIlBaN25T$4eRHwUXze3R{9yx6Pz>8kdQQgh;;(+zr{{v0xWu^}=`X zzA0rHRPHcx3R08;L)f$Ca@t$#EN04?3x};{PMXH}vu6r!emJ1ADDAdXDNxw&K?IKX zuW-Lc2YeYuH?C7Th|ha+)hz7*9^X;gU8>a;)4Hf15d2E36$8Q!2G(d1^r(bTmmqYe ztOtjq<}W4dFB{*D(kAU2=>opMuPsDqQ}m*qWn5vn&kW#0sw+l7Y7}Nkqdz1!43Mgq zMWg6H0@UWN5t}yr&ix{(i_)}oDtPbu)_6pZvAzh%3woq*m`Q%#NnZiq?;rRtajJj20NGi}pj(m^T*DYKJEiN^8>ul+wM@iQn0N zqjY*UnVN+#p`$$%ohALIQ$4Y@BlB-7BVhI6b^ z-sz+!9OyWREzU$^rZvg%)q81Bw6Ty!SH2>FM`hxB2 zv|HS$vEP6Ype>*zzqGkpt4V$uqy+e}RzMTIN?_Yqi$hk1zK@l#<03jF#4Mt^z&Ez$vRts_eG^`ap9;Q8N+@9Wjt=~B(+`{PqAvCq@voPYqT@AK`+R9_}XA1k1A zQE%>zw`{93bCVITCNSx>AFLC+%Y+*vy_d{VuZ2D`wfJ`>5C4Jcca7v_9>;VY)RTX_ z6t%+cS#pG36aYkL=Jyaq^2ai>55ZZDq@(Z;*QEk9zTHiR6!=qAG=gvu;cfL A3V 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 literal 0 HcmV?d00001 diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html new file mode 100644 index 0000000..2117d95 --- /dev/null +++ b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html @@ -0,0 +1,179 @@ + + + + + + + Leistungsbeschreibung Terminplaner – WEBklar + + + + + + diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ebacd9c0b77b80de4a78a79d722a404b064f4a05 GIT binary patch literal 81210 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 hYD)` + + + + + + Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen) – WEBklar + + + +
    + + +
    WEBKLAR
    +

    Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)

    + +

    Fassung 2026-10-05

    + +

    Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular.

    + +

    Widerrufsbelehrung

    + +

    Widerrufsrecht

    + +

    Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.

    + +

    Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.

    + +

    Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.

    + +

    Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.

    + +

    Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.

    + +

    Folgen des Widerrufs

    + +

    Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.

    + +

    Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.

    + +

    Muster-Widerrufsformular

    + +

    (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)

    + +

    – An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:

    + +

    – Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)

    + +

    – Bestellt am (*)/erhalten am (*)

    + +

    – Name des/der Verbraucher(s)

    + +

    – Anschrift des/der Verbraucher(s)

    + +

    – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)

    + +

    – Datum

    + +

    (*) Unzutreffendes streichen.

    + +

    Hinweise zum Widerruf

    + +

    Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt.

    + +
      +
    • Das Widerrufsrecht gilt für jede Bestellung einer Funktion gesondert, also für jedes Website-Projekt, für das der Kunde eine Funktion bestellt. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss.
    • +
    • Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt.
    • +
    • Hat WEBklar auf ausdrücklichen Wunsch des Kunden vor Ablauf der Widerrufsfrist mit der Einrichtung begonnen, schuldet der Kunde bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung. Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung.
    • +
    • Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe.
    • +
    +
    + + diff --git a/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf b/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf new file mode 100644 index 0000000000000000000000000000000000000000..076fd1a13b8ea15cbc08772af5c52d04171769dc GIT binary patch literal 53665 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 S2&@3YlNFJYQb<}D@xK5D{dZyj literal 0 HcmV?d00001 diff --git a/scripts/build-legal-html.mjs b/scripts/build-legal-html.mjs new file mode 100755 index 0000000..0a250fd --- /dev/null +++ b/scripts/build-legal-html.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +// HTML-Generator der Rechtstexte: rendert die Markdown-Quellen unter docs/rechtliches/ deterministisch in die Seiten unter +// public/rechtliches/-.html (dieselbe Seitenhülle wie die Seiten der Website-Analyse). Die PDFs entstehen +// danach aus diesen Seiten (scripts/build-legal-pdfs.sh termine). +// +// node scripts/build-legal-html.mjs termine schreibt die drei Seiten des Terminplaners +// node scripts/build-legal-html.mjs termine --check vergleicht mit den eingecheckten Seiten (Exit 1 bei Abweichung, schreibt nichts) +// +// Fassung = config.termine.avvVersion. Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und +// von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ### +// (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen). +// Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab, +// damit nie eine Seite mit falsch dargestelltem Rechtstext entsteht. +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { config } from '../server/config.js' +import { legalPaths } from '../server/services/legalDocs.js' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const FASSUNG_RE = /^\d{4}-\d{2}-\d{2}$/ + +export class MarkdownFehler extends Error { + constructor(zeile, was) { + super(`Markdown, Zeile ${zeile}: nicht unterstützt (${was})`) + this.name = 'MarkdownFehler' + } +} + +const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } +/** HTML-Escape für Text und Attributwerte. */ +export const esc = (text) => String(text).replace(/[&<>"']/g, (z) => ESCAPES[z]) + +/** Zeichenkette für ein CSS-content (in doppelten Anführungszeichen). */ +const cssText = (text) => String(text).replace(/[\\"\n]/g, (z) => (z === '\n' ? '\\A ' : `\\${z}`)) + +// ---------------------------------------------------------------------------------------------------------- Markdown + +/** Text außerhalb von `code`: rohes HTML (ein "<" unmittelbar vor Buchstabe, "/", "!" oder "?") bricht ab, alles andere wird maskiert. */ +function klartext(text, zeile) { + if (/<[A-Za-z/!?]/.test(text)) throw new MarkdownFehler(zeile, 'rohes HTML') + return esc(text) +} + +/** Inline-Teil: **fett**, `code`, sonst Text. Nur Text ohne Verschachtelung; alles andere wird escaped oder bricht ab. */ +function inline(text, zeile) { + if (/!\[|\]\(/.test(text)) throw new MarkdownFehler(zeile, 'Bild oder Link') + if (text.includes('***')) throw new MarkdownFehler(zeile, '***') + let aus = '' + let i = 0 + while (i < text.length) { + if (text.startsWith('**', i)) { + const ende = text.indexOf('**', i + 2) + if (ende < 0) throw new MarkdownFehler(zeile, 'offenes **') + const innen = text.slice(i + 2, ende) + if (innen === '' || innen.includes('`')) throw new MarkdownFehler(zeile, 'leeres oder verschachteltes **fett**') + aus += `${klartext(innen, zeile)}` + i = ende + 2 + } else if (text[i] === '`') { + const ende = text.indexOf('`', i + 1) + if (ende < 0) throw new MarkdownFehler(zeile, 'offenes `') + if (ende === i + 1) throw new MarkdownFehler(zeile, 'leerer Code') + aus += `${esc(text.slice(i + 1, ende))}` + i = ende + 1 + } else { + let ende = i + while (ende < text.length && text[ende] !== '`' && !text.startsWith('**', ende)) ende += 1 + aus += klartext(text.slice(i, ende), zeile) + i = ende + } + } + return aus +} + +/** Zerlegt Markdown in Blöcke { art: 'h'|'p'|'ul'|'table', … }; unbekannte Syntax wirft MarkdownFehler. */ +export function parse(md) { + if (typeof md !== 'string') throw new TypeError('parse: Markdown als Text erwartet') + const zeilen = md.replace(/\r/g, '').split('\n') + const bloecke = [] + let offen = null // der Block, an den die nächste Zeile anschließt (p | ul | table) + const schliesse = () => { + offen = null + } + zeilen.forEach((roh, index) => { + const nr = index + 1 + if (roh.trim() === '') return schliesse() + if (/^\s/.test(roh)) throw new MarkdownFehler(nr, 'Einrückung') + if (/^(```|~~~)/.test(roh)) throw new MarkdownFehler(nr, 'Codeblock') + if (roh.startsWith('>')) throw new MarkdownFehler(nr, 'Zitat') + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(roh)) throw new MarkdownFehler(nr, 'Trennlinie') + if (/^[*+]\s/.test(roh)) throw new MarkdownFehler(nr, 'Liste mit * oder +') + if (/^\d+[.)]\s/.test(roh)) throw new MarkdownFehler(nr, 'nummerierte Liste') + if (roh.startsWith('#')) { + const m = /^(#{1,3}) (\S.*)$/.exec(roh) + if (!m) throw new MarkdownFehler(nr, /^#{4,}/.test(roh) ? 'Überschrift ab Ebene 4' : 'Überschrift ohne Leerzeichen') + schliesse() + bloecke.push({ art: 'h', ebene: m[1].length, html: inline(m[2].trim(), nr), zeile: nr }) + return + } + if (roh.startsWith('- ')) { + const text = roh.slice(2).trim() + if (text === '') throw new MarkdownFehler(nr, 'leerer Listenpunkt') + if (offen?.art !== 'ul') { + offen = { art: 'ul', punkte: [], zeile: nr } + bloecke.push(offen) + } + offen.punkte.push(inline(text, nr)) + return + } + if (roh.startsWith('|')) { + if (!roh.trimEnd().endsWith('|')) throw new MarkdownFehler(nr, 'Tabellenzeile ohne schließendes |') + const zellen = roh.trim().slice(1, -1).split('|').map((z) => z.trim()) + if (offen?.art !== 'table') { + offen = { art: 'table', kopf: zellen.map((z) => inline(z, nr)), trenn: false, zeilen: [], zeile: nr } + bloecke.push(offen) + return + } + if (!offen.trenn) { + if (zellen.length !== offen.kopf.length || !zellen.every((z) => /^:?-{3,}:?$/.test(z)) || zellen.some((z) => z.includes(':'))) { + throw new MarkdownFehler(nr, 'Tabelle: zweite Zeile muss die Trennzeile |---|---| sein (ohne Ausrichtung)') + } + offen.trenn = true + return + } + if (zellen.length !== offen.kopf.length) throw new MarkdownFehler(nr, 'Tabellenzeile mit anderer Spaltenzahl') + offen.zeilen.push(zellen.map((z) => inline(z, nr))) + return + } + // Absatz (aufeinanderfolgende Zeilen gehören zusammen) + if (offen?.art === 'p') { + offen.html += ` ${inline(roh.trim(), nr)}` + return + } + offen = { art: 'p', html: inline(roh.trim(), nr), zeile: nr } + bloecke.push(offen) + }) + for (const b of bloecke) { + if (b.art === 'table' && !b.trenn) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Trennzeile') + if (b.art === 'table' && b.zeilen.length === 0) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Zeilen') + } + return bloecke +} + +// ---------------------------------------------------------------------------------------------------------- Seitenhülle + +const STYLE = (seitenkopf) => ` :root { color-scheme: light; } + * { box-sizing: border-box; } + body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } + main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; } + .brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; } + h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; } + h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; } + h3 { font-size: 16.5px; margin: 22px 0 6px; } + p, li { margin: 6px 0; } + ul { padding-left: 24px; } + .meta { color: #6b6b70; font-size: 14px; } + .box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; } + .box.widerspruch { border: 2px solid #6d1fd1; } + table { border-collapse: collapse; width: 100%; font-size: 15px; } + td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; } + th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; } + a { color: #6d1fd1; } + nav.top { font-size: 14px; margin-bottom: 22px; } + .offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; } + @media print { + @page { size: A4; margin: 18mm 17mm 18mm; + @top-left { content: "WEBklar · ${cssText(seitenkopf)}"; font: 8pt sans-serif; color: #888; } + @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } } + body { background: #fff; font-size: 10pt; line-height: 1.45; } + main { max-width: none; padding: 0; } + nav.top { display: none; } + h1 { font-size: 20pt; } + h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; } + a { color: inherit; text-decoration: none; } + .box, tr, ol.abs > li { break-inside: avoid; } + } +` + +/** Adresse eines Links der Navigation: relativ zur Wurzel ("/…", nicht "//") oder https. */ +function pruefeHref(href) { + if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>]/.test(href)) { + throw new Error(`render: ungültige Adresse in der Navigation: ${String(href).slice(0, 60)}`) + } + return href +} + +function bloeckeHtml(bloecke) { + const aus = [] + bloecke.forEach((b, i) => { + if (b.art === 'h') { + if (b.ebene === 1) { + if (i !== 0) throw new MarkdownFehler(b.zeile, '# nur als erste Zeile') + aus.push(`
    WEBKLAR
    \n

    ${b.html}

    `) + } else { + aus.push(`${b.html}`) + } + } else if (b.art === 'p') { + aus.push(`${b.html}

    `) + } else if (b.art === 'ul') { + aus.push(`
      \n${b.punkte.map((p) => `
    • ${p}
    • `).join('\n')}\n
    `) + } else { + aus.push( + [ + '', + `${b.kopf.map((z) => ``).join('')}`, + '', + ...b.zeilen.map((zeile) => `${zeile.map((z) => ``).join('')}`), + '', + '
    ${z}
    ${z}
    ', + ].join('\n '), + ) + } + }) + return aus.join('\n\n ') +} + +/** + * Rendert ein Dokument in die Seitenhülle der Rechtstexte. + * @param {string} md Markdown (Teilmenge, siehe Kopf); erste Zeile "# Titel", danach der Absatz "**Fassung JJJJ-MM-TT**[ · Zusatz]" + * @param {{ fassung: string, titel: string, seitenkopf: string, nav: Array<{ href: string, text: string }> }} meta + * titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · " + * @returns {string} HTML mit abschließendem Zeilenumbruch + */ +export function render(md, meta) { + if (!meta || !FASSUNG_RE.test(String(meta.fassung))) throw new Error('render: meta.fassung muss JJJJ-MM-TT sein') + for (const feld of ['titel', 'seitenkopf']) { + if (typeof meta[feld] !== 'string' || meta[feld].trim() === '') throw new Error(`render: meta.${feld} fehlt`) + } + if (!Array.isArray(meta.nav) || meta.nav.length === 0) throw new Error('render: meta.nav fehlt') + const bloecke = parse(md) + if (bloecke[0]?.art !== 'h' || bloecke[0].ebene !== 1) throw new MarkdownFehler(1, 'die erste Zeile muss "# Titel" sein') + if (bloecke.filter((b) => b.art === 'h' && b.ebene === 1).length !== 1) throw new MarkdownFehler(1, 'genau eine Überschrift #') + if (bloecke[1]?.art !== 'p' || !bloecke[1].html.startsWith(`Fassung ${meta.fassung}`)) { + throw new MarkdownFehler(3, `der Absatz nach der Überschrift muss mit **Fassung ${meta.fassung}** beginnen`) + } + const nav = meta.nav.map((l) => `${esc(l.text)}`).join(' · ') + return ` + + + + + + ${esc(meta.titel)} – WEBklar + + + +
    + + + ${bloeckeHtml(bloecke)} +
    + + +` +} + +// ---------------------------------------------------------------------------------------------------------- Dokumentsätze + +/** Namen je Dokument des Terminplaners (Schlüssel wie in legalPaths: lb, avv, widerruf). */ +const TERMINE_NAMEN = { + lb: { titel: 'Leistungsbeschreibung Terminplaner', seitenkopf: 'Leistungsbeschreibung Terminplaner' }, + avv: { titel: 'Auftragsverarbeitungsvertrag Terminplaner', seitenkopf: 'AVV Terminplaner' }, + widerruf: { titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', seitenkopf: 'Widerrufsbelehrung Funktionen' }, +} +const NAV_NAMEN = { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' } + +/** + * Die drei Dokumente des Terminplaners in der Fassung: { doc, md, html, meta } mit Pfaden relativ zur Wurzel des Projekts. + * Quelle docs/rechtliches/.md, Ziel public/rechtliches/-.html (Slugs und Adressen aus legalDocs.js). + */ +export function termineDokumente(fassung = config.termine.avvVersion) { + const adressen = legalPaths('termine', fassung) + const nav = [ + { href: '/dashboard.html', text: '← Kundenportal' }, + { href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' }, + ...Object.keys(adressen).map((doc) => ({ href: adressen[doc], text: NAV_NAMEN[doc] })), + { href: '/datenschutz.html', text: 'Datenschutzerklärung' }, + { href: 'https://webklar.com/impressum', text: 'Impressum' }, + ] + return Object.entries(adressen).map(([doc, adresse]) => ({ + doc, + md: `docs/rechtliches/${basename(adresse, `-${fassung}.html`)}.md`, + html: `public${adresse}`, + meta: { fassung, ...TERMINE_NAMEN[doc], nav }, + })) +} + +// ---------------------------------------------------------------------------------------------------------- Aufruf + +/** + * Kommandozeile: `termine [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien). + * => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf + */ +export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) { + const [satz, ...optionen] = argv + const pruefen = optionen.includes('--check') + if (satz !== 'termine' || optionen.some((o) => o !== '--check')) { + fehler('Aufruf: node scripts/build-legal-html.mjs termine [--check]') + return 2 + } + let abweichungen = 0 + for (const d of termineDokumente()) { + const html = render(readFileSync(join(root, d.md), 'utf8'), d.meta) + const ziel = join(root, d.html) + if (pruefen) { + const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === html + log(`${gleich ? 'ok ' : 'ABWEICHT'} ${d.html}`) + if (!gleich) abweichungen += 1 + } else { + writeFileSync(ziel, html) + log(`-> ${d.html}`) + } + } + return abweichungen > 0 ? 1 : 0 +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + process.exitCode = lauf(process.argv.slice(2)) + } catch (err) { + console.error(err.message) + process.exitCode = 1 + } +} diff --git a/scripts/build-legal-pdfs.sh b/scripts/build-legal-pdfs.sh index 950a8b6..b90739c 100755 --- a/scripts/build-legal-pdfs.sh +++ b/scripts/build-legal-pdfs.sh @@ -1,15 +1,19 @@ #!/usr/bin/env bash # Erzeugt die Druck-/PDF-Fassungen der Vertragsunterlagen aus den HTML-Quellen. # Laeuft auf dem Host (braucht Chrome/Chromium), die PDFs werden eingecheckt. -# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse] +# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|datenschutz] # analyse = nur AVV und Leistungsbeschreibung der Website-Analyse (Fassung ANALYSE_VERSION), andere PDFs bleiben unveraendert +# termine = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners (Fassung TERMINE_VERSION, Standard: config.termine.avvVersion); +# die HTML-Seiten kommen vorher aus `node scripts/build-legal-html.mjs termine` +# datenschutz = nur die Datenschutzerklaerung (docs/WEBklar-Datenschutzerklaerung.pdf) +# Vorher `free -m` pruefen und mit `nice` aufrufen; jeder Chrome-Aufruf laeuft hoechstens 120 Sekunden (timeout), die PDFs nacheinander. set -euo pipefail cd "$(dirname "$0")/.." CHROME="${CHROME:-$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome 2>/dev/null | tail -1)}" [ -x "$CHROME" ] || { echo "Chrome nicht gefunden (CHROME=...)"; exit 1; } pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF) - "$CHROME" --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer \ + timeout 120 "$CHROME" --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer \ --virtual-time-budget=3000 --print-to-pdf="$2" "file://$PWD/$1" 2>/dev/null echo "-> $2" } @@ -17,11 +21,31 @@ pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF) MODE="${1:-all}" ANALYSE_VERSION="${ANALYSE_VERSION:-2026-10-02}" +# Fassung des Terminplaners: TERMINE_VERSION, sonst die Konstante des Servers (kein zweites Datum im Skript) +termine_version() { + local v="${TERMINE_VERSION:-}" + if [ -z "$v" ]; then + v="$(node --max-old-space-size=256 --input-type=module -e "const { config } = await import('./server/config.js'); console.log(config.termine.avvVersion)")" + fi + [[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; } + echo "$v" +} + case "$MODE" in analyse) pdf "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.pdf" pdf "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.pdf" ;; +termine) + TERMINE_VERSION="$(termine_version)" + for slug in leistungsbeschreibung-terminplaner avv-terminplaner widerrufsbelehrung-funktionen; do + [ -f "public/rechtliches/$slug-$TERMINE_VERSION.html" ] || { echo "HTML fehlt: public/rechtliches/$slug-$TERMINE_VERSION.html (zuerst node scripts/build-legal-html.mjs termine)"; exit 1; } + pdf "public/rechtliches/$slug-$TERMINE_VERSION.html" "public/rechtliches/$slug-$TERMINE_VERSION.pdf" + done + ;; +datenschutz) + pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf + ;; all) pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf # Fassung 3 (public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf, Archiv unter public/vertragsunterlagen/v3/) wird nicht neu erzeugt @@ -29,6 +53,6 @@ all) pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf ;; *) - echo "Usage: $0 [all|analyse]"; exit 1 + echo "Usage: $0 [all|analyse|termine|datenschutz]"; exit 1 ;; esac diff --git a/test/buildLegalHtml.test.js b/test/buildLegalHtml.test.js new file mode 100644 index 0000000..daeb5fd --- /dev/null +++ b/test/buildLegalHtml.test.js @@ -0,0 +1,337 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import express, { Router } from 'express' +import { useTestEnv } from './support/testEnv.js' + +// HTML-Generator der Rechtstexte (scripts/build-legal-html.mjs) und die ausgelieferten Dateien des Terminplaners (Task F6). +// Die Markdown-Quellen stehen unter docs/rechtliches/, die Seiten und PDFs unter public/rechtliches/-.html|pdf. + +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { startTestApp } = await import('./support/testApp.js') +const { legalPaths, legalPdfFiles, legalPdfAttachments, legalRouter } = await import('../server/services/legalDocs.js') +const { render, parse, lauf, termineDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs') + +const V = config.termine.avvVersion +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8') +const DOKUMENTE = termineDokumente(V) + +const META = { fassung: '2026-10-05', titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] } +const KOPF = '# Titel\n\n**Fassung 2026-10-05**\n\n' + +/** HTML in Klartext: Tags weg, Entitäten zurück, Leerraum normalisiert. */ +const klartext = (html) => + html + .replace(/
    + + +
    WEBKLAR
    +

    Leistungsbeschreibung Terminplaner

    + +

    Fassung 2026-10-05 · Zusatzbedingungen zum Wartungs-Abo

    + +

    1. Geltung

    + +

    (1) Diese Leistungsbeschreibung regelt die Funktion „Terminplaner“ im Wartungs-Abo. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht. Sie wird vereinbart, wenn der Kunde die Einrichtung des Terminplaners im Kundenportal bestellt und dabei dieser Leistungsbeschreibung, dem Auftragsverarbeitungsvertrag Terminplaner („AVV Terminplaner“) und der Widerrufsbelehrung zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Terminplaner.

    + +

    (2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.

    + +

    (3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz nach Nr. 4 Abs. 9 und die Gästetexte nach Nr. 2 Abs. 6. Beides ist Teil des Terminplaners.

    + +

    2. Leistung

    + +

    (1) Der Terminplaner ist ein Buchungs-Widget für die Website des Kunden mit einer Verwaltung im Kundenportal. WEBklar baut je Website-Projekt ein Widget an einer Stelle der Website ein, entweder eingebettet in die Seite oder hinter einem Knopf. Die Gestaltung richtet sich nach dem Fragebogen, den der Kunde vor der Bestellung im Kundenportal beantwortet.

    + +

    (2) Der Kunde legt seine Leistungen, seine Personen oder Plätze, seine Zeiten und seine Regeln für Buchungen fest. Gäste buchen im Widget einen Termin; je nach Einstellung ist die Buchung verbindlich oder eine Anfrage.

    + +

    (3) Der Terminplaner versendet im Auftrag des Kunden E-Mails an die Gäste: den Bestätigungslink, die Bestätigung, Änderungen, Absagen, auf Wunsch des Gastes eine Erinnerung (nur mit seinem Haken) und die Eingangsbestätigung eines Widerrufs. Den Kunden benachrichtigt er über neue und geänderte Buchungen.

    + +

    (4) Der Kunde sieht und verwaltet seine Termine im Kundenportal und kann sie als CSV-Datei exportieren. Dazu erhält er einen geheimen Kalender-Link (ICS), mit dem er seine Termine in einem Kalenderprogramm abonnieren kann.

    + +

    (5) Der Terminplaner enthält einen Missbrauchsschutz gegen automatisierte Buchungen. Die Löschfristen für Buchungsdaten nennt Nummer 9 des AVV Terminplaner.

    + +

    (6) Bezahlt wird nur vor Ort. Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz für die Website des Kunden und die Widerrufsbelehrung für Gäste sind Mustertexte der Standardkonfiguration (Nr. 11 Abs. 1).

    + +

    3. Nicht enthalten

    + +

    (1) Nicht enthalten sind SMS, Online-Zahlung, ein Kalender-Abgleich (es gibt nur das Abonnement des Kalender-Links nach Nr. 2 Abs. 4), Konten für Gäste, Marketing-E-Mails, Gruppentermine, Mehrsprachigkeit und der Einsatz im Gesundheitsbereich (Nr. 4 Abs. 3).

    + +

    (2) Zusätzliche Wünsche, etwa ein zweites Widget, Knöpfe je Leistung oder ein Sonderdesign, gehören nicht zur Einrichtung nach Nr. 5. WEBklar führt sie nach Stunden aus (§ 4 der Abo-Bedingungen).

    + +

    4. Voraussetzungen und Pflichten des Kunden

    + +

    (1) Der Kunde hat ein laufendes Wartungs-Abo.

    + +

    (2) Der Kunde trägt vor der Bestellung seine Betriebsangaben ein, dazu die Adresse seines Impressums und die Adresse seiner Datenschutzerklärung.

    + +

    (3) Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt, weil dort besondere Kategorien personenbezogener Daten anfallen können (Art. 9 DSGVO). Der Kunde setzt ihn dort nicht ein und fordert von seinen Gästen keine Gesundheitsangaben. WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.

    + +

    (4) Richtet sich das Angebot des Kunden gezielt an Kinder unter 16 Jahren, nutzt der Kunde den Terminplaner nicht.

    + +

    (5) Der Kunde nennt eine Telefonnummer, unter der Gäste Termine vereinbaren können, wenn das Widget nicht erreichbar ist (Nr. 6 Abs. 2).

    + +

    (6) Er lässt die Texte der Haken und Hinweise im Widget unverändert.

    + +

    (7) Er informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt.

    + +

    (8) Die Pflichten gegenüber seinen Gästen trägt der Kunde: Preisangaben, Allgemeine Geschäftsbedingungen und Stornobedingungen sowie die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung.

    + +

    (9) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für die Online-Terminbuchung ein; das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.

    + +

    5. Einrichtung und Preis

    + +

    (1) Die Einrichtung umfasst die Einrichtung nach dem Fragebogen, den Einbau des Widgets in die Website, den Datenschutz-Absatz nach Nr. 4 Abs. 9, die Abnahme durch den Kunden und die Freischaltung durch WEBklar.

    + +

    (2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG.

    + +

    (3) Die voraussichtliche Dauer der Einrichtung nennt die Bestellseite vor der Bestellung. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins.

    + +

    (4) Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.

    + +

    (5) Der Preis steht neben dem Stundenkontingent des Wartungs-Abos: Die Einrichtung wird weder auf das Kontingent angerechnet noch damit bezahlt. Für den Betrieb des Terminplaners berechnet WEBklar neben dem Wartungs-Abo nichts weiter; er läuft, solange das Wartungs-Abo läuft (Nr. 8).

    + +

    6. Verfügbarkeit und Störungen

    + +

    (1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Terminplaner. Störungen des Terminplaners beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).

    + +

    (2) Ist der Terminplaner nicht erreichbar, zeigt das Widget einen Ausweichtext mit der Telefonnummer des Kunden (Nr. 4 Abs. 5).

    + +

    (3) Alle Zeiten gelten in der Zeitzone Europe/Berlin.

    + +

    (4) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.

    + +

    (5) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.

    + +

    7. Haftung für verpasste Termine

    + +

    (1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden.

    + +

    (2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4).

    + +

    (3) Für entgangenen Gewinn und Folgeschäden aus verpassten Terminen haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.

    + +

    (4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt.

    + +

    8. Ende der Leistung

    + +

    (1) Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.

    + +

    (2) Mit dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an, und das Widget zeigt den Ausweichtext. Die Termine bleiben im Kundenportal sichtbar, und der Kunde kann sie als CSV-Datei exportieren. Gäste können über ihren Link absagen. Bereits angeforderte Erinnerungen laufen weiter. Der Kunde informiert seine Gäste.

    + +

    (3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.

    + +

    (4) Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).

    + +

    (5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.

    + +

    (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 10.

    + +

    9. Änderungen und Einstellung

    + +

    (1) WEBklar darf die Technik des Terminplaners verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Terminplaner.

    + +

    (2) WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn

    + +
      +
    • ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Terminplaner Nummer 12), oder
    • +
    • eine Sicherheitslücke sich nicht anders beheben lässt.
    • +
    + +

    Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.

    + +

    (3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Terminplaner ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.

    + +

    (4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.

    + +

    10. Widerruf

    + +

    (1) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“.

    + +

    (2) Ob das Widerrufsrecht erlischt und ob der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die der Kunde bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage.

    + +

    (3) Mit dem Widerruf endet der Terminplaner.

    + +

    11. Hinweise

    + +

    (1) Keine Rechtsberatung. Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz und die Widerrufsbelehrung für Gäste sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung trägt der Kunde. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht.

    + +

    (2) Barrierefreiheit. Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.

    +