Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein

datenschutzAbsatz(einstellung, anrede) liefert den Absatz "Online-Terminbuchung" fuer die
Datenschutzerklaerung der Betriebs-Website als reinen Text (Sie- und Du-Fassung, fuenf bedingte
Saetze: Telefon-Feld, Nachricht/Zusatzfelder, Erinnerung, Widerruf ueber zeigtWiderruf, Telefon des
Betriebs). RECHTSTEXTE_ENTWURF = true. Test pinnt den Wortlaut gegen die Markdown-Quelle in allen
32 Bedingungskombinationen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-10-04 23:47:58 +00:00
1 parent 1ac142b92f
commit 06ebcd1b0a
2 files changed
+403 -5

No files matched your search

+193 -3
View File
@@ -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
/**
+210 -2
View File
@@ -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 <b>'Anna'</b> & 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 <b>'Anna'</b> & Co, Ihre Antworten"))
assert.ok(text.includes("Verantwortlich ist Frisör $& $1 <b>'Anna'</b> & Co, Weg 1, 67722 Winnweiler, E-Mail a&b@salon.de."))
assert.ok(text.includes('telefonisch erreichen: 06301 $` 1234.'))
assert.ok(!text.includes('&amp;') && !text.includes('&lt;'))
})
// ---------------------------------------------------------------- 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)', () => {