diff --git a/public/funktionen.js b/public/funktionen.js index 50abfed..b75d396 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -151,34 +151,72 @@ 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.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. */ 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`) -function pickTermineProject(projects) { - return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => termineOf(p).state === 'disabled') || projects[0] +// 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 } -function termineTile(projects) { - const states = projects.map((p) => termineOf(p).state) - const active = states.filter((s) => s === 'active').length +// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (entsteht beim ersten Speichern des Fragebogens) +const hatEntwurf = (project) => termineOf(project).state === 'off' && Boolean(termineOf(project).siteKey) + +// Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann Entwurf, dann das erste +function pickTermineProject(projects) { + return projects.find((p) => termineOf(p).state === 'active') + || projects.find((p) => isSetup(termineOf(p).state)) + || projects.find((p) => termineOf(p).state === 'disabled') + || projects.find(hatEntwurf) + || projects[0] +} + +function termineTile(projects, angebot = {}) { + const active = projects.filter((p) => termineOf(p).state === 'active').length const target = pickTermineProject(projects) - const disabled = !active && states.length > 0 && states.every((s) => s === 'disabled') - const pill = active ? ['Aktiv', 'on'] : disabled ? ['Abgeschaltet', 'off'] : ['Aus', 'off'] - let extra - let action - if (active) { + const state = target ? termineOf(target).state : 'off' + const knopf = (text) => `` + let pill = ['Aus', 'off'] + let extra = '' + let action = '' + if (state === 'active') { + pill = ['Aktiv', 'on'] extra = '' - action = `` - } else if (disabled) { + action = knopf('Termine öffnen') + } else if (state === 'ordered') { + pill = ['Zahlung offen', 'setup'] + action = knopf('Status ansehen') + } else if (state === 'setup') { + pill = ['In Einrichtung', 'setup'] + action = knopf('Status ansehen') + } else if (state === 'disabled') { + pill = ['Abgeschaltet', 'off'] extra = '

Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.

' - action = `` + action = knopf('Termine ansehen') + } else if (!target) { + extra = '

Sobald deine Website angelegt ist, kannst du den Terminplaner einrichten.

' + } else if (angebot.bestellbar === true) { + const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : '' + action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`) } else { - extra = '

Wir schalten den Terminplaner für dich frei.

' - action = '' + extra = `

${termineAboHinweis(angebot)}

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

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

` : '' return ` @@ -217,7 +255,7 @@ export async function initFunktionen(root) { root.innerHTML = `
${newsTile(projects, newsOffer(features).price)} - ${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)} + ${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)} ${analyseTile(projects)}
` root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project }) diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 160c52a..9c862ff 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -16,6 +16,7 @@ import { } from '../services/siteFeatures.js' import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js' 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' @@ -88,6 +89,29 @@ function analyseProjectId(value) { return value } +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). + */ +async function termineAngebot(email, customerId) { + if (!termineFuerKunde(email)) return { verfuegbar: false } + const register = featureConfig('termine') + let bestellbar = null + let grund = null + try { + const abo = await aboMemo().bestellbar(customerId) + bestellbar = abo?.ok === true + grund = bestellbar || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund + } 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 ?? '' } +} + // Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet router.get('/', requireSession, async (req, res) => { try { @@ -114,8 +138,9 @@ router.get('/', requireSession, async (req, res) => { ustHinweis: config.ustHinweis, setupEstimate: config.funktionen.setupEstimate, consentText: config.funktionen.consentText, - // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel - termine: { verfuegbar: termineFuerKunde(req.session.email) }, + // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch + // mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers + termine: await termineAngebot(req.session.email, customerId), }) } catch (err) { fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.') diff --git a/test/funktionenTermine.test.js b/test/funktionenTermine.test.js index 59469f0..a4ee08b 100644 --- a/test/funktionenTermine.test.js +++ b/test/funktionenTermine.test.js @@ -148,20 +148,93 @@ const termineMit = (mode, pilotEmails = []) => { config.termine.pilotEmails = pilotEmails } after(() => termineMit(MODUS.mode, MODUS.pilotEmails)) -const verfuegbar = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine +const termineAntwort = async (c = cookie) => (await get('/api/funktionen', { cookie: c })).termine +const verfuegbar = async (c = cookie) => (await termineAntwort(c)).verfuegbar -test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail', async () => { +test('GET /api/funktionen: termine.verfuegbar je Modus und Pilot-E-Mail; false bleibt exakt { verfuegbar: false }', async () => { termineMit('off') - assert.deepEqual(await verfuegbar(), { verfuegbar: false }) + assert.deepEqual(await termineAntwort(), { verfuegbar: false }) termineMit('pilot', ['k@example.invalid']) - assert.deepEqual(await verfuegbar(), { verfuegbar: true }) - assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste') + assert.equal(await verfuegbar(), true) + assert.deepEqual(await termineAntwort(customerCookie('c2')), { verfuegbar: false }, 'Kunde nicht in der Pilotliste') termineMit('pilot', []) - assert.deepEqual(await verfuegbar(), { verfuegbar: false }) + assert.deepEqual(await termineAntwort(), { verfuegbar: false }) termineMit('pilot', ['*']) - assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true }) + assert.equal(await verfuegbar(customerCookie('c2')), true) termineMit('on') - assert.deepEqual(await verfuegbar(customerCookie('c2')), { verfuegbar: true }) + assert.equal(await verfuegbar(customerCookie('c2')), true) +}) + +// ---- Abo-Felder der Kachel (C10a): bestellbar, grund, preis, einrichtung nur bei verfuegbar: true +const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) +const angebot = (bestellbar, grund) => ({ + verfuegbar: true, + bestellbar, + grund, + preis: config.features.termine.priceLabel, + einrichtung: config.features.termine.setupEstimate, +}) + +test('GET /api/funktionen: Kunde mit Wartungs-Abo => bestellbar true, grund null, Preis und Einrichtung aus dem Register', async (t) => { + t.after(() => aw.seed('subscriptions', hostingDoc)) + termineMit('pilot', ['*']) + wartung() + 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']) +}) + +test('GET /api/funktionen: ohne Wartungs-Abo (nur Hosting, gar kein Abo, beendet) => bestellbar false, grund kein_abo', async (t) => { + t.after(() => aw.seed('subscriptions', hostingDoc)) + termineMit('pilot', ['*']) + assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'c1 hat seit seedFixtures nur Hosting') + assert.deepEqual(await termineAntwort(customerCookie('c3')), angebot(false, 'kein_abo'), 'c3 hat gar kein Abo') + wartung({ status: 'canceled', endedAt: '2026-09-01T00:00:00.000Z' }) + assert.deepEqual(await termineAntwort(), angebot(false, 'kein_abo'), 'beendetes Wartungs-Abo') +}) + +test('GET /api/funktionen: Kuendigung vorgemerkt => grund gekuendigt; past_due und trialing => nicht_aktiv', async (t) => { + t.after(() => aw.seed('subscriptions', hostingDoc)) + termineMit('pilot', ['*']) + wartung({ cancelAtPeriodEnd: true }) + assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt')) + wartung({ cancelAt: '2026-11-30T00:00:00.000Z' }) + assert.deepEqual(await termineAntwort(), angebot(false, 'gekuendigt')) + wartung({ status: 'past_due' }) + assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv')) + wartung({ status: 'trialing' }) + assert.deepEqual(await termineAntwort(), angebot(false, 'nicht_aktiv')) +}) + +test('GET /api/funktionen: Lesefehler im Abo => bestellbar null, grund null, die Projekte kommen trotzdem; nichts Technisches nach aussen', async (t) => { + t.after(() => aw.seed('subscriptions', hostingDoc)) + const fehler = t.mock.method(console, 'error', () => {}) + termineMit('pilot', ['*']) + wartung() + 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.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') + // der naechste Aufruf liest wieder, nichts wird gemerkt + assert.deepEqual(await termineAntwort(), angebot(true, null)) +}) + +test('GET /api/funktionen: ist der Terminplaner nicht verfuegbar, wird das Abo nicht gelesen', async (t) => { + const echt = globalThis.fetch + const abfragen = [] + globalThis.fetch = async (url, optionen) => { + if (/\/collections\/subscriptions\/documents/.test(String(url))) abfragen.push(String(url)) + return echt(url, optionen) + } + t.after(() => (globalThis.fetch = echt)) + termineMit('off') + assert.deepEqual(await termineAntwort(), { verfuegbar: false }) + assert.equal(abfragen.length, 0, abfragen.join(' | ')) }) test('GET /api/funktionen: Projekte tragen features.termine und News wie bisher', async () => { diff --git a/test/funktionenTiles.test.js b/test/funktionenTiles.test.js index a73f019..ac7dc23 100644 --- a/test/funktionenTiles.test.js +++ b/test/funktionenTiles.test.js @@ -4,6 +4,8 @@ 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-')) @@ -144,9 +146,11 @@ test('statisch: News postet weiter consent: true an /api/funktionen/news/checkou }) // ---- Terminplaner-Kachel: nur mit Schalter (termine.verfuegbar), Zustand je Projekt aus features.termine -const withTermine = (id, state) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state } } }) +const withTermine = (id, state, extra = {}) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news, termine: { state, ...extra } } }) const noAnalyse = { comingSoon: true } -async function renderTermine(projects, { verfuegbar, anfragen = {} } = {}) { +// 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' } +async function renderTermine(projects, { verfuegbar, anfragen = {}, abo = {} } = {}) { const calls = [] globalThis.fetch = async (url) => { calls.push(String(url)) @@ -154,7 +158,8 @@ async function renderTermine(projects, { verfuegbar, anfragen = {} } = {}) { const id = new URL(url, 'http://x').searchParams.get('projectId') return { ok: true, status: 200, json: async () => ({ anfragenOffen: anfragen[id] ?? 0 }) } } - return { ok: true, status: 200, json: async () => ({ projects, ...(verfuegbar === undefined ? {} : { termine: { verfuegbar } }) }) } + const termine = verfuegbar === undefined ? {} : { termine: verfuegbar ? { verfuegbar, ...ABO_OK, ...abo } : { verfuegbar } } + return { ok: true, status: 200, json: async () => ({ projects, ...termine }) } } const count = { textContent: '', hidden: true } const root = { innerHTML: '', querySelector: (sel) => (sel === '[data-termine-count]' ? count : null) } @@ -173,18 +178,106 @@ test('Terminplaner-Kachel: fehlt termine.verfuegbar oder ist false ⇒ „Kommt } }) -test('Terminplaner-Kachel: Aus ⇒ „Wir schalten den Terminplaner für dich frei.“ + Chat', async () => { - for (const projects of [[{ ...project('a', noAnalyse), features: { news } }], [withTermine('a', 'off')], [withTermine('a', 'ordered')]]) { - const { html } = await renderTermine(projects, { verfuegbar: true }) +// 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\.