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 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 01:18:30 +00:00
1 parent f830698dc3
commit 938b7099dd
3 files changed
+399 -7

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
# Terminplaner auf der Kundenwebsite einbauen # 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 ## 1. Das Snippet
+77 -6
View File
@@ -1,5 +1,6 @@
import { Router } from 'express' import { Router } from 'express'
import { config } from '../../config.js' import { config } from '../../config.js'
import { aboMemo } from '../../services/aboStatus.js'
import { getDocument } from '../../services/appwriteAdmin.js' import { getDocument } from '../../services/appwriteAdmin.js'
import { featureConfig } from '../../services/featureRegistry.js' import { featureConfig } from '../../services/featureRegistry.js'
import { withLocks } from '../../services/keyedLock.js' import { withLocks } from '../../services/keyedLock.js'
@@ -8,6 +9,7 @@ import {
FEATURE_LABELS, FEATURE_LABELS,
adminActivate, adminActivate,
assertOwnProject, assertOwnProject,
customerOf,
disable, disable,
freischaltenNachAbnahme, freischaltenNachAbnahme,
getFeatureDoc, getFeatureDoc,
@@ -15,6 +17,9 @@ import {
listCustomerFeatures, listCustomerFeatures,
ticketNachlegen, ticketNachlegen,
} from '../../services/siteFeatures.js' } 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"). * 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 return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry
} }
async function ticketExists(ticketId) { /** Das Ticket oder null (keine ticketId, Ticket geloescht); jeder andere Lesefehler wird weitergereicht. */
if (!ticketId) return false async function readTicket(ticketId) {
if (!ticketId) return null
try { try {
await getDocument(config.collections.workorders, ticketId) return await getDocument(config.collections.workorders, ticketId)
return true
} catch (err) { } catch (err) {
if (err.status === 404) return false if (err.status === 404) return null
throw err 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) { async function adminView(customerId) {
const docs = await listCustomerFeatureDocs(customerId) const docs = await listCustomerFeatureDocs(customerId)
const projects = await listCustomerFeatures(customerId, { docs }) const projects = await listCustomerFeatures(customerId, { docs })
const abo = aboMemo()
for (const p of projects) { for (const p of projects) {
for (const key of FEATURE_KEYS) { for (const key of FEATURE_KEYS) {
const view = p.features[key] const view = p.features[key]
const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key) const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key)
view.adminNote = doc?.adminNote || '' view.adminNote = doc?.adminNote || ''
view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId))) 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 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 // 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 // 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. // Funktionen mit ticketFreischaltung im Register.
+321
View File
@@ -23,6 +23,9 @@ const { deleteCustomerWithPortalAccess } = await import('../server/services/cust
const { aboMeldung } = await import('../server/services/featureOrder.js') const { aboMeldung } = await import('../server/services/featureOrder.js')
const { setMailTransportForTests } = await import('../server/services/mailer.js') const { setMailTransportForTests } = await import('../server/services/mailer.js')
const { mailSink } = await import('./support/mailSink.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 }) const app = await startTestApp({ '/api/funktionen': funktionenRoutes, '/api/team': teamRoutes })
// Mails (K16 der Abnahme, K15/T16 des nachgelegten Tickets) landen in einer Senke // 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.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') 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)
})