From 13bbadc729d2535cbe7f8304980ec03871a6572d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 00:07:38 +0000 Subject: [PATCH] Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen) einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt, kunde, entwurf }) setzt die ersten sechs Abschnitte der Team-Anleitung zusammen (modell, leistungen, ressourcen, regeln, gastfelder, benachrichtigungen, feste Reihenfolge, nicht kopierbar) plus stand { version, updatedAt } und den Volltext. Rein: kein I/O, config nur beim Aufruf (Portal-Adresse, Haltezeit); gleiche Eingabe und gleiches now ergeben gleiche Ausgabe, die Eingabe bleibt unveraendert. Feldweissliste statt Objektkopie: nie feedToken, keine Betriebs-E-Mail, keine Leistungsbeschreibung, keine unbekannten Zusatzfelder. Kundentexte stehen unmaskiert als Text, einzeilige Werte werden auf eine Zeile gebracht (keine vorgetaeuschte Ueberschrift). Abwesenheiten und Sonderoeffnungen nur ab heute (Berliner Kalendertag), Listen ueber 20 Eintraege enden mit "und N weitere". Hilfen wochenplanText und kuerzeListe sind exportiert. Warnungen und Kurzfassung bleiben leer (kommen mit D3b und D4); ohne Einstellung entstehen keine Abschnitte. Co-Authored-By: Claude Sonnet 5.5 --- server/services/termine/einbauanleitung.js | 324 +++++++++++++- test/termineEinbauanleitung.test.js | 484 ++++++++++++++++++++- 2 files changed, 800 insertions(+), 8 deletions(-) diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js index d13541b..fa91905 100644 --- a/server/services/termine/einbauanleitung.js +++ b/server/services/termine/einbauanleitung.js @@ -1,12 +1,18 @@ -import { VORGABE_FARBE } from './einstellungen.js' +import { config } from '../../config.js' +import { VORGABE_FARBE, preisHinweis, preisText, sichtbareLeistungen, zahlungText, zeigtWiderruf } from './einstellungen.js' +import { RECHTSTEXTE_ENTWURF } from './rechtstexte.js' +import { datumKurz, heute } from './zeit.js' /** - * Einbau des Terminplaners auf der Website des Kunden: Bausteine fuer Einbau-Code und Farbvorschlag - * (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt Einbauanleitung; Anleitung fuer das Team: - * docs/funktionen/termine-einbau.md, Abschnitte 1 und 3). + * Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt + * Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md). * - * Reine Funktionen ohne Appwrite und ohne config: die Adresse 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). + * Zwei Teile in einer Datei: + * - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse + * 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). * * 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 @@ -119,3 +125,309 @@ export function cssVorschlag(farbe) { ].join('\n') return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise } } + +// ================================================================ 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`). +// +// 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. + +const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] +const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ +const MAX_ABWESENHEITEN = 20 +const MAX_SONDEROEFFNUNGEN = 20 +const NICHT_ANGEGEBEN = 'nicht angegeben' +const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' } +const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' } +const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' } + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const liste = (wert) => (Array.isArray(wert) ? wert : []) +const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}` +const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe) + +/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */ +const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '') + +/** '2026-10-05' => '05.10.2026'. */ +const tagText = (tag) => tag.split('-').reverse().join('.') + +/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */ +function fensterText(fenster) { + return liste(fenster) + .filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string') + .map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`) + .join(', ') +} + +/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */ +export function wochenplanText(plan) { + const aus = [] + for (let tag = 1; tag <= 7; tag += 1) { + const text = fensterText(istObjekt(plan) ? plan[tag] : null) + if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`) + } + return aus +} + +/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */ +export function kuerzeListe(eintraege, max) { + if (eintraege.length <= max) return [...eintraege] + return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`] +} + +const neuerAbschnitt = (id, titel, text) => ({ id, titel, text, kopierbar: false }) + +/** Zeilen als Aufzaehlung: '- Eintrag'. */ +const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`) + +// ---------------------------------------------------------------- Abschnitt Modell + +function standText(d) { + if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version' + return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}` +} + +function abschnittModell(d) { + const { e, betrieb: b } = d + const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length + const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN + const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '') + return neuerAbschnitt('modell', 'Modell', [ + `Erzeugt am ${datumKurz(d.now)}`, + `Stand der Einstellungen: ${standText(d)}`, + ...(d.kunde ? [`Kunde: ${d.kunde}`] : []), + ...(d.projekt ? [`Projekt: ${d.projekt}`] : []), + `Betrieb: ${angabe(b.name)}`, + `Anschrift: ${angabe(b.adresse)}`, + `Telefon: ${angabe(b.telefon)}`, + `Impressum: ${angabe(b.impressumUrl)}`, + `Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`, + ...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []), + ...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []), + `Farbe: ${d.farbe}`, + `Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`, + `Gäste: ${gaeste}`, + `Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), ` + + `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`, + `Portal-Adresse (data-api): ${d.portal}`, + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Leistungen + +/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */ +function zuordnungText(ids, nachId) { + const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean) + if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet' + const plaetze = treffer.filter((r) => r.typ === 'platz').length + const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze' + return `${label}: ${treffer.map((r) => r.name).join(', ')}` +} + +function leistungsZeile(l, nachId) { + const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt' + const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : '' + return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, ` + + `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}` +} + +function abschnittLeistungen(d) { + if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.') + const aktiv = d.leistungen.filter((l) => l.aktiv).length + return neuerAbschnitt('leistungen', 'Leistungen', [ + `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`, + ...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Ressourcen + +function ressourcenBlock(r) { + const plan = wochenplanText(r.plan) + return [ + `- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`, + ...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`), + ].join('\n') +} + +/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */ +function abwesenheitsZeilen(d) { + const eintraege = liste(d.e.abwesenheiten) + .filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag) + .sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0)) + .map((a) => { + const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}` + const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle' + return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})` + }) + return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN)) +} + +/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */ +function sonderoeffnungsZeilen(d) { + const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {} + const eintraege = Object.keys(roh) + .filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag) + .sort() + .map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`) + return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN)) +} + +function abschnittRessourcen(d) { + const abwesenheiten = abwesenheitsZeilen(d) + const sonder = sonderoeffnungsZeilen(d) + return neuerAbschnitt('ressourcen', 'Personen und Plätze', [ + d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.', + ...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []), + ...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []), + ].join('\n\n')) +} + +// ---------------------------------------------------------------- Abschnitt Regeln + +/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */ +function vorlaufText(minuten) { + if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN + if (minuten === 0) return 'kein Vorlauf' + if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage') + if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden') + return anzahl(minuten, 'Minute', 'Minuten') +} + +function stornoZeilen(storno) { + const text = typeof storno?.text === 'string' ? storno.text.trim() : '' + if (!text) return ['Stornobedingungen: keine hinterlegt'] + return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)] +} + +function abschnittRegeln(d) { + const { e } = d + const r = istObjekt(e.regeln) ? e.regeln : {} + const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN) + return neuerAbschnitt('regeln', 'Regeln', [ + `Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`, + `Vorlauf: ${vorlaufText(r.vorlaufMin)}`, + `Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`, + `Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`, + `Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`, + `Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`, + `Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`, + zahlungText(e), + `Preishinweis: ${preisHinweis(e)}`, + zeigtWiderruf(e) + ? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt' + : 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)', + ...stornoZeilen(e.storno), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Gastfelder + +function abschnittGastfelder(d) { + const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {} + const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => { + const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text' + return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})` + }) + return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [ + 'Name: Pflicht', + 'E-Mail-Adresse: Pflicht', + `Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`, + `Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`, + ...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Benachrichtigungen + +function abschnittBenachrichtigungen(d) { + const { e } = d + const sichtbar = sichtbareLeistungen(e) + const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage') + const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage') + const widerruf = zeigtWiderruf(e) + const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher) + const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : '' + return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [ + `Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), ` + + 'Antworten gehen an den Betrieb.', + ['Mails an Gäste:', ...aufzaehlung([ + `Bestätigungslink zur E-Mail-Adresse${haltezeit}`, + ...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []), + ...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []), + ...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []), + 'Absage und Verschiebung', + ...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []), + ])].join('\n'), + ['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([ + ...(sofort ? ['Neue Buchung'] : []), + ...(anfrage ? ['Neue Terminanfrage'] : []), + 'Absage oder Verschiebung durch den Gast', + ...(widerruf ? ['Widerruf'] : []), + ])].join('\n'), + 'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.', + ].join('\n\n')) +} + +// ---------------------------------------------------------------- Zusammenbau + +const ABSCHNITTE = [abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen] + +/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */ +const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n') + +/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */ +function projektText(project) { + const name = zeile(project?.projectName) || zeile(project?.subdomain) + const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain) + return name && domain && name !== domain ? `${name} (${domain})` : name || domain +} + +/** + * Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now` + * 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.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 {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 + * @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. + */ +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 ressourcen = liste(einstellung.ressourcen).filter(istObjekt) + const geaendert = updatedAt ? new Date(updatedAt) : null + const daten = { + e: einstellung, + now, + heuteTag: heute(now), + version, + geaendert: geaendert && !Number.isNaN(geaendert.getTime()) ? datumKurz(geaendert) : '', + betrieb: istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}, + 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' }])), + } + const abschnitte = ABSCHNITTE.map((baue) => baue(daten)) + return { stand, warnungen: [], abschnitte, text: volltext(abschnitte), kurz: '' } +} diff --git a/test/termineEinbauanleitung.test.js b/test/termineEinbauanleitung.test.js index 005fc79..115c8c2 100644 --- a/test/termineEinbauanleitung.test.js +++ b/test/termineEinbauanleitung.test.js @@ -1,11 +1,16 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import { VORGABE_FARBE } from '../server/services/termine/einstellungen.js' -import { cssVorschlag, einbauSnippet, esc, kontrast, luminanz, textFarbeAuf } from '../server/services/termine/einbauanleitung.js' +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 { + 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. // Das Snippet war bis D2 in server/routes/termine.js; die Aufrufstelle und ihre Antwort-Tests stehen in termineRoutes.test.js. +// Der Generator einbauanleitung() (Task D3a, ganz unten) liest config nur beim Aufruf (Portal-Adresse), sonst ist auch er rein. const SITE_KEY = 'abcdefghijklmnop' const API = 'https://project.webklar.com' @@ -204,3 +209,478 @@ test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`) } }) + +// ================================================================ Generator einbauanleitung() 1 (Task D3a) +// Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Rein: kein I/O, nur ausdruecklich benannte Felder. + +const NOW = new Date('2026-10-05T10:00:00Z') // Montag, 12:00 Berliner Zeit +const SPAETER = new Date('2026-10-06T10:00:00Z') // einen Tag spaeter: nur die "Erzeugt am"-Zeile aendert sich +const GEHEIM = 'XYZ-GEHEIM' +const BETRIEB = { + name: 'Salon Muster', + email: 'inhaber-intern@salon-muster.example', + telefon: '0631 123456', + adresse: 'Hauptstraße 1, 67655 Kaiserslautern', + kleinunternehmer: false, + impressumUrl: 'https://salon-muster.example/impressum', + datenschutzUrl: 'https://salon-muster.example/datenschutz', + bundesland: 'RP', +} + +/** Salon-Vorlage mit Farbe, zwei Personen und einem Platz, Wochenplan, Abwesenheiten, Sonderoeffnungen und einer Anfrage-Leistung. */ +function fixtureEinstellung() { + const e = ausVorlage('salon', BETRIEB, NOW) + e.farbe = '#9d4b6f' + const [anna] = e.ressourcen + anna.name = 'Anna' + anna.plan = { 1: [['09:00', '12:30'], ['14:00', '18:00']], 3: [['09:00', '12:30']], 7: [['10:00', '12:00']] } + e.ressourcen.push( + { id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() }, + { id: 'r_stuhl001', typ: 'platz', name: 'Stuhl 2', aktiv: false, plan: {}, feedToken: neuerFeedToken() }, + ) + e.leistungen[0].ressourcen = [anna.id, 'r_ben00001'] + e.leistungen[1].aktiv = false // Damenschnitt + e.leistungen[2].buchungsart = 'anfrage' + e.leistungen[2].preisArt = 'keiner' + e.leistungen[2].preisCent = 0 + e.abwesenheiten = [ + { id: 'a_alt00001', ressourceId: anna.id, von: '2026-09-28', bis: '2026-10-02', titel: 'Urlaub vorbei' }, + { id: 'a_jetzt001', ressourceId: 'r_ben00001', von: '2026-10-05', bis: '2026-10-05', titel: 'Fortbildung' }, + { id: 'a_laeuft01', ressourceId: anna.id, von: '2026-10-01', bis: '2026-10-07', titel: 'Kur' }, + { id: 'a_zukunft1', ressourceId: null, von: '2026-12-24', bis: '2026-12-31', titel: 'Betriebsferien' }, + ] + e.sonderoeffnungen = { '2026-10-03': [['09:00', '12:00']], '2026-12-24': [], '2026-11-07': [['10:00', '14:00']] } + e.gastFelder = { + telefon: 'pflicht', + nachricht: 'optional', + zusatz: [ + { key: 'haare', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true }, + { key: 'wunsch', label: 'Wunschfrisur', typ: 'text', optionen: [], pflicht: false }, + ], + } + return e +} + +const PROJEKT = { projectName: 'Salon Muster Website', subdomain: 'salon-muster', liveDomain: 'salon-muster.example', previewUrl: 'https://salon-muster.project.webklar.com' } + +/** Standard-Aufruf des Generators; Abweichungen ueber `zusatz`. */ +function erzeuge(zusatz = {}) { + return einbauanleitung({ + feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup' }, + project: PROJEKT, + einstellung: fixtureEinstellung(), + fragebogen: null, + version: 3, + updatedAt: '2026-10-03T08:15:00.000Z', + now: NOW, + kunde: { name: 'Salon Muster GbR' }, + ...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() + 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']) + 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') + 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) +}) + +test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => { + assert.deepEqual(erzeuge().stand, { version: 3, updatedAt: '2026-10-03T08:15:00.000Z' }) + assert.deepEqual(erzeuge({ updatedAt: undefined }).stand, { version: 3, updatedAt: null }) + assert.deepEqual(erzeuge({ version: 0, updatedAt: undefined }).stand, { version: 0, updatedAt: null }) +}) + +test('D3a Modell: Betrieb, Kontakt, Adressen, Farbe, Anrede, Portal-Adresse, Erzeugt-am und Stand der Einstellungen', () => { + const modell = abschnitt(erzeuge(), 'modell').text + for (const erwartet of [ + 'Erzeugt am 05.10.2026', + 'Stand der Einstellungen: Version 3, zuletzt geändert 03.10.2026', + 'Kunde: Salon Muster GbR', + 'Projekt: Salon Muster Website (salon-muster.example)', + 'Betrieb: Salon Muster', + 'Anschrift: Hauptstraße 1, 67655 Kaiserslautern', + 'Telefon: 0631 123456', + 'Impressum: https://salon-muster.example/impressum', + 'Datenschutzerklärung: https://salon-muster.example/datenschutz', + 'Farbe: #9d4b6f', + 'Anrede der Gäste: Sie', + 'Gäste: Privatpersonen (Verbraucher)', + `Portal-Adresse (data-api): ${config.publicBaseUrl}`, + 'Umfang: 3 Personen und Plätze (2 aktiv), 4 Leistungen (3 aktiv)', + ]) assert.ok(zeilen(modell).includes(erwartet), `${erwartet}\n---\n${modell}`) +}) + +test('D3a Modell: fehlende Angaben stehen als nicht angegeben, Du-Anrede, Projekt ohne Domain, Version 0, keine Kundenzeile', () => { + const e = fixtureEinstellung() + e.betrieb.telefon = '' + e.betrieb.adresse = '' + e.betrieb.impressumUrl = null + e.betrieb.datenschutzUrl = null + e.anrede = 'du' + e.recht = { gaesteSind: 'gemischt', freizeit: true } + const modell = abschnitt(erzeuge({ einstellung: e, project: { projectName: 'Nur Name' }, kunde: null, version: 0, updatedAt: null }), 'modell').text + const z = zeilen(modell) + for (const erwartet of [ + 'Betrieb: Salon Muster', + 'Anschrift: nicht angegeben', + 'Telefon: nicht angegeben', + 'Impressum: nicht angegeben', + 'Datenschutzerklärung: nicht angegeben', + 'Anrede der Gäste: Du', + 'Gäste: Privatpersonen und Unternehmen, Freizeitleistung', + 'Projekt: Nur Name', + 'Stand der Einstellungen: keine gespeicherte Version', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${modell}`) + assert.ok(!z.some((x) => x.startsWith('Kunde:')), 'ohne Kunde keine Kundenzeile') +}) + +test('D3a Modell: AGB- und Website-Adresse nur, wenn vorhanden; die Portal-Adresse wird beim Aufruf gelesen', () => { + const ohne = abschnitt(erzeuge(), 'modell').text + assert.ok(!/AGB|Website:/.test(ohne), ohne) + const e = fixtureEinstellung() + e.betrieb.agbUrl = 'https://salon-muster.example/agb' + e.betrieb.websiteUrl = 'https://salon-muster.example/' + const mit = zeilen(abschnitt(erzeuge({ einstellung: e }), 'modell').text) + assert.ok(mit.includes('AGB: https://salon-muster.example/agb')) + assert.ok(mit.includes('Website: https://salon-muster.example/')) + const vorher = config.publicBaseUrl + try { + config.publicBaseUrl = 'https://portal.test.example' + assert.ok(zeilen(abschnitt(erzeuge(), 'modell').text).includes('Portal-Adresse (data-api): https://portal.test.example')) + } finally { + config.publicBaseUrl = vorher + } +}) + +test('D3a Leistungen: Name, Dauer, Preis, Buchungsart, Personen; inaktive und Anfrage-Leistungen markiert', () => { + const text = abschnitt(erzeuge(), 'leistungen').text + const z = zeilen(text) + assert.equal(z[0], '4 Leistungen (3 aktiv):') + assert.ok(z.includes('- Herrenschnitt: 30 Min. (+ 5 Min. Pause danach), 28,00 €, sofort buchbar, Personen: Anna, Ben'), text) + assert.ok(z.includes('- Damenschnitt (inaktiv): 45 Min. (+ 5 Min. Pause danach), ab 45,00 €, sofort buchbar, Personen: Anna'), text) + assert.ok(z.includes('- Kinderschnitt: 30 Min. (+ 5 Min. Pause danach), kein Preis, nur als Anfrage, Personen: Anna'), text) + assert.ok(z.includes('- Färben: 120 Min. (+ 10 Min. Pause danach), ab 75,00 €, sofort buchbar, Personen: Anna'), text) + assert.equal(z.filter((x) => x.includes('(inaktiv)')).length, 1, 'nur die inaktive Leistung ist markiert') +}) + +test('D3a Leistungen: Plaetze heissen Plaetze, gemischt Personen und Plaetze, ohne Pause keine Klammer, unbekannte Zuordnung faellt weg', () => { + const e = fixtureEinstellung() + e.leistungen[0].ressourcen = ['r_stuhl001'] + e.leistungen[1].ressourcen = [e.ressourcen[0].id, 'r_stuhl001', 'r_gibtsnicht'] + e.leistungen[3].pufferNach = 0 + e.leistungen[3].ressourcen = [] + const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'leistungen').text) + assert.ok(z.some((x) => x.startsWith('- Herrenschnitt:') && x.endsWith('Plätze: Stuhl 2')), z.join('\n')) + assert.ok(z.some((x) => x.startsWith('- Damenschnitt (inaktiv):') && x.endsWith('Personen und Plätze: Anna, Stuhl 2')), z.join('\n')) + assert.ok(z.includes('- Färben: 120 Min., ab 75,00 €, sofort buchbar, keine Person und kein Platz zugeordnet'), z.join('\n')) +}) + +test('D3a Ressourcen: Wochenplan je Tag, nur kuenftige Abwesenheiten, Sonderoeffnungen ab heute', () => { + const text = abschnitt(erzeuge(), 'ressourcen').text + const z = zeilen(text) + assert.ok(z.includes('- Anna (Person)'), text) + assert.ok(z.includes(' Mo 09:00–12:30, 14:00–18:00'), text) + assert.ok(z.includes(' Mi 09:00–12:30'), text) + assert.ok(z.includes(' So 10:00–12:00'), text) + assert.ok(z.includes('- Ben (Person)') && z.includes(' Di 08:00–16:00'), text) + assert.ok(z.includes('- Stuhl 2 (Platz, inaktiv)'), text) + assert.ok(z.includes(' keine Zeiten eingetragen'), text) + // Abwesenheiten: abgelaufene fehlen, heute endende und laufende bleiben, nach Beginn sortiert + const ab = z.slice(z.indexOf('Abwesenheiten (ab heute):') + 1).filter((x) => x.startsWith('- ') && /Fortbildung|Kur|Betriebsferien|Urlaub/.test(x)) + assert.deepEqual(ab, [ + '- 01.10.2026–07.10.2026: Kur (Anna)', + '- 05.10.2026: Fortbildung (Ben)', + '- 24.12.2026–31.12.2026: Betriebsferien (alle)', + ]) + assert.ok(!text.includes('Urlaub vorbei'), 'die abgelaufene Abwesenheit steht nirgends') + // Sonderoeffnungen: der 03.10. liegt vor heute und faellt weg, der 07.11. kommt vor dem 24.12. + const so = z.slice(z.indexOf('Sonderöffnungszeiten (ab heute, für alle):') + 1).filter((x) => x.startsWith('- ')) + assert.deepEqual(so.slice(-2), ['- 07.11.2026: 10:00–14:00', '- 24.12.2026: geschlossen']) + assert.ok(!text.includes('03.10.2026')) +}) + +test('D3a Ressourcen: ohne Abwesenheiten und Sonderoeffnungen entfallen die Ueberschriften; ohne Ressourcen steht ein Hinweis', () => { + const e = fixtureEinstellung() + e.abwesenheiten = [{ id: 'a_alt00001', ressourceId: null, von: '2026-01-01', bis: '2026-01-02', titel: 'alt' }] + e.sonderoeffnungen = { '2026-01-01': [] } + const text = abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text + assert.ok(!text.includes('Abwesenheiten'), text) + assert.ok(!text.includes('Sonderöffnungszeiten'), text) + e.ressourcen = [] + assert.equal(abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text, 'Keine Personen oder Plätze angelegt.') +}) + +test('D3a Regeln: Raster, Vorlauf, Horizont, Absagefrist, Obergrenze, Storno-Text mit Fassung, Zahlung', () => { + const e = fixtureEinstellung() + const text = abschnitt(erzeuge({ einstellung: e }), 'regeln').text + const z = zeilen(text) + for (const erwartet of [ + 'Raster: 15 Minuten', + 'Vorlauf: 2 Stunden', + 'Buchungszeitraum: 60 Tage im Voraus', + 'Absage durch Gäste: bis 24 Stunden vor dem Termin', + 'Offene Termine je E-Mail-Adresse: höchstens 3', + 'Person wählbar: ja', + 'Feiertage: geschlossen (Bundesland RP)', + 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte', + 'Preishinweis: inkl. USt', + 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt', + `Stornobedingungen (Fassung ${e.storno.version}):`, + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + assert.match(e.storno.version, /^\d{4}-\d{2}-\d{2}-\d+$/) + assert.ok(text.includes(` ${e.storno.text}`), 'der Storno-Text steht vollstaendig, eingerueckt') +}) + +test('D3a Regeln: andere Werte, kein Storno-Text, Unternehmen ohne Widerruf, Kleinunternehmer, keine Zahlarten', () => { + const e = fixtureEinstellung() + e.regeln = { raster: 30, vorlaufMin: 0, horizontTage: 14, absageBisStd: 0, maxOffeneJeEmail: 1, personWaehlbar: false } + e.feiertageGeschlossen = false + e.storno = { text: '', version: '2026-10-01-2' } + e.recht = { gaesteSind: 'unternehmer', freizeit: false } + e.betrieb.kleinunternehmer = true + e.zahlung = { modus: 'vorort', vorOrtArten: [] } + const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text) + for (const erwartet of [ + 'Raster: 30 Minuten', + 'Vorlauf: kein Vorlauf', + 'Buchungszeitraum: 14 Tage im Voraus', + 'Absage durch Gäste: bis zum Terminbeginn', + 'Offene Termine je E-Mail-Adresse: höchstens 1', + 'Person wählbar: nein', + 'Feiertage: geöffnet wie im Wochenplan (Bundesland RP)', + 'Bezahlung vor Ort', + 'Preishinweis: kein Ausweis der USt nach § 19 UStG', + 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)', + 'Stornobedingungen: keine hinterlegt', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${z.join('\n')}`) + assert.ok(!z.join('\n').includes('2026-10-01-2'), 'ohne Text keine Fassung im Text') +}) + +test('D3a Regeln: Vorlauf in Minuten, Stunden und Tagen, Absagefrist in Tagen', () => { + const vorlauf = (minuten) => { + const e = fixtureEinstellung() + e.regeln.vorlaufMin = minuten + return zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text).find((x) => x.startsWith('Vorlauf:')) + } + assert.equal(vorlauf(45), 'Vorlauf: 45 Minuten') + assert.equal(vorlauf(60), 'Vorlauf: 1 Stunde') + assert.equal(vorlauf(90), 'Vorlauf: 90 Minuten') + assert.equal(vorlauf(1440), 'Vorlauf: 1 Tag') + assert.equal(vorlauf(20160), 'Vorlauf: 14 Tage') +}) + +test('D3a Gastfelder: Name und E-Mail immer Pflicht, Telefon und Nachricht nach Einstellung, Zusatzfelder mit Auswahl', () => { + const text = abschnitt(erzeuge(), 'gastfelder').text + const z = zeilen(text) + for (const erwartet of [ + 'Name: Pflicht', + 'E-Mail-Adresse: Pflicht', + 'Telefon: Pflicht', + 'Nachricht: optional', + 'Zusatzfelder (2):', + '- Haarlänge (Auswahl: kurz, mittel, lang; Pflicht)', + '- Wunschfrisur (Text; optional)', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + const e = fixtureEinstellung() + e.gastFelder = { telefon: 'aus', nachricht: 'aus', zusatz: [] } + const leer = zeilen(abschnitt(erzeuge({ einstellung: e }), 'gastfelder').text) + assert.ok(leer.includes('Telefon: aus') && leer.includes('Nachricht: aus') && leer.includes('Zusatzfelder: keine'), leer.join('\n')) +}) + +test('D3a Benachrichtigungen: Mails an Gaeste nach Konfiguration, Mails an den Betrieb ohne Adresse, Kalender-Abo ohne Link', () => { + const text = abschnitt(erzeuge(), 'benachrichtigungen').text + const z = zeilen(text) + for (const erwartet of [ + 'Gäste werden mit „Sie“ angesprochen. Absender ist der Betriebsname (Salon Muster), Antworten gehen an den Betrieb.', + 'Mails an Gäste:', + '- Bestätigungslink zur E-Mail-Adresse (gültig 30 Minuten)', + '- Terminbestätigung (sofort buchbare Leistungen)', + '- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', + '- Erinnerung 24 Stunden vor dem Termin (nur wenn der Gast sie ankreuzt)', + '- Absage und Verschiebung', + '- Eingangsbestätigung des Widerrufs', + 'Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', + '- Neue Buchung', + '- Neue Terminanfrage', + '- Absage oder Verschiebung durch den Gast', + '- Widerruf', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + assert.ok(text.includes('Kalender-Abo (ICS)') && text.includes('im Portal'), text) + assert.ok(!text.includes(BETRIEB.email), 'die Betriebs-E-Mail steht nicht in der Anleitung') + // die Gueltigkeit des Bestaetigungslinks wird beim Aufruf aus der Konfiguration gelesen + const vorher = config.termine.holdMinutes + try { + config.termine.holdMinutes = 45 + assert.ok(zeilen(abschnitt(erzeuge(), 'benachrichtigungen').text).includes('- Bestätigungslink zur E-Mail-Adresse (gültig 45 Minuten)')) + } finally { + config.termine.holdMinutes = vorher + } +}) + +test('D3a Benachrichtigungen: ohne Erinnerung, ohne Anfrage-Leistung, ohne Widerruf und mit Du-Anrede entfallen die Zeilen', () => { + const e = fixtureEinstellung() + e.anrede = 'du' + e.erinnerung = { anbieten: false, stundenVorher: 24 } + e.leistungen[2].buchungsart = 'sofort' + e.recht = { gaesteSind: 'unternehmer', freizeit: false } + const text = abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text + const z = zeilen(text) + assert.ok(text.startsWith('Gäste werden mit „Du“ angesprochen.'), text) + for (const fehlt of ['- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', '- Neue Terminanfrage', '- Eingangsbestätigung des Widerrufs', '- Widerruf']) { + assert.ok(!z.includes(fehlt), `${fehlt}\n---\n${text}`) + } + assert.ok(!z.some((x) => x.startsWith('- Erinnerung')), text) + assert.ok(z.includes('- Neue Buchung') && z.includes('- Absage oder Verschiebung durch den Gast'), text) + // nur Anfrage-Leistungen: keine Terminbestaetigung und keine "Neue Buchung" + e.leistungen.forEach((l) => { l.buchungsart = 'anfrage' }) + const nurAnfrage = zeilen(abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text) + assert.ok(!nurAnfrage.includes('- Terminbestätigung (sofort buchbare Leistungen)') && !nurAnfrage.includes('- Neue Buchung')) + assert.ok(nurAnfrage.includes('- Neue Terminanfrage')) + // inaktive Leistungen zaehlen nicht + const f = fixtureEinstellung() + f.leistungen[2].aktiv = false + assert.ok(!zeilen(abschnitt(erzeuge({ einstellung: f }), 'benachrichtigungen').text).includes('- Neue Terminanfrage')) +}) + +test('D3a feedToken: jede Ressource hat einen echten Token, das Ergebnis enthaelt weder einen Token noch das Wort feedToken', () => { + const e = fixtureEinstellung() + assert.ok(e.ressourcen.length >= 3) + for (const r of e.ressourcen) assert.match(r.feedToken, /^[A-Za-z0-9_-]{43}$/) + const json = JSON.stringify(erzeuge({ einstellung: e })) + assert.ok(!json.includes('feedToken')) + assert.ok(!json.toLowerCase().includes('feedtoken')) + for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken), `Token von ${r.name} steht im Ergebnis`) +}) + +test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und Kopf tauchen nirgends auf', () => { + const e = fixtureEinstellung() + e.geheim = GEHEIM + e.betrieb.geheim = GEHEIM + e.regeln.geheim = GEHEIM + e.gastFelder.geheim = GEHEIM + e.gastFelder.zusatz[0].geheim = GEHEIM + e.storno.geheim = GEHEIM + e.ressourcen[0].geheim = GEHEIM + e.ressourcen[0].notiz = 'NOTIZ-INTERN' + e.leistungen[0].geheim = GEHEIM + e.leistungen[0].notiz = 'NOTIZ-INTERN' + e.leistungen[0].beschreibung = 'BESCHREIBUNG-NUR-FUER-GAESTE' + e.abwesenheiten[3].geheim = GEHEIM + const json = JSON.stringify(erzeuge({ + einstellung: e, + feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, geheim: GEHEIM, feedToken: GEHEIM, adminNote: GEHEIM }, + project: { ...PROJEKT, geheim: GEHEIM, apiKey: GEHEIM }, + 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) +}) + +test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => { + const e = fixtureEinstellung() + e.betrieb.name = 'Evil\n## Regeln\nRaster: 1' + e.ressourcen[0].name = ' & "Anna"' + 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.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) +}) + +test('D3a ist deterministisch und veraendert die Eingabe nicht', () => { + const e = fixtureEinstellung() + // Abwesenheiten absichtlich unsortiert: der Generator sortiert, darf dabei die Eingabe aber nicht umordnen + e.abwesenheiten.reverse() + const vorher = structuredClone(e) + const a = erzeuge({ einstellung: e }) + assert.deepEqual(e, vorher, 'nach dem ersten Aufruf: die Einstellung bleibt unveraendert (kein Sortieren oder Filtern in place)') + const b = erzeuge({ einstellung: e }) + assert.deepEqual(e, vorher, 'nach dem zweiten Aufruf ebenso') + assert.deepEqual(a, b) +}) + +test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung vergangener Dinge', () => { + const normal = (r) => ({ ...r, text: r.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT'), abschnitte: r.abschnitte.map((a) => ({ ...a, text: a.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT') })) }) + // einen Tag spaeter, ohne dass etwas ablaeuft (die Fortbildung liegt hier erst am 20.10.): nur die Erzeugt-am-Zeile unterscheidet + const spaeterEnde = fixtureEinstellung() + spaeterEnde.abwesenheiten[1].von = '2026-10-20' + spaeterEnde.abwesenheiten[1].bis = '2026-10-20' + const eins = erzeuge({ einstellung: spaeterEnde }) + const zwei = erzeuge({ now: SPAETER, einstellung: spaeterEnde }) + assert.notDeepEqual(eins, zwei) + assert.ok(abschnitt(eins, 'modell').text.includes('Erzeugt am 05.10.2026')) + assert.ok(abschnitt(zwei, 'modell').text.includes('Erzeugt am 06.10.2026')) + assert.deepEqual(normal(eins), normal(zwei), 'sonst bleibt alles gleich') + // mit der Fixture-Abwesenheit vom 05.10. faellt sie am Folgetag weg (bis < heute) + const a = erzeuge() + const b = erzeuge({ now: SPAETER }) + assert.ok(abschnitt(a, 'ressourcen').text.includes('Fortbildung')) + assert.ok(!abschnitt(b, 'ressourcen').text.includes('Fortbildung')) + assert.ok(abschnitt(b, 'ressourcen').text.includes('Kur'), 'die laufende Kur bleibt') + // zwei Monate spaeter: vergangene Abwesenheiten und Sonderoeffnungen fehlen, sonst bleibt alles gleich + const spaet = erzeuge({ now: new Date('2026-12-20T10:00:00Z') }) + const ressourcen = abschnitt(spaet, 'ressourcen').text + assert.ok(!ressourcen.includes('Kur') && !ressourcen.includes('Fortbildung') && !ressourcen.includes('07.11.2026'), ressourcen) + assert.ok(ressourcen.includes('Betriebsferien') && ressourcen.includes('24.12.2026: geschlossen'), ressourcen) + for (const id of ['leistungen', 'regeln', 'gastfelder', 'benachrichtigungen']) assert.equal(abschnitt(spaet, id).text, abschnitt(a, id).text, id) + // ein Abwesenheitstag, der in Berlin schon der naechste Tag ist, zaehlt nach Berliner Kalender (23:30 UTC = 01:30 Berlin am 06.10.) + const nachtgrenze = erzeuge({ now: new Date('2026-10-05T23:30:00Z') }) + assert.ok(!abschnitt(nachtgrenze, 'ressourcen').text.includes('Fortbildung'), 'der 05.10. ist in Berlin schon vorbei') + 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', () => { + const r = erzeuge({ einstellung: null, version: 0, updatedAt: null }) + assert.deepEqual(r.abschnitte, []) + assert.equal(r.text, '') + 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)) + } +}) + +test('D3a Hilfen: wochenplanText und kuerzeListe', () => { + assert.deepEqual(wochenplanText({ 1: [['09:00', '12:30'], ['14:00', '18:00']], 5: [['09:00', '13:00']] }), ['Mo 09:00–12:30, 14:00–18:00', 'Fr 09:00–13:00']) + assert.deepEqual(wochenplanText({ 7: [['10:00', '12:00']], 2: [] }), ['So 10:00–12:00'], 'leere Tage fehlen, Reihenfolge Montag bis Sonntag') + assert.deepEqual(wochenplanText({}), []) + assert.deepEqual(wochenplanText(undefined), []) + assert.deepEqual(wochenplanText({ 1: 'kaputt', 8: [['09:00', '10:00']], 3: [['09:00']] }), [], 'Unbrauchbares wird uebersprungen') + assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 3), ['a', 'b', 'c']) + assert.deepEqual(kuerzeListe(['a', 'b', 'c', 'd', 'e'], 2), ['a', 'b', '… und 3 weitere']) + assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 2), ['a', 'b', '… und 1 weitere']) + assert.deepEqual(kuerzeListe([], 5), []) +}) + +test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N weitere, Leistungen stehen vollstaendig', () => { + const e = fixtureEinstellung() + e.abwesenheiten = Array.from({ length: 30 }, (_, i) => ({ + id: `a_x${String(i).padStart(7, '0')}`, ressourceId: null, von: `2027-01-${String(i + 1).padStart(2, '0')}`, bis: `2027-01-${String(i + 1).padStart(2, '0')}`, titel: `Tag ${i + 1}`, + })) + e.sonderoeffnungen = Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`2027-02-${String(i + 1).padStart(2, '0')}`, [['10:00', '12:00']]])) + e.leistungen = Array.from({ length: 50 }, (_, i) => ({ ...e.leistungen[0], id: `s_l${String(i).padStart(6, '0')}`, name: `Leistung ${i + 1}` })) + const r = erzeuge({ einstellung: e }) + const z = zeilen(abschnitt(r, 'ressourcen').text) + assert.equal(z.filter((x) => /^- \d\d\.01\.2027/.test(x)).length, 20) + assert.ok(z.includes('- … und 10 weitere'), z.join('\n')) + assert.equal(z.filter((x) => /^- \d\d\.02\.2027/.test(x)).length, 20) + assert.ok(z.includes('- … und 5 weitere'), z.join('\n')) + const l = zeilen(abschnitt(r, 'leistungen').text) + assert.equal(l[0], '50 Leistungen (50 aktiv):') + assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50) +})