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) +})