diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js
index fa91905..b8ec150 100644
--- a/server/services/termine/einbauanleitung.js
+++ b/server/services/termine/einbauanleitung.js
@@ -1,6 +1,10 @@
import { config } from '../../config.js'
-import { VORGABE_FARBE, preisHinweis, preisText, sichtbareLeistungen, zahlungText, zeigtWiderruf } from './einstellungen.js'
-import { RECHTSTEXTE_ENTWURF } from './rechtstexte.js'
+import { anleitungsLink } from '../featureRegistry.js'
+import { gesundheitsWarnungen } from './fragebogen.js'
+import {
+ VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf,
+} from './einstellungen.js'
+import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from './rechtstexte.js'
import { datumKurz, heute } from './zeit.js'
/**
@@ -12,7 +16,8 @@ import { datumKurz, heute } from './zeit.js'
* des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js;
* GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert).
* - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen.
- * Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit).
+ * Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit, WOMS-Adresse,
+ * Preis). Er nutzt die reinen Hilfen gesundheitsWarnungen (fragebogen.js) und datenschutzAbsatz (rechtstexte.js).
*
* Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende),
* bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js
@@ -127,13 +132,18 @@ export function cssVorschlag(farbe) {
}
// ================================================================ Generator einbauanleitung()
-// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b haengt Einbau bis Abrechnung, die
-// Warnungen und den vollen Volltext an, Task D4 die Kurzfassung (`kurz`).
+// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze,
+// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4 haengt die Kurzfassung (`kurz`) an.
//
// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender-
-// Abo der Ressource), die Betriebs-E-Mail (sie steht nur im Datenschutz-Absatz, D3b), Beschreibungen der Leistungen und alles,
-// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text) stehen unmaskiert als Text; wer sie anzeigt, setzt
-// sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, damit ein Name keine Ueberschrift vortaeuschen kann.
+// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles,
+// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text, Wuensche zum Einbau) stehen unmaskiert als Text; wer sie
+// anzeigt, setzt sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, mehrzeilige eingerueckt, damit ein Kundentext
+// keine Ueberschrift vortaeuschen kann. Wuensche des Kunden stehen nie im Snippet und nie im CSS (nur der Knopf-Text, maskiert,
+// als data-knopf).
+
+/** Kennzeichnung der Gaestetexte als Entwurf; erste Warnung, solange `entwurf` gilt (Standard RECHTSTEXTE_ENTWURF). */
+export const ENTWURF_BANNER = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']
const TAG_RE = /^\d{4}-\d{2}-\d{2}$/
@@ -179,7 +189,7 @@ export function kuerzeListe(eintraege, max) {
return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`]
}
-const neuerAbschnitt = (id, titel, text) => ({ id, titel, text, kopierbar: false })
+const neuerAbschnitt = (id, titel, text, kopierbar = false) => ({ id, titel, text, kopierbar })
/** Zeilen als Aufzaehlung: '- Eintrag'. */
const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`)
@@ -373,9 +383,197 @@ function abschnittBenachrichtigungen(d) {
].join('\n\n'))
}
+// ---------------------------------------------------------------- Wuensche des Kunden (Fragebogen, Schritt Einbau)
+
+const EINBAU_MODI = ['inline', 'knopf']
+const MODUS_TEXT = {
+ inline: 'inline – die Buchungsstrecke steht direkt in der Seite',
+ knopf: 'knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog',
+}
+// Grenzen wie in fragebogen.js (TEXTGRENZEN): hier erneut angewandt, die Anleitung traut dem gespeicherten Fragebogen nicht blind
+const WUNSCH_GRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
+const WUNSCH_MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
+
+/** Modus (ohne Fragebogen oder ohne gueltige Wahl: inline) und die vier Wunschtexte, bereinigt, gekuerzt und wohlgeformt. */
+function kundenwunsch(fragebogen) {
+ const einbau = istObjekt(fragebogen) && istObjekt(fragebogen.einbau) ? fragebogen.einbau : {}
+ const modusGewaehlt = EINBAU_MODI.includes(einbau.modus)
+ const wunsch = { modus: modusGewaehlt ? einbau.modus : 'inline', modusGewaehlt }
+ for (const [feld, max] of Object.entries(WUNSCH_GRENZEN)) {
+ wunsch[feld] = bereinigterText(einbau[feld], max, { mehrzeilig: WUNSCH_MEHRZEILIG.has(feld) }).toWellFormed()
+ }
+ return wunsch
+}
+
+/** ' Stelle: erste Zeile' und weitere Zeilen um vier Leerzeichen eingerueckt (nie am Zeilenanfang, also nie eine Ueberschrift). */
+function wunschZeilen(bezeichnung, wert) {
+ if (!wert) return []
+ const [erste, ...weitere] = wert.split('\n')
+ return [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => (z === '' ? '' : ` ${z}`))]
+}
+
+function kundenwunschBlock(wunsch) {
+ const zeilen = [
+ ...wunschZeilen('Knopf-Text', wunsch.knopfText),
+ ...wunschZeilen('Stelle', wunsch.stelle),
+ ...wunschZeilen('Gestaltung', wunsch.gestaltung),
+ ...wunschZeilen('Bemerkung', wunsch.bemerkung),
+ ]
+ return zeilen.length ? ['Kundenwunsch (Text des Kunden):', ...zeilen].join('\n') : 'Kundenwunsch (Text des Kunden): keine Angaben im Fragebogen'
+}
+
+// ---------------------------------------------------------------- Abschnitte Einbau, Snippet, CSS
+
+function abschnittEinbau(d) {
+ const w = d.wunsch
+ const quelle = w.modusGewaehlt ? 'Wahl des Kunden im Fragebogen' : 'Standard, der Fragebogen nennt keinen Modus'
+ const telefon = zeile(d.betrieb.telefon) || NICHT_ANGEGEBEN
+ const impressum = zeile(d.betrieb.impressumUrl) || NICHT_ANGEGEBEN
+ const datenschutz = zeile(d.betrieb.datenschutzUrl) || NICHT_ANGEGEBEN
+ return neuerAbschnitt('einbau', 'Einbau', [
+ `siteKey: ${d.siteKey || NICHT_ANGEGEBEN}`,
+ `Modus: ${MODUS_TEXT[w.modus]} (${quelle})`,
+ ['So wird eingebaut:', ...aufzaehlung([
+ 'Platzhalter und Skript aus dem Abschnitt „Einbau-Code (Snippet)“ in die Seite setzen: den Platzhalter an die Stelle, die der Kunde nennt '
+ + '(siehe Kundenwunsch), das Skript einmal pro Seite, am besten vor dem schließenden body-Tag. Das Skript hat keine Abhängigkeiten, '
+ + 'setzt keine Cookies und lädt nichts von Dritten.',
+ 'Der Ausweichtext im Platzhalter gehört der Website: Er bleibt ohne JavaScript, bei falschem siteKey, abgeschalteter Funktion, fehlenden '
+ + `Pflichtangaben und Netzfehlern stehen. Telefon des Betriebs: ${telefon}. Gestalte ihn im Stil der Seite.`,
+ 'Aussehen: den Abschnitt „Aussehen (CSS)“ in die CSS der Website übernehmen. Weitere Variablen stehen in docs/funktionen/termine-einbau.md, Abschnitt 3.',
+ 'Datenschutz: den Absatz „Online-Terminbuchung“ (Abschnitte „Datenschutz-Absatz für die Website“) in die Datenschutzerklärung der Website aufnehmen, '
+ + 'bevor das Widget freigeschaltet wird.',
+ `Impressum und Datenschutzerklärung: Die URLs stehen in den Betriebsangaben im Portal (Impressum: ${impressum}; Datenschutzerklärung: ${datenschutz}), `
+ + 'das Widget verlinkt sie im Fuß.',
+ ])].join('\n'),
+ kundenwunschBlock(w),
+ ].join('\n\n'))
+}
+
+/** Einbau-Code aus den Angaben des Betriebs und dem Modus des Fragebogens; Wuensche ausser dem Knopf-Text kommen nie hinein. */
+const baueSnippet = (d) =>
+ einbauSnippet({ siteKey: d.siteKey, apiBase: d.portal, telefon: d.telefon, modus: d.wunsch.modus, knopfText: d.wunsch.knopfText })
+
+const abschnittSnippet = (d) => neuerAbschnitt('snippet', 'Einbau-Code (Snippet)', baueSnippet(d), true)
+
+const abschnittCss = (d) => neuerAbschnitt('css', 'Aussehen (CSS)', cssVorschlag(d.farbe).css, true)
+
+// ---------------------------------------------------------------- Abschnitte Datenschutz-Absatz (Sie, Du)
+
+const DATENSCHUTZ_HINWEIS =
+ 'Der Datenschutz-Absatz konnte nicht erzeugt werden: In den Einstellungen fehlt der Name oder die E-Mail-Adresse des Betriebs. '
+ + 'Beides im Portal eintragen lassen und die Anleitung neu laden.'
+
+/** { sie, du } oder null, wenn Name oder E-Mail-Adresse des Betriebs fehlen (datenschutzAbsatz wirft dann einen TypeError). */
+function datenschutzTexte(einstellung) {
+ try {
+ return { sie: datenschutzAbsatz(einstellung, 'sie'), du: datenschutzAbsatz(einstellung, 'du') }
+ } catch (err) {
+ if (err instanceof TypeError) return null
+ throw err
+ }
+}
+
+/** Nur ein echter Absatz ist kopierbar; der Hinweis, dass er fehlt, nie (er gehoert nicht in eine Datenschutzerklaerung). */
+const abschnittDatenschutz = (anrede) => (d) => {
+ const id = `datenschutz_${anrede}`
+ const titel = `Datenschutz-Absatz für die Website (${anrede === 'du' ? 'Du' : 'Sie'}-Form)`
+ const absatz = d.datenschutz?.[anrede]
+ return absatz ? neuerAbschnitt(id, titel, absatz, true) : neuerAbschnitt(id, titel, DATENSCHUTZ_HINWEIS)
+}
+
+// ---------------------------------------------------------------- Abschnitt Abnahme-Checkliste
+
+// Zwischenregel bis Block F (F10b2 ersetzt sie durch die Endregel aus F4: WEBklar fuegt den Absatz ein, wenn die Website von WEBklar betreut wird)
+const DATENSCHUTZ_ZWISCHENREGEL = 'liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden'
+
+function abschnittCheckliste(d) {
+ const { e } = d
+ const widerruf = zeigtWiderruf(e)
+ const preis = sichtbareLeistungen(e).some(hatPreis)
+ const knopf = d.wunsch.modus === 'knopf'
+ const impressum = zeile(d.betrieb.impressumUrl) || 'fehlt'
+ const datenschutz = zeile(d.betrieb.datenschutzUrl) || 'fehlt'
+ const wo = d.anleitungsLink ? ` (Kunde → Reiter Funktionen, Zeile Terminplaner: ${d.anleitungsLink})` : ''
+ const punkte = [
+ // vor der Freischaltung
+ 'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.',
+ ...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []),
+ 'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.',
+ `Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${DATENSCHUTZ_ZWISCHENREGEL}).`,
+ `URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`,
+ `„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`,
+ // am sichtbaren Widget
+ 'Probebuchung bis „Fast geschafft“ durchgespielt: Die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. '
+ + 'Danach den Testtermin stornieren (Verwaltungslink aus der E-Mail oder im Portal).',
+ ...(widerruf ? ['Widerrufsbelehrung ist im Widget aufklappbar (Verbraucher-Betrieb, auch bei nur Anfrage-Leistungen).'] : []),
+ ...(widerruf && preis ? ['Der Haken für den vorzeitigen Beginn erscheint bei Leistungen mit Preis für Termine in den nächsten 14 Tagen.'] : []),
+ ...(widerruf ? ['Der Knopf „Vertrag widerrufen“ steht im Fuß, das Formular funktioniert.'] : []),
+ ...(widerruf && knopf
+ ? ['Modus knopf: Widerrufsknopf dauerhaft neben dem Buchungsknopf (nicht erst im Dialog); er öffnet den Dialog gleich beim Widerrufsformular.']
+ : []),
+ 'Kontrast geprüft: Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1; globale Regeln des Themes (button, input) stören nicht.',
+ 'Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, der Dialog passt in den Bildschirm; Zoom 200 %: nichts wird abgeschnitten.',
+ 'Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog.',
+ ]
+ return neuerAbschnitt('checkliste', 'Abnahme-Checkliste', punkte.map((p) => `- [ ] ${p}`).join('\n'))
+}
+
+// ---------------------------------------------------------------- Abschnitt Abrechnung
+
+const RECHNUNG_RE = /^https:\/\/\S+$/i
+
+/** Preis der Einrichtung aus dem Register (config.features.termine.priceLabel), sonst „149 €“. */
+function einrichtungsPreis() {
+ const preis = zeile(config.features?.termine?.priceLabel)
+ return preis || '149 €'
+}
+
+function abschnittAbrechnung(d) {
+ if (d.feature?.unlockedByPurchase !== true) {
+ return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.')
+ }
+ const rechnung = zeile(d.feature.invoiceUrl)
+ return neuerAbschnitt('abrechnung', 'Abrechnung', [
+ `Abrechnung: Einrichtung bezahlt (${einrichtungsPreis()}, Rechnung ${RECHNUNG_RE.test(rechnung) ? rechnung : 'folgt im Portal'}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden.`,
+ 'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.',
+ ].join('\n'))
+}
+
+// ---------------------------------------------------------------- Warnungen
+
+const WARNUNG_PFLICHT = {
+ 'betrieb.telefon': 'Telefonnummer des Betriebs fehlt: Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
+ 'betrieb.adresse':
+ 'Anschrift des Betriebs fehlt (sie steht in der Widerrufsbelehrung): Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.',
+}
+const WARNUNG_OHNE_URL = 'fehlt: Im Fuß des Widgets fehlt der Link.'
+const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder E-Mail-Adresse des Betriebs fehlt in den Einstellungen.'
+
+/**
+ * Meldungen aus gesundheitsWarnungen (Block C) tragen Namen des Kunden: gekuerzt in UTF-16-Einheiten (ein halbes Zeichenpaar ist moeglich)
+ * und ungemaskiert. Hier werden sie wohlgeformt und einzeilig; sie bleiben Klartext, der Anzeiger setzt sie als Text ein (nie als HTML).
+ */
+const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '')
+
+/** Warnungen fuer das Team in fester Reihenfolge: Banner, Pflichtangaben, Impressum, Datenschutz-URL, Gesundheit, Kontrast, Datenschutz-Absatz. */
+function warnungenAus(d) {
+ const warnungen = []
+ if (d.entwurf) warnungen.push(ENTWURF_BANNER)
+ for (const feld of fehlendePflichtangaben(d.e)) warnungen.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`)
+ if (!zeile(d.betrieb.impressumUrl)) warnungen.push(`Impressum-URL ${WARNUNG_OHNE_URL}`)
+ if (!zeile(d.betrieb.datenschutzUrl)) warnungen.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`)
+ for (const meldung of gesundheitsWarnungen(d.e)) warnungen.push(meldungText(meldung))
+ warnungen.push(...cssVorschlag(d.farbe).hinweise)
+ if (!d.datenschutz) warnungen.push(WARNUNG_OHNE_DATENSCHUTZ)
+ return warnungen
+}
+
// ---------------------------------------------------------------- Zusammenbau
-const ABSCHNITTE = [abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen]
+const ABSCHNITTE = [
+ abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen,
+ abschnittEinbau, abschnittSnippet, abschnittCss, abschnittDatenschutz('sie'), abschnittDatenschutz('du'), abschnittCheckliste, abschnittAbrechnung,
+]
/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */
const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n')
@@ -392,42 +590,68 @@ function projektText(project) {
* ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen.
*
* @param {object} p
- * @param {object} p.feature portalFeatures-Dokument der Funktion (D3b: siteKey, unlockedByPurchase; der Registereintrag
- * config.features.termine wird nicht gebraucht)
+ * @param {object} [p.feature] portalFeatures-Dokument der Funktion (gelesen: siteKey, customerId, projectId, unlockedByPurchase,
+ * invoiceUrl; der Registereintrag config.features.termine wird nicht gebraucht)
* @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl)
* @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine
- * @param {object|null} [p.fragebogen] geparster Fragebogen oder null (D3b: Kundenwuensche zum Einbau)
+ * @param {object|null} [p.fragebogen] geparster Fragebogen oder null (gelesen: einbau.modus, knopfText, stelle, gestaltung, bemerkung)
* @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert)
* @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError
* @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen
* @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name
- * @param {boolean} [p.entwurf] D3b: Rechtstexte sind noch ENTWURF (Warnung); Standard RECHTSTEXTE_ENTWURF
+ * @param {boolean} [p.entwurf] Rechtstexte sind noch ENTWURF (erste Warnung ENTWURF_BANNER); Standard RECHTSTEXTE_ENTWURF
* @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}}
- * D3a: warnungen [] und kurz '' (kommen mit D3b und D4); ohne Einstellung keine Abschnitte. Die Anrede der Gaeste kommt aus einstellung.anrede.
+ * abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze.
+ * text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie).
+ * Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert". `kurz` ist '' (kommt mit D4).
*/
export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt')
const stand = { version, updatedAt }
- if (!istObjekt(einstellung)) return { stand, warnungen: [], abschnitte: [], text: '', kurz: '' }
+ const banner = entwurf ? [ENTWURF_BANNER] : []
+ const gemeinsam = {
+ feature,
+ siteKey: zeile(feature?.siteKey),
+ portal: zeile(config.publicBaseUrl),
+ wunsch: kundenwunsch(fragebogen),
+ anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '',
+ }
+
+ if (!istObjekt(einstellung)) {
+ const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })]
+ return { stand, warnungen: [...banner, 'Keine Einstellungen gespeichert'], abschnitte, text: volltext(abschnitte), kurz: '' }
+ }
const ressourcen = liste(einstellung.ressourcen).filter(istObjekt)
const geaendert = updatedAt ? new Date(updatedAt) : null
+ const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const daten = {
+ ...gemeinsam,
e: einstellung,
now,
+ entwurf: Boolean(entwurf),
heuteTag: heute(now),
version,
geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '',
- betrieb: istObjekt(einstellung.betrieb) ? einstellung.betrieb : {},
+ betrieb,
+ telefon: zeile(betrieb.telefon),
kunde: zeile(kunde?.name),
projekt: projektText(project),
farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE,
- portal: zeile(config.publicBaseUrl),
haltezeit: config.termine?.holdMinutes,
ressourcen,
leistungen: liste(einstellung.leistungen).filter(istObjekt),
nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])),
+ datenschutz: datenschutzTexte(einstellung),
}
const abschnitte = ABSCHNITTE.map((baue) => baue(daten))
- return { stand, warnungen: [], abschnitte, text: volltext(abschnitte), kurz: '' }
+ // der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt
+ const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du'
+ return {
+ stand,
+ warnungen: warnungenAus(daten),
+ abschnitte,
+ text: volltext(abschnitte.filter((a) => a.id !== andere)),
+ kurz: '',
+ }
}
diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js
index 115c8c2..3c45c51 100644
--- a/test/termineEinbauanleitung.test.js
+++ b/test/termineEinbauanleitung.test.js
@@ -4,8 +4,9 @@ import { readFileSync } from 'node:fs'
import { config } from '../server/config.js'
import { VORGABE_FARBE, neuerFeedToken } from '../server/services/termine/einstellungen.js'
import { ausVorlage } from '../server/services/termine/vorlagen.js'
+import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from '../server/services/termine/rechtstexte.js'
import {
- cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
+ ENTWURF_BANNER, cssVorschlag, einbauanleitung, einbauSnippet, esc, kontrast, kuerzeListe, luminanz, textFarbeAuf, wochenplanText,
} from '../server/services/termine/einbauanleitung.js'
// Einbau-Snippet und Farbvorschlag des Terminplaners (TP2 Task D2). Reine Funktionen ohne Appwrite und ohne config.
@@ -281,21 +282,22 @@ function erzeuge(zusatz = {}) {
const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id)
const zeilen = (text) => text.split('\n')
-test('D3a: Ergebnisform, die sechs Abschnitte in fester Reihenfolge, keiner kopierbar', () => {
- const r = erzeuge()
+test('D3a: Ergebnisform, die ersten sechs Abschnitte in fester Reihenfolge, keiner davon kopierbar', () => {
+ const r = erzeuge({ entwurf: false })
assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen'])
- assert.deepEqual(r.abschnitte.map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen'])
+ assert.deepEqual(r.abschnitte.slice(0, 6).map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen'])
for (const a of r.abschnitte) {
assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
- assert.equal(a.kopierbar, false, a.id)
assert.ok(a.titel && typeof a.titel === 'string', a.id)
assert.ok(a.text && typeof a.text === 'string', a.id)
}
- assert.deepEqual(r.warnungen, [], 'Warnungen kommen mit D3b')
+ for (const a of r.abschnitte.slice(0, 6)) assert.equal(a.kopierbar, false, a.id)
+ assert.deepEqual(r.warnungen, [], 'ein vollstaendiger Betrieb ohne Entwurf-Kennzeichnung hat keine Warnungen')
assert.equal(r.kurz, '', 'die Kurzfassung kommt mit D4')
- // der Volltext fuehrt jeden Abschnitt mit Ueberschrift
- assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), r.abschnitte.map((a) => `## ${a.titel}`))
- for (const a of r.abschnitte) assert.ok(r.text.includes(a.text), a.id)
+ // der Volltext fuehrt jeden Abschnitt mit Ueberschrift, die Datenschutz-Fassung der anderen Anrede fehlt (D3b)
+ const ohneDu = r.abschnitte.filter((a) => a.id !== 'datenschutz_du')
+ assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), ohneDu.map((a) => `## ${a.titel}`))
+ for (const a of ohneDu) assert.ok(r.text.includes(a.text), a.id)
})
test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => {
@@ -587,7 +589,10 @@ test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und K
kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM },
fragebogen: { geheim: GEHEIM },
}))
- for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE', BETRIEB.email]) assert.ok(!json.includes(verboten), verboten)
+ for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE']) assert.ok(!json.includes(verboten), verboten)
+ // die Betriebs-E-Mail steht nur in den beiden Datenschutz-Absaetzen (Verantwortlicher), sonst nirgends
+ const r = erzeuge({ einstellung: e })
+ for (const a of r.abschnitte) assert.equal(a.text.includes(BETRIEB.email), a.id.startsWith('datenschutz_'), a.id)
})
test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => {
@@ -597,7 +602,7 @@ test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-
e.leistungen[0].name = 'Schnitt\r\n## Gastfelder'
e.abwesenheiten[3].titel = 'Ferien\n## Modell'
const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } })
- assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 6, r.text)
+ assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12, r.text) // 13 Abschnitte, nur eine Datenschutz-Fassung
assert.ok(r.text.includes(' & "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein')
assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text)
assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1)
@@ -645,10 +650,9 @@ test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung ver
assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026'))
})
-test('D3a ohne Einstellung wirft nichts und liefert keine Abschnitte; ein ungueltiges now wirft', () => {
+test('D3a ohne Einstellung wirft nichts; ein ungueltiges now wirft', () => {
const r = erzeuge({ einstellung: null, version: 0, updatedAt: null })
- assert.deepEqual(r.abschnitte, [])
- assert.equal(r.text, '')
+ assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'], 'D3b: nur der Einbau-Code')
assert.deepEqual(r.stand, { version: 0, updatedAt: null })
for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) {
assert.throws(() => erzeuge({ now }), TypeError, String(now))
@@ -684,3 +688,448 @@ test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N
assert.equal(l[0], '50 Leistungen (50 aktiv):')
assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50)
})
+
+// ================================================================ Generator einbauanleitung() 2 (Task D3b)
+// Einbau, Snippet, CSS, Datenschutz-Absaetze, Checkliste, Abrechnung, Warnungen, Volltext.
+
+const ALLE_IDS = [
+ 'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen',
+ 'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung',
+]
+const KOPIERBAR = ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du']
+const KNOPF_FRAGEBOGEN = {
+ einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht, wie die Seite', bemerkung: '' },
+}
+const PAKET_FEATURE = { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup', unlockedByPurchase: true }
+const hatText = (ergebnis, ...teile) => teile.every((t) => ergebnis.text.includes(t))
+
+test('D3b: die 13 Abschnitt-IDs in fester Reihenfolge, kopierbar nur Snippet, CSS und beide Datenschutz-Absaetze', () => {
+ const r = erzeuge()
+ assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS)
+ for (const a of r.abschnitte) {
+ assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id)
+ assert.equal(a.kopierbar, KOPIERBAR.includes(a.id), a.id)
+ assert.ok(a.titel && a.text, a.id)
+ }
+ assert.equal(new Set(r.abschnitte.map((a) => a.titel)).size, 13, 'jede Ueberschrift kommt einmal vor')
+})
+
+test('D3b: ENTWURF_BANNER hat den Wortlaut aus dem Brief', () => {
+ assert.equal(ENTWURF_BANNER, 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen')
+})
+
+test('D3b Volltext: genau eine Datenschutz-Fassung, die zur Anrede passt; abschnitte enthalten beide', () => {
+ const sie = fixtureEinstellung()
+ const du = fixtureEinstellung()
+ du.anrede = 'du'
+ const textSie = datenschutzAbsatz(sie, 'sie')
+ const textDu = datenschutzAbsatz(sie, 'du')
+ assert.notEqual(textSie, textDu)
+
+ const a = erzeuge({ einstellung: sie })
+ assert.equal(abschnitt(a, 'datenschutz_sie').text, textSie)
+ assert.equal(abschnitt(a, 'datenschutz_du').text, datenschutzAbsatz(sie, 'du'))
+ assert.ok(a.text.includes(textSie) && !a.text.includes(textDu), 'Sie-Betrieb: nur die Sie-Fassung')
+ assert.ok(a.text.includes(`## ${abschnitt(a, 'datenschutz_sie').titel}`) && !a.text.includes(`## ${abschnitt(a, 'datenschutz_du').titel}`))
+
+ const b = erzeuge({ einstellung: du })
+ assert.deepEqual(b.abschnitte.map((x) => x.id), ALLE_IDS, 'die Abschnitte bleiben gleich, nur der Volltext waehlt')
+ assert.ok(b.text.includes(datenschutzAbsatz(du, 'du')) && !b.text.includes(datenschutzAbsatz(du, 'sie')), 'Du-Betrieb: nur die Du-Fassung')
+ assert.equal(abschnitt(b, 'datenschutz_sie').text, datenschutzAbsatz(du, 'sie'))
+ // jeder andere Abschnitt steht im Volltext, in der Reihenfolge der Abschnitte
+ const ids = b.abschnitte.filter((x) => x.id !== 'datenschutz_sie').map((x) => `## ${x.titel}`)
+ assert.deepEqual(zeilen(b.text).filter((z) => z.startsWith('## ')), ids)
+ // eine unbekannte Anrede gilt wie in D3a als Sie
+ const seltsam = fixtureEinstellung()
+ seltsam.anrede = 'Herr'
+ assert.ok(erzeuge({ einstellung: seltsam }).text.includes(datenschutzAbsatz(seltsam, 'sie')))
+})
+
+test('D3b Datenschutz-Absatz: kein ENTWURF-Banner im kopierbaren Text, die Betriebs-E-Mail steht nur dort', () => {
+ const r = erzeuge({ entwurf: true })
+ for (const id of ['datenschutz_sie', 'datenschutz_du']) {
+ const t = abschnitt(r, id).text
+ assert.ok(!t.includes('ENTWURF'), id)
+ assert.ok(t.startsWith('Online-Terminbuchung'), id)
+ assert.ok(t.includes(BETRIEB.email), id)
+ }
+})
+
+test('D3b Snippet und CSS: genau das, was die Bausteine liefern, ohne Fragebogen im Modus inline', () => {
+ const r = erzeuge()
+ const erwartet = einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'inline' })
+ assert.equal(abschnitt(r, 'snippet').text, erwartet)
+ assert.ok(erwartet.includes('data-modus="inline"') && !erwartet.includes('data-knopf'))
+ assert.ok(erwartet.includes(`data-webklar-termine="${SITE_KEY}"`) && erwartet.includes(`data-api="${config.publicBaseUrl}"`))
+ assert.ok(erwartet.includes('
Termine telefonisch unter 0631 123456.
'), 'der Ausweichtext nennt die Telefonnummer des Betriebs') + assert.equal(abschnitt(r, 'css').text, cssVorschlag('#9d4b6f').css) + assert.ok(abschnitt(r, 'css').text.includes('--wkt-akzent:#9d4b6f;')) + // Fragebogen ohne Modus oder mit unbekanntem Modus: inline + for (const fragebogen of [{ einbau: { modus: null } }, { einbau: { modus: 'dialog' } }, { einbau: 'kaputt' }, {}, 'text']) { + assert.equal(abschnitt(erzeuge({ fragebogen }), 'snippet').text, erwartet, JSON.stringify(fragebogen)) + } + // die Portal-Adresse wird beim Aufruf gelesen + const vorher = config.publicBaseUrl + try { + config.publicBaseUrl = 'https://portal.test.example' + assert.ok(abschnitt(erzeuge(), 'snippet').text.includes('data-api="https://portal.test.example"')) + } finally { + config.publicBaseUrl = vorher + } +}) + +test('D3b Snippet: Telefonnummer und siteKey sind maskiert (kein Ausbrechen aus dem Platzhalter)', () => { + const e = fixtureEinstellung() + e.betrieb.telefon = '0631 12' + const r = erzeuge({ einstellung: e, feature: { ...PAKET_FEATURE, siteKey: 'a">']) { + assert.ok(einbau.includes(wunsch), `${wunsch}\n---\n${einbau}`) + } + assert.equal((einbau.match(/Kundenwunsch \(Text des Kunden\)/g) || []).length, 1, 'ein einziger Block') + // Stelle, Gestaltung und Bemerkung stehen in keinem anderen Abschnitt (der Knopf-Text nur zusaetzlich im Snippet) + for (const a of r.abschnitte.filter((x) => x.id !== 'einbau')) { + for (const wunsch of ['Startseite unter dem Hero', 'Schlicht, wie die Seite', 'alert(1)']) assert.ok(!a.text.includes(wunsch), `${wunsch} in ${a.id}`) + } + assert.ok(!snippet.includes('alert(1)') && !abschnitt(r, 'css').text.includes('alert(1)')) + assert.equal((snippet.match(/