Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-05 00:22:04 +00:00
1 parent 13bbadc729
commit 90692b646b
2 files changed
+706 -33

No files matched your search

+243 -19
View File
@@ -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: '',
}
}
+463 -14
View File
@@ -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('<img src=x onerror=alert(1)> & "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: '<script>alert(1)</script>' },
}
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('<p>Termine telefonisch unter 0631 123456.</p>'), '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 <b>12</b>'
const r = erzeuge({ einstellung: e, feature: { ...PAKET_FEATURE, siteKey: 'a"><script>x' } })
const snippet = abschnitt(r, 'snippet').text
assert.ok(snippet.includes('<p>Termine telefonisch unter 0631 &lt;b&gt;12&lt;/b&gt;.</p>'), snippet)
assert.ok(snippet.includes('data-webklar-termine="a&quot;&gt;&lt;script&gt;x"'), snippet)
})
test('D3b Kundenwunsch: Knopf im Snippet, Wuensche nur als Text im Einbau-Abschnitt, nie in Snippet und CSS', () => {
const r = erzeuge({ fragebogen: KNOPF_FRAGEBOGEN })
const snippet = abschnitt(r, 'snippet').text
assert.ok(snippet.includes('data-modus="knopf" data-knopf="Termin sichern"'), snippet)
assert.equal(snippet, einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'knopf', knopfText: 'Termin sichern' }))
const einbau = abschnitt(r, 'einbau').text
assert.ok(einbau.includes('Kundenwunsch (Text des Kunden)'), einbau)
for (const wunsch of ['Termin sichern', 'Startseite unter dem Hero', 'Schlicht, wie die Seite', '<script>alert(1)</script>']) {
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(/<script/g) || []).length, 1, 'im Snippet steht nur das eigene Skript-Element')
// der Modus steht auch im Einbau-Abschnitt
assert.ok(/Modus: knopf/.test(einbau), einbau)
assert.ok(/Modus: inline/.test(abschnitt(erzeuge(), 'einbau').text))
})
test('D3b Kundenwunsch: ohne Fragebogen steht, dass es keine Angaben gibt; der Knopf-Text zaehlt nur im Modus knopf', () => {
const ohne = abschnitt(erzeuge(), 'einbau').text
assert.ok(ohne.includes('Kundenwunsch (Text des Kunden): keine Angaben'), ohne)
const inline = erzeuge({ fragebogen: { einbau: { modus: 'inline', knopfText: 'Buchen', stelle: 'Unten', gestaltung: '', bemerkung: '' } } })
assert.ok(!abschnitt(inline, 'snippet').text.includes('data-knopf'), 'inline: kein Knopf-Text im Snippet')
assert.ok(abschnitt(inline, 'einbau').text.includes('Unten'))
})
test('D3b Modus: ohne gueltige Wahl gilt inline und der Einbau-Abschnitt sagt, dass es der Standard ist', () => {
for (const fragebogen of [null, { einbau: { modus: 'dialog' } }, { einbau: { modus: 'KNOPF' } }, { einbau: { modus: null } }]) {
const r = erzeuge({ fragebogen })
const einbau = abschnitt(r, 'einbau').text
assert.ok(einbau.includes('Modus: inline – die Buchungsstrecke steht direkt in der Seite (Standard, der Fragebogen nennt keinen Modus)'), `${JSON.stringify(fragebogen)}\n${einbau}`)
assert.ok(!einbau.includes('undefined') && !/--wkt-akzent immer/.test(abschnitt(r, 'checkliste').text))
}
const gewaehlt = abschnitt(erzeuge({ fragebogen: { einbau: { modus: 'inline' } } }), 'einbau').text
assert.ok(gewaehlt.includes('(Wahl des Kunden im Fragebogen)'), gewaehlt)
assert.ok(abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'einbau').text.includes('Modus: knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog (Wahl des Kunden im Fragebogen)'))
})
test('D3b Kundenwunsch: die Grenzen 40/300/1000/1000 gelten erneut', () => {
const lang = (n) => 'ä'.repeat(n + 5)
const r = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: lang(40), stelle: lang(300), gestaltung: lang(1000), bemerkung: lang(1000) } } })
const einbau = abschnitt(r, 'einbau').text
const wert = (label) => zeilen(einbau).find((z) => z.trim().startsWith(`${label}: `)).trim().slice(label.length + 2)
assert.equal(wert('Knopf-Text'), 'ä'.repeat(40))
assert.equal(wert('Stelle'), 'ä'.repeat(300))
assert.equal(wert('Gestaltung'), 'ä'.repeat(1000))
assert.equal(wert('Bemerkung'), 'ä'.repeat(1000))
assert.equal(/data-knopf="([^"]*)"/.exec(abschnitt(r, 'snippet').text)[1], 'ä'.repeat(40))
// genau an der Grenze bleibt alles stehen; Emoji zaehlen als ein Zeichen und werden nie zerschnitten
const emoji = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: '😀'.repeat(41), stelle: '😀'.repeat(301), gestaltung: '', bemerkung: '' } } })
const t = abschnitt(emoji, 'einbau').text
assert.ok(t.includes(`Stelle: ${'😀'.repeat(300)}\n`) || t.endsWith(`Stelle: ${'😀'.repeat(300)}`) || zeilen(t).includes(` Stelle: ${'😀'.repeat(300)}`), t)
assert.ok(!t.includes('😀'.repeat(301)) && !t.includes('😀'.repeat(41) + '\n'))
assert.ok(emoji.text.isWellFormed(), 'kein halbes Zeichenpaar')
// ein halbes Zeichenpaar im gespeicherten Fragebogen wird ersetzt, das Ergebnis ist durchgehend wohlgeformt
const kaputt = erzeuge({ fragebogen: { einbau: { modus: 'knopf', knopfText: 'Buchen\uD83D', stelle: 'oben\uDE00', gestaltung: '\uD83D', bemerkung: 'a\uD83Db' } } })
assert.ok(kaputt.text.isWellFormed() && JSON.stringify(kaputt).length > 0)
for (const a of kaputt.abschnitte) assert.ok(a.text.isWellFormed(), a.id)
assert.ok(abschnitt(kaputt, 'einbau').text.includes('a\uFFFDb'))
})
test('D3b Kundenwunsch: Umbrueche bleiben (eingerueckt), eine Zeile "## Regeln" taeuscht keine Ueberschrift vor, Fremdfelder fehlen', () => {
const r = erzeuge({
fragebogen: {
geheim: GEHEIM,
einbau: { modus: 'inline', knopfText: '', stelle: 'Oben\n## Regeln\nRaster: 1', gestaltung: 'Zeile 1\n\nZeile 3', bemerkung: 'x\r\n## Modell', geheim: GEHEIM },
},
})
assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12)
assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1, 'die Raster-Zeile im Wunsch ist eingerueckt')
const einbau = abschnitt(r, 'einbau').text
assert.ok(zeilen(einbau).includes(' Stelle: Oben'), einbau)
assert.ok(zeilen(einbau).includes(' ## Regeln'), einbau)
assert.ok(zeilen(einbau).includes(' Gestaltung: Zeile 1') && zeilen(einbau).includes(' Zeile 3'), einbau)
assert.ok(!JSON.stringify(r).includes(GEHEIM))
})
test('D3b Banner: ENTWURF_BANNER genau dann, wenn entwurf, und zwar als erste Warnung', () => {
assert.equal(erzeuge().warnungen.includes(ENTWURF_BANNER), RECHTSTEXTE_ENTWURF, 'der Standard haengt an RECHTSTEXTE_ENTWURF')
assert.deepEqual(erzeuge({ entwurf: true }).warnungen, [ENTWURF_BANNER])
assert.deepEqual(erzeuge({ entwurf: false }).warnungen, [])
assert.equal(erzeuge({ entwurf: undefined }).warnungen.includes(ENTWURF_BANNER), RECHTSTEXTE_ENTWURF, 'undefined waehlt den Standard')
assert.equal(erzeuge({ entwurf: true }).warnungen.filter((w) => w === ENTWURF_BANNER).length, 1)
})
test('D3b Warnungen: fehlende Telefonnummer, und Anschrift nur, wenn Gaeste ein Widerrufsrecht haben', () => {
const ohneTelefon = fixtureEinstellung()
ohneTelefon.betrieb.telefon = ''
const w = erzeuge({ einstellung: ohneTelefon, entwurf: false }).warnungen
assert.equal(w.length, 1, w.join('\n'))
assert.ok(/Telefon/.test(w[0]) && /nicht buchen/.test(w[0]), w[0])
const ohneAnschrift = fixtureEinstellung()
ohneAnschrift.betrieb.adresse = ' '
const v = erzeuge({ einstellung: ohneAnschrift, entwurf: false }).warnungen
assert.equal(v.length, 1, v.join('\n'))
assert.ok(/Anschrift/.test(v[0]) && /nicht buchen/.test(v[0]), v[0])
ohneAnschrift.recht = { gaesteSind: 'unternehmer', freizeit: false }
assert.deepEqual(erzeuge({ einstellung: ohneAnschrift, entwurf: false }).warnungen, [], 'Unternehmen: keine Widerrufsbelehrung, die Anschrift ist nicht Pflicht')
ohneTelefon.betrieb.adresse = ''
const beides = erzeuge({ einstellung: ohneTelefon, entwurf: false }).warnungen
assert.deepEqual(beides.map((x) => /Telefon|Anschrift/.exec(x)[0]), ['Telefon', 'Anschrift'], 'Telefon vor Anschrift')
})
test('D3b Warnungen: fehlende Impressum- oder Datenschutz-URL', () => {
const e = fixtureEinstellung()
e.betrieb.impressumUrl = null
const a = erzeuge({ einstellung: e, entwurf: false }).warnungen
assert.equal(a.length, 1)
assert.ok(/Impressum/.test(a[0]) && /Link/.test(a[0]), a[0])
e.betrieb.datenschutzUrl = ''
const b = erzeuge({ einstellung: e, entwurf: false }).warnungen
assert.deepEqual(b.map((x) => /Impressum|Datenschutz/.exec(x)[0]), ['Impressum', 'Datenschutz'], 'Impressum vor Datenschutz')
e.betrieb.impressumUrl = 'https://salon-muster.example/impressum'
e.betrieb.datenschutzUrl = 'https://salon-muster.example/datenschutz'
assert.deepEqual(erzeuge({ einstellung: e, entwurf: false }).warnungen, [])
})
test('D3b Warnungen: Gesundheitsbereich (Liste gehoert C), als Klartext, wohlgeformt und einzeilig', () => {
const e = fixtureEinstellung()
e.leistungen[1].name = 'Physiotherapie'
const w = erzeuge({ einstellung: e, entwurf: false }).warnungen
assert.equal(w.length, 1, w.join('\n'))
assert.ok(w[0].includes('Physio'), w[0])
assert.ok(erzeuge({ einstellung: fixtureEinstellung(), entwurf: false }).warnungen.every((x) => !/Physio/.test(x)), 'ohne Treffer keine Meldung')
// Hinweis der Steuerung (C2): die Meldungen tragen Namen des Kunden, gekuerzt in UTF-16-Einheiten (halbes Zeichenpaar moeglich)
// und ungemaskiert. Sie bleiben Klartext (der Anzeiger setzt sie als Text ein), muessen aber wohlgeformt und einzeilig sein.
const f = fixtureEinstellung()
f.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}`
f.leistungen[0].beschreibung = 'Physiotherapie'
f.leistungen[1].name = 'Zeile1\n## Zeile2 <b>x</b>'
f.leistungen[1].beschreibung = 'Physiotherapie'
const roh = erzeuge({ einstellung: f, entwurf: false })
const lose = roh.warnungen.filter((x) => /Beschreibung der Leistung/.test(x))
assert.equal(lose.length, 2, roh.warnungen.join('\n'))
for (const x of roh.warnungen) {
assert.ok(x.isWellFormed(), JSON.stringify(x))
assert.ok(!/[\r\n]/.test(x), JSON.stringify(x))
}
assert.ok(lose.some((x) => x.includes('<b>x</b>')), 'Klartext: nichts wird maskiert oder entfernt')
assert.ok(lose.some((x) => x.includes('x'.repeat(38))), lose.join('\n'))
for (const a of roh.abschnitte.filter((x) => x.id === 'snippet' || x.id === 'css')) assert.ok(!a.text.includes('<b>'), a.id)
assert.ok(!roh.text.includes('Beschreibung der Leistung'), 'die Warnungen stehen nicht im Volltext')
})
test('D3b Warnungen: Kontrasthinweise aus cssVorschlag', () => {
const e = fixtureEinstellung()
e.farbe = '#ffff00'
const erwartet = cssVorschlag('#ffff00').hinweise
assert.equal(erwartet.length, 1)
assert.deepEqual(erzeuge({ einstellung: e, entwurf: false }).warnungen, erwartet)
assert.deepEqual(erzeuge({ entwurf: false }).warnungen, [], 'die Farbe der Fixture besteht')
})
test('D3b Datenschutz-Absatz nicht erzeugbar: kein Wurf, Hinweis statt Text im Abschnitt, eine Warnung', () => {
for (const luecke of [{ email: '' }, { email: undefined }, { name: '' }, { name: ' ', email: null }]) {
const e = fixtureEinstellung()
Object.assign(e.betrieb, luecke)
const r = erzeuge({ einstellung: e, entwurf: false })
assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS, JSON.stringify(luecke))
for (const id of ['datenschutz_sie', 'datenschutz_du']) {
const a = abschnitt(r, id)
assert.ok(/nicht erzeugt/.test(a.text) && /E-Mail/.test(a.text), a.text)
assert.ok(!a.text.includes('Online-Terminbuchung'), 'es ist kein Absatz fuer die Website, nur ein Hinweis')
assert.equal(a.kopierbar, false, `${id}: ein Hinweis wird nie als Datenschutz-Absatz zum Kopieren angeboten`)
}
assert.equal(r.warnungen.length, 1, r.warnungen.join('\n'))
assert.ok(/Datenschutz-Absatz nicht erzeugbar/.test(r.warnungen[0]), r.warnungen[0])
assert.ok(r.text.includes('nicht erzeugt'), 'der Hinweis steht im Volltext')
}
})
test('D3b Warnungen: feste Reihenfolge Banner, Pflichtangaben, URLs, Gesundheit, Kontrast, Datenschutz', () => {
const e = fixtureEinstellung()
e.betrieb.telefon = ''
e.betrieb.adresse = ''
e.betrieb.impressumUrl = ''
e.betrieb.datenschutzUrl = ''
e.betrieb.email = ''
e.farbe = '#ffff00'
e.leistungen[1].name = 'Physiotherapie'
const w = erzeuge({ einstellung: e, entwurf: true }).warnungen
const treffer = [ENTWURF_BANNER, /Telefon/, /Anschrift/, /Impressum/, /Datenschutz-URL/, /Physio/, /Kontrast gegen Weiß/, /Datenschutz-Absatz nicht erzeugbar/]
const stellen = treffer.map((t) => w.findIndex((x) => (typeof t === 'string' ? x === t : t.test(x))))
assert.ok(stellen.every((i) => i >= 0), `${JSON.stringify(stellen)}\n${w.join('\n')}`)
assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b), w.join('\n'))
assert.equal(w.length, 8)
})
test('D3b Checkliste: immer-Punkte (Freischalten, Probebuchung, stornieren, Kontrast, 390 px, Tastatur, Datenschutz, URLs)', () => {
const text = abschnitt(erzeuge(), 'checkliste').text
const z = zeilen(text)
assert.ok(z.every((x) => x.startsWith('- [ ] ')), 'jeder Punkt ist ein Kaestchen')
for (const erwartet of [
'Freischalten (Abnahme)', 'Fast geschafft', 'stornieren', 'öffentliche Schnittstelle', 'unsichtbar', 'Ausweichtext',
'Datenschutz-Absatz', 'Impressum', 'Kontrast', '390 px', 'Tastatur',
]) assert.ok(text.includes(erwartet), `${erwartet}\n---\n${text}`)
assert.ok(text.includes(BETRIEB.impressumUrl) && text.includes(BETRIEB.datenschutzUrl), 'die URLs stehen zum Pruefen im Punkt')
assert.ok(text.includes('liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden'), 'Zwischenregel bis Block F')
assert.ok(text.includes(`${config.womsBaseUrl}/customers/c1?tab=funktionen`), 'Link ins WOMS-Kundenpanel')
// die Reihenfolge: erst Einbau, dann Freischalten, dann Probebuchung, dann die Pruefungen am sichtbaren Widget
const stelle = (teil) => z.findIndex((x) => x.includes(teil))
assert.ok(stelle('Einbau-Code') < stelle('Freischalten (Abnahme)') && stelle('Freischalten (Abnahme)') < stelle('Fast geschafft'), text)
assert.ok(stelle('Fast geschafft') < stelle('Kontrast') && stelle('Kontrast') < stelle('390 px') && stelle('390 px') < stelle('Tastatur'), text)
})
test('D3b Checkliste: der WOMS-Link wird beim Aufruf gelesen und fehlt ohne Kunden- und Projekt-ID', () => {
const vorher = config.womsBaseUrl
try {
config.womsBaseUrl = 'https://woms.test.example'
assert.ok(abschnitt(erzeuge(), 'checkliste').text.includes('https://woms.test.example/customers/c1?tab=funktionen'))
} finally {
config.womsBaseUrl = vorher
}
const text = abschnitt(erzeuge({ feature: { siteKey: SITE_KEY } }), 'checkliste').text
assert.ok(text.includes('Freischalten (Abnahme)') && !text.includes('/customers/'), text)
})
test('D3b Checkliste: Verbraucher haben die Widerrufspunkte, Unternehmen keinen einzigen', () => {
const verbraucher = abschnitt(erzeuge(), 'checkliste').text
for (const erwartet of ['Widerrufsbelehrung', 'aufklappbar', 'vorzeitigen Beginn', 'Vertrag widerrufen']) {
assert.ok(verbraucher.includes(erwartet), `${erwartet}\n---\n${verbraucher}`)
}
for (const gaesteSind of ['unternehmer']) {
const e = fixtureEinstellung()
e.recht = { gaesteSind, freizeit: false }
const t = abschnitt(erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(!/Widerruf|widerruf|vorzeitig/.test(t), `${gaesteSind}\n---\n${t}`)
}
const freizeit = fixtureEinstellung()
freizeit.recht = { gaesteSind: 'verbraucher', freizeit: true }
assert.ok(!/Widerruf|widerruf/.test(abschnitt(erzeuge({ einstellung: freizeit }), 'checkliste').text), 'Freizeitleistung: kein Widerrufsrecht')
// keine sichtbare Leistung: auch dann keine Widerrufspunkte (zeigtWiderruf)
const leer = fixtureEinstellung()
leer.leistungen.forEach((l) => { l.aktiv = false })
assert.ok(!/Widerruf|widerruf/.test(abschnitt(erzeuge({ einstellung: leer }), 'checkliste').text))
})
test('D3b Checkliste: der Haken fuer den vorzeitigen Beginn erscheint nur bei Leistungen mit Preis', () => {
const e = fixtureEinstellung()
assert.ok(abschnitt(erzeuge({ einstellung: e }), 'checkliste').text.includes('vorzeitigen Beginn'))
e.leistungen.forEach((l) => { l.preisArt = 'keiner'; l.preisCent = 0 })
const t = abschnitt(erzeuge({ einstellung: e }), 'checkliste').text
assert.ok(!t.includes('vorzeitigen Beginn'), t)
assert.ok(t.includes('Widerrufsbelehrung') && t.includes('Vertrag widerrufen'), 'die uebrigen Widerrufspunkte bleiben')
})
test('D3b Checkliste: Verbraucher mit Modus knopf haben den dauerhaften Widerrufsknopf, mit inline nicht', () => {
const knopf = abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(knopf.includes('Widerrufsknopf dauerhaft neben dem Buchungsknopf'), knopf)
const inline = abschnitt(erzeuge({ fragebogen: { einbau: { modus: 'inline' } } }), 'checkliste').text
const ohne = abschnitt(erzeuge(), 'checkliste').text
for (const t of [inline, ohne]) {
assert.ok(!t.includes('dauerhaft neben dem Buchungsknopf') && !t.includes('Widerrufsknopf'), t)
assert.ok(t.includes('Vertrag widerrufen'), 'der Knopf im Fuss bleibt')
}
})
test('D3b Checkliste: Modus knopf verlangt --wkt-akzent immer, inline nicht', () => {
const knopf = abschnitt(erzeuge({ fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(/--wkt-akzent immer setzen/.test(knopf), knopf)
// auch ohne Widerrufsrecht
const e = fixtureEinstellung()
e.recht = { gaesteSind: 'unternehmer', freizeit: false }
const ohneWiderruf = abschnitt(erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN }), 'checkliste').text
assert.ok(/--wkt-akzent immer setzen/.test(ohneWiderruf) && !/Widerruf/.test(ohneWiderruf), ohneWiderruf)
assert.ok(!/--wkt-akzent/.test(abschnitt(erzeuge(), 'checkliste').text))
})
test('D3b Abrechnung: bezahlt mit Rechnung, bezahlt ohne Rechnung, ohne Zahlung freigeschaltet', () => {
const rechnung = 'https://invoice.stripe.com/i/acct_1/test_abc'
const mit = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, invoiceUrl: rechnung } }), 'abrechnung').text
assert.ok(mit.includes(`Einrichtung bezahlt (149 €, Rechnung ${rechnung}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden`), mit)
assert.ok(mit.includes('ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen'), mit)
assert.ok(!mit.includes('Rechnung folgt'))
for (const invoiceUrl of [undefined, null, '', ' ', 'javascript:alert(1)', 'http://invoice.example/x', 'kein url']) {
const ohne = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, invoiceUrl } }), 'abrechnung').text
assert.ok(ohne.includes('Einrichtung bezahlt (149 €, Rechnung folgt im Portal), Zeit im Arbeitsblatt, nicht auf Abo-Stunden'), `${invoiceUrl}\n${ohne}`)
assert.ok(ohne.includes('Leistungsumfang') && !ohne.includes('javascript:'), ohne)
}
for (const unlockedByPurchase of [false, undefined, null, 'true', 1]) {
const pilot = abschnitt(erzeuge({ feature: { ...PAKET_FEATURE, unlockedByPurchase, invoiceUrl: rechnung } }), 'abrechnung').text
assert.ok(pilot.includes('ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung'), `${unlockedByPurchase}\n${pilot}`)
assert.ok(!pilot.includes('149 €') && !pilot.includes(rechnung) && !pilot.includes('Abo-Stunden'), pilot)
}
const pilot = abschnitt(erzeuge({ feature: null }), 'abrechnung').text
assert.ok(pilot.includes('keine Abrechnung'), 'ohne Funktionsdokument: keine Abrechnung')
})
test('D3b ohne Einstellung: nur der Abschnitt snippet (Modus aus dem Fragebogen), die Warnung, ein Volltext aus dem Snippet', () => {
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: false })
assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'])
assert.equal(r.abschnitte[0].kopierbar, true)
assert.deepEqual(r.warnungen, ['Keine Einstellungen gespeichert'])
assert.equal(r.abschnitte[0].text, einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: undefined, modus: 'inline' }))
assert.ok(r.abschnitte[0].text.includes('Termine telefonisch vereinbaren.'), 'ohne Einstellung gibt es keine Telefonnummer')
assert.equal(r.text, `## ${r.abschnitte[0].titel}\n${r.abschnitte[0].text}`)
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
assert.equal(r.kurz, '')
const knopf = erzeuge({ einstellung: null, fragebogen: KNOPF_FRAGEBOGEN, version: 0, entwurf: false })
assert.ok(knopf.abschnitte[0].text.includes('data-modus="knopf" data-knopf="Termin sichern"'), knopf.abschnitte[0].text)
assert.ok(!knopf.text.includes('alert(1)') && !knopf.text.includes('Startseite unter dem Hero'), 'die Wuensche stehen auch hier nicht im Snippet')
// das Banner gilt unabhaengig von den Einstellungen und steht zuerst
assert.deepEqual(erzeuge({ einstellung: null, entwurf: true }).warnungen, [ENTWURF_BANNER, 'Keine Einstellungen gespeichert'])
// kein Objekt gilt wie null
for (const einstellung of [undefined, 'text', 42, []]) assert.deepEqual(erzeuge({ einstellung, entwurf: false }).abschnitte.map((a) => a.id), ['snippet'], JSON.stringify(einstellung))
})
test('D3b ist deterministisch, veraendert Eingaben nicht und gibt nie ein Token aus', () => {
const e = fixtureEinstellung()
e.betrieb.email = ''
e.leistungen[1].name = 'Physiotherapie'
const fragebogen = structuredClone(KNOPF_FRAGEBOGEN)
const feature = { ...PAKET_FEATURE, invoiceUrl: 'https://invoice.stripe.com/i/x' }
const vorher = structuredClone({ e, fragebogen, feature })
const a = erzeuge({ einstellung: e, fragebogen, feature })
const b = erzeuge({ einstellung: e, fragebogen, feature })
assert.deepEqual(a, b)
assert.deepEqual(structuredClone({ e, fragebogen, feature }), vorher)
const json = JSON.stringify(a)
for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken))
assert.ok(!/feedtoken/i.test(json))
})
test('D3b Volltext hat die Abschnitte in der Reihenfolge der Tabelle und nur Text (keine HTML-Maskierung der Kundentexte)', () => {
const e = fixtureEinstellung()
e.ressourcen[0].name = 'Anna <b>&</b>'
const r = erzeuge({ einstellung: e, fragebogen: KNOPF_FRAGEBOGEN })
assert.ok(r.text.includes('Anna <b>&</b>'))
assert.ok(hatText(r, abschnitt(r, 'snippet').text, abschnitt(r, 'css').text, abschnitt(r, 'checkliste').text, abschnitt(r, 'abrechnung').text, abschnitt(r, 'einbau').text))
const stellen = r.abschnitte.filter((a) => a.id !== 'datenschutz_du').map((a) => r.text.indexOf(`## ${a.titel}\n`))
assert.ok(stellen.every((i) => i >= 0))
assert.deepEqual(stellen, [...stellen].sort((x, y) => x - y))
})