diff --git a/server/services/termine/rechtstexte.js b/server/services/termine/rechtstexte.js
index 8b404ab..378d8b5 100644
--- a/server/services/termine/rechtstexte.js
+++ b/server/services/termine/rechtstexte.js
@@ -2,8 +2,9 @@
* ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen.
*
* Rechtstext-Bausteine des Terminplaners fuer Gaeste (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitt 11).
- * Reine Funktionen und Konstanten, ohne Appwrite und ohne config. Die Texte sind Rechtstexte und stehen deshalb immer
- * in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem Formular folgt der Anrede des Betriebs.
+ * Reine Funktionen und Konstanten, ohne Appwrite; sie nutzen nur Hilfsfunktionen aus einstellungen.js (zeigtWiderruf). Die Texte
+ * sind Rechtstexte und stehen deshalb immer in der Sie-Form (auch bei Du-Betrieben); nur der Datenschutz-Hinweis unter dem
+ * Formular und der Datenschutz-Absatz folgen der Anrede.
*
* widerrufsbelehrung: Muster der Anlage 1 und Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB
* (docs/rechtliches/quellen/, abgerufen 2026-10-02), Gestaltungshinweise fuer einen Dienstleistungsvertrag angewendet:
@@ -13,11 +14,20 @@
* Absatz zum elektronischen Webformular (es gibt nur die Widerrufsfunktion). test/termineRechtstexte.test.js prueft
* den Wortlaut gegen die Quelldateien.
*
+ * datenschutzAbsatz: Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs (Betrieb = Verantwortlicher).
+ * Der Wortlaut steht auch in docs/rechtliches/termine-datenschutz-absatz.md (Fassung Sie / Fassung Du); test/termineRechtstexte.test.js
+ * haelt beide gleich. Aenderungen am Text immer in Markdown und Funktion gemeinsam.
+ *
* Die Rueckgabe ist reiner Text mit Zeilenumbruechen und NICHT HTML-maskiert: Die Mailvorlage maskiert beim Einsetzen.
*/
+import { zeigtWiderruf } from './einstellungen.js'
+
export const RECHTSTEXTE_VERSION = '2026-10-02'
+/** true, solange die Rechtstexte als ENTWURF gelten (Kopf KOPF der Dokumente in docs/rechtliches/); Block F setzt false zusammen mit den Koepfen. */
+export const RECHTSTEXTE_ENTWURF = true
+
const ZEILENUMBRUCH = '\n'
const ABSATZ = '\n\n'
@@ -123,6 +133,13 @@ const LOESCHFRIST_ABGESAGT_TAGE = 30
const LOESCHFRIST_UNBESTAETIGT_STUNDEN = 48
const AUFBEWAHRUNG_VORGABE_TAGE = 90
+/**
+ * Aufbewahrung vergangener Termine in Tagen; ein nicht ganzzahliger Wert ist die Vorgabe (90). Den Bereich 14 bis 365 stellt die
+ * Einstellung sicher (normalisiereEinstellung); der Loeschlauf (jobs.js) prueft ihn zusaetzlich beim Lesen.
+ */
+const aufbewahrungNachTermin = (einstellung) =>
+ Number.isInteger(einstellung?.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
+
/**
* Kurzer Datenschutz-Hinweis (Art. 13 DSGVO gestuft) vor dem Absenden; Sie oder Du nach einstellung.anrede.
* Die Loeschfristen entsprechen dem Loeschlauf: nicht bestaetigt 48 Stunden nach Ablauf der Bestaetigungsfrist, abgesagt
@@ -132,7 +149,7 @@ const AUFBEWAHRUNG_VORGABE_TAGE = 90
export function formularHinweis(einstellung) {
const daten = betriebsdaten(einstellung?.betrieb)
const du = einstellung.anrede === 'du'
- const tage = Number.isInteger(einstellung.aufbewahrungTage) ? einstellung.aufbewahrungTage : AUFBEWAHRUNG_VORGABE_TAGE
+ const tage = aufbewahrungNachTermin(einstellung)
const verantwortlich = verbinde([daten.name, daten.adresse])
const frist = `bis ${tage} Tage nach dem Termin, abgesagte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage, `
+ `nicht bestätigte Buchungen ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf der Bestätigungsfrist.`
@@ -147,6 +164,179 @@ export function formularHinweis(einstellung) {
+ `Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: ${verantwortlich}.`
}
+// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (recherche-recht.md 11.1)
+
+const DS_PROTOKOLL_TAGE = 14 // Server-Protokolle (Datenschutzerklaerung von WEBklar, Abschnitt 3)
+const DS_EMAIL_WARTESCHLANGE =
+ 'Bis zum Versand liegt die E-Mail in einer Warteschlange; nach dem Versand wird dort die Empfängeradresse samt Inhalt gelöscht, '
+ + 'nicht zustellbare E-Mails nach 48 Stunden.'
+
+/** Auftragsverarbeiter und Server: in beiden Anreden derselbe Text. */
+const DS_AUFTRAGSVERARBEITER =
+ 'Wer verarbeitet die Daten in unserem Auftrag? Den Terminkalender betreibt die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, '
+ + 'als unser Auftragsverarbeiter nach Art. 28 DSGVO. Die Daten liegen auf Servern der Hetzner Online GmbH, Industriestraße 25, '
+ + '91710 Gunzenhausen, in Deutschland (Rechenzentren Falkenstein und Nürnberg). Der E-Mail-Versand läuft ebenfalls über Hetzner. '
+ + 'Eine Übermittlung in Drittländer findet nicht statt.'
+
+const wenn = (bedingung, text) => (bedingung ? text : '')
+/** Sätze eines Absatzes, durch ein Leerzeichen getrennt; ein weggelassener Satz (leer) hinterlässt keine Lücke. */
+const saetze = (...liste) => liste.filter(Boolean).join(' ')
+
+/**
+ * Die fuenf bedingten Stellen des Absatzes (in der Markdown-Quelle „[[wenn …: …]]“): Telefon-Feld, Nachricht oder Zusatzfelder,
+ * Erinnerung, Widerruf (wie der Widerrufsknopf im Widget: zeigtWiderruf), Telefonnummer des Betriebs.
+ */
+function datenschutzBedingungen(einstellung, daten) {
+ const gast = einstellung.gastFelder || {}
+ return {
+ telefonFeld: gast.telefon !== 'aus',
+ freitext: gast.nachricht !== 'aus' || (Array.isArray(gast.zusatz) && gast.zusatz.length > 0),
+ erinnerung: Boolean(einstellung.erinnerung?.anbieten),
+ widerruf: zeigtWiderruf(einstellung),
+ telefon: daten.telefon !== '',
+ }
+}
+
+// Absaetze der Sie-Fassung, je Absatz eine kleine Funktion; d = { firma, verantwortlich, email, telefon, tage, ...Bedingungen }
+const DS_SIE = [
+ () => 'Online-Terminbuchung',
+ () => 'Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
+ (d) =>
+ 'Welche Daten? Ihren Namen, Ihre E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz'
+ + wenn(d.telefonFeld, ', auf Wunsch Ihre Telefonnummer')
+ + wenn(d.freitext, ' und die Angaben, die Sie uns im Formular mitteilen')
+ + '. Außerdem speichern wir, wann Sie den Termin bestätigt haben, welche Fassung der Hinweise Sie bei der Buchung gesehen haben '
+ + 'und welche Zustimmungen Sie erteilt haben'
+ + wenn(d.erinnerung, ', zum Beispiel ob Sie eine Erinnerung gewünscht haben')
+ + '.',
+ (d) =>
+ saetze(
+ 'Wofür und auf welcher Grundlage?',
+ 'Wir brauchen die Daten, um Ihren Termin zu verwalten, Ihnen den Termin zu bestätigen und Sie über Änderungen oder Absagen zu informieren.',
+ 'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf Ihre Anfrage).',
+ wenn(
+ d.erinnerung,
+ 'Wenn Sie eine Erinnerung vor dem Termin wünschen, beruht diese auf Ihrer Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. '
+ + 'Sie können sie jederzeit mit Wirkung für die Zukunft widerrufen, indem Sie uns eine kurze Nachricht senden oder den Termin über den Link in der E-Mail absagen.',
+ ),
+ wenn(
+ d.widerruf,
+ 'Widerrufen Sie einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir Ihre Erklärung mit Datum und Uhrzeit '
+ + 'und senden Ihnen eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).',
+ ),
+ 'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns Ihre IP-Adresse '
+ + 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).',
+ ),
+ (d) =>
+ 'E-Mails. Sie erhalten eine E-Mail, in der Sie Ihre E-Mail-Adresse und den Termin per Link bestätigen, danach eine Bestätigung'
+ + wenn(d.erinnerung, ' und, wenn Sie das angekreuzt haben, eine Erinnerung')
+ + `. Sagen wir den Termin ab oder verschieben ihn, informieren wir Sie ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. `
+ + `Absender ist ${d.firma}, Ihre Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
+ () => DS_AUFTRAGSVERARBEITER,
+ () =>
+ 'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt Ihr Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), '
+ + 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt Ihre IP-Adresse und Angaben zu Ihrem Browser übertragen. '
+ + `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). `
+ + 'Wir setzen dafür keine Cookies und speichern nichts auf Ihrem Gerät.',
+ (d) =>
+ `Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
+ + `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
+ + 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
+ + 'Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. '
+ + 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
+ (d) =>
+ saetze(
+ 'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
+ wenn(d.telefon, `Sie können uns auch telefonisch erreichen: ${d.telefonnummer}.`),
+ 'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit.',
+ ),
+ (d) =>
+ 'Ihre Rechte. Sie haben das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, '
+ + 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung können Sie jederzeit widerrufen. '
+ + `Sie können sich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`,
+]
+
+// Absaetze der Du-Fassung, gleicher Aufbau wie DS_SIE
+const DS_DU = [
+ () => 'Online-Terminbuchung',
+ () => 'Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten:',
+ (d) =>
+ 'Welche Daten? Deinen Namen, deine E-Mail-Adresse, den gewählten Termin, die gewählte Leistung und die gewählte Person oder den gewählten Platz'
+ + wenn(d.telefonFeld, ', auf Wunsch deine Telefonnummer')
+ + wenn(d.freitext, ' und die Angaben, die du uns im Formular mitteilst')
+ + '. Außerdem speichern wir, wann du den Termin bestätigt hast, welche Fassung der Hinweise du bei der Buchung gesehen hast '
+ + 'und welche Zustimmungen du erteilt hast'
+ + wenn(d.erinnerung, ', zum Beispiel ob du eine Erinnerung gewünscht hast')
+ + '.',
+ (d) =>
+ saetze(
+ 'Wofür und auf welcher Grundlage?',
+ 'Wir brauchen die Daten, um deinen Termin zu verwalten, dir den Termin zu bestätigen und dich über Änderungen oder Absagen zu informieren.',
+ 'Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO (Vertrag über den gebuchten Termin, bei einer Terminanfrage vorvertragliche Maßnahmen auf deine Anfrage).',
+ wenn(
+ d.erinnerung,
+ 'Wenn du eine Erinnerung vor dem Termin wünschst, beruht diese auf deiner Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO. '
+ + 'Du kannst sie jederzeit mit Wirkung für die Zukunft widerrufen, indem du uns eine kurze Nachricht sendest oder den Termin über den Link in der E-Mail absagst.',
+ ),
+ wenn(
+ d.widerruf,
+ 'Widerrufst du einen Vertrag über die Schaltfläche „Vertrag widerrufen“, speichern wir deine Erklärung mit Datum und Uhrzeit '
+ + 'und senden dir eine Eingangsbestätigung (Art. 6 Abs. 1 lit. c DSGVO in Verbindung mit § 356a BGB).',
+ ),
+ 'Zum Schutz vor Missbrauch (zum Beispiel automatisierten Buchungen) und um die Zahl der Anfragen zu begrenzen, merken wir uns deine IP-Adresse '
+ + 'kurzzeitig nur im Arbeitsspeicher des Servers, nicht dauerhaft (Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere Betrieb der Terminbuchung).',
+ ),
+ (d) =>
+ 'E-Mails. Du erhältst eine E-Mail, in der du deine E-Mail-Adresse und den Termin per Link bestätigst, danach eine Bestätigung'
+ + wenn(d.erinnerung, ' und, wenn du das angekreuzt hast, eine Erinnerung')
+ + `. Sagen wir den Termin ab oder verschieben ihn, informieren wir dich ebenfalls per E-Mail. Die E-Mails enthalten keine Werbung und keine Tracking-Pixel. `
+ + `Absender ist ${d.firma}, deine Antworten gehen an uns. ${DS_EMAIL_WARTESCHLANGE}`,
+ () => DS_AUFTRAGSVERARBEITER,
+ () =>
+ 'Beim Aufruf der Terminbuchung. Beim Aufruf dieser Seite lädt dein Browser das Programm der Terminbuchung vom Server der WEBklar GbR (project.webklar.com), '
+ + 'beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei werden technisch bedingt deine IP-Adresse und Angaben zu deinem Browser übertragen. '
+ + `Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach ${DS_PROTOKOLL_TAGE} Tagen (Art. 6 Abs. 1 lit. f DSGVO; berechtigtes Interesse ist der sichere und funktionsfähige Betrieb). `
+ + 'Wir setzen dafür keine Cookies und speichern nichts auf deinem Gerät.',
+ (d) =>
+ `Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir ${LOESCHFRIST_UNBESTAETIGT_STUNDEN} Stunden nach Ablauf dieser Frist. `
+ + `Bestätigte Termine löschen wir ${d.tage} Tage nach dem Termin, abgesagte oder abgelehnte Termine ${LOESCHFRIST_ABGESAGT_TAGE} Tage nach der Absage oder Ablehnung. `
+ + 'Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. '
+ + 'Wird die Online-Terminbuchung abgeschaltet, löschen wir alle Termindaten 30 Tage nach dem Abschalten. '
+ + 'Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders.',
+ (d) =>
+ saetze(
+ 'Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben.',
+ wenn(d.telefon, `Du kannst uns auch telefonisch erreichen: ${d.telefonnummer}.`),
+ 'Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit.',
+ ),
+ (d) =>
+ 'Deine Rechte. Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit sowie das Recht, '
+ + 'der Verarbeitung auf Grundlage berechtigter Interessen zu widersprechen (Art. 15 bis 21 DSGVO). Eine erteilte Einwilligung kannst du jederzeit widerrufen. '
+ + `Du kannst dich bei einer Datenschutzaufsichtsbehörde beschweren. Verantwortlich ist ${d.verantwortlich}, E-Mail ${d.email}.`,
+]
+
+/**
+ * Absatz „Online-Terminbuchung“ fuer die Datenschutzerklaerung der Website des Betriebs, als reiner Text (nicht HTML-maskiert):
+ * Absaetze durch eine Leerzeile, erste Zeile „Online-Terminbuchung“. anrede === 'du' ist die Du-Fassung, alles andere die Sie-Fassung
+ * (wie formularHinweis); die Anrede kommt aus dem Parameter, nicht aus einstellung.anrede. Sätze, die zu abgeschalteten Funktionen
+ * gehören, fehlen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs, siehe datenschutzBedingungen).
+ * Die Aufbewahrung nach dem Termin ist einstellung.aufbewahrungTage (ungültig: 90). Fehlt der Betriebsname oder die E-Mail-Adresse:
+ * TypeError (wie bei den anderen Rechtstexten); eine fehlende Anschrift steht einfach nicht im Text.
+ */
+export function datenschutzAbsatz(einstellung, anrede) {
+ const daten = betriebsdaten(einstellung?.betrieb)
+ const absaetze = anrede === 'du' ? DS_DU : DS_SIE
+ const d = {
+ ...datenschutzBedingungen(einstellung, daten),
+ firma: daten.name,
+ verantwortlich: verbinde([daten.name, daten.adresse]),
+ email: daten.email,
+ telefonnummer: daten.telefon,
+ tage: aufbewahrungNachTermin(einstellung),
+ }
+ return absaetze.map((absatz) => absatz(d)).join(ABSATZ)
+}
+
// ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben
/**
diff --git a/test/termineRechtstexte.test.js b/test/termineRechtstexte.test.js
index 88355bb..b5c3e28 100644
--- a/test/termineRechtstexte.test.js
+++ b/test/termineRechtstexte.test.js
@@ -3,8 +3,10 @@ import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import {
RECHTSTEXTE_VERSION,
+ RECHTSTEXTE_ENTWURF,
widerrufsbelehrung,
formularHinweis,
+ datenschutzAbsatz,
ZUSTIMMUNG_VORZEITIG,
ZUSTIMMUNG_STORNO,
ZUSTIMMUNG_ERINNERUNG,
@@ -19,6 +21,10 @@ const KOPF = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen'
const lies = (rel) => readFileSync(new URL(`../${rel}`, import.meta.url), 'utf8')
const zeilen = (text) => text.split('\n').map((z) => z.trim())
+// Wortlisten fuer die Anredepruefung: Du-Woerter duerfen in der Sie-Fassung nicht stehen und umgekehrt
+const DU_WOERTER = /\bdein|\bdu\b|\bdir\b|\bdich\b/i
+const SIE_WOERTER = /\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/
+
const BETRIEB = { name: 'Salon Müller & Söhne', adresse: 'Hauptstraße 1, 67722 Winnweiler', telefon: '06301 1234', email: 'info@salon-mueller.de' }
const KONTAKT = 'Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, Telefon: 06301 1234, E-Mail: info@salon-mueller.de'
// Platzhalter-Betrieb: so steht die Belehrung in docs/rechtliches/termine-widerrufsbelehrung-muster.md
@@ -214,6 +220,208 @@ test('Formular-Hinweis: Sie ist die Vorgabe, Aufbewahrung 90 Tage ohne gültigen
assert.throws(() => formularHinweis(undefined), TypeError)
})
+// ---------------------------------------------------------------- Datenschutz-Absatz „Online-Terminbuchung“ (Quelle der Einbauanleitung und der Doku)
+
+test('Rechtstexte sind Entwurf, bis Block F die ENTWURF-Köpfe und diese Konstante zusammen entfernt', () => {
+ assert.equal(RECHTSTEXTE_ENTWURF, true)
+})
+
+const DS_SCHALTER = ['telefonFeld', 'freitext', 'erinnerung', 'widerruf', 'telefon']
+// Bedingungstext der Quelle ([[wenn …: …]]) -> Schalter; eine unbekannte Bedingung in der Quelle lässt den Test scheitern
+const DS_BEDINGUNGEN = [
+ [/^Telefon-Feld aktiv$/, 'telefonFeld'],
+ [/^Nachricht oder Zusatzfelder aktiv$/, 'freitext'],
+ [/^Erinnerung angeboten$/, 'erinnerung'],
+ [/^Gäste Verbraucher sein können$/, 'widerruf'],
+ [/^Telefon hinterlegt$/, 'telefon'],
+]
+// Satz bzw. Satzteil, den jeder Schalter einschaltet (Sie- und Du-Fassung)
+const DS_MARKER = {
+ telefonFeld: /Telefonnummer/,
+ freitext: /im Formular mitteil/,
+ erinnerung: /Erinnerung/,
+ widerruf: /Vertrag widerrufen/,
+ telefon: /telefonisch erreichen/,
+}
+const DS_ALLE_AN = Object.fromEntries(DS_SCHALTER.map((k) => [k, true]))
+const DS_ALLE_AUS = Object.fromEntries(DS_SCHALTER.map((k) => [k, false]))
+
+/** Echte, normalisierte Einstellung; jeder Schalter steuert genau die Einstellung, aus der die Funktion ihn ableitet. */
+function dsEinstellung(zustand, extra = {}) {
+ return {
+ anrede: 'sie',
+ aufbewahrungTage: 90,
+ betrieb: { ...BETRIEB, telefon: zustand.telefon ? BETRIEB.telefon : '' },
+ gastFelder: { telefon: zustand.telefonFeld ? 'optional' : 'aus', nachricht: zustand.freitext ? 'optional' : 'aus', zusatz: [] },
+ erinnerung: { anbieten: zustand.erinnerung, stundenVorher: 24 },
+ recht: { gaesteSind: zustand.widerruf ? 'verbraucher' : 'unternehmer', freizeit: false },
+ ressourcen: [{ id: 'r1', aktiv: true }],
+ leistungen: [{ id: 'l1', aktiv: true, ressourcen: ['r1'] }],
+ ...extra,
+ }
+}
+
+/** Absätze durch Leerzeile, innerhalb eines Absatzes jeder Leerraum ein Leerzeichen. */
+const normalisiere = (text) => text.split(/\n[ \t]*\n/).map((a) => a.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n\n')
+
+/**
+ * Datenschutz-Absatz aus der Markdown-Quelle: Blockquote der Fassung („Sie“ | „Du“), `> `, `**` und `###` entfernt,
+ * [[wenn …: X]] durch X (Schalter an) oder nichts (Schalter aus) ersetzt, Platzhalter mit den Testwerten gefüllt.
+ * Ein Satzteil, der mit einem Wort beginnt („und die Angaben …“), steht in der Quelle ohne Leerzeichen am Vordertext
+ * („Platz[[…: und die Angaben …]]“); er wird hier mit Leerzeichen angefügt (Satzteile mit Komma bleiben unmittelbar dran).
+ */
+function datenschutzQuelle(fassung, zustand, { tage = 90 } = {}) {
+ const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '')
+ const teil = md.split(/^## Fassung /m).slice(1).find((t) => t.startsWith(fassung))
+ assert.ok(teil, `Fassung ${fassung} fehlt in der Quelle`)
+ const zitat = teil.split('\n').filter((z) => z.startsWith('>')).map((z) => z.replace(/^>\s?/, '').replace(/^### /, ''))
+ assert.ok(zitat.length > 10, 'Blockquote der Quelle nicht gefunden')
+ const roh = zitat.join('\n').replace(/\*\*/g, '')
+ const bedingt = roh.replace(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g, (_, bedingung, satz, offset, ganz) => {
+ const eintrag = DS_BEDINGUNGEN.find(([muster]) => muster.test(bedingung))
+ assert.ok(eintrag, `unbekannte Bedingung in der Quelle: ${bedingung}`)
+ if (!zustand[eintrag[1]]) return ''
+ return /^[A-Za-zÄÖÜäöüß]/.test(satz) && offset > 0 && !/\s/.test(ganz[offset - 1]) ? ` ${satz}` : satz
+ })
+ const werte = { Firma: BETRIEB.name, Anschrift: BETRIEB.adresse, 'E-Mail': BETRIEB.email, Telefon: BETRIEB.telefon, Tage: String(tage) }
+ const gefuellt = bedingt.replace(/\{\{([^}]+)\}\}/g, (_, name) => {
+ assert.ok(Object.hasOwn(werte, name), `unbekannter Platzhalter in der Quelle: ${name}`)
+ return werte[name]
+ })
+ assert.ok(!/\[\[|\]\]|\{\{|\}\}/.test(gefuellt), 'offene Klammer nach dem Ersetzen')
+ return normalisiere(gefuellt)
+}
+
+const alleZustaende = () =>
+ Array.from({ length: 2 ** DS_SCHALTER.length }, (_, maske) => Object.fromEntries(DS_SCHALTER.map((k, i) => [k, Boolean(maske & (1 << i))])))
+
+test('Datenschutz-Absatz: Wortlaut entspricht der Markdown-Quelle, beide Anreden, alle 32 Bedingungskombinationen', () => {
+ for (const [fassung, anrede] of [['Sie', 'sie'], ['Du', 'du']]) {
+ for (const zustand of alleZustaende()) {
+ assert.equal(
+ normalisiere(datenschutzAbsatz(dsEinstellung(zustand), anrede)),
+ datenschutzQuelle(fassung, zustand),
+ `${fassung} ${JSON.stringify(zustand)}`,
+ )
+ }
+ }
+})
+
+test('Datenschutz-Absatz: Ausgabe ist schon normalisiert (Absätze durch Leerzeile, keine doppelten Leerzeichen, kein Platzhalterrest)', () => {
+ for (const anrede of ['sie', 'du']) {
+ for (const zustand of alleZustaende()) {
+ const text = datenschutzAbsatz(dsEinstellung(zustand), anrede)
+ assert.equal(text, normalisiere(text), `${anrede} ${JSON.stringify(zustand)}`)
+ assert.ok(!/ {2}| [,.;:]|\[\[|\]\]|\{\{|\}\}|undefined|null|NaN/.test(text), `${anrede} ${JSON.stringify(zustand)}`)
+ assert.ok(text.startsWith('Online-Terminbuchung\n\n'))
+ assert.equal(text.split('\n\n')[0], 'Online-Terminbuchung')
+ }
+ }
+})
+
+test('Datenschutz-Absatz: gleiche Quelle auch mit anderer Aufbewahrung (Pin mit 365 Tagen)', () => {
+ const einstellung = dsEinstellung(DS_ALLE_AN, { aufbewahrungTage: 365 })
+ assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'sie')), datenschutzQuelle('Sie', DS_ALLE_AN, { tage: 365 }))
+ assert.equal(normalisiere(datenschutzAbsatz(einstellung, 'du')), datenschutzQuelle('Du', DS_ALLE_AN, { tage: 365 }))
+})
+
+test('Datenschutz-Absatz: jede Bedingung schaltet genau ihren Satz', () => {
+ for (const anrede of ['sie', 'du']) {
+ for (const schalter of DS_SCHALTER) {
+ const aus = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AN, [schalter]: false }), anrede)
+ const an = datenschutzAbsatz(dsEinstellung({ ...DS_ALLE_AUS, [schalter]: true }), anrede)
+ for (const [name, muster] of Object.entries(DS_MARKER)) {
+ assert.equal(muster.test(aus), name !== schalter, `${anrede}: ${schalter} aus, Marker ${name}`)
+ assert.equal(muster.test(an), name === schalter, `${anrede}: nur ${schalter} an, Marker ${name}`)
+ }
+ }
+ }
+})
+
+test('Datenschutz-Absatz: die Bedingungen lesen die Einstellung wie das Widget', () => {
+ const alleAn = dsEinstellung(DS_ALLE_AN)
+ const text = (e, anrede = 'sie') => datenschutzAbsatz(e, anrede)
+ // Telefon-Feld: nur „aus“ blendet aus, optional und Pflicht zählen
+ assert.ok(DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'pflicht', nachricht: 'aus', zusatz: [] } })))
+ assert.ok(!DS_MARKER.telefonFeld.test(text({ ...alleAn, gastFelder: { telefon: 'aus', nachricht: 'aus', zusatz: [] } })))
+ // Nachricht oder Zusatzfelder: eines von beiden genügt
+ const aus = { telefon: 'aus', nachricht: 'aus' }
+ assert.ok(!DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [] } })))
+ assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, nachricht: 'pflicht', zusatz: [] } })))
+ assert.ok(DS_MARKER.freitext.test(text({ ...alleAn, gastFelder: { ...aus, zusatz: [{ key: 'wunsch', label: 'Wunsch', typ: 'text', optionen: [], pflicht: false }] } })))
+ // Widerruf: genau die Bedingung des Widerrufsknopfs (Verbraucher-Gäste und mindestens eine sichtbare Leistung)
+ assert.ok(DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'gemischt', freizeit: false } })))
+ assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'unternehmer', freizeit: false } })))
+ assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, recht: { gaesteSind: 'verbraucher', freizeit: true } })))
+ assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, leistungen: [{ id: 'l1', aktiv: false, ressourcen: ['r1'] }] })))
+ assert.ok(!DS_MARKER.widerruf.test(text({ ...alleAn, ressourcen: [{ id: 'r1', aktiv: false }] })))
+ // Telefon des Betriebs: Leerraum zählt als nicht hinterlegt
+ assert.ok(!DS_MARKER.telefon.test(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' ' } })))
+ assert.ok(text({ ...alleAn, betrieb: { ...BETRIEB, telefon: ' 06301\n1234 ' } }).includes('telefonisch erreichen: 06301 1234.'))
+})
+
+test('Datenschutz-Absatz: Aufbewahrung nach dem Termin, ungültiger Wert wird zu 90 Tagen (wie der Formular-Hinweis)', () => {
+ for (const anrede of ['sie', 'du']) {
+ const mit = (aufbewahrungTage) => datenschutzAbsatz(dsEinstellung(DS_ALLE_AN, { aufbewahrungTage }), anrede)
+ assert.ok(mit(365).includes('Bestätigte Termine löschen wir 365 Tage nach dem Termin,'))
+ assert.ok(mit(14).includes('löschen wir 14 Tage nach dem Termin,'))
+ for (const ungueltig of ['viel', undefined, null, 14.5, NaN]) {
+ assert.ok(mit(ungueltig).includes('löschen wir 90 Tage nach dem Termin,'), `${anrede}: ${String(ungueltig)}`)
+ }
+ }
+})
+
+test('Datenschutz-Absatz: Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter', () => {
+ for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) {
+ const sie = datenschutzAbsatz(dsEinstellung(zustand), 'sie')
+ const du = datenschutzAbsatz(dsEinstellung(zustand), 'du')
+ assert.ok(!DU_WOERTER.test(sie), 'Du-Form in der Sie-Fassung')
+ assert.ok(!SIE_WOERTER.test(du), 'Sie-Form in der Du-Fassung')
+ assert.ok(/Sie|Ihr/.test(sie) && /\bdu\b|\bdein/.test(du), 'Fassungen sind nicht zu unterscheiden')
+ assert.ok(sie.includes('Auf unserer Website können Sie Termine online buchen.'))
+ assert.ok(du.includes('Auf unserer Website kannst du Termine online buchen.'))
+ }
+})
+
+test('Datenschutz-Absatz: die Anrede kommt aus dem Parameter, alles außer „du“ ist die Sie-Fassung', () => {
+ const einstellung = dsEinstellung(DS_ALLE_AN, { anrede: 'du' })
+ assert.ok(datenschutzAbsatz(einstellung, 'sie').includes('können Sie Termine online buchen'))
+ assert.ok(datenschutzAbsatz(einstellung, 'du').includes('kannst du Termine online buchen'))
+ for (const anrede of [undefined, 'unbekannt', 'Du', null]) {
+ assert.equal(datenschutzAbsatz(einstellung, anrede), datenschutzAbsatz(einstellung, 'sie'), String(anrede))
+ }
+})
+
+test('Datenschutz-Absatz: Name und E-Mail sind Pflicht (TypeError), eine leere Anschrift lässt kein leeres Komma stehen', () => {
+ const ohne = (feld) => ({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, [feld]: undefined } })
+ assert.throws(() => datenschutzAbsatz(ohne('name'), 'sie'), TypeError)
+ assert.throws(() => datenschutzAbsatz(ohne('email'), 'du'), TypeError)
+ assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, name: ' \n ' } }, 'sie'), TypeError)
+ assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, email: '' } }, 'sie'), TypeError)
+ assert.throws(() => datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: undefined }, 'sie'), TypeError)
+ assert.throws(() => datenschutzAbsatz({}, 'sie'), TypeError)
+ assert.throws(() => datenschutzAbsatz(undefined, 'sie'), TypeError)
+
+ for (const adresse of ['', ' ', undefined]) {
+ for (const anrede of ['sie', 'du']) {
+ const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb: { ...BETRIEB, adresse } }, anrede)
+ assert.ok(text.endsWith('Verantwortlich ist Salon Müller & Söhne, E-Mail info@salon-mueller.de.'), `${anrede}: ${String(adresse)}`)
+ assert.ok(!/, ,|,,|, E-Mail , /.test(text))
+ }
+ }
+ const voll = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), 'sie')
+ assert.ok(voll.endsWith('Verantwortlich ist Salon Müller & Söhne, Hauptstraße 1, 67722 Winnweiler, E-Mail info@salon-mueller.de.'))
+})
+
+test('Datenschutz-Absatz: reiner Text, nicht HTML-maskiert, Betriebsangaben unverändert eingesetzt (kein Ersetzungsmuster)', () => {
+ const betrieb = { name: "Frisör $& $1 'Anna' & Co", adresse: 'Weg 1,\n67722 Winnweiler', telefon: '06301 $` 1234', email: 'a&b@salon.de' }
+ const text = datenschutzAbsatz({ ...dsEinstellung(DS_ALLE_AN), betrieb }, 'sie')
+ assert.ok(text.includes("Absender ist Frisör $& $1 'Anna' & Co, Ihre Antworten"))
+ assert.ok(text.includes("Verantwortlich ist Frisör $& $1 'Anna' & Co, Weg 1, 67722 Winnweiler, E-Mail a&b@salon.de."))
+ assert.ok(text.includes('telefonisch erreichen: 06301 $` 1234.'))
+ assert.ok(!text.includes('&') && !text.includes('<'))
+})
+
// ---------------------------------------------------------------- Dokumente
const DOKUMENTE = [
@@ -260,8 +468,8 @@ test('Datenschutz-Absatz: Löschfristen der Vorgaben, Sie- und Du-Fassung, Auftr
assert.ok(f.includes('Art. 6 Abs. 1 lit. b DSGVO') && f.includes('Art. 6 Abs. 1 lit. a DSGVO'))
}
assert.ok(fassungen[0].startsWith('Sie') && fassungen[1].startsWith('Du'))
- assert.ok(!/\bdein|\bdu\b|\bdir\b|\bdich\b/i.test(fassungen[0]), 'Du-Form in der Sie-Fassung')
- assert.ok(!/\bIhr|\bIhnen\b|\bSie (haben|können|erhalten|wünschen|machen|buchen|klicken|sind|wollen)\b/.test(fassungen[1]), 'Sie-Form in der Du-Fassung')
+ assert.ok(!DU_WOERTER.test(fassungen[0]), 'Du-Form in der Sie-Fassung')
+ assert.ok(!SIE_WOERTER.test(fassungen[1]), 'Sie-Form in der Du-Fassung')
})
test('6c-Entwurf: nur als Entwurfsdatei, public/datenschutz.html bleibt unverändert (ohne Abschnitt 6c)', () => {