From 90692b646b3ccc34a9421447d3ed8a80e5794a1e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:22:04 +0000 Subject: [PATCH] Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen) Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/einbauanleitung.js | 262 ++++++++++- test/termineEinbauanleitung.test.js | 477 ++++++++++++++++++++- 2 files changed, 706 insertions(+), 33 deletions(-) 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(/