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:
1 parent
1ac142b92f
commit
06ebcd1b0a
2 files changed
+403
-5
No files matched your search
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
@@ -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('&') && !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)', () => {
|
||||
|
||||
Reference in new issue
Block a user