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)', () => {