diff --git a/docs/WEBklar-Datenschutzerklaerung.pdf b/docs/WEBklar-Datenschutzerklaerung.pdf index 30950a4..d94699e 100644 Binary files a/docs/WEBklar-Datenschutzerklaerung.pdf and b/docs/WEBklar-Datenschutzerklaerung.pdf differ diff --git a/docs/funktionen/termine-einbau.md b/docs/funktionen/termine-einbau.md index 1cc00bb..4703ae6 100644 --- a/docs/funktionen/termine-einbau.md +++ b/docs/funktionen/termine-einbau.md @@ -1,6 +1,6 @@ # Terminplaner auf der Kundenwebsite einbauen -Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` (16 Zeichen, a–z und 2–7) und den fertigen Einbau-Code findest Du im Portal: in der Kundenansicht des Kunden unter Terminplaner → Einstellungen, Abschnitt „Einbau auf deiner Website“ (mit Knopf „Code kopieren“). Den `siteKey` zeigt auch das Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Terminplaner, mit Kopierknopf. Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite. +Anleitung für das WEBklar-Team. Das Widget lässt Gäste auf der Website des Kunden Termine buchen. Leistungen, Personen und Öffnungszeiten pflegt der Kunde im Portal (Tab „Funktionen“, Kachel „Terminplaner“). Den `siteKey` (16 Zeichen, a–z und 2–7) und den fertigen Einbau-Code findest Du im Portal: in der Kundenansicht des Kunden unter Terminplaner → Einstellungen, Abschnitt „Einbau auf deiner Website“ (mit Knopf „Code kopieren“). Den `siteKey` zeigt auch das Ticketsystem (WOMS): Kunde → Reiter „Funktionen“, Zeile Terminplaner, mit Kopierknopf. Ohne aktive Funktion, ohne gespeicherte Einstellung und ohne Telefonnummer des Betriebs (bei Verbrauchern auch ohne Anschrift) ist nichts buchbar: Das Widget zeigt dann den Ausweichtext der Seite. Die Anleitung für einen bestimmten Kunden erzeugt das Ticketsystem (WOMS) selbst: Kunde → Reiter „Funktionen“ → Anleitung des Projekts, mit den Einstellungen des Betriebs, dem fertigen Einbau-Code, dem CSS-Vorschlag, den Datenschutz-Absätzen, der Abnahme-Checkliste und den Warnungen. Im Einrichtungs-Ticket steht davon die Kurzfassung (Stand der Einstellungen, Einbau-Code, Schritte) mit dem Link auf diese Anleitung. Dieses Dokument ist die allgemeine Referenz dahinter: Es erklärt Variablen, Stile, Datenschutz und API, die für jeden Betrieb gelten. ## 1. Das Snippet @@ -124,7 +124,7 @@ Bedienung nur mit der Tastatur: Tab und Umschalt+Tab zwischen den Elementen, im ## 6. Datenschutzerklärung des Kunden -Das Skript lädt beim Aufruf der Seite die Konfiguration (Texte, Leistungen) von `project.webklar.com`; im Modus `inline` folgen die Termine beim Durchklicken, im Modus `knopf` erst beim Klick auf den Knopf. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung der Website braucht deshalb den Absatz „Online-Terminbuchung“ aus `docs/rechtliches/termine-datenschutz-absatz.md` (zwei Fassungen: Sie und Du, nimm die zur Erklärung der Website passende). Der Absatz trägt den Kopf **„ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“**. Solange der Kopf dort steht, gilt: nur beim Pilotkunden einsetzen, bei anderen Betrieben erst nach der Rechtsprüfung. Die `[[wenn …]]`-Sätze richten sich nach den Einstellungen des Betriebs (Telefon-Feld, Nachricht, Erinnerung, Verbraucher). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht. Die URL der Datenschutzerklärung (und des Impressums) muss in den Betriebsangaben im Portal stehen, sonst fehlt der Link im Fuß. +Das Skript lädt beim Aufruf der Seite die Konfiguration (Texte, Leistungen) von `project.webklar.com`; im Modus `inline` folgen die Termine beim Durchklicken, im Modus `knopf` erst beim Klick auf den Knopf. Dabei sieht der Server die IP-Adresse des Besuchers. Die Datenschutzerklärung der Website braucht deshalb den Absatz „Online-Terminbuchung“ aus `docs/rechtliches/termine-datenschutz-absatz.md` (zwei Fassungen: Sie und Du, nimm die zur Erklärung der Website passende). Die `[[wenn …]]`-Sätze richten sich nach den Einstellungen des Betriebs (Telefon-Feld, Nachricht, Erinnerung, Verbraucher). Bau das Widget erst ein, wenn der Absatz in der Datenschutzerklärung steht. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar den Absatz beim Einbau ein (Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet); sonst erhält der Kunde den Absatz als Textbaustein per Chat und fügt ihn selbst ein. Die URL der Datenschutzerklärung (und des Impressums) muss in den Betriebsangaben im Portal stehen, sonst fehlt der Link im Fuß. ## 7. Abnahme-Checkliste diff --git a/docs/rechtliches/archiv/README.md b/docs/rechtliches/archiv/README.md index 0dd1b97..6747a98 100644 --- a/docs/rechtliches/archiv/README.md +++ b/docs/rechtliches/archiv/README.md @@ -9,3 +9,26 @@ Jede ausgelieferte Fassung bleibt hier nachvollziehbar. `BANNER_VERSION` im Clie Änderung der Zusatzseite am 02.10.2026 (Fassung bleibt 2026-10-02, Banner-Text unverändert): Der Knopf zum Wiederöffnen des Banners ist jetzt ein Keks-Symbol (Name für Screenreader und Beschriftung beim Darüberfahren weiter „Datenschutz-Einstellungen“). Die Zusatzseite nennt deshalb bei Widerruf und Widerspruch zusätzlich „(das Keks-Symbol)“. Die vorige Formulierung war bis dahin nur auf der WEBklar-Testseite (test-website.project.webklar.com) ausgeliefert, keiner Kunden-Website: [datenschutz-analyse-2026-10-02-erstfassung.html](datenschutz-analyse-2026-10-02-erstfassung.html). In WOMS → Rechtliches als neue Version der Karte „datenschutz-website-analyse“ hinterlegt. AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-.html|pdf` und `leistungsbeschreibung-website-analyse-.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen. + +## Terminplaner + +Die Texte des Terminplaners (Leistungsbeschreibung, AVV, Widerrufsbelehrung mit Muster-Widerrufsformular) tragen die Fassung `config.termine.avvVersion`. Quelle ist je Dokument eine Markdown-Datei unter `docs/rechtliches/`; `node scripts/build-legal-html.mjs termine` erzeugt daraus die HTML-Seiten, `scripts/build-legal-pdfs.sh termine` die PDFs (die Mail K15 hängt sie an). Eine ausgelieferte Fassung wird nie überschrieben: jede Änderung nach der Übergabe an die Kanzlei oder dem Livegang bekommt ein neues Datum und neue Dateien. + +| Fassung | gültig ab | Leistungsbeschreibung | AVV | Widerrufsbelehrung | +|---|---|---|---|---| +| 2026-10-05 | offen bis Livegang | [HTML](../../../public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html) · [PDF](../../../public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf) | [HTML](../../../public/rechtliches/avv-terminplaner-2026-10-05.html) · [PDF](../../../public/rechtliches/avv-terminplaner-2026-10-05.pdf) | [HTML](../../../public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html) · [PDF](../../../public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf) | + +Adressen ohne Fassung (`/rechtliches/leistungsbeschreibung-terminplaner.html`, `/rechtliches/avv-terminplaner.html`, `/rechtliches/widerrufsbelehrung-funktionen.html`) liefern die Seite der aktuellen Fassung; Mails und Zustimmungstexte verlinken nur die Adressen mit Fassung. „Gültig ab“ wird beim Livegang eingetragen. + +## Terminplaner-Gästetexte + +Die Texte, die ein Gast beim Buchen sieht (Hinweis am Formular, Texte der Haken, Gesundheitshinweis, Knopftexte, Widerrufsbelehrung für Gäste) und der Datenschutz-Absatz für die Website des Betriebs tragen die Fassung `RECHTSTEXTE_VERSION` in `server/services/termine/rechtstexte.js`. Der Nachweis einer Buchung speichert die Fassung und Hashes der Texte, nicht die Texte selbst (AVV Terminplaner Nr. 8 Abs. 5); den Wortlaut je Fassung hält dieses Archiv. `node scripts/termine-texte-archiv.mjs` erzeugt `termine-gaestetexte-.md` aus den Texten (Betriebsangaben als Platzhalter), `--check` vergleicht mit der eingecheckten Datei. Eine Datei einer früheren Fassung wird nie neu erzeugt oder geändert (`test/termineRechtstexteArchiv.test.js` pinnt ihren Hash); jede Textänderung bekommt ein neues Datum und eine neue Datei. Das Archiv bleibt bis drei Jahre nach dem Ende des Terminplaners erhalten. + +| Fassung | gültig ab | Archiv | +|---|---|---| +| 2026-10-02 | 2026-10-03 (Pilot) | [termine-gaestetexte-2026-10-02.md](termine-gaestetexte-2026-10-02.md) | +| 2026-10-05 | offen bis Livegang | [termine-gaestetexte-2026-10-05.md](termine-gaestetexte-2026-10-05.md) | + +„Gültig ab“ einer neuen Fassung wird beim Livegang eingetragen; ihr Zeitraum endet mit dem „gültig ab“ der nächsten. + +Änderungen der Fassung 2026-10-05 gegenüber 2026-10-02 (nur diese beiden Stellen): Der Haken für die Erinnerung endet mit dem Satz „Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO); der Datenschutz-Absatz sagt bei der Abschaltung: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ Buchungen, die vor dem Livegang der neuen Fassung entstanden sind, behalten im Nachweis die Fassung 2026-10-02. diff --git a/docs/rechtliches/archiv/termine-gaestetexte-2026-10-02.md b/docs/rechtliches/archiv/termine-gaestetexte-2026-10-02.md new file mode 100644 index 0000000..9d138c9 --- /dev/null +++ b/docs/rechtliches/archiv/termine-gaestetexte-2026-10-02.md @@ -0,0 +1,141 @@ +# Gästetexte des Terminplaners, Fassung 2026-10-02 + +Dieses Archiv hält den Wortlaut der Gästetexte fest, die ein Gast in dieser Fassung sieht (AVV Terminplaner Nr. 8 Abs. 5). Der Nachweis einer Buchung speichert die Fassung, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes (SHA-256) der übrigen Texte, nicht die Texte selbst. + +Fassung der Gästetexte: **2026-10-02** (`RECHTSTEXTE_VERSION` in `server/services/termine/rechtstexte.js`). Der Zeitraum der Verwendung ergibt sich aus „gültig ab“ dieser Fassung und der nächsten Fassung in der Tabelle „Terminplaner-Gästetexte“ in `README.md` dieses Ordners. + +Erzeugt mit `node scripts/termine-texte-archiv.mjs`, geprüft mit `--check`. Nicht von Hand ändern: eine Textänderung bekommt eine neue Fassung und eine neue Datei; diese Datei bleibt, wie sie war. + +Die Betriebsangaben stehen als Platzhalter: `{{Name des Betriebs}}`, `{{Anschrift}}`, `{{Telefon}}` und `{{E-Mail}}` stammen aus der Einstellung des Betriebs, `{{Stunden}}` ist die gewählte Vorlaufzeit der Erinnerung (der Nachweis speichert sie), die Aufbewahrung nach dem Termin ist mit der Vorgabe von 90 Tagen dargestellt (einstellbar von 14 bis 365 Tagen). + +## 1. Hinweis unter dem Buchungsformular (Datenschutz) + +### Sie-Fassung + +> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}. + +### Du-Fassung + +> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}. + +## 2. Haken + +Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen. + +### Vorzeitiger Beginn (Pflicht bei einer Leistung mit Preis für Verbraucher, wenn der Termin vor Ablauf der Widerrufsfrist beginnt, bei Terminanfragen immer) + +> Ich verlange ausdrücklich, dass {{Name des Betriebs}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). + +### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version) + +> Ich habe die Stornobedingungen gelesen. + +### Erinnerung (freiwillig, nie vorangekreuzt, nur wenn der Betrieb Erinnerungen anbietet) + +> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. + +## 3. Hinweis zu Gesundheitsangaben + +Steht über dem Nachrichtenfeld des Formulars. + +> Bitte keine Gesundheitsangaben. + +## 4. Knopftexte + +| Leistung | Knopf im Widget | Knopf auf der Bestätigungsseite | +|---|---|---| +| Sofort buchbar, ohne Preis | Termin buchen | Termin bestätigen | +| Sofort buchbar, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen | +| Terminanfrage | Termin anfragen | Anfrage absenden | + +Der Text des Buchungsknopfs im Widget steht im Nachweis (`knopf`). + +## 5. Widerrufsbelehrung mit Muster-Widerrufsformular für Gäste + +Sie-Form, mit den Betriebsangaben als Platzhalter. + +> Widerrufsbelehrung +> +> Widerrufsrecht +> +> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. +> +> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. +> +> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. +> +> Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. +> +> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. +> +> Folgen des Widerrufs +> +> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. +> +> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. +> +> Muster-Widerrufsformular +> +> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) +> +> – An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}: +> – Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*) +> – Bestellt am (*)/erhalten am (*) +> – Name des/der Verbraucher(s) +> – Anschrift des/der Verbraucher(s) +> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +> – Datum +> +> (*) Unzutreffendes streichen. + +## 6. Datenschutz-Absatz „Online-Terminbuchung“ für die Datenschutzerklärung der Website des Betriebs + +Hier mit allen bedingten Sätzen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs); ein Betrieb ohne die jeweilige Funktion hat den Satz nicht. + +### Sie-Fassung + +> Online-Terminbuchung +> +> Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten: +> +> 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, auf Wunsch Ihre Telefonnummer 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, zum Beispiel ob Sie eine Erinnerung gewünscht haben. +> +> 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 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. 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). +> +> 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 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 {{Name des Betriebs}}, Ihre Antworten gehen an uns. 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. +> +> 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. +> +> 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 14 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. +> +> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 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. +> +> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Sie können uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit. +> +> 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 {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}. + +### Du-Fassung + +> Online-Terminbuchung +> +> Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten: +> +> 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, auf Wunsch deine Telefonnummer 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, zum Beispiel ob du eine Erinnerung gewünscht hast. +> +> 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 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. 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). +> +> 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 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 {{Name des Betriebs}}, deine Antworten gehen an uns. 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. +> +> 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. +> +> 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 14 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. +> +> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 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. +> +> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Du kannst uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit. +> +> 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 {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}. + +## 7. Nachweis + +Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26). diff --git a/docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md b/docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md new file mode 100644 index 0000000..2f58b8b --- /dev/null +++ b/docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md @@ -0,0 +1,141 @@ +# Gästetexte des Terminplaners, Fassung 2026-10-05 + +Dieses Archiv hält den Wortlaut der Gästetexte fest, die ein Gast in dieser Fassung sieht (AVV Terminplaner Nr. 8 Abs. 5). Der Nachweis einer Buchung speichert die Fassung, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes (SHA-256) der übrigen Texte, nicht die Texte selbst. + +Fassung der Gästetexte: **2026-10-05** (`RECHTSTEXTE_VERSION` in `server/services/termine/rechtstexte.js`). Der Zeitraum der Verwendung ergibt sich aus „gültig ab“ dieser Fassung und der nächsten Fassung in der Tabelle „Terminplaner-Gästetexte“ in `README.md` dieses Ordners. + +Erzeugt mit `node scripts/termine-texte-archiv.mjs`, geprüft mit `--check`. Nicht von Hand ändern: eine Textänderung bekommt eine neue Fassung und eine neue Datei; diese Datei bleibt, wie sie war. + +Die Betriebsangaben stehen als Platzhalter: `{{Name des Betriebs}}`, `{{Anschrift}}`, `{{Telefon}}` und `{{E-Mail}}` stammen aus der Einstellung des Betriebs, `{{Stunden}}` ist die gewählte Vorlaufzeit der Erinnerung (der Nachweis speichert sie), die Aufbewahrung nach dem Termin ist mit der Vorgabe von 90 Tagen dargestellt (einstellbar von 14 bis 365 Tagen). + +## 1. Hinweis unter dem Buchungsformular (Datenschutz) + +### Sie-Fassung + +> Datenschutz. Wir verwenden Ihre Angaben, um Ihren Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern Ihre Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte machen Sie keine Angaben zu Ihrer Gesundheit. Mehr erfahren Sie in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}. + +### Du-Fassung + +> Datenschutz. Wir verwenden deine Angaben, um deinen Termin zu verwalten und zu bestätigen (Art. 6 Abs. 1 lit. b DSGVO). Betrieben wird der Terminkalender in unserem Auftrag von der WEBklar GbR (Server: Hetzner, Deutschland). Wir speichern deine Daten bis 90 Tage nach dem Termin, abgesagte Termine 30 Tage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist. Es werden keine Cookies gesetzt. Bitte mach keine Angaben zu deiner Gesundheit. Mehr erfährst du in unserer Datenschutzerklärung. Verantwortlich: {{Name des Betriebs}}, {{Anschrift}}. + +## 2. Haken + +Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen. + +### Vorzeitiger Beginn (Pflicht bei einer Leistung mit Preis für Verbraucher, wenn der Termin vor Ablauf der Widerrufsfrist beginnt, bei Terminanfragen immer) + +> Ich verlange ausdrücklich, dass {{Name des Betriebs}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). + +### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version) + +> Ich habe die Stornobedingungen gelesen. + +### Erinnerung (freiwillig, nie vorangekreuzt, nur wenn der Betrieb Erinnerungen anbietet) + +> Ich möchte {{Stunden}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen. + +## 3. Hinweis zu Gesundheitsangaben + +Steht über dem Nachrichtenfeld des Formulars. + +> Bitte keine Gesundheitsangaben. + +## 4. Knopftexte + +| Leistung | Knopf im Widget | Knopf auf der Bestätigungsseite | +|---|---|---| +| Sofort buchbar, ohne Preis | Termin buchen | Termin bestätigen | +| Sofort buchbar, mit Preis | Zahlungspflichtig buchen | Zahlungspflichtig bestätigen | +| Terminanfrage | Termin anfragen | Anfrage absenden | + +Der Text des Buchungsknopfs im Widget steht im Nachweis (`knopf`). + +## 5. Widerrufsbelehrung mit Muster-Widerrufsformular für Gäste + +Sie-Form, mit den Betriebsangaben als Platzhalter. + +> Widerrufsbelehrung +> +> Widerrufsrecht +> +> Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. +> +> Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. +> +> Um Ihr Widerrufsrecht auszuüben, müssen Sie uns ({{Name des Betriebs}}, {{Anschrift}}, Telefon: {{Telefon}}, E-Mail: {{E-Mail}}) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. +> +> Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. +> +> Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. +> +> Folgen des Widerrufs +> +> Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. +> +> Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. +> +> Muster-Widerrufsformular +> +> (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) +> +> – An {{Name des Betriebs}}, {{Anschrift}}, {{E-Mail}}: +> – Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*) +> – Bestellt am (*)/erhalten am (*) +> – Name des/der Verbraucher(s) +> – Anschrift des/der Verbraucher(s) +> – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) +> – Datum +> +> (*) Unzutreffendes streichen. + +## 6. Datenschutz-Absatz „Online-Terminbuchung“ für die Datenschutzerklärung der Website des Betriebs + +Hier mit allen bedingten Sätzen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs); ein Betrieb ohne die jeweilige Funktion hat den Satz nicht. + +### Sie-Fassung + +> Online-Terminbuchung +> +> Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten: +> +> 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, auf Wunsch Ihre Telefonnummer 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, zum Beispiel ob Sie eine Erinnerung gewünscht haben. +> +> 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 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. 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). +> +> 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 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 {{Name des Betriebs}}, Ihre Antworten gehen an uns. 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. +> +> 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. +> +> 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 14 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. +> +> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders. +> +> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Sie können uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit. +> +> 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 {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}. + +### Du-Fassung + +> Online-Terminbuchung +> +> Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten: +> +> 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, auf Wunsch deine Telefonnummer 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, zum Beispiel ob du eine Erinnerung gewünscht hast. +> +> 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 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. 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). +> +> 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 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 {{Name des Betriebs}}, deine Antworten gehen an uns. 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. +> +> 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. +> +> 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 14 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. +> +> Wie lange speichern wir? Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir 90 Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders. +> +> Pflicht zur Angabe. Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. Du kannst uns auch telefonisch erreichen: {{Telefon}}. Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit. +> +> 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 {{Name des Betriebs}}, {{Anschrift}}, E-Mail {{E-Mail}}. + +## 7. Nachweis + +Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26). diff --git a/docs/rechtliches/avv-terminplaner.md b/docs/rechtliches/avv-terminplaner.md new file mode 100644 index 0000000..c7f00f5 --- /dev/null +++ b/docs/rechtliches/avv-terminplaner.md @@ -0,0 +1,145 @@ +# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO) + +**Fassung 2026-10-05** + +zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“). + +## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen + +(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Terminplaners bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Terminplaner“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt. + +(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1 anschließend. + +(3) Dieser Vertrag ist ein eigener Vertrag für den Terminplaner. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Terminplaner geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor. + +## 1. Gegenstand und Dauer + +(1) WEBklar betreibt für die öffentliche Website des Kunden ein Buchungs-Widget mit einer Verwaltung im Kundenportal („Terminplaner“) und verarbeitet dabei personenbezogene Daten der Gäste und der Beschäftigten des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung. + +(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab (Nr. 8 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind. + +## 2. Art und Zweck der Verarbeitung + +(1) Art: Erheben über das Widget auf der Website des Kunden, Übermitteln an den Server von WEBklar, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen eines Kalender-Links und einer CSV-Datei und Löschen. + +(2) Zwecke: Der Kunde soll Termine seiner Gäste verwalten. Dazu nimmt der Terminplaner Buchungen und Terminanfragen entgegen, verhindert Doppelbuchungen, bestätigt, ändert und sagt Termine ab, erinnert Gäste auf deren Wunsch und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Terminplaner für den Kunden vor Missbrauch (Mengenbegrenzung, Erkennen automatisierter Buchungen). + +(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Gäste. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der gelöschten Buchungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke. + +## 3. Datenarten und Betroffene + +(1) Daten der Gäste: Name und E-Mail-Adresse; auf Einstellung des Kunden außerdem Telefonnummer, eine Nachricht und Zusatzfelder, die der Kunde im Formular festlegt; die gebuchte Leistung, die Person oder der Platz und der Termin; der Status der Buchung mit den Zeitpunkten (Anlegen, Bestätigung, Annahme, Absage, Widerruf); Absagegrund und Vermerke zu einer Absage oder einem Widerruf (Freitext, auch mit dem Namen aus einer Widerrufserklärung); Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; der Nachweis der Zustimmungen (Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte); der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast. + +(2) Daten der Beschäftigten des Kunden: Namen oder Bezeichnungen der Personen oder Plätze, deren Arbeitszeiten und Abwesenheiten sowie ein geheimer Kalender-Link je Person oder Platz. + +(3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt; der Kunde fordert von seinen Gästen keine Gesundheitsangaben (Nummer 11 Abs. 4). + +(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse nur im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen). + +(5) Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes. + +(6) Betroffene: Gäste, die über die Website des Kunden Termine buchen oder anfragen, und Beschäftigte des Kunden, die im Terminplaner geführt werden. + +## 4. Weisungen + +(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet. + +(2) Mit der Bestellung weist der Kunde WEBklar an, den Terminplaner nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Leistungen, Personen oder Plätze, Zeiten, Felder des Formulars, Aufbewahrungsdauer, Erinnerungen, Einschalten und Ausschalten). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Terminplaner abschalten lassen (Nr. 8 Abs. 6 der Leistungsbeschreibung). Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um. + +(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist. + +## 5. Vertraulichkeit + +WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen. + +## 6. Technische und organisatorische Maßnahmen + +(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt. + +(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt. + +## 7. Unterauftragsverarbeiter + +(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Terminplaner läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Buchungs-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt. + +(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Terminplaner abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6). + +(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO). + +(4) Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar. Daten von Gästen oder Beschäftigten des Kunden gehen nicht an Stripe. Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags. + +## 8. Unterstützung des Kunden, Betroffenenrechte + +(1) WEBklar leitet Anfragen von Gästen und Beschäftigten, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung. + +(2) Dafür bietet das Kundenportal die Aktion „Gastdaten löschen“ und den Export der Termine als CSV-Datei. Mit „Gastdaten löschen“ leert der Kunde Name, E-Mail-Adresse, Telefonnummer, Nachricht und Zusatzfelder einer Buchung; Status und Zeiten bleiben. Noch nicht versandte E-Mails zu dieser Buchung löscht WEBklar dabei, und die Links des Gastes werden ungültig. Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1). + +(3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen. + +(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht. + +(5) Die Buchung speichert als Nachweis der Zustimmungen die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte, die der Gast gesehen hat, nicht diese Texte selbst. Damit sich der Wortlaut nachweisen lässt, bewahrt WEBklar jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz und Widerrufsbelehrung für Gäste) mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende des Terminplaners. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung. + +(6) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2). + +## 9. Löschung und Rückgabe + +(1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten. + +(2) Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt. + +(3) Bestätigte und erledigte Termine löscht WEBklar nach der Aufbewahrungsdauer, gerechnet ab dem Ende des Termins. Der Kunde stellt sie im Kundenportal ein; die Vorgabe sind 90 Tage, einstellbar sind 14 bis 365 Tage. + +(4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag. + +(5) Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu löschen, als ein Lauf schafft, folgt der Rest im nächsten Lauf. + +(6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Bis dahin kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung). Früher löscht der Kunde einzelne Gastdaten selbst mit der Aktion „Gastdaten löschen“ (Nummer 8 Abs. 2); alle Daten löscht WEBklar auf Weisung des Kunden in Textform unverzüglich. + +(7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung. + +(8) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar. + +(9) Gesetzliche Aufbewahrungspflichten bleiben unberührt. + +## 10. Nachweise und Kontrollen + +WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen. + +## 11. Pflichten des Kunden + +(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste und seiner Beschäftigten im Terminplaner. + +(2) Der Kunde informiert seine Gäste nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für die Online-Terminbuchung in seine Datenschutzerklärung auf (Nr. 4 Abs. 9 der Leistungsbeschreibung) und lässt den Datenschutz-Hinweis am Buchungsformular unverändert. + +(3) Der Kunde informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt (Nr. 4 Abs. 7 der Leistungsbeschreibung). + +(4) Der Kunde setzt den Terminplaner nicht im Gesundheitsbereich ein und fordert von seinen Gästen keine Angaben zu ihrer Gesundheit, auch nicht über Zusatzfelder oder das Nachrichtenfeld (Nr. 4 Abs. 3 der Leistungsbeschreibung). + +(5) Der Kunde verändert den Haken für die Erinnerung und die Texte der übrigen Haken nicht (Nr. 4 Abs. 6 der Leistungsbeschreibung). Erinnerungen versendet der Terminplaner nur, wenn der Gast den Haken gesetzt hat. + +## 12. Haftung, Änderungen, Schlussbestimmungen + +(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen. + +(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter. + +(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Terminplaner für seine Website nach Nr. 9 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 9 Abs. 3 der Leistungsbeschreibung aussetzen. Das Wartungs-Abo bleibt davon unberührt. + +(4) Im Übrigen gilt § 16 der Abo-Bedingungen. + +## Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO) + +| Bereich | Maßnahmen | +|---|---| +| Übertragung | Das Widget, die Seiten der Gäste und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://. | +| Speicher im Browser | Es werden keine Cookies gesetzt, und der Terminplaner speichert nichts im Browser des Gastes (kein lokaler Speicher, kein Sitzungsspeicher). Das Widget sendet seine Anfragen ohne Cookies, liest weder den Referrer noch die Zeitzone des Browsers und ruft nur die Schnittstelle des Terminplaners auf. | +| Zugriffskontrolle | Das Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen eingeschalteten Terminplaner Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Buchungen nimmt sie nur von Seiten des Kunden und des Portals an. Der Kalender-Link enthält ein geheimes Token, das mit einem Vergleich in konstanter Zeit geprüft wird. | +| Missbrauchsschutz | Ein verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Buchungen. Grenzen je IP-Adresse, je Website und je E-Mail-Adresse sowie das Mailbudget je Stunde (insgesamt und je Funktion) gelten nur im Arbeitsspeicher; ein Neustart leert sie. | +| Datenminimierung | Pflicht sind immer nur Name und E-Mail-Adresse; ob Telefonnummer, Nachricht und Zusatzfelder abgefragt werden, legt der Kunde fest. Mit der Buchung werden keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Der Nachweis der Zustimmungen besteht aus der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte, nicht aus diesen Texten. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert. | +| Trennung | Alle Buchungen, Belegungen und Warteschlangeneinträge tragen die Kennung der Funktion (eines Terminplaners je Website-Projekt); das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion. Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht. Die Einstellungen liegen je Funktion in einem eigenen Dokument. | +| Löschung | Täglicher Löschlauf für Buchungen, Belegungen, Warteschlange und Entwürfe nach den Fristen der Nummer 9; Termindaten und Einstellungen abgeschalteter Funktionen 30 Tage nach dem Abschalten. Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht. | +| Protokolle | Die Programmprotokolle des Terminplaners und der Tagesbericht enthalten nur Zählungen und Buchungsnummern, keine Namen, E-Mail-Adressen oder Nachrichten von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen). | +| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). | +| Rechenzentrum | Hetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. | +| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Archiv der Fassungen der Gästetexte (Nummer 8 Abs. 5), Überprüfung der Maßnahmen mindestens einmal im Jahr. | diff --git a/docs/rechtliches/leistungsbeschreibung-terminplaner.md b/docs/rechtliches/leistungsbeschreibung-terminplaner.md new file mode 100644 index 0000000..e47671c --- /dev/null +++ b/docs/rechtliches/leistungsbeschreibung-terminplaner.md @@ -0,0 +1,127 @@ +# Leistungsbeschreibung Terminplaner + +**Fassung 2026-10-05** · Zusatzbedingungen zum Wartungs-Abo + +## 1. Geltung + +(1) Diese Leistungsbeschreibung regelt die Funktion „Terminplaner“ im Wartungs-Abo. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht. Sie wird vereinbart, wenn der Kunde die Einrichtung des Terminplaners im Kundenportal bestellt und dabei dieser Leistungsbeschreibung, dem Auftragsverarbeitungsvertrag Terminplaner („AVV Terminplaner“) und der Widerrufsbelehrung zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Terminplaner. + +(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner. + +(3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz nach Nr. 4 Abs. 9 und die Gästetexte nach Nr. 2 Abs. 6. Beides ist Teil des Terminplaners. + +## 2. Leistung + +(1) Der Terminplaner ist ein Buchungs-Widget für die Website des Kunden mit einer Verwaltung im Kundenportal. WEBklar baut je Website-Projekt ein Widget an einer Stelle der Website ein, entweder eingebettet in die Seite oder hinter einem Knopf. Die Gestaltung richtet sich nach dem Fragebogen, den der Kunde vor der Bestellung im Kundenportal beantwortet. + +(2) Der Kunde legt seine Leistungen, seine Personen oder Plätze, seine Zeiten und seine Regeln für Buchungen fest. Gäste buchen im Widget einen Termin; je nach Einstellung ist die Buchung verbindlich oder eine Anfrage. + +(3) Der Terminplaner versendet im Auftrag des Kunden E-Mails an die Gäste: den Bestätigungslink, die Bestätigung, Änderungen, Absagen, auf Wunsch des Gastes eine Erinnerung (nur mit seinem Haken) und die Eingangsbestätigung eines Widerrufs. Den Kunden benachrichtigt er über neue und geänderte Buchungen. + +(4) Der Kunde sieht und verwaltet seine Termine im Kundenportal und kann sie als CSV-Datei exportieren. Dazu erhält er einen geheimen Kalender-Link (ICS), mit dem er seine Termine in einem Kalenderprogramm abonnieren kann. + +(5) Der Terminplaner enthält einen Missbrauchsschutz gegen automatisierte Buchungen. Die Löschfristen für Buchungsdaten nennt Nummer 9 des AVV Terminplaner. + +(6) Bezahlt wird nur vor Ort. Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz für die Website des Kunden und die Widerrufsbelehrung für Gäste sind Mustertexte der Standardkonfiguration (Nr. 11 Abs. 1). + +## 3. Nicht enthalten + +(1) Nicht enthalten sind SMS, Online-Zahlung, ein Kalender-Abgleich (es gibt nur das Abonnement des Kalender-Links nach Nr. 2 Abs. 4), Konten für Gäste, Marketing-E-Mails, Gruppentermine, Mehrsprachigkeit und der Einsatz im Gesundheitsbereich (Nr. 4 Abs. 3). + +(2) Zusätzliche Wünsche, etwa ein zweites Widget, Knöpfe je Leistung oder ein Sonderdesign, gehören nicht zur Einrichtung nach Nr. 5. WEBklar führt sie nach Stunden aus (§ 4 der Abo-Bedingungen). + +## 4. Voraussetzungen und Pflichten des Kunden + +(1) Der Kunde hat ein laufendes Wartungs-Abo. + +(2) Der Kunde trägt vor der Bestellung seine Betriebsangaben ein, dazu die Adresse seines Impressums und die Adresse seiner Datenschutzerklärung. + +(3) Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt, weil dort besondere Kategorien personenbezogener Daten anfallen können (Art. 9 DSGVO). Der Kunde setzt ihn dort nicht ein und fordert von seinen Gästen keine Gesundheitsangaben. WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten. + +(4) Richtet sich das Angebot des Kunden gezielt an Kinder unter 16 Jahren, nutzt der Kunde den Terminplaner nicht. + +(5) Der Kunde nennt eine Telefonnummer, unter der Gäste Termine vereinbaren können, wenn das Widget nicht erreichbar ist (Nr. 6 Abs. 2). + +(6) Er lässt die Texte der Haken und Hinweise im Widget unverändert. + +(7) Er informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt. + +(8) Die Pflichten gegenüber seinen Gästen trägt der Kunde: Preisangaben, Allgemeine Geschäftsbedingungen und Stornobedingungen sowie die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung. + +(9) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für die Online-Terminbuchung ein; das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht. + +## 5. Einrichtung und Preis + +(1) Die Einrichtung umfasst die Einrichtung nach dem Fragebogen, den Einbau des Widgets in die Website, den Datenschutz-Absatz nach Nr. 4 Abs. 9, die Abnahme durch den Kunden und die Freischaltung durch WEBklar. + +(2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. + +(3) Die voraussichtliche Dauer der Einrichtung nennt die Bestellseite vor der Bestellung. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins. + +(4) Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung. + +(5) Der Preis steht neben dem Stundenkontingent des Wartungs-Abos: Die Einrichtung wird weder auf das Kontingent angerechnet noch damit bezahlt. Für den Betrieb des Terminplaners berechnet WEBklar neben dem Wartungs-Abo nichts weiter; er läuft, solange das Wartungs-Abo läuft (Nr. 8). + +## 6. Verfügbarkeit und Störungen + +(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Terminplaner. Störungen des Terminplaners beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen). + +(2) Ist der Terminplaner nicht erreichbar, zeigt das Widget einen Ausweichtext mit der Telefonnummer des Kunden (Nr. 4 Abs. 5). + +(3) Alle Zeiten gelten in der Zeitzone Europe/Berlin. + +(4) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab. + +(5) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt. + +## 7. Haftung für verpasste Termine + +(1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden. + +(2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4). + +(3) Für entgangenen Gewinn und Folgeschäden aus verpassten Terminen haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen. + +(4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt. + +## 8. Ende der Leistung + +(1) Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo. + +(2) Mit dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an, und das Widget zeigt den Ausweichtext. Die Termine bleiben im Kundenportal sichtbar, und der Kunde kann sie als CSV-Datei exportieren. Gäste können über ihren Link absagen. Bereits angeforderte Erinnerungen laufen weiter. Der Kunde informiert seine Gäste. + +(3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten. + +(4) Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5). + +(5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig. + +(6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 10. + +## 9. Änderungen und Einstellung + +(1) WEBklar darf die Technik des Terminplaners verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Terminplaner. + +(2) WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn +- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Terminplaner Nummer 12), oder +- eine Sicherheitslücke sich nicht anders beheben lässt. + +Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist. + +(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Terminplaner ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist. + +(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt. + +## 10. Widerruf + +(1) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“. + +(2) Ob das Widerrufsrecht erlischt und ob der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die der Kunde bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage. + +(3) Mit dem Widerruf endet der Terminplaner. + +## 11. Hinweise + +(1) **Keine Rechtsberatung.** Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz und die Widerrufsbelehrung für Gäste sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung trägt der Kunde. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht. + +(2) **Barrierefreiheit.** Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde. diff --git a/docs/rechtliches/termine-datenschutz-6c-entwurf.md b/docs/rechtliches/termine-datenschutz-6c-entwurf.md deleted file mode 100644 index d049801..0000000 --- a/docs/rechtliches/termine-datenschutz-6c-entwurf.md +++ /dev/null @@ -1,37 +0,0 @@ -ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen - -# Datenschutzerklärung von WEBklar: Abschnitt 6c „Online-Terminbuchung auf Websites unserer Kunden“ - -Fassung 2026-10-02. Entwurf für `public/datenschutz.html`, **nicht eingebaut und nicht veröffentlicht**: Die Datei `public/datenschutz.html` bleibt bis TP2 unverändert (dort: Livegang des Terminplaners, Prüfrunde wie bei der Website-Analyse). Der Abschnitt beschreibt, was WEBklar selbst verarbeitet: als Auftragsverarbeiter für die Gäste der Betriebe und als Verantwortlicher für die Betriebsdaten im Kundenportal. Den Text für die Website des Betriebs enthält `termine-datenschutz-absatz.md`. - -## Einbau in TP2 (Checkliste) - -1. Abschnitt als `

` nach 6b (Website-Analyse) und vor 7 einfügen; Stil wie 6a und 6b. -2. Fassung (Stand) oben auf der Seite und in den Vertragsunterlagen nachziehen; `docs/WEBklar-Datenschutzerklaerung.pdf` neu erzeugen und in WOMS „Rechtliches“ hochladen (der Nutzer lädt die Fassung hoch). -3. Abschnitt 12 (Empfänger) nennt Hetzner bereits; keine neue Zeile nötig. Abschnitt 13 („Derzeit stützen wir keine Verarbeitung auf eine Einwilligung“) bleibt richtig, weil die Einwilligung zur Erinnerung beim Betrieb liegt. -4. Die Kachel und die Leistungsbeschreibung „Terminplaner“ sowie der AVV „Terminplaner“ (Geschwister des AVV Website-Analyse) müssen vor dem Livegang vorliegen; dieser Abschnitt verweist auf sie nicht, weil die Datenschutzerklärung keine Vertragsunterlagen zitiert. -5. Test `test/termineRechtstexte.test.js` (Absicherung „6c nicht in datenschutz.html“) in TP2 entfernen. - -## Abschnitt (HTML) - -```html -

6c. Online-Terminbuchung auf Websites unserer Kunden

- -

Sobald diese Funktion freigeschaltet ist, können Kunden mit Wartungs-Abo auf ihrer eigenen Website eine Online-Terminbuchung anbieten („Terminplaner“). Dabei verarbeiten wir die Daten der Personen, die dort einen Termin buchen („Gäste“), ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer auf der Website eines Kunden einen Termin bucht, findet die Informationen zur Buchung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website. Anfragen von Gästen zu ihren Daten leiten wir an den Kunden weiter.

-

Für den Betrieb des Terminplaners speichern wir im Auftrag des Kunden: Name, E-Mail-Adresse und, wenn der Kunde diese Felder eingeschaltet hat, Telefonnummer, Nachricht und weitere Angaben des Gastes; die gewählte Leistung, Person oder den gewählten Platz und den Termin; den Stand der Buchung (zum Beispiel bestätigt, abgesagt oder widerrufen) mit den Zeitpunkten; als Nachweis den Zeitpunkt der Bestätigung und der Zustimmungen sowie die Fassung der angezeigten Hinweise; gegebenenfalls den Wunsch nach einer Erinnerung. Der Terminplaner ist nicht für Leistungen der Heilkunde und der Gesundheitsversorgung bestimmt; Gäste werden gebeten, keine Gesundheitsangaben zu machen.

-

Der Kunde sieht, ändert und löscht die Termine im Kundenportal. Dort verarbeiten wir als Verantwortliche seine Einstellungen für den Terminplaner (Betriebsangaben, Leistungen, Namen und Zeiten der gebuchten Personen oder Plätze, Öffnungszeiten, Absagefristen und Texte), wie die übrigen Portal-Daten (Abschnitt 6) auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Für jede Person oder jeden Platz kann der Kunde einen geheimen Link erzeugen, mit dem er die bestätigten Termine in seinem Kalenderprogramm abonniert; wer den Link kennt, sieht diese Termine. Der Kunde kann den Link jederzeit durch einen neuen ersetzen.

-

Im Namen des Kunden senden wir E-Mails an Gäste (Bestätigung der E-Mail-Adresse, Bestätigung, Änderung oder Absage eines Termins, Eingangsbestätigung eines Widerrufs und, wenn der Gast das gewählt hat, eine Erinnerung) und an den Kunden (neue Buchung, Absage, Widerruf). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Abschnitt 8); als Absender erscheint der Kunde, Antworten gehen an ihn. Wir verschicken keine Werbung, setzen keine Tracking-Pixel ein und erhalten keine Kopie. Bis zum Versand liegt jede E-Mail in einer Warteschlange. Nach dem Versand löschen wir dort Empfängeradresse und Inhalt, den Eintrag selbst nach sieben Tagen; E-Mails, die nicht zugestellt werden konnten, löschen wir nach 48 Stunden.

-

Beim Aufruf einer Kundenwebsite mit Terminbuchung lädt der Browser das Programm der Terminbuchung von unserem Server (project.webklar.com), beim Öffnen der Terminbuchung zusätzlich die Termine und Texte. Dabei gelten die Protokolle in Abschnitt 3. Die Terminbuchung setzt keine Cookies und speichert nichts im Browser des Gastes. Zum Schutz vor Missbrauch und um die Zahl der Anfragen zu begrenzen (höchstens zehn Buchungen je zehn Minuten und je IP-Adresse), merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit der Buchung speichern wir die IP-Adresse nicht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.

-

Löschfristen: Nicht bestätigte Buchungen löschen wir 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten, abgesagte oder abgelehnte Termine nach 30 Tagen, bestätigte Termine nach der Frist, die der Kunde eingestellt hat (Vorgabe 90 Tage, einstellbar von 14 bis 365 Tagen nach dem Termin). Endet das Wartungs-Abo oder schaltet der Kunde die Funktion ab, löschen wir alle Termindaten des Kunden 30 Tage danach. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).

-``` - -## Für die Rechtsprüfung offen - -1. **Rolle**: WEBklar als Auftragsverarbeiter für die Gäste, als Verantwortliche für die Betriebsdaten und die Missbrauchsabwehr (IP im Arbeitsspeicher). Wie bei 6b anwaltlich bestätigen lassen. -2. **IP-Adresse im Arbeitsspeicher**: Der Satz „mit der Buchung speichern wir die IP-Adresse nicht“ muss zur fertigen Buchungsroute passen (Feld `nachweis` ohne IP, kein IP-Log); beim Livegang gegen den Code prüfen. -3. **Kalender-Abo-Link**: Der Link enthält nur ein geheimes Token je Person oder Platz und liefert nur bestätigte Termine der nächsten 90 Tage. Prüfen, ob der Kunde seine Beschäftigten darüber informieren muss (Beschäftigtendaten: Namen und Zeiten). -4. **Sicherungen**: Die Datensicherung bei Hetzner behält gelöschte Daten noch einige Zeit; die Frist steht nicht im Text und ist vor dem Livegang zu klären (AVV Terminplaner Nr. 9). -5. **Zahlung (TP3)**: Wenn Online-Zahlung kommt, braucht dieser Abschnitt einen Satz zu Stripe (Abschnitt 7 und `recherche-recht-zahlung.md` 7.3 G). -6. **Geschäftsbriefe**: Bestätigungen mit Preis sind keine Rechnungen; die Aufbewahrung nach Abschnitt 9 betrifft den Terminplaner nicht. -7. **Ende des Wartungs-Abos**: Der Satz „Endet das Wartungs-Abo oder schaltet der Kunde die Funktion ab, löschen wir alle Termindaten des Kunden 30 Tage danach“ gilt in TP1 nur für das Abschalten (WOMS „Abschalten“, Kunde gelöscht). Ein Haken für das Ende des Wartungs-Abos kommt erst mit TP2; bis dahin den Satz anpassen oder den Haken liefern. -8. **Knopfmodus**: Dort lädt das Widget die Konfiguration (Texte und Leistungen, nicht die Termine) schon beim Laden der Seite, nicht erst beim Öffnen (wegen des dauerhaften Knopfs „Vertrag widerrufen“, R54). Die Formulierung „beim Öffnen der Terminbuchung zusätzlich die Termine und Texte“ danach prüfen; es geht an denselben Server wie das Skript, ein neuer Empfänger entsteht nicht. diff --git a/docs/rechtliches/termine-datenschutz-6c.md b/docs/rechtliches/termine-datenschutz-6c.md new file mode 100644 index 0000000..cb2bedc --- /dev/null +++ b/docs/rechtliches/termine-datenschutz-6c.md @@ -0,0 +1,15 @@ +# Datenschutzerklärung von WEBklar: Abschnitt 6c „Online-Terminbuchung auf Websites unserer Kunden“ + +Fassung 2026-10-05 (Stand der Datenschutzerklärung 05.10.2026). Markdown-Quelle des Abschnitts 6c in `public/datenschutz.html`; der Abschnitt steht dort wörtlich so (`test/legalTexts.test.js` prüft, dass beide übereinstimmen). Er beschreibt, was WEBklar selbst verarbeitet: als Auftragsverarbeiter für die Gäste der Betriebe und als Verantwortlicher für die Betriebsdaten, den Entwurf des Fragebogens und die Bestellung im Kundenportal. Den Text für die Website des Betriebs enthält `termine-datenschutz-absatz.md`. + +## Abschnitt (HTML) + +```html +

6c. Online-Terminbuchung auf Websites unserer Kunden

+

Kunden mit Wartungs-Abo können auf ihrer eigenen Website eine Online-Terminbuchung anbieten („Terminplaner“), sobald wir die Funktion für sie freigeschaltet haben. Dabei verarbeiten wir die Daten der Personen, die dort einen Termin buchen („Gäste“), ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Verantwortliche sind wir für die Betriebsdaten des Kunden im Kundenportal, für den Entwurf seines Fragebogens und für seine Bestellung. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer auf der Website eines Kunden einen Termin bucht, findet die Informationen zur Buchung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website. Anfragen von Gästen zu ihren Daten leiten wir an den Kunden weiter.

+

Für den Betrieb des Terminplaners speichern wir im Auftrag des Kunden: Name, E-Mail-Adresse und, wenn der Kunde diese Felder eingeschaltet hat, Telefonnummer, Nachricht und weitere Angaben des Gastes; die gewählte Leistung, Person oder den gewählten Platz und den Termin; den Stand der Buchung (zum Beispiel bestätigt, abgesagt oder widerrufen) mit den Zeitpunkten; als Nachweis den Zeitpunkt der Bestätigung und der Zustimmungen sowie die Fassung der angezeigten Hinweise; gegebenenfalls den Wunsch nach einer Erinnerung. Der Terminplaner ist nicht für Leistungen der Heilkunde und der Gesundheitsversorgung bestimmt; Gäste werden gebeten, keine Gesundheitsangaben zu machen.

+

Der Kunde sieht, ändert und löscht die Termine im Kundenportal. Dort verarbeiten wir als Verantwortliche seine Einstellungen für den Terminplaner (Betriebsangaben, Leistungen, Namen und Zeiten der gebuchten Personen oder Plätze, Öffnungszeiten, Absagefristen und Texte), wie die übrigen Portal-Daten (Abschnitt 6) auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Bevor der Kunde den Terminplaner bestellt, füllt er im Kundenportal einen Fragebogen aus (Branche und Betrieb, Personen oder Plätze, Leistungen, Zeiten, Regeln, Angaben von Gästen, Benachrichtigungen und Website); seine Eingaben speichern wir als Entwurf seiner Einstellungen. Bestellt er nicht, löschen wir den Entwurf 90 Tage nach der letzten Änderung, bei der Löschung seines Kundenkontos zugleich mit dem Konto. Die Bestellung und ihren Nachweis beschreibt Abschnitt 7. Für jede Person oder jeden Platz kann der Kunde einen geheimen Link erzeugen, mit dem er die bestätigten Termine in seinem Kalenderprogramm abonniert; wer den Link kennt, sieht diese Termine. Der Kunde kann den Link jederzeit durch einen neuen ersetzen.

+

Im Namen des Kunden senden wir E-Mails an Gäste (Bestätigung der E-Mail-Adresse, Bestätigung, Änderung oder Absage eines Termins, Eingangsbestätigung eines Widerrufs und, wenn der Gast das gewählt hat, eine Erinnerung) und an den Kunden (neue Buchung, Absage, Widerruf). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Rechenzentrum Nürnberg, Abschnitt 8); als Absender erscheint der Kunde, Antworten gehen an ihn. Wir verschicken keine Werbung, setzen keine Tracking-Pixel ein und erhalten keine Kopie. Bis zum Versand liegt jede E-Mail in einer Warteschlange. Nach dem Versand löschen wir dort Empfängeradresse und Inhalt, den Eintrag selbst nach sieben Tagen; E-Mails, die nicht zugestellt werden konnten, löschen wir nach 48 Stunden.

+

Beim Aufruf einer Kundenwebsite mit Terminbuchung lädt der Browser das Programm der Terminbuchung von unserem Server (project.webklar.com) und schon beim Laden der Seite die Konfiguration mit den Texten und Leistungen. Ist die Terminbuchung unmittelbar in die Seite eingebaut, holt sie die freien Termine, sobald der Gast durchklickt; sitzt sie hinter einem Knopf, geschieht das erst nach dem Klick auf den Knopf. Dabei gelten die Protokolle in Abschnitt 3. Die Terminbuchung setzt keine Cookies und speichert nichts im Browser des Gastes. Zum Schutz vor Missbrauch und um die Zahl der Anfragen zu begrenzen (höchstens zehn Buchungen je zehn Minuten und je IP-Adresse), merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit der Buchung speichern wir die IP-Adresse nicht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.

+

Löschfristen: Nicht bestätigte Buchungen löschen wir 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten, abgesagte oder abgelehnte Termine nach 30 Tagen, bestätigte Termine nach der Frist, die der Kunde eingestellt hat (Vorgabe 90 Tage, einstellbar von 14 bis 365 Tagen nach dem Termin). Nicht bestellte Entwürfe löschen wir 90 Tage nach der letzten Änderung. Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein. In unseren Datensicherungen bleiben gelöschte Daten noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).

+``` diff --git a/docs/rechtliches/termine-datenschutz-absatz.md b/docs/rechtliches/termine-datenschutz-absatz.md index 12be453..43dded2 100644 --- a/docs/rechtliches/termine-datenschutz-absatz.md +++ b/docs/rechtliches/termine-datenschutz-absatz.md @@ -1,15 +1,13 @@ -ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen - # Datenschutz-Absatz „Online-Terminbuchung“ für die Website des Betriebs -Fassung 2026-10-02. Grundlage: `recherche-recht.md` Abschnitt 11.1. Der Absatz gehört in die Datenschutzerklärung der Website des **Betriebs** (der Betrieb ist Verantwortlicher, WEBklar Auftragsverarbeiter). Die Einbauanleitung für Mitarbeiter (`docs/funktionen/termine-einbau.md`, TP2) verweist hierher. Für die Datenschutzerklärung von WEBklar selbst gilt `termine-datenschutz-6c-entwurf.md`. +Fassung 2026-10-05. Grundlage: `recherche-recht.md` Abschnitt 11.1. Der Absatz gehört in die Datenschutzerklärung der Website des **Betriebs** (der Betrieb ist Verantwortlicher, WEBklar Auftragsverarbeiter). Die Einbauanleitung für Mitarbeiter (`docs/funktionen/termine-einbau.md`, TP2) verweist hierher. Für die Datenschutzerklärung von WEBklar selbst gilt `termine-datenschutz-6c.md`. Zwei Fassungen weiter unten: Sie und Du. Nimm die Fassung, die zur Datenschutzerklärung der Website passt (in der Regel zur `anrede` des Betriebs). ## Platzhalter und optionale Sätze - `{{Firma}}`, `{{Anschrift}}`, `{{E-Mail}}`, `{{Telefon}}`: Betriebsangaben aus der Einstellung. `{{Tage}}`: Aufbewahrung nach dem Termin (`aufbewahrungTage`, Vorgabe 90, einstellbar 14 bis 365). -- `[[wenn …: …]]`: Satz oder Satzteil nur aufnehmen, wenn die genannte Einstellung aktiv ist; sonst samt Klammern streichen. +- `[[wenn …: …]]`: Satz oder Satzteil nur aufnehmen, wenn die genannte Einstellung aktiv ist; sonst samt Klammern streichen. Beginnt der Satzteil mit „und“, steht nach dem Doppelpunkt ein zweites Leerzeichen: Es ist das Leerzeichen zwischen dem Vordertext und „und“ und fällt mit dem Satzteil weg. ## Löschfristen (Vorgaben des Löschlaufs, im Text unten verwendet) @@ -30,32 +28,23 @@ Zwei Fassungen weiter unten: Sie und Du. Nimm die Fassung, die zur Datenschutzer 4. **Nachweise**: Gespeichert werden auch der Zeitpunkt der Zustimmungen und die Fassung der angezeigten Texte (Spezifikation, Feld `nachweis`). 5. **Löschfristen** angepasst (siehe Tabelle); 11.1 kannte nur „nicht bestätigt 48 Stunden“, „abgesagt 30 Tage“ und „bestätigt {{90}} Tage“. -## Für die Rechtsprüfung offen - -1. **Server-Protokolle**: Frist (14 Tage) und Rechtsgrundlage (Art. 6 Abs. 1 lit. f DSGVO) müssen zur Datenschutzerklärung von WEBklar passen; bei Änderung dort hier nachziehen. -2. **Drittländer**: „Eine Übermittlung in Drittländer findet nicht statt“ gilt nur, solange Server und E-Mail-Versand bei Hetzner in Deutschland bleiben (WEBklar-Datenschutzerklärung Abschnitte 3 und 8). -3. **Löschlauf**: „48 Stunden“ und „30 Tage“ sind die frühesten Zeitpunkte, an denen der Löschlauf löscht; er läuft in Abständen. -4. **Kontakt der Gäste**: Anfragen nach Auskunft oder Löschung gehen an den Betrieb; WEBklar leitet Anfragen, die bei ihr eingehen, als Auftragsverarbeiter weiter (in der Datenschutzerklärung von WEBklar, 6c). -5. **Gesundheitsbereich**: Der Terminplaner ist dafür nicht bestimmt; der Satz „Bitte keine Angaben zur Gesundheit“ steht auch im Formular. -6. **Knopfmodus**: Das Widget lädt die Konfiguration (Texte und Leistungen) im Knopfmodus schon beim Laden der Seite (R54); „Aufruf“ (Abweichung 3) stimmt also auch für diesen Abruf. Prüfen, ob der Text zwischen Skript und Konfiguration unterscheiden muss; beides geht an denselben Server. - ## Fassung Sie > ### Online-Terminbuchung > > Auf unserer Website können Sie Termine online buchen. Dafür verarbeiten wir die folgenden Daten: > -> **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 Telefon-Feld aktiv: , auf Wunsch Ihre Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: 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 Erinnerung angeboten: , zum Beispiel ob Sie eine Erinnerung gewünscht haben]]. +> **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 Telefon-Feld aktiv: , auf Wunsch Ihre Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: 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 Erinnerung angeboten: , zum Beispiel ob Sie eine Erinnerung gewünscht haben]]. > > **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 Erinnerung angeboten: 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 Gäste Verbraucher sein können: 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). > -> **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 Erinnerung angeboten: 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 {{Firma}}, Ihre Antworten gehen an uns. 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. +> **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 Erinnerung angeboten: 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 {{Firma}}, Ihre Antworten gehen an uns. 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. > > **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. > > **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 14 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. > -> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 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. +> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders. > > **Pflicht zur Angabe.** Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. [[wenn Telefon hinterlegt: Sie können uns auch telefonisch erreichen: {{Telefon}}.]] Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte machen Sie bei der Buchung keine Angaben zu Ihrer Gesundheit. > @@ -67,17 +56,17 @@ Zwei Fassungen weiter unten: Sie und Du. Nimm die Fassung, die zur Datenschutzer > > Auf unserer Website kannst du Termine online buchen. Dafür verarbeiten wir die folgenden Daten: > -> **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 Telefon-Feld aktiv: , auf Wunsch deine Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: 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 Erinnerung angeboten: , zum Beispiel ob du eine Erinnerung gewünscht hast]]. +> **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 Telefon-Feld aktiv: , auf Wunsch deine Telefonnummer]][[wenn Nachricht oder Zusatzfelder aktiv: 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 Erinnerung angeboten: , zum Beispiel ob du eine Erinnerung gewünscht hast]]. > > **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 Erinnerung angeboten: 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 Gäste Verbraucher sein können: 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). > -> **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 Erinnerung angeboten: 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 {{Firma}}, deine Antworten gehen an uns. 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. +> **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 Erinnerung angeboten: 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 {{Firma}}, deine Antworten gehen an uns. 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. > > **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. > > **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 14 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. > -> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 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. +> **Wie lange speichern wir?** Nicht bestätigte Buchungen (der Bestätigungslink gilt 30 Minuten) löschen wir 48 Stunden nach Ablauf dieser Frist. Bestätigte Termine löschen wir {{Tage}} Tage nach dem Termin, abgesagte oder abgelehnte Termine 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt. Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. Gesetzliche Aufbewahrungspflichten für Geschäftsunterlagen (zum Beispiel Rechnungen) bleiben davon unberührt; sie erfüllen wir außerhalb des Terminkalenders. > > **Pflicht zur Angabe.** Ohne Name, E-Mail-Adresse und Terminwahl können wir keinen Termin online vergeben. [[wenn Telefon hinterlegt: Du kannst uns auch telefonisch erreichen: {{Telefon}}.]] Eine automatisierte Entscheidungsfindung findet nicht statt. Bitte mach bei der Buchung keine Angaben zu deiner Gesundheit. > diff --git a/docs/rechtliches/termine-hinweise.md b/docs/rechtliches/termine-hinweise.md index 1713c4d..e99c7b8 100644 --- a/docs/rechtliches/termine-hinweise.md +++ b/docs/rechtliches/termine-hinweise.md @@ -1,8 +1,6 @@ -ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen - # Hinweise, Haken und Knopftexte für Gäste (Terminplaner) -Fassung 2026-10-02. Grundlage: `recherche-recht.md` Abschnitt 11.2 (Hinweis unter dem Formular) und 11.3, `recherche-recht-zahlung.md` Abschnitt 7.3 C bis E (Haken, Widerrufshinweis, Widerrufsfunktion). Die Texte in den Abschnitten 1 bis 3 sind die Fassung aus `server/services/termine/rechtstexte.js` (Platzhalter in doppelten geschweiften Klammern); `test/termineRechtstexte.test.js` prüft, dass sie hier wörtlich stehen. Ändere sie deshalb nur in `rechtstexte.js` und übernimm sie hierher. Die Abschnitte 4 bis 6 sind reine Entwurfstexte für das Widget und die Mails; sie stehen nicht im Code. +Fassung 2026-10-05. Grundlage: `recherche-recht.md` Abschnitt 11.2 (Hinweis unter dem Formular) und 11.3, `recherche-recht-zahlung.md` Abschnitt 7.3 C bis E (Haken, Widerrufshinweis, Widerrufsfunktion). Die Texte in den Abschnitten 1 bis 3 sind die Fassung aus `server/services/termine/rechtstexte.js` (Platzhalter in doppelten geschweiften Klammern); `test/termineRechtstexte.test.js` prüft, dass sie hier wörtlich stehen. Ändere sie deshalb nur in `rechtstexte.js` und übernimm sie hierher. Die Abschnitte 4 bis 6 geben Texte des Widgets und der Mails wieder (Knopftexte: `knopfText` in `server/services/termine/einstellungen.js`; Widerrufshinweis: Mailvorlage `server/mail-templates/G02 Termin bestaetigt.html`; Eingangsbestätigung: `server/mail-templates/G08 Eingang Widerruf.html`); maßgeblich sind die Mailvorlagen und das Widget, nicht dieses Dokument. Platzhalter: `{{Firma}}` = Betriebsname, `{{Anschrift}}` = Anschrift des Betriebs, `{{n}}` = Stunden vor dem Termin (`erinnerung.stundenVorher`: 2, 12, 24 oder 48). Die Texte zeigen die Vorgabe von 90 Tagen Aufbewahrung nach dem Termin; der Wert kommt aus `aufbewahrungTage` (14 bis 365) der Einstellung. @@ -32,7 +30,7 @@ Wortlaut immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen: Es si |---|---|---|---| | Vorzeitiger Beginn | Verbraucher, mit Preis: bei sofort buchbaren Leistungen, wenn der Terminbeginn nicht nach dem Ende der Widerrufsfrist liegt (14 Tage ab dem Tag nach dem Vertragsschluss, Berliner Kalender, §§ 187, 188, 193 BGB; gerechnet mit dem Zeitpunkt der Buchung plus Haltezeit; R29); bei bepreisten Anfragen immer, weil der Tag der Annahme nicht feststeht (R31) | ja | Ich verlange ausdrücklich, dass {{Firma}} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB). | | Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt | ja | Ich habe die Stornobedingungen gelesen. | -| Erinnerung | der Betrieb bietet Erinnerungen an | nein | Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. | +| Erinnerung | der Betrieb bietet Erinnerungen an | nein | Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen. | ## 3. Hinweis über dem Nachrichtenfeld @@ -49,11 +47,11 @@ Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt (Spezifikation, | Anfrage | Termin anfragen | Anfrage absenden | | Widerruf (Funktion) | Vertrag widerrufen | Widerruf bestätigen | -## 5. Widerrufshinweis im Widget und in der Mail (Entwurf, Rahmen) +## 5. Widerrufshinweis in der Bestätigungs-E-Mail Er ersetzt nicht die Widerrufsbelehrung (`termine-widerrufsbelehrung-muster.md`), sondern führt zu ihr. Gilt für Buchungen von Verbrauchern, auch für Anfragen: Der Vertrag kommt mit der Annahme zustande, ist aber ebenfalls ein Fernabsatzvertrag, und die Frist läuft ab der Annahme (R55, R29). Die Widerrufsfunktion steht im Widget als zweiter Knopf im Fuß und im Knopfmodus dauerhaft neben dem Buchungsknopf (R54). -**Mit Widerrufsrecht** (Zusammenfassung vor dem Knopf und G02 vor der Belehrung): +**Mit Widerrufsrecht** (Bestätigungs-E-Mail G02, vor der Belehrung; das Widget zeigt dort die ausführliche Belehrung zum Aufklappen): > **Widerrufsrecht.** Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Dafür nutzen Sie die Schaltfläche „Vertrag widerrufen“ im Terminbereich auf unserer Website oder den Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular stehen in Ihrer Bestätigungs-E-Mail. @@ -65,7 +63,7 @@ Maßgeblich für die Mail ist die Vorlage G02: Bis zum Beginn des Termins geht d Gäste, die nur Unternehmen sind (`recht.gaesteSind = unternehmer`): kein Hinweis. -## 6. Widerrufsfunktion (Entwurf der Texte) +## 6. Widerrufsfunktion (Texte) - Schaltfläche oder Link: **Vertrag widerrufen** (im Terminbereich der Website, auf der Verwaltungsseite hinter „Termin verwalten“). - Formular ohne Anmeldung: Name · Buchungsnummer · E-Mail-Adresse für die Eingangsbestätigung. Schaltfläche: **Widerruf bestätigen**. @@ -76,10 +74,3 @@ Gäste, die nur Unternehmen sind (`recht.gaesteSind = unternehmer`): kein Hinwei > Wir bestätigen den Eingang Ihres Widerrufs. Inhalt Ihrer Erklärung: Widerruf des Vertrags zur Buchung {{nummer}}. Eingegangen am {{eingang}} (Datum und Uhrzeit). {{Nur im Block „zugeordnet“: Ihr Termin ist abgesagt.}} Absender: {{Firma}}. Der Eingang muss mit Datum und Uhrzeit bestätigt werden (§ 356a BGB); das ist auch der Text der Belehrung („Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs“). - -## Für die Rechtsprüfung und das Widget offen - -1. **Belehrung vor dem Vertragsschluss** (erledigt durch R14, R55): Das Widget zeigt die vollständige Belehrung samt Muster-Widerrufsformular (`texte.widerrufsbelehrung`, aufklappbar) in der Zusammenfassung vor dem Knopf, bei jeder Verbraucher-Buchung auch bei Anfragen; der Nachweis der Buchung hält ihren Hash fest. Die Bestätigungs-E-Mail wiederholt sie. Offen bleibt nur, ob der Rechtsprüfer die aufklappbare Darstellung genügen lässt. -2. **Widerruf der Einwilligung zur Erinnerung**: Der feste Wortlaut des Hakens (Abschnitt 2) enthält den Hinweis auf den Widerruf nicht; er steht im Datenschutz-Absatz (`termine-datenschutz-absatz.md`). Prüfen, ob ein kurzer Zusatz unter dem Haken nötig ist („Sie können diese Einwilligung jederzeit widerrufen.“). -3. **Link zur Datenschutzerklärung** neben dem Hinweis: `betrieb.datenschutzUrl` ist in der Einstellung optional. Ohne Link verweist der Hinweis auf eine Datenschutzerklärung, die der Gast nicht findet; das Portal sollte die Adresse vor dem Einschalten verlangen. -4. **Bestätigungsfrist**: Der Hinweis nennt „48 Stunden nach Ablauf der Bestätigungsfrist“. Der Löschlauf läuft in Abständen; „48 Stunden“ ist die früheste Löschung, nicht eine Sekundengarantie. diff --git a/docs/rechtliches/termine-widerrufsbelehrung-muster.md b/docs/rechtliches/termine-widerrufsbelehrung-muster.md index 4d2472c..47ee6d2 100644 --- a/docs/rechtliches/termine-widerrufsbelehrung-muster.md +++ b/docs/rechtliches/termine-widerrufsbelehrung-muster.md @@ -1,8 +1,6 @@ -ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen - # Widerrufsbelehrung und Muster-Widerrufsformular (Terminplaner, Dienstleistung) -Fassung 2026-10-02. Quelle des Wortlauts: Anlage 1 und Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB (Fundstelle BGBl. I 2013, S. 3663 bis 3665), abgerufen am 2026-10-02 von gesetze-im-internet.de; die Textdateien liegen unverändert in `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt`. +Fassung 2026-10-05. Quelle des Wortlauts: Anlage 1 und Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB (Fundstelle BGBl. I 2013, S. 3663 bis 3665), abgerufen am 2026-10-02 von gesetze-im-internet.de; die Textdateien liegen unverändert in `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt`. Der Text unten ist die Fassung, die `widerrufsbelehrung(betrieb)` in `server/services/termine/rechtstexte.js` erzeugt, mit Platzhaltern statt der Betriebsdaten. `test/termineRechtstexte.test.js` prüft, dass (a) jeder Absatz des Musters wörtlich aus den Quelldateien im Ergebnis steht und (b) der Textblock unten genau der Ausgabe der Funktion entspricht. Ändere den Text deshalb nur in `rechtstexte.js` und übernimm ihn hierher. @@ -62,12 +60,3 @@ Muster-Widerrufsformular - G02 (Bestätigung an den Gast), Block `widerruf`, als `{{widerrufsbelehrung}}` (die Mailvorlage setzt den Text HTML-maskiert ein); nur wenn die Buchung ein Widerrufsrecht hat (verbindlich, Verbraucher). - Bei einer Freizeit-Dienstleistung mit festem Termin (§ 312g Abs. 2 Nr. 9 BGB) und bei Gästen, die nur Unternehmen sind, entfallen Belehrung, Formular und Widerrufsfunktion; der Hinweis steht dann in `termine-hinweise.md`. - -## Für die Rechtsprüfung offen - -1. **Lieferkosten-Klammer** im Absatz „Folgen des Widerrufs“ („einschließlich der Lieferkosten (mit Ausnahme …)“) steht unverändert im Text. Anlage 1 enthält keinen Gestaltungshinweis, der sie bei reinen Dienstleistungen zu streichen erlaubt; sie ist deshalb nicht gekürzt. Prüfen, ob sie für Dienstleistungen entfallen darf. -2. **„… beginnen soll“**: Nach dem Streichen von „oder Lieferung von Wasser/Gas/Strom/Fernwärme“ steht „die Dienstleistungen … beginnen soll“ (Singular im Gesetzestext). Der Wortlaut ist bewusst nicht geglättet, damit das Muster unverändert bleibt. Prüfen, ob „sollen“ zulässig ist. -3. **Anschrift und Telefon**: Das Muster verlangt Anschrift und Telefonnummer. `widerrufsbelehrung()` wirft deshalb einen TypeError, wenn eine der vier Angaben fehlt. Die Einstellungsprüfung (späterer Task) soll Anschrift und Telefon verlangen, sobald Gäste ein Widerrufsrecht haben können (öffentlich `widerruf: true`). -4. **Wertersatz-Absatz** gilt nur, wenn der Gast den vorzeitigen Beginn verlangt hat (Zustimmung `ZUSTIMMUNG_VORZEITIG`, nur bei Terminbeginn innerhalb von 14 Tagen). Der Absatz steht trotzdem immer in der Belehrung, wie das Muster es für Dienstleistungen vorsieht. -5. **Hinweis statt Internetadresse**: „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ist als geeigneter Hinweis gewählt (Entscheidung R12). Prüfen, ob ein Hinweis ohne Internetadresse genügt oder die Adresse der Seite genannt werden muss. -6. **Gültigkeit der Gesetzlichkeitsfiktion**: Sie gilt nur, wenn das Muster inhaltlich und gestalterisch dem Gesetz entspricht. Jede Abweichung (auch Zeilenumbrüche, Überschriften, die „–“-Aufzählung im Formular) ist hier Darstellung, nicht Inhalt. Vor dem Einsatz bei fremden Betrieben durch eine Kanzlei prüfen lassen. diff --git a/docs/rechtliches/widerrufsbelehrung-funktionen.md b/docs/rechtliches/widerrufsbelehrung-funktionen.md new file mode 100644 index 0000000..a635114 --- /dev/null +++ b/docs/rechtliches/widerrufsbelehrung-funktionen.md @@ -0,0 +1,54 @@ +# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen) + +**Fassung 2026-10-05** + +Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular. + +## Widerrufsbelehrung + +### Widerrufsrecht + +Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen. + +Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses. + +Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist. + +Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs. + +Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden. + +### Folgen des Widerrufs + +Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet. + +Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht. + +## Muster-Widerrufsformular + +(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.) + +– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com: + +– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*) + +– Bestellt am (*)/erhalten am (*) + +– Name des/der Verbraucher(s) + +– Anschrift des/der Verbraucher(s) + +– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier) + +– Datum + +(*) Unzutreffendes streichen. + +## Hinweise zum Widerruf + +Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt. + +- Das Widerrufsrecht gilt für jede Bestellung einer Funktion gesondert, also für jedes Website-Projekt, für das der Kunde eine Funktion bestellt. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss. +- Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt. +- Hat WEBklar auf ausdrücklichen Wunsch des Kunden vor Ablauf der Widerrufsfrist mit der Einrichtung begonnen, schuldet der Kunde bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung. Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung. +- Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe. diff --git a/docs/superpowers/specs/2026-09-30-funktionen-news-design.md b/docs/superpowers/specs/2026-09-30-funktionen-news-design.md index 6d349ef..d7222a8 100644 --- a/docs/superpowers/specs/2026-09-30-funktionen-news-design.md +++ b/docs/superpowers/specs/2026-09-30-funktionen-news-design.md @@ -69,13 +69,18 @@ auf `off`). - Grobe Zeiteinschätzung: „meist innerhalb von 1–2 Wochen“ (Text in `config`, kein Versprechen). - Projektauswahl bei mehreren Websites. - Pflicht-Checkbox (Widerruf, analog Abo-Bestellung): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf - der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald - der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).“ Einwilligung wird mit Zeitstempel und - Textfassung am Feature-Dokument gespeichert (wie die Abo-Consent-Metadaten). + der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen + Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, + schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Einwilligung + wird mit Zeitstempel und Textfassung am Feature-Dokument gespeichert (wie die Abo-Consent-Metadaten). + *Korrekturvermerk 2026-10-05 (Terminplaner TP2, Task F2): Ursprünglich stand hier „… erlischt, sobald der Einbau + vollständig erbracht ist (§ 356 Abs. 4 BGB).“ Zitat und Wertersatz-Satz sind korrigiert bzw. ergänzt (Entwurf bis + zum Befund der Kanzlei, Kanzlei-Paket Frage Q3). Bestehende Bestellungen behalten den damals gespeicherten Text + und die damalige Fassung.* - Knopf „**Zahlungspflichtig bestellen · 30 €**“ ⇒ `POST /api/funktionen/news/checkout`. **Zahlung**: Stripe Checkout `mode: 'payment'`, einmaliger Preis über `ensurePrice('news_setup')` -(Katalog in `config`/`services/products.js`, 30,00 € brutto), `invoice_creation.enabled = true`, +(Katalog in `config`/`services/products.js`, 30,00 €; *Korrekturvermerk 2026-10-05: ohne Umsatzsteuer gemäß § 19 UStG, nicht „brutto“*), `invoice_creation.enabled = true`, Zahlarten wie beim Abo (Karte, SEPA). Metadaten `{ customerId, projectId, featureKey: 'news' }`. **Stripe-Webhook** `checkout.session.completed` (bzw. `checkout.session.async_payment_succeeded` bei SEPA): diff --git a/public/datenschutz.html b/public/datenschutz.html index a620d7a..06f8a72 100644 --- a/public/datenschutz.html +++ b/public/datenschutz.html @@ -44,7 +44,7 @@
WEBKLAR

Datenschutzerklärung

-

Stand 01.10.2026

+

Stand 05.10.2026

Hier erfahren Sie, welche personenbezogenen Daten wir verarbeiten, wenn Sie unsere Websites besuchen, unser Kundenportal nutzen oder mit uns einen Vertrag schließen, und welche Rechte Sie haben.

@@ -79,7 +79,7 @@

Wir nutzen die Daten, um Ihre Anfrage zu beantworten. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, wenn Ihre Anfrage auf einen Vertrag zielt, sonst Art. 6 Abs. 1 lit. f DSGVO (Interesse an der Beantwortung). Entsteht kein Vertrag, löschen wir die Anfrage spätestens sechs Monate nach der letzten Nachricht.

6. Kundenportal

-

Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen, Ihre Abos und Bestellungen (Abschnitt 7) sowie gegebenenfalls Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Schreiben wir Ihnen im Chat, erhalten Sie eine E-Mail-Benachrichtigung mit unserer Nachricht, höchstens eine alle 15 Minuten. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen. Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen. Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten. Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.

+

Für Ihr Kundenkonto verarbeiten wir Name, E-Mail-Adresse, Passwort (nur verschlüsselt gespeichert), Kundennummer sowie gegebenenfalls Anschrift und Telefonnummer. Im Portal kommen hinzu: Ihre Projekte (Dateien und Änderungsverlauf Ihrer Website), unsere Arbeitsnachweise, Nachrichten im Chat, Rechnungen, Ihre Abos und Bestellungen (Abschnitt 7) sowie gegebenenfalls Ihr Stundenkontingent. Schreiben Sie uns im Chat, erhalten unsere Mitarbeiter eine E-Mail-Benachrichtigung mit Ihrer Nachricht. Schreiben wir Ihnen im Chat, erhalten Sie eine E-Mail-Benachrichtigung mit unserer Nachricht, höchstens eine alle 15 Minuten. Um Sie zu unterstützen, können unsere Mitarbeiter Ihr Portal so öffnen, wie Sie es sehen, und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern. Bestellungen, Zahlungen, Änderungen an Ihrem Passwort sowie Nachrichten oder Anträge in Ihrem Namen sind dabei ausgeschlossen. Jedes Öffnen und jede Änderung halten wir mit Mitarbeiter und Zeitpunkt fest und löschen diese Einträge nach 12 Monaten. Rechtsgrundlage dafür ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist die Nachvollziehbarkeit von Zugriffen auf Ihr Konto.

Im Chat können Sie uns auch Bilder, Fotos, Dateien und Sprachnachrichten schicken. Auf Kamera und Mikrofon greift das Portal nur zu, während Sie selbst ein Foto aufnehmen oder eine Sprachnachricht aufzeichnen, und nur, wenn Sie das in Ihrem Browser erlauben. Anhänge werden schon beim Hinzufügen auf unseren Server übertragen. Nicht gesendete Anhänge, auch solche, die Sie vor dem Senden wieder entfernen, löschen wir spätestens nach 24 Stunden. Gesendete Anhänge speichern wir wie Ihre Chat-Nachrichten. Die E-Mail-Benachrichtigung an unsere Mitarbeiter nennt nur Art und Namen der Anhänge, nicht die Dateien selbst.

Alle Portal-Daten liegen in unseren selbst betriebenen Systemen auf unserem Server bei Hetzner. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO. Wir speichern die Daten für die Dauer der Geschäftsbeziehung und danach bis zum Ablauf der Verjährungsfrist (in der Regel drei Jahre). Für Ihre Abos und Bestellungen gelten die Fristen in Abschnitt 7. Rechnungen bewahren wir wie in Abschnitt 9 beschrieben auf.

@@ -94,9 +94,18 @@

Sobald diese Funktion freigeschaltet ist, können Kunden mit Wartungs-Abo für ihre eigene Website eine Reichweitenmessung einschalten. Dabei verarbeiten wir die Daten der Besucher dieser Websites ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer eine Kundenwebsite besucht, findet die Informationen zur Messung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) auf der Website des Kunden, in der Regel unter „/datenschutz-analyse.html“. Die Daten liegen dann auf unserem Server bei der Hetzner Online GmbH in Deutschland.

+

6c. Online-Terminbuchung auf Websites unserer Kunden

+

Kunden mit Wartungs-Abo können auf ihrer eigenen Website eine Online-Terminbuchung anbieten („Terminplaner“), sobald wir die Funktion für sie freigeschaltet haben. Dabei verarbeiten wir die Daten der Personen, die dort einen Termin buchen („Gäste“), ausschließlich als Auftragsverarbeiter nach den Weisungen des jeweiligen Kunden, der dafür Verantwortlicher ist. Wir verwenden diese Daten nicht für eigene Zwecke. Verantwortliche sind wir für die Betriebsdaten des Kunden im Kundenportal, für den Entwurf seines Fragebogens und für seine Bestellung. Diese Datenschutzerklärung gilt für unsere eigenen Seiten und das Kundenportal. Wer auf der Website eines Kunden einen Termin bucht, findet die Informationen zur Buchung (Zwecke, Rechtsgrundlagen, Speicherdauer, Rechte) in der Datenschutzerklärung dieser Website. Anfragen von Gästen zu ihren Daten leiten wir an den Kunden weiter.

+

Für den Betrieb des Terminplaners speichern wir im Auftrag des Kunden: Name, E-Mail-Adresse und, wenn der Kunde diese Felder eingeschaltet hat, Telefonnummer, Nachricht und weitere Angaben des Gastes; die gewählte Leistung, Person oder den gewählten Platz und den Termin; den Stand der Buchung (zum Beispiel bestätigt, abgesagt oder widerrufen) mit den Zeitpunkten; als Nachweis den Zeitpunkt der Bestätigung und der Zustimmungen sowie die Fassung der angezeigten Hinweise; gegebenenfalls den Wunsch nach einer Erinnerung. Der Terminplaner ist nicht für Leistungen der Heilkunde und der Gesundheitsversorgung bestimmt; Gäste werden gebeten, keine Gesundheitsangaben zu machen.

+

Der Kunde sieht, ändert und löscht die Termine im Kundenportal. Dort verarbeiten wir als Verantwortliche seine Einstellungen für den Terminplaner (Betriebsangaben, Leistungen, Namen und Zeiten der gebuchten Personen oder Plätze, Öffnungszeiten, Absagefristen und Texte), wie die übrigen Portal-Daten (Abschnitt 6) auf Grundlage von Art. 6 Abs. 1 lit. b DSGVO. Bevor der Kunde den Terminplaner bestellt, füllt er im Kundenportal einen Fragebogen aus (Branche und Betrieb, Personen oder Plätze, Leistungen, Zeiten, Regeln, Angaben von Gästen, Benachrichtigungen und Website); seine Eingaben speichern wir als Entwurf seiner Einstellungen. Bestellt er nicht, löschen wir den Entwurf 90 Tage nach der letzten Änderung, bei der Löschung seines Kundenkontos zugleich mit dem Konto. Die Bestellung und ihren Nachweis beschreibt Abschnitt 7. Für jede Person oder jeden Platz kann der Kunde einen geheimen Link erzeugen, mit dem er die bestätigten Termine in seinem Kalenderprogramm abonniert; wer den Link kennt, sieht diese Termine. Der Kunde kann den Link jederzeit durch einen neuen ersetzen.

+

Im Namen des Kunden senden wir E-Mails an Gäste (Bestätigung der E-Mail-Adresse, Bestätigung, Änderung oder Absage eines Termins, Eingangsbestätigung eines Widerrufs und, wenn der Gast das gewählt hat, eine Erinnerung) und an den Kunden (neue Buchung, Absage, Widerruf). Der Versand läuft über den E-Mail-Dienst der Hetzner Online GmbH (Rechenzentrum Nürnberg, Abschnitt 8); als Absender erscheint der Kunde, Antworten gehen an ihn. Wir verschicken keine Werbung, setzen keine Tracking-Pixel ein und erhalten keine Kopie. Bis zum Versand liegt jede E-Mail in einer Warteschlange. Nach dem Versand löschen wir dort Empfängeradresse und Inhalt, den Eintrag selbst nach sieben Tagen; E-Mails, die nicht zugestellt werden konnten, löschen wir nach 48 Stunden.

+

Beim Aufruf einer Kundenwebsite mit Terminbuchung lädt der Browser das Programm der Terminbuchung von unserem Server (project.webklar.com) und schon beim Laden der Seite die Konfiguration mit den Texten und Leistungen. Ist die Terminbuchung unmittelbar in die Seite eingebaut, holt sie die freien Termine, sobald der Gast durchklickt; sitzt sie hinter einem Knopf, geschieht das erst nach dem Klick auf den Knopf. Dabei gelten die Protokolle in Abschnitt 3. Die Terminbuchung setzt keine Cookies und speichert nichts im Browser des Gastes. Zum Schutz vor Missbrauch und um die Zahl der Anfragen zu begrenzen (höchstens zehn Buchungen je zehn Minuten und je IP-Adresse), merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit der Buchung speichern wir die IP-Adresse nicht. Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO; unser berechtigtes Interesse ist der sichere und störungsfreie Betrieb.

+

Löschfristen: Nicht bestätigte Buchungen löschen wir 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten, abgesagte oder abgelehnte Termine nach 30 Tagen, bestätigte Termine nach der Frist, die der Kunde eingestellt hat (Vorgabe 90 Tage, einstellbar von 14 bis 365 Tagen nach dem Termin). Nicht bestellte Entwürfe löschen wir 90 Tage nach der letzten Änderung. Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein. In unseren Datensicherungen bleiben gelöschte Daten noch bis zu sieben Tage erhalten und werden dann überschrieben. Die Daten liegen auf unserem Server bei Hetzner im Rechenzentrum Falkenstein (Deutschland), wie die übrigen Portal-Daten (Abschnitt 3).

+

7. Abos und Zahlung über Stripe

Schließen Sie eines unserer Abos ab, verarbeiten wir Name, E-Mail-Adresse, Rechnungsanschrift, das gewählte Abo und den gebuchten Zusatzspeicher, Abo- und Zahlungsstatus sowie Ihre Rechnungen. Zahlungsdaten wie Kartennummer oder IBAN geben Sie direkt bei unserem Zahlungsdienstleister ein. Wir erhalten davon nur eingeschränkte Angaben, etwa die Art des Zahlungsmittels und die letzten vier Ziffern.

Um Ihre Bestellungen nachweisen zu können, speichern wir bei jeder Bestellung Zeitpunkt, Version der Vertragsbedingungen, gegebenenfalls Ihre Angaben zum Leistungsbeginn sowie IP-Adresse und Browserkennung, im Kundenportal und als Angaben zum Abo bei Stripe. Das gilt für den Abschluss eines Abos, für den Wechsel vom Hosting-Abo ins Wartungs-Abo und für jede Bestellung von E-Mail-Zusatzspeicher. Außerdem halten wir Ihre Bestellungen und Änderungen als Vertragsereignisse mit Zeitpunkt und Status fest: Abschluss, Wechsel, Zusatzspeicher einschließlich Verringerungen, Kündigung und Widerruf. Bei Verringerungen, Kündigung und Widerruf speichern wir als Vertragsereignis nur Zeitpunkt und Status, keine IP-Adresse und Browserkennung. Den Stand Ihrer Bestellungen, etwa laufende Widerrufsfristen und die Einrichtung von Zusatzspeicher, sehen Sie im Kundenportal unter „Verträge“. Für uns sind die Vertragsereignisse zugleich die Aufgabenliste, um Änderungen am Zusatzspeicher umzusetzen. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO. Unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und Ihrer Bestellungen. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet.

+

Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.

Die Zahlungen wickelt die Stripe Payments Europe, Limited, 1 Grand Canal Street Lower, Grand Canal Dock, Dublin, D02 H210, Irland („Stripe“) ab. Stripe verarbeitet die Daten teils in unserem Auftrag und teils als eigener Verantwortlicher, etwa zur Betrugsvorbeugung und zur Erfüllung gesetzlicher Pflichten. Dabei können Daten an die Stripe, LLC in den USA übermittelt werden. Die Stripe, LLC ist nach dem EU-US Data Privacy Framework zertifiziert, für das ein Angemessenheitsbeschluss der EU-Kommission besteht. Auf der Zahlungsseite von Stripe gelten dessen Datenschutzhinweise: stripe.com/de/privacy. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO.

Beantragen Sie im Kundenportal eine Übernahmegutschrift, verarbeiten wir Ihre Angaben zum bisherigen Anbieter (Anbieter, Leistung, Domain, Rechnungsbetrag, bezahlter Zeitraum), Ihre Nachricht, die hochgeladenen Nachweise wie Rechnung oder Kündigungsbestätigung, die E-Mail-Adresse für unsere Antwort sowie Zeitpunkt und IP-Adresse Ihrer Bestätigung. Die Nachweise speichern wir auf unserem Server; unsere Mitarbeiter erhalten per E-Mail nur einen Hinweis auf den Antrag. Zur Prüfung fragen wir die öffentlichen DNS-Einträge der angegebenen Domain ab (Nameserver, Mailserver, IP-Adresse). Eine genehmigte Gutschrift buchen wir bei Stripe als Guthaben auf Ihr Kundenkonto. Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, für den Nachweis Ihrer Bestätigung Art. 6 Abs. 1 lit. f DSGVO. Nachweise zu abgelehnten Anträgen löschen wir sechs Monate nach der Entscheidung. Bei genehmigten Anträgen sind Antrag und Nachweise Beleg für die Gutschrift und werden wie in Abschnitt 9 beschrieben aufbewahrt.

Über die Seiten project.webklar.com/kuendigen.html und project.webklar.com/widerrufen.html können Sie Ihren Vertrag ohne Anmeldung kündigen oder widerrufen (§§ 312k, 356a BGB). Dabei verarbeiten wir Ihren Namen, Ihre E-Mail-Adresse, den gewählten Vertrag, beim E-Mail-Zusatzspeicher die Zahl der Pakete, die Sie kündigen, gegebenenfalls Ihre Kundennummer, bei der Kündigung außerdem Art und gewünschten Zeitpunkt der Kündigung und gegebenenfalls Ihre Begründung, sowie den Zeitpunkt, zu dem Ihre Erklärung bei uns eingegangen ist. Sind Sie im Kundenportal angemeldet, füllen wir Name, E-Mail-Adresse und Kundennummer aus Ihrem Konto vor. Wir nutzen die Angaben, um Ihre Erklärung zu bearbeiten: Anhand der E-Mail-Adresse ordnen wir sie Ihrem Abo zu, vermerken den Eingang beim Abo und tragen die Kündigung oder den Widerruf, soweit möglich, bei Stripe ein. Sie erhalten sofort eine Bestätigung des Eingangs mit Inhalt, Datum und Uhrzeit Ihrer Erklärung an die angegebene E-Mail-Adresse; unser Team erhält eine E-Mail mit diesen Angaben. Rechtsgrundlage ist Art. 6 Abs. 1 lit. c DSGVO (§§ 312k, 356a BGB) in Verbindung mit Art. 6 Abs. 1 lit. b DSGVO. Die IP-Adresse Ihres Aufrufs nutzen wir nur, um die Zahl der Anfragen zu begrenzen (höchstens zehn pro Stunde je IP-Adresse). Dafür merken wir uns die Zeitpunkte der Aufrufe je IP-Adresse nur im Arbeitsspeicher des Servers, nicht dauerhaft; mit Ihrer Erklärung speichern wir die IP-Adresse nicht. Im Übrigen gilt für den Aufruf Abschnitt 3. Ihre Erklärung und unsere Bestätigung bewahren wir wie die übrigen Vertragsdaten drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet auf; soweit die E-Mails Geschäftsbriefe sind, gilt die Frist aus Abschnitt 9.

diff --git a/public/embed/termine.js b/public/embed/termine.js index ddef321..eab6abb 100644 --- a/public/embed/termine.js +++ b/public/embed/termine.js @@ -179,6 +179,14 @@ } // @telefon-ende + // @storno-anfang (reine Funktion, test/termineEmbedStatic.test.js fuehrt sie einzeln aus) + // Gelten die Stornobedingungen des Betriebs fuer diese Leistung? Nur bei einer Leistung mit Preis (l.preis ist der Preistext des Servers, + // bei kostenlosen Leistungen ''), wie der Server den Haken nur dort verlangt: ohne Preis gibt es weder die Box noch den Haken noch die Pruefung. + function stornoGilt (storno, l) { + return !!storno && !!l && typeof l.preis === 'string' && l.preis !== '' + } + // @storno-ende + function apiBasis (root) { var a = String(root.getAttribute('data-api') || '').replace(/\/+$/, '') if (a) return a @@ -1212,9 +1220,10 @@ // Stornoregel im Klartext, daneben der Link auf die AGB des Betriebs (nur https, wenn hinterlegt) var agbUrl = httpsUrl(c.betrieb.agbUrl) - if ((c.storno && istText(c.storno.text)) || agbUrl) { + var stornoAn = stornoGilt(c.storno, l) + if ((stornoAn && istText(c.storno.text)) || agbUrl) { var sb = el('div', 'wkt-box') - if (c.storno && istText(c.storno.text)) { + if (stornoAn && istText(c.storno.text)) { sb.appendChild(el('h3', '', 'Stornobedingungen')) sb.appendChild(el('p', '', c.storno.text)) } @@ -1256,7 +1265,7 @@ var form = el('form', 'wkt-form') form.noValidate = true var refs = {} - if (c.storno) form.appendChild(haken(refs, 'storno', c.texte.storno, dd.storno, true, function (v) { dd.storno = v })) + if (stornoAn) form.appendChild(haken(refs, 'storno', c.texte.storno, dd.storno, true, function (v) { dd.storno = v })) if (S.vorzeitig) form.appendChild(haken(refs, 'vorzeitig', c.texte.vorzeitig, dd.vorzeitig, true, function (v) { dd.vorzeitig = v })) if (c.erinnerung && c.erinnerung.anbieten === true) form.appendChild(haken(refs, 'erinnerung', c.texte.erinnerung, dd.erinnerung, false, function (v) { dd.erinnerung = v })) form.appendChild(honigfeld()) @@ -1276,7 +1285,7 @@ e.preventDefault() if (S.sendet) return var f = {} - if (c.storno && !dd.storno) f.storno = T('Bitte bestätigen Sie, dass Sie die Stornobedingungen gelesen haben.', 'Bitte bestätige, dass du die Stornobedingungen gelesen hast.') + if (stornoAn && !dd.storno) f.storno = T('Bitte bestätigen Sie, dass Sie die Stornobedingungen gelesen haben.', 'Bitte bestätige, dass du die Stornobedingungen gelesen hast.') if (S.vorzeitig && !dd.vorzeitig) f.vorzeitig = T('Bitte stimmen Sie der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.', 'Bitte stimme der Leistung vor Ablauf der Widerrufsfrist ausdrücklich zu, sonst ist die Buchung nicht möglich.') fehlerBox.textContent = '' if (Object.keys(f).length) { @@ -1310,7 +1319,7 @@ nachricht: dd.nachricht.trim(), zusatz: zusatz, erinnerung: !!dd.erinnerung, - zustimmungen: { storno: !!dd.storno, vorzeitig: !!dd.vorzeitig }, + zustimmungen: { storno: stornoGilt(S.cfg.storno, aktuelleLeistung()) && !!dd.storno, vorzeitig: !!dd.vorzeitig }, kontakt_url: S.honig, formToken: S.cfg.formToken }).then(function (d) { diff --git a/public/funktion-bestellen.js b/public/funktion-bestellen.js index 0d9c151..8dfcad5 100644 --- a/public/funktion-bestellen.js +++ b/public/funktion-bestellen.js @@ -259,10 +259,14 @@ function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) { * Karte für eine Bestellung im Zustand ordered („Zahlung offen“) oder setup („In Einrichtung“). * projekt = Eintrag des Projekts aus GET /api/funktionen (projectId), bestellung = projekt.features.termine (state, orderedAt, ticketWoid, * invoiceUrl), einrichtung = Text der Zeiteinschätzung (Register: setupEstimate), danke = Rückkehr von Stripe, kundenansicht = Sitzung in der - * Kundenansicht. weiter() wird gerufen, wenn „Zahlung fortsetzen“ mit 409 zustimmung_fehlt antwortet (keine offene Sitzung mit Nachweis): es - * lädt die Seite neu und zeigt wieder das Bestellformular. Fehlt weiter, steht die Meldung des Servers am Knopf. + * Kundenansicht, meldung = Text, der am Knopf „Zahlung fortsetzen“ steht (z. B. der Fehler eines Versuchs vor dem Neuzeichnen). + * weiter(fall) wird gerufen, wenn „Zahlung fortsetzen“ scheitert (Ruling R110): fall = { bestellen, meldung }, bestellen ist wahr bei 409 + * zustimmung_fehlt (keine offene Sitzung mit Nachweis: die Ansicht zeigt wieder das Bestellformular), meldung der Fehlertext der Antwort. + * weiter() lädt neu und zeichnet die Ansicht nach dem Zustand von neuem, damit keine veraltete Karte stehen bleibt (z. B. wenn die Bestellung + * inzwischen bezahlt ist und der Server 400 antwortet); wirft es, bleibt die Karte stehen und der Fehler steht am Knopf. Fehlt weiter, + * steht die Meldung des Servers am Knopf. */ -export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht } = {}, { weiter } = {}) { +export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht, meldung } = {}, { weiter } = {}) { const bestellt = bestellung?.state === 'ordered' const datum = datumTag(bestellung?.orderedAt) const ticket = bestellung?.ticketWoid @@ -275,7 +279,7 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans !bestellt && text(einrichtung) ? zeile('Zeiteinschätzung', einrichtung) : null, ].filter(Boolean) - const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' }) + const meldungEl = h('p', { class: 'tm-fehler', role: 'alert', 'data-meldung': 'fortsetzen' }, text(meldung) || null) const fortsetzenKnopf = bestellt ? h('button', { type: 'button', class: 'kb-btn-primary', 'data-aktion': 'fortsetzen', disabled: kundenansicht === true, onclick: () => fortsetzen() }, 'Zahlung fortsetzen') : null @@ -289,15 +293,18 @@ export function statusKarte({ projekt, bestellung, einrichtung, danke, kundenans if (geheZu(antwort?.url)) return // die Seite wird verlassen meldungEl.textContent = ZAHLUNGSSEITE } catch (err) { - if (err?.status === 409 && err.code === 'zustimmung_fehlt' && typeof weiter === 'function') { + const nachricht = err?.message || ZAHLUNGSSEITE + const zustimmungFehlt = err?.status === 409 && err.code === 'zustimmung_fehlt' + if (typeof weiter === 'function') { try { - await weiter() + await weiter({ bestellen: zustimmungFehlt, meldung: nachricht }) return // die Ansicht zeichnet sich neu } catch (fehler) { - meldungEl.textContent = fehler?.message || ZAHLUNGSSEITE + // Neuladen gescheitert: bei fehlender Zustimmung ist das der Grund, der den Kunden betrifft, sonst bleibt der Fehler des Versuchs + meldungEl.textContent = zustimmungFehlt ? fehler?.message || ZAHLUNGSSEITE : nachricht } } else { - meldungEl.textContent = err?.message || ZAHLUNGSSEITE + meldungEl.textContent = nachricht } } fortsetzenKnopf.disabled = false diff --git a/public/funktionen.js b/public/funktionen.js index b75d396..a8e6889 100644 --- a/public/funktionen.js +++ b/public/funktionen.js @@ -5,8 +5,11 @@ import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js' import { HEAT_DEVICES, deviceHtml, heatCaption } from '/geraete.js' +import { ZU_VERTRAEGE } from './vertraege-link.js' const PRICE = '30 €' // Rueckfall, wenn die Antwort von /api/funktionen kein features.news (Register) enthaelt +// Rueckfall fuer den Preishinweis, wenn die Antwort von /api/funktionen kein ustHinweis hat (gleicher Wortlaut wie config.ustHinweis) +const UST_HINWEIS = 'ohne Umsatzsteuer gemäß § 19 UStG' const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um const PHOTO_MAX_BYTES = 10 * 1024 * 1024 @@ -112,6 +115,10 @@ function newsTile(projects, price = PRICE) { ` } +// Link in den Hinweisen ohne Wartungs-Abo (kein_abo): Analyse-Kachel, Analyse-Seite und Terminplaner-Kachel teilen ihn, und die Ansicht +// #termine (termine.js) nimmt Text, Ziel und Klasse aus derselben Quelle (vertraege-link.js) +const ZU_VERTRAEGE_LINK = `` + /* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */ const analyseOf = (project) => project.analyse || { comingSoon: true } const analyseState = (a) => (a.comingSoon ? 'soon' : a.state === 'unknown' ? 'unknown' : a.state === 'disabled' ? 'disabled' : a.state === 'active' ? 'active' : a.eligible ? 'off' : 'blocked') @@ -136,7 +143,7 @@ function analyseTile(projects) { else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '' } else if (state === 'active') action = `` else if (state === 'off') action = `` - else if (a.reason === 'kein_abo') action = '

Im Wartungs-Abo enthalten.

' + else if (a.reason === 'kein_abo') action = `

Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE_LINK}

` else action = `

${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}

` const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return ` @@ -153,29 +160,15 @@ function analyseTile(projects) { /* Terminplaner: Kachel nur, wenn der Schalter es erlaubt (termine.verfuegbar), sonst "Kommt bald". Die Ansicht selbst (termine.js) wird von app.js geladen; hier steht nur die Kachel, damit funktionen.js keinen weiteren Import braucht. termine (Antwort von /api/funktionen) traegt bei verfuegbar: true bestellbar (true | false | null = Abo nicht lesbar), - grund (kein_abo | gekuendigt | nicht_aktiv | null), preis und einrichtung. Das Abo betrifft nur die Bestellung: ohne laufendes - Abo gibt es im Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */ + grund (kein_abo | gekuendigt | nicht_aktiv | null), preis, einrichtung und hinweis (Abo-Text des Servers, aboMeldung in + server/services/featureOrder.js, bei bestellbar true null). Das Abo betrifft nur die Bestellung: ohne laufendes Abo gibt es im + Zustand Aus keinen Weg in die Ansicht; wer schon bestellt hat oder den Terminplaner nutzt, behaelt seinen. */ const TERMINE_TEXT = 'Deine Kundschaft bucht Termine direkt über deine Website.' const termineOf = (project) => project.features?.termine || { state: 'off' } // Doppelt zu anfragenText in termine-view.js: funktionen.js darf dort nichts importieren (die Kachel testet test/funktionenTiles.test.js // mit einem umgebogenen Import). Aenderungen an beiden Stellen machen. const anfragenText = (n) => (n === 1 ? '1 Anfrage wartet' : `${n} Anfragen warten`) -// Abo-Hinweise der Kachel. Kopie von aboMeldung (server/services/featureOrder.js, einzige Quelle der Abo-Hinweistexte) fuer den -// Terminplaner; test/funktionenTiles.test.js haelt beide gleich. kein_abo steht wie bei der Analyse im Wortlaut der Kachel. -const TERMINE_ABO_TEXTE = { - gekuendigt: 'Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.', - nicht_aktiv: 'Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.', - unbekannt: 'Das Abo konnte gerade nicht geprüft werden.', -} -const KEIN_ABO_HINWEIS = 'Im Wartungs-Abo enthalten. ' - -function termineAboHinweis(angebot) { - if (angebot.bestellbar !== false) return TERMINE_ABO_TEXTE.unbekannt // null (Lesefehler) oder Feld fehlt: nicht geprueft - if (angebot.grund === 'kein_abo') return KEIN_ABO_HINWEIS - return Object.hasOwn(TERMINE_ABO_TEXTE, angebot.grund) && angebot.grund !== 'unbekannt' ? TERMINE_ABO_TEXTE[angebot.grund] : TERMINE_ABO_TEXTE.unbekannt -} - // Entwurf: Funktionsdokument im Zustand Aus mit siteKey (entsteht beim ersten Speichern des Fragebogens) const hatEntwurf = (project) => termineOf(project).state === 'off' && Boolean(termineOf(project).siteKey) @@ -216,7 +209,10 @@ function termineTile(projects, angebot = {}) { const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : '' action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`) } else { - extra = `

${termineAboHinweis(angebot)}

` + // Hinweis des Servers (maskiert), bei kein_abo dazu der Weg in die Verträge; ohne Text bleibt nur der Link oder nichts + const hinweis = typeof angebot.hinweis === 'string' ? esc(angebot.hinweis) : '' + const zeile = [hinweis, angebot.bestellbar === false && angebot.grund === 'kein_abo' ? ZU_VERTRAEGE_LINK : ''].filter(Boolean).join(' ') + if (zeile) extra = `

${zeile}

` } const count = projects.length > 1 && active ? `

aktiv für ${active} von ${projects.length} Websites

` : '' return ` @@ -272,7 +268,7 @@ export async function initFunktionen(root) { export async function initNews(root, { projectId } = {}) { if (!root) return if (projectId) selection.projectId = projectId - const { projects = [], setupEstimate = '', consentText = '', features } = await request('/api/funktionen') + const { projects = [], setupEstimate = '', consentText = '', features, ustHinweis } = await request('/api/funktionen') if (!projects.length) { root.innerHTML = '
Noch keine Website-Projekte zugewiesen.
' return @@ -307,7 +303,7 @@ export async function initNews(root, { projectId } = {}) { if (feature.state === 'active') renderEditor(stage, project) else if (isSetup(feature.state)) stage.innerHTML = thanks + statusCard(feature, setupEstimate) else if (feature.state === 'disabled') stage.innerHTML = '

News-Pinnwand ist abgeschaltet

Für diese Website ist die Pinnwand im Moment nicht aktiv. , dann klären wir das.

' - else renderBooking(stage, project, { setupEstimate, offer: newsOffer(features, consentText), thanks }) + else renderBooking(stage, project, { setupEstimate, offer: { ...newsOffer(features, consentText), ustHinweis: textOr(ustHinweis, UST_HINWEIS) }, thanks }) } function renderBooking(stage, project, { setupEstimate, offer, thanks }) { @@ -321,7 +317,7 @@ function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
  • Beiträge jederzeit selbst schreiben, ändern und abnehmen
  • Nutzung danach kostenlos, kein Abo
  • -

    ${esc(offer.price)} einmalig, inklusive Mehrwertsteuer

    +

    ${esc(offer.price)} einmalig,

    ${setupEstimate ? `

    Einrichtung: ${esc(setupEstimate)}

    ` : ''} @@ -329,6 +325,7 @@ function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
    ` stage.querySelector('#news-consent-text').textContent = offer.consentText + stage.querySelector('#news-ust-hinweis').textContent = offer.ustHinweis const box = stage.querySelector('#news-consent') const button = stage.querySelector('#news-order') const error = stage.querySelector('#news-order-error') @@ -695,7 +692,7 @@ export async function initAnalyse(root, { projectId } = {}) { if (a.state === 'active') renderAnalyseStats(stage, project, reload) else if (a.state === 'disabled') stage.innerHTML = '

    Analyse ist abgeschaltet

    Für diese Website ist die Analyse im Moment gesperrt. , dann klären wir das.

    ' else if (a.state === 'unknown') stage.innerHTML = '

    Die Daten konnten gerade nicht geladen werden.

    ' - else if (!a.eligible) stage.innerHTML = `

    Analyse nicht verfügbar

    ${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' ' : ''}

    ` + else if (!a.eligible) stage.innerHTML = `

    Analyse nicht verfügbar

    ${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` ${ZU_VERTRAEGE_LINK}` : ''}

    ` else renderAnalyseActivation(stage, project, reload) } diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.html b/public/rechtliches/avv-terminplaner-2026-10-05.html new file mode 100644 index 0000000..dd80625 --- /dev/null +++ b/public/rechtliches/avv-terminplaner-2026-10-05.html @@ -0,0 +1,197 @@ + + + + + + + Auftragsverarbeitungsvertrag Terminplaner – WEBklar + + + +
    + + +
    WEBKLAR
    +

    Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)

    + +

    Fassung 2026-10-05

    + +

    zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).

    + +

    Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen

    + +

    (1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Einrichtung des Terminplaners bestellt und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Terminplaner“ („Leistungsbeschreibung“) in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte und die Widerrufsbelehrung in dieser Fassung als PDF enthält und auf sie verlinkt.

    + +

    (2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1 anschließend.

    + +

    (3) Dieser Vertrag ist ein eigener Vertrag für den Terminplaner. Er ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für den Terminplaner geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung vor.

    + +

    1. Gegenstand und Dauer

    + +

    (1) WEBklar betreibt für die öffentliche Website des Kunden ein Buchungs-Widget mit einer Verwaltung im Kundenportal („Terminplaner“) und verarbeitet dabei personenbezogene Daten der Gäste und der Beschäftigten des Kunden im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung.

    + +

    (2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Abschalten, spätestens mit dem Ende des Wartungs-Abos; den Terminplaner schaltet WEBklar dann spätestens am folgenden Tag ab (Nr. 8 Abs. 1 der Leistungsbeschreibung). Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.

    + +

    2. Art und Zweck der Verarbeitung

    + +

    (1) Art: Erheben über das Widget auf der Website des Kunden, Übermitteln an den Server von WEBklar, Speichern, Verwalten und Anzeigen im Kundenportal, Versenden von E-Mails, Bereitstellen eines Kalender-Links und einer CSV-Datei und Löschen.

    + +

    (2) Zwecke: Der Kunde soll Termine seiner Gäste verwalten. Dazu nimmt der Terminplaner Buchungen und Terminanfragen entgegen, verhindert Doppelbuchungen, bestätigt, ändert und sagt Termine ab, erinnert Gäste auf deren Wunsch und benachrichtigt den Kunden. WEBklar versendet die E-Mails im Auftrag des Kunden. Außerdem schützt WEBklar den Terminplaner für den Kunden vor Missbrauch (Mengenbegrenzung, Erkennen automatisierter Buchungen).

    + +

    (3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Gäste. Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht (etwa die Zahl der gelöschten Buchungen je Tag oder die Länge der Versandwarteschlange), sind keine eigenen Zwecke.

    + +

    3. Datenarten und Betroffene

    + +

    (1) Daten der Gäste: Name und E-Mail-Adresse; auf Einstellung des Kunden außerdem Telefonnummer, eine Nachricht und Zusatzfelder, die der Kunde im Formular festlegt; die gebuchte Leistung, die Person oder der Platz und der Termin; der Status der Buchung mit den Zeitpunkten (Anlegen, Bestätigung, Annahme, Absage, Widerruf); Absagegrund und Vermerke zu einer Absage oder einem Widerruf (Freitext, auch mit dem Namen aus einer Widerrufserklärung); Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; der Nachweis der Zustimmungen (Fassung der Gästetexte, Zeitpunkt, Text des Buchungsknopfs, Anrede und Hashes (Prüfsummen) der gezeigten Texte); der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast.

    + +

    (2) Daten der Beschäftigten des Kunden: Namen oder Bezeichnungen der Personen oder Plätze, deren Arbeitszeiten und Abwesenheiten sowie ein geheimer Kalender-Link je Person oder Platz.

    + +

    (3) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet. Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt; der Kunde fordert von seinen Gästen keine Gesundheitsangaben (Nummer 11 Abs. 4).

    + +

    (4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Widgets und jeder Anfrage. Die Mengenbegrenzung (Missbrauchsschutz) verwendet die IP-Adresse nur im Arbeitsspeicher des Servers; ein Neustart leert sie. Mit der Buchung wird keine IP-Adresse und kein Browser-Kennzeichen gespeichert. In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).

    + +

    (5) Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.

    + +

    (6) Betroffene: Gäste, die über die Website des Kunden Termine buchen oder anfragen, und Beschäftigte des Kunden, die im Terminplaner geführt werden.

    + +

    4. Weisungen

    + +

    (1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.

    + +

    (2) Mit der Bestellung weist der Kunde WEBklar an, den Terminplaner nach seinen Einstellungen und der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Leistungen, Personen oder Plätze, Zeiten, Felder des Formulars, Aufbewahrungsdauer, Erinnerungen, Einschalten und Ausschalten). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die über diese Einstellungen hinausgehen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde den Terminplaner abschalten lassen (Nr. 8 Abs. 6 der Leistungsbeschreibung). Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.

    + +

    (3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.

    + +

    5. Vertraulichkeit

    + +

    WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.

    + +

    6. Technische und organisatorische Maßnahmen

    + +

    (1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.

    + +

    (2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.

    + +

    7. Unterauftragsverarbeiter

    + +

    (1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Terminplaner läuft, und für dessen Sicherung (Rechenzentrum Falkenstein, Deutschland) sowie für den Versand der Buchungs-E-Mails über den SMTP-Dienst des Webhostings (Rechenzentrum Nürnberg, Deutschland). Eine Übermittlung in Drittländer findet nicht statt.

    + +

    (2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde den Terminplaner abschalten lassen und WEBklar ihn für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Weisung des Kunden früher (Nummer 9 Abs. 6).

    + +

    (3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).

    + +

    (4) Die Zahlung der Einrichtung über Stripe betrifft nur den Vertrag zwischen dem Kunden und WEBklar. Daten von Gästen oder Beschäftigten des Kunden gehen nicht an Stripe. Stripe ist deshalb kein Unterauftragsverarbeiter dieses Vertrags.

    + +

    8. Unterstützung des Kunden, Betroffenenrechte

    + +

    (1) WEBklar leitet Anfragen von Gästen und Beschäftigten, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.

    + +

    (2) Dafür bietet das Kundenportal die Aktion „Gastdaten löschen“ und den Export der Termine als CSV-Datei. Mit „Gastdaten löschen“ leert der Kunde Name, E-Mail-Adresse, Telefonnummer, Nachricht und Zusatzfelder einer Buchung; Status und Zeiten bleiben. Noch nicht versandte E-Mails zu dieser Buchung löscht WEBklar dabei, und die Links des Gastes werden ungültig. Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1).

    + +

    (3) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.

    + +

    (4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht.

    + +

    (5) Die Buchung speichert als Nachweis der Zustimmungen die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte, die der Gast gesehen hat, nicht diese Texte selbst. Damit sich der Wortlaut nachweisen lässt, bewahrt WEBklar jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz und Widerrufsbelehrung für Gäste) mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende des Terminplaners. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung.

    + +

    (6) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).

    + +

    9. Löschung und Rückgabe

    + +

    (1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten.

    + +

    (2) Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung. Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.

    + +

    (3) Bestätigte und erledigte Termine löscht WEBklar nach der Aufbewahrungsdauer, gerechnet ab dem Ende des Termins. Der Kunde stellt sie im Kundenportal ein; die Vorgabe sind 90 Tage, einstellbar sind 14 bis 365 Tage.

    + +

    (4) In der Versandwarteschlange für E-Mails leert WEBklar die Empfängeradresse und den Inhalt einer E-Mail nach dem Versand und löscht den Eintrag 7 Tage nach dem Versand. E-Mails, die nicht zugestellt werden können, löscht WEBklar mit Empfängeradresse und Inhalt 48 Stunden nach dem endgültigen Fehlschlag.

    + +

    (5) Der Löschlauf läuft täglich; gelöscht wird frühestens nach Ablauf der jeweiligen Frist. Ist an einem Tag mehr zu löschen, als ein Lauf schafft, folgt der Rest im nächsten Lauf.

    + +

    (6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Bis dahin kann der Kunde die Termine im Kundenportal als CSV-Datei exportieren. Beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen wieder ein (Nr. 8 Abs. 4 der Leistungsbeschreibung). Früher löscht der Kunde einzelne Gastdaten selbst mit der Aktion „Gastdaten löschen“ (Nummer 8 Abs. 2); alle Daten löscht WEBklar auf Weisung des Kunden in Textform unverzüglich.

    + +

    (7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung.

    + +

    (8) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.

    + +

    (9) Gesetzliche Aufbewahrungspflichten bleiben unberührt.

    + +

    10. Nachweise und Kontrollen

    + +

    WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.

    + +

    11. Pflichten des Kunden

    + +

    (1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste und seiner Beschäftigten im Terminplaner.

    + +

    (2) Der Kunde informiert seine Gäste nach Art. 13 DSGVO. Dazu nimmt er den Datenschutz-Absatz für die Online-Terminbuchung in seine Datenschutzerklärung auf (Nr. 4 Abs. 9 der Leistungsbeschreibung) und lässt den Datenschutz-Hinweis am Buchungsformular unverändert.

    + +

    (3) Der Kunde informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt (Nr. 4 Abs. 7 der Leistungsbeschreibung).

    + +

    (4) Der Kunde setzt den Terminplaner nicht im Gesundheitsbereich ein und fordert von seinen Gästen keine Angaben zu ihrer Gesundheit, auch nicht über Zusatzfelder oder das Nachrichtenfeld (Nr. 4 Abs. 3 der Leistungsbeschreibung).

    + +

    (5) Der Kunde verändert den Haken für die Erinnerung und die Texte der übrigen Haken nicht (Nr. 4 Abs. 6 der Leistungsbeschreibung). Erinnerungen versendet der Terminplaner nur, wenn der Gast den Haken gesetzt hat.

    + +

    12. Haftung, Änderungen, Schlussbestimmungen

    + +

    (1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.

    + +

    (2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung, auch wegen geänderter Datenerhebung, teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.

    + +

    (3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar den Terminplaner für seine Website nach Nr. 9 Abs. 2 der Leistungsbeschreibung beenden oder nach Nr. 9 Abs. 3 der Leistungsbeschreibung aussetzen. Das Wartungs-Abo bleibt davon unberührt.

    + +

    (4) Im Übrigen gilt § 16 der Abo-Bedingungen.

    + +

    Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)

    + + + + + + + + + + + + + + + + +
    BereichMaßnahmen
    ÜbertragungDas Widget, die Seiten der Gäste und das Kundenportal werden nur über TLS (HTTPS) übertragen. Der Einbau-Code verweist auf eine Adresse mit https://.
    Speicher im BrowserEs werden keine Cookies gesetzt, und der Terminplaner speichert nichts im Browser des Gastes (kein lokaler Speicher, kein Sitzungsspeicher). Das Widget sendet seine Anfragen ohne Cookies, liest weder den Referrer noch die Zeitzone des Browsers und ruft nur die Schnittstelle des Terminplaners auf.
    ZugriffskontrolleDas Kundenportal ist nur nach Anmeldung erreichbar; jeder Kunde sieht nur die Daten seiner eigenen Projekte. Die öffentliche Schnittstelle des Widgets liefert nur für einen eingeschalteten Terminplaner Daten und ist über den Website-Schlüssel (Site-Key) adressiert; Buchungen nimmt sie nur von Seiten des Kunden und des Portals an. Der Kalender-Link enthält ein geheimes Token, das mit einem Vergleich in konstanter Zeit geprüft wird.
    MissbrauchsschutzEin verstecktes Feld (Honeypot), eine Mindestzeit bis zum Absenden des Formulars mit signiertem Formular-Token und eine Mindestantwortzeit schützen vor automatisierten Buchungen. Grenzen je IP-Adresse, je Website und je E-Mail-Adresse sowie das Mailbudget je Stunde (insgesamt und je Funktion) gelten nur im Arbeitsspeicher; ein Neustart leert sie.
    DatenminimierungPflicht sind immer nur Name und E-Mail-Adresse; ob Telefonnummer, Nachricht und Zusatzfelder abgefragt werden, legt der Kunde fest. Mit der Buchung werden keine IP-Adresse und kein Browser-Kennzeichen gespeichert. Der Nachweis der Zustimmungen besteht aus der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte, nicht aus diesen Texten. Nach dem Versand einer E-Mail werden Empfängeradresse und Inhalt in der Warteschlange geleert.
    TrennungAlle Buchungen, Belegungen und Warteschlangeneinträge tragen die Kennung der Funktion (eines Terminplaners je Website-Projekt); das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion. Die Hintergrundläufe (Löschung, Versand der E-Mails, Erinnerungen) gehen die Dokumente aller Funktionen nacheinander durch und schreiben jeweils nur in das Dokument, um das es geht. Die Einstellungen liegen je Funktion in einem eigenen Dokument.
    LöschungTäglicher Löschlauf für Buchungen, Belegungen, Warteschlange und Entwürfe nach den Fristen der Nummer 9; Termindaten und Einstellungen abgeschalteter Funktionen 30 Tage nach dem Abschalten. Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht.
    ProtokolleDie Programmprotokolle des Terminplaners und der Tagesbericht enthalten nur Zählungen und Buchungsnummern, keine Namen, E-Mail-Adressen oder Nachrichten von Gästen. Server-Protokolle des Webservers werden nach 14 Tagen gelöscht (Anlage 2 der Abo-Bedingungen).
    DatensicherungTägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen).
    RechenzentrumHetzner Online GmbH, Rechenzentren Falkenstein und Nürnberg (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren.
    OrganisationVerpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 3), Archiv der Fassungen der Gästetexte (Nummer 8 Abs. 5), Überprüfung der Maßnahmen mindestens einmal im Jahr.
    +
    + + diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.pdf b/public/rechtliches/avv-terminplaner-2026-10-05.pdf new file mode 100644 index 0000000..a2c853d Binary files /dev/null and b/public/rechtliches/avv-terminplaner-2026-10-05.pdf differ diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 b/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 new file mode 100644 index 0000000..880d406 --- /dev/null +++ b/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 @@ -0,0 +1 @@ +93776bde9b84709dbb6b5065cbf6c31c27080744c8096804d5b6508f9e87c14b diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html new file mode 100644 index 0000000..2117d95 --- /dev/null +++ b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html @@ -0,0 +1,179 @@ + + + + + + + Leistungsbeschreibung Terminplaner – WEBklar + + + +
    + + +
    WEBKLAR
    +

    Leistungsbeschreibung Terminplaner

    + +

    Fassung 2026-10-05 · Zusatzbedingungen zum Wartungs-Abo

    + +

    1. Geltung

    + +

    (1) Diese Leistungsbeschreibung regelt die Funktion „Terminplaner“ im Wartungs-Abo. Sie setzt ein laufendes Wartungs-Abo voraus; ein Hosting-Abo genügt nicht. Sie wird vereinbart, wenn der Kunde die Einrichtung des Terminplaners im Kundenportal bestellt und dabei dieser Leistungsbeschreibung, dem Auftragsverarbeitungsvertrag Terminplaner („AVV Terminplaner“) und der Widerrufsbelehrung zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Terminplaner.

    + +

    (2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.

    + +

    (3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für den Datenschutz-Absatz nach Nr. 4 Abs. 9 und die Gästetexte nach Nr. 2 Abs. 6. Beides ist Teil des Terminplaners.

    + +

    2. Leistung

    + +

    (1) Der Terminplaner ist ein Buchungs-Widget für die Website des Kunden mit einer Verwaltung im Kundenportal. WEBklar baut je Website-Projekt ein Widget an einer Stelle der Website ein, entweder eingebettet in die Seite oder hinter einem Knopf. Die Gestaltung richtet sich nach dem Fragebogen, den der Kunde vor der Bestellung im Kundenportal beantwortet.

    + +

    (2) Der Kunde legt seine Leistungen, seine Personen oder Plätze, seine Zeiten und seine Regeln für Buchungen fest. Gäste buchen im Widget einen Termin; je nach Einstellung ist die Buchung verbindlich oder eine Anfrage.

    + +

    (3) Der Terminplaner versendet im Auftrag des Kunden E-Mails an die Gäste: den Bestätigungslink, die Bestätigung, Änderungen, Absagen, auf Wunsch des Gastes eine Erinnerung (nur mit seinem Haken) und die Eingangsbestätigung eines Widerrufs. Den Kunden benachrichtigt er über neue und geänderte Buchungen.

    + +

    (4) Der Kunde sieht und verwaltet seine Termine im Kundenportal und kann sie als CSV-Datei exportieren. Dazu erhält er einen geheimen Kalender-Link (ICS), mit dem er seine Termine in einem Kalenderprogramm abonnieren kann.

    + +

    (5) Der Terminplaner enthält einen Missbrauchsschutz gegen automatisierte Buchungen. Die Löschfristen für Buchungsdaten nennt Nummer 9 des AVV Terminplaner.

    + +

    (6) Bezahlt wird nur vor Ort. Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz für die Website des Kunden und die Widerrufsbelehrung für Gäste sind Mustertexte der Standardkonfiguration (Nr. 11 Abs. 1).

    + +

    3. Nicht enthalten

    + +

    (1) Nicht enthalten sind SMS, Online-Zahlung, ein Kalender-Abgleich (es gibt nur das Abonnement des Kalender-Links nach Nr. 2 Abs. 4), Konten für Gäste, Marketing-E-Mails, Gruppentermine, Mehrsprachigkeit und der Einsatz im Gesundheitsbereich (Nr. 4 Abs. 3).

    + +

    (2) Zusätzliche Wünsche, etwa ein zweites Widget, Knöpfe je Leistung oder ein Sonderdesign, gehören nicht zur Einrichtung nach Nr. 5. WEBklar führt sie nach Stunden aus (§ 4 der Abo-Bedingungen).

    + +

    4. Voraussetzungen und Pflichten des Kunden

    + +

    (1) Der Kunde hat ein laufendes Wartungs-Abo.

    + +

    (2) Der Kunde trägt vor der Bestellung seine Betriebsangaben ein, dazu die Adresse seines Impressums und die Adresse seiner Datenschutzerklärung.

    + +

    (3) Der Terminplaner ist nicht für Angebote im Gesundheitsbereich bestimmt, weil dort besondere Kategorien personenbezogener Daten anfallen können (Art. 9 DSGVO). Der Kunde setzt ihn dort nicht ein und fordert von seinen Gästen keine Gesundheitsangaben. WEBklar darf die Einrichtung für eine solche Website ablehnen oder den Terminplaner dort abschalten.

    + +

    (4) Richtet sich das Angebot des Kunden gezielt an Kinder unter 16 Jahren, nutzt der Kunde den Terminplaner nicht.

    + +

    (5) Der Kunde nennt eine Telefonnummer, unter der Gäste Termine vereinbaren können, wenn das Widget nicht erreichbar ist (Nr. 6 Abs. 2).

    + +

    (6) Er lässt die Texte der Haken und Hinweise im Widget unverändert.

    + +

    (7) Er informiert seine Beschäftigten darüber, dass ihre Namen oder Bezeichnungen, ihre Arbeitszeiten und Abwesenheiten im Terminplaner stehen und dass es einen geheimen Kalender-Link gibt.

    + +

    (8) Die Pflichten gegenüber seinen Gästen trägt der Kunde: Preisangaben, Allgemeine Geschäftsbedingungen und Stornobedingungen sowie die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung.

    + +

    (9) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau dort den Datenschutz-Absatz für die Online-Terminbuchung ein; das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Absatz als Textbaustein und fügt ihn selbst ein. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf, weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.

    + +

    5. Einrichtung und Preis

    + +

    (1) Die Einrichtung umfasst die Einrichtung nach dem Fragebogen, den Einbau des Widgets in die Website, den Datenschutz-Absatz nach Nr. 4 Abs. 9, die Abnahme durch den Kunden und die Freischaltung durch WEBklar.

    + +

    (2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG.

    + +

    (3) Die voraussichtliche Dauer der Einrichtung nennt die Bestellseite vor der Bestellung. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins.

    + +

    (4) Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.

    + +

    (5) Der Preis steht neben dem Stundenkontingent des Wartungs-Abos: Die Einrichtung wird weder auf das Kontingent angerechnet noch damit bezahlt. Für den Betrieb des Terminplaners berechnet WEBklar neben dem Wartungs-Abo nichts weiter; er läuft, solange das Wartungs-Abo läuft (Nr. 8).

    + +

    6. Verfügbarkeit und Störungen

    + +

    (1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für den Terminplaner. Störungen des Terminplaners beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen).

    + +

    (2) Ist der Terminplaner nicht erreichbar, zeigt das Widget einen Ausweichtext mit der Telefonnummer des Kunden (Nr. 4 Abs. 5).

    + +

    (3) Alle Zeiten gelten in der Zeitzone Europe/Berlin.

    + +

    (4) WEBklar übernimmt keine Gewähr dafür, dass E-Mails zugestellt und gelesen werden. Das hängt vom Postfach des Empfängers und von den Mailgrenzen seines Anbieters ab.

    + +

    (5) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.

    + +

    7. Haftung für verpasste Termine

    + +

    (1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden.

    + +

    (2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4).

    + +

    (3) Für entgangenen Gewinn und Folgeschäden aus verpassten Terminen haftet WEBklar nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen.

    + +

    (4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt.

    + +

    8. Ende der Leistung

    + +

    (1) Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.

    + +

    (2) Mit dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an, und das Widget zeigt den Ausweichtext. Die Termine bleiben im Kundenportal sichtbar, und der Kunde kann sie als CSV-Datei exportieren. Gäste können über ihren Link absagen. Bereits angeforderte Erinnerungen laufen weiter. Der Kunde informiert seine Gäste.

    + +

    (3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.

    + +

    (4) Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).

    + +

    (5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.

    + +

    (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab. Eine Erstattung des Einrichtungspreises folgt daraus nicht; für den Widerruf gilt Nr. 10.

    + +

    9. Änderungen und Einstellung

    + +

    (1) WEBklar darf die Technik des Terminplaners verbessern, solange der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Terminplaner.

    + +

    (2) WEBklar darf den Terminplaner für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn

    + +
      +
    • ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Terminplaner Nummer 12), oder
    • +
    • eine Sicherheitslücke sich nicht anders beheben lässt.
    • +
    + +

    Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.

    + +

    (3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar den Terminplaner ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt den Betrieb wieder auf, sobald das zulässig und technisch möglich ist.

    + +

    (4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.

    + +

    10. Widerruf

    + +

    (1) Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen. Belehrung und Muster-Widerrufsformular stehen in der Anlage „Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)“.

    + +

    (2) Ob das Widerrufsrecht erlischt und ob der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung schuldet, richtet sich nach der Zustimmung, die der Kunde bei der Bestellung gibt, und nach den Hinweisen zum Widerruf in der Anlage.

    + +

    (3) Mit dem Widerruf endet der Terminplaner.

    + +

    11. Hinweise

    + +

    (1) Keine Rechtsberatung. Der Hinweis am Buchungsformular, die Texte der Haken, der Datenschutz-Absatz und die Widerrufsbelehrung für Gäste sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Die Einstufung seiner Leistungen als Leistung für Verbraucher oder Unternehmer und als Freizeitleistung trägt der Kunde. Eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht.

    + +

    (2) Barrierefreiheit. Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.

    +
    + + diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf new file mode 100644 index 0000000..ebacd9c Binary files /dev/null and b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf differ diff --git a/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf.sha256 b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf.sha256 new file mode 100644 index 0000000..9ff7cbf --- /dev/null +++ b/public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf.sha256 @@ -0,0 +1 @@ +68039f8293eec7128135e96060b8e04f7e6301de123e099c180feb86530f39f7 diff --git a/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html b/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html new file mode 100644 index 0000000..3516072 --- /dev/null +++ b/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html @@ -0,0 +1,105 @@ + + + + + + + Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen) – WEBklar + + + +
    + + +
    WEBKLAR
    +

    Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)

    + +

    Fassung 2026-10-05

    + +

    Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen. Die Belehrung gilt für die Bestellung der Einrichtung einer Funktion im Kundenportal, zum Beispiel des Terminplaners. Hinweise dazu stehen nach dem Muster-Widerrufsformular.

    + +

    Widerrufsbelehrung

    + +

    Widerrufsrecht

    + +

    Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.

    + +

    Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.

    + +

    Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com) mittels einer eindeutigen Erklärung (z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.

    + +

    Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.

    + +

    Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.

    + +

    Folgen des Widerrufs

    + +

    Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten (mit Ausnahme der zusätzlichen Kosten, die sich daraus ergeben, dass Sie eine andere Art der Lieferung als die von uns angebotene, günstigste Standardlieferung gewählt haben), unverzüglich und spätestens binnen vierzehn Tagen ab dem Tag zurückzuzahlen, an dem die Mitteilung über Ihren Widerruf dieses Vertrags bei uns eingegangen ist. Für diese Rückzahlung verwenden wir dasselbe Zahlungsmittel, das Sie bei der ursprünglichen Transaktion eingesetzt haben, es sei denn, mit Ihnen wurde ausdrücklich etwas anderes vereinbart; in keinem Fall werden Ihnen wegen dieser Rückzahlung Entgelte berechnet.

    + +

    Haben Sie verlangt, dass die Dienstleistungen während der Widerrufsfrist beginnen soll, so haben Sie uns einen angemessenen Betrag zu zahlen, der dem Anteil der bis zu dem Zeitpunkt, zu dem Sie uns von der Ausübung des Widerrufsrechts hinsichtlich dieses Vertrags unterrichten, bereits erbrachten Dienstleistungen im Vergleich zum Gesamtumfang der im Vertrag vorgesehenen Dienstleistungen entspricht.

    + +

    Muster-Widerrufsformular

    + +

    (Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)

    + +

    – An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:

    + +

    – Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)

    + +

    – Bestellt am (*)/erhalten am (*)

    + +

    – Name des/der Verbraucher(s)

    + +

    – Anschrift des/der Verbraucher(s)

    + +

    – Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)

    + +

    – Datum

    + +

    (*) Unzutreffendes streichen.

    + +

    Hinweise zum Widerruf

    + +

    Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt.

    + +
      +
    • Das Widerrufsrecht gilt für jede Bestellung einer Funktion gesondert, also für jedes Website-Projekt, für das der Kunde eine Funktion bestellt. Die Frist von vierzehn Tagen beginnt jeweils mit dem Vertragsschluss.
    • +
    • Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt.
    • +
    • Hat WEBklar auf ausdrücklichen Wunsch des Kunden vor Ablauf der Widerrufsfrist mit der Einrichtung begonnen, schuldet der Kunde bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung. Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung.
    • +
    • Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe.
    • +
    +
    + + diff --git a/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf b/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf new file mode 100644 index 0000000..076fd1a Binary files /dev/null and b/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf differ diff --git a/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf.sha256 b/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf.sha256 new file mode 100644 index 0000000..2861dae --- /dev/null +++ b/public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf.sha256 @@ -0,0 +1 @@ +a23b62f8dd5b6a1eb08ad4570e3bd797bb97be6636c400981377f4996690b1d8 diff --git a/public/termine-tab-einstellungen.js b/public/termine-tab-einstellungen.js index 851a20b..bfa353c 100644 --- a/public/termine-tab-einstellungen.js +++ b/public/termine-tab-einstellungen.js @@ -13,14 +13,17 @@ import { abschnitt, allgemeinerFehler, exportLink, feld, gesperrt, h, hakenFeld, import { nachSpeichern, neuerSpeicher } from './termine-speichern.js' // Rechtliche Hinweise (Ruling R52): Wortlaut nicht aendern ohne Ruling -const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). ' +export const WIDERRUF_HINWEIS = 'Privatpersonen können eine Buchung innerhalb von 14 Tagen nach Vertragsschluss widerrufen (bei Anfragen zählt deine Annahme). ' + 'Das gesetzliche Widerrufsrecht geht deiner Stornoregel vor: In dieser Zeit fällt bei einem Widerruf keine Stornogebühr an, auch wenn deine Stornofrist schon abgelaufen ist. ' + 'Liegt der Termin innerhalb dieser 14 Tage, fragt der Terminplaner deine Gäste, ob du vorher beginnen darfst. ' + + 'Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB). ' + 'Belehrung und Knopf „Vertrag widerrufen“ zeigt der Terminplaner automatisch, wenn deine Gäste Privatpersonen sind, bei sofort buchbaren Leistungen ebenso wie bei Anfragen.' -const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend oder ein Escape Room. ' - + 'Friseur, Kosmetik, Massage, Tattoo, Beratung, Fotoshootings und Weiterbildungen sind in der Regel keine Freizeitleistung. ' +export const FREIZEIT_HINWEIS = 'Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. ' + + 'Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. ' + 'Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. ' - + 'Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.' + + 'Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.' +// Hinweis neben dem Stornotext (nur im Portal, nicht im Gästetext): der Vorschlag der Vorlage und das Muster sind kein fertiger Rechtstext +export const STORNO_MUSTER_HINWEIS = 'Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.' const wahl = (liste, aktuell) => liste.map(([wert, text]) => h('option', { value: wert, selected: String(aktuell) === String(wert) }, text)) const zahlenFeld = (name, label, wert, { min, max, schritt = 1, hinweis }) => { @@ -262,9 +265,13 @@ export function storno(ctx, e, fristFeld) { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'muster-uebernehmen', onclick: () => { textfeld.value = muster.textContent; textfeld.focus() }, }, 'Muster in das Textfeld übernehmen') + const textBlock = feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.') + // der Hinweis „Muster“ steht unmittelbar unter dem Textfeld, vor dem Hinweis zur Fassung (Spec 7.3): der Text kann der Vorschlag der Vorlage sein + textBlock.insertBefore(h('p', { class: 'tm-hinweis tm-muster-hinweis', role: 'note', 'data-hinweis': 'storno-muster' }, STORNO_MUSTER_HINWEIS), textBlock.querySelector('.tm-hinweis')) const el = abschnitt('Stornobedingungen', - 'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu.', - feld('storno.text', 'Dein Text', textfeld, s.version ? `Aktuelle Fassung: ${s.version}. Ändert sich der Text, bekommt er beim Speichern eine neue Fassung, und Gäste stimmen immer der Fassung zu, die sie gesehen haben.` : 'Beim ersten Speichern bekommt der Text eine Fassungsnummer.'), + 'Gäste müssen diesen Text vor dem Buchen bestätigen. Lässt du das Feld leer, gibt es keine Stornobedingungen und keinen Haken dazu. ' + + 'Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.', + textBlock, h('div', { class: 'tm-muster', role: 'group', 'aria-label': 'Mustertext' }, h('p', null, h('span', { class: 'tm-pille tm-pille-wait' }, 'Muster'), ' Ein Vorschlag, kein fertiger Rechtstext. Prüfe ihn und passe Frist, Prozentsatz und Anrede an deinen Betrieb an. Er ist in der förmlichen Anrede geschrieben. Das gesetzliche Widerrufsrecht deiner privaten Gäste bleibt unberührt.'), muster, @@ -341,6 +348,8 @@ function einbau(ctx) { /* ------------------------------------------------------------ Export, Status --- */ function exportUndStatus(ctx) { + // vor der Freischaltung (off, ordered, setup) gibt es weder Termine zum Exportieren noch ein „Aktiv“; null: der Aufrufer laesst den Abschnitt weg + if (['off', 'ordered', 'setup'].includes(ctx.daten.state)) return null const seit = aktivSeitText(ctx.daten.aktivSeit) const abgeschaltet = ctx.daten.state === 'disabled' return abschnitt('Export und Status', null, diff --git a/public/termine-tab-leistungen.js b/public/termine-tab-leistungen.js index 03dba47..6271d7e 100644 --- a/public/termine-tab-leistungen.js +++ b/public/termine-tab-leistungen.js @@ -92,7 +92,8 @@ export function render(panel, ctx) { }) aktualisiere() - wurzel.replaceChildren( + // neu.leiste ist null, solange die Funktion nur lesbar ist: replaceChildren wuerde sonst den Text "null" in die Seite schreiben + wurzel.replaceChildren(...[ sp.konflikt, h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, h('strong', null, GESUNDHEIT_HINWEIS), ' Namen und Beschreibungen deiner Leistungen liest jeder, der auf deiner Website bucht. Schreib dort zum Beispiel keine Krankheiten oder Beschwerden hinein.')), @@ -100,7 +101,8 @@ export function render(panel, ctx) { neu.box, leiste, kopfFehler, - leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.')) + leistungen.length ? liste : h('p', { class: 'tm-leer' }, 'Noch keine Leistungen. Ohne Leistung kann niemand buchen.'), + ].filter(Boolean)) } zeichne() } diff --git a/public/termine.css b/public/termine.css index d572a12..04cb944 100644 --- a/public/termine.css +++ b/public/termine.css @@ -114,6 +114,7 @@ .tm-feld [aria-invalid='true'] { border-color: #f87171; } .tm-feld input:focus, .tm-feld select:focus, .tm-feld textarea:focus { border-color: var(--kb-purple-line); box-shadow: 0 0 0 3px var(--kb-purple-soft); } .tm-hinweis { margin: 0; font-size: 12.5px; color: var(--kb-muted); } +.tm-feld > .tm-muster-hinweis { margin-top: 6px; font-weight: 600; } .tm-fehler { margin: 0; font-size: 13px; color: #fca5a5; } .tm-fehler:empty { display: none; } .tm-zeiten { display: flex; flex-wrap: wrap; gap: 8px; } diff --git a/public/termine.js b/public/termine.js index 4b1dadd..7db98a9 100644 --- a/public/termine.js +++ b/public/termine.js @@ -1,10 +1,16 @@ /* Terminplaner im Kundenbereich (ES-Modul; app.js laedt es erst beim Oeffnen der Ansicht #termine). Das ist die Huelle: - initTermine(rootEl, { projectId }): Projektauswahl, Vorlagenauswahl beim ersten Oeffnen, Reiter-Geruest und die Meldung ueber den Reitern. + initTermine(rootEl, { projectId }): Projektauswahl und je nach Zustand der Funktion (GET /api/termine/einstellungen, state) die Ansicht: + off: ohne laufendes Wartungs-Abo (daten.abo.ok) der Hinweis wie in der Kachel (Text vom Server, GET /api/funktionen termine.hinweis; kein + Fragebogen), sonst der Fragebogen mit der Bestellseite (termine-fragebogen.js); ordered und setup: die Statuskarte (funktion-bestellen.js) + und darunter die Reiter Leistungen, Team & Plaetze und Einstellungen nur zum Lesen (der Reiter Termine entfaellt, der Server antwortet dort + mit 409); active: die Reiter, ohne Einstellung die Vorlagenauswahl; disabled: der Banner der Abschaltung (abschaltText) und die Reiter nur + zum Lesen. Rueckkehr von Stripe: #termine?bestellt=1&projekt= (Zahlung) und #termine?projekt= (Abbruch) waehlen das Projekt, + „Danke!“ gibt es nur mit bestellt=1; beides wird einmal gelesen und aus der Adresse genommen. Die Reiter stehen in REITER: jeder ist ein Modul mit render(panel, ctx) => void | Promise (termine-tab-termine.js, termine-tab-leistungen.js, termine-tab-team.js, termine-tab-einstellungen.js; die drei letzten speichern ueber termine-speichern.js). ctx = { s, projectId, daten, nurLesen, auswahl, aktuell(), api, meldung, hinweise, zumReiter, neuLaden } (baueCtx): Zustand und Pruefung "bin ich noch aktuell?" bekommen die Reiter nur ueber ctx, nie ueber Modulvariablen. daten ist die Antwort von GET /api/termine/einstellungen - (die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer abgeschaltete Funktionen, + (die Reiter, die speichern, halten daten.einstellung und daten.version aktuell), nurLesen gilt fuer alles ausser off und active, hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (z. B. wenn Telefon und Anschrift nach dem Speichern nicht mehr fehlen). Server: server/routes/termine.js. Tage sind Berliner Kalendertage 'JJJJ-MM-TT'; gerechnet wird in termine-view.js. Helfer ohne Zustand: termine-dom.js. Styles: termine.css. */ @@ -14,19 +20,40 @@ import { render as renderTermine } from './termine-tab-termine.js' import { render as renderLeistungen } from './termine-tab-leistungen.js' import { render as renderTeam } from './termine-tab-team.js' import { render as renderEinstellungen } from './termine-tab-einstellungen.js' +import { renderFragebogen } from './termine-fragebogen.js' +import { renderBestellen, statusKarte } from './funktion-bestellen.js' +import { ZU_VERTRAEGE } from './vertraege-link.js' // Auswahl bleibt beim Wechsel zwischen Funktionen und Terminplaner erhalten (der Tag nicht: er beginnt immer bei heute) const auswahl = { projectId: '', reiter: 'termine', ansicht: 'tag', filter: 'alle' } let S = null // aktuelle Sitzung der Ansicht; eine alte Antwort darf eine neuere nicht ueberschreiben (S !== s) /* --------------------------------------------------------- Einstieg --- */ +/** + * Rueckkehr von Stripe aus der Adresse lesen und die Adresse bereinigen (wie initNews): #termine?bestellt=1&projekt= nach der Zahlung + * (success_url) und #termine?projekt= nach dem Abbruch (cancel_url). Gelesen wird einmal, danach steht nur noch #termine da. + */ +function leseRueckkehr() { + const hash = window.location.hash || '' + if (!hash.startsWith('#termine?')) return { bestellt: false, projekt: '' } + const query = new URLSearchParams(hash.slice(hash.indexOf('?') + 1)) + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}#termine`) + return { bestellt: query.get('bestellt') === '1', projekt: query.get('projekt') || '' } +} + +const zustandVon = (projekt) => projekt?.features?.termine?.state || 'off' +const istBestellt = (zustand) => zustand === 'ordered' || zustand === 'setup' +// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (wie die Kachel in funktionen.js) +const hatEntwurf = (projekt) => zustandVon(projekt) === 'off' && Boolean(projekt?.features?.termine?.siteKey) + export async function initTermine(root, { projectId } = {}) { if (!root) return - const wunsch = projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab + const rueckkehr = leseRueckkehr() + const wunsch = rueckkehr.projekt || projectId || root.dataset.wunschProjekt || '' // die Kachel in Funktionen legt ihr Projekt hier ab delete root.dataset.wunschProjekt if (wunsch) auswahl.projectId = wunsch const { projects = [], termine } = await request('/api/funktionen') - const s = { root, projects, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null } + const s = { root, projects, termine: termine || null, projekt: null, daten: null, tag: heuteBerlin(), nurLesen: false, lauf: 0, buehne: null, meldungEl: null, danke: false } S = s if (!termine?.verfuegbar) { root.replaceChildren(infoKarte('Der Terminplaner ist noch nicht verfügbar', 'Sobald er für dich bereit ist, findest du ihn hier.')) @@ -36,10 +63,13 @@ export async function initTermine(root, { projectId } = {}) { root.replaceChildren(h('div', { class: 'kb-panel kb-empty' }, 'Noch keine Website-Projekte zugewiesen.')) return } - const zustand = (p) => p.features?.termine?.state + // Wahl wie bei der Kachel: das gewuenschte Projekt, dann aktiv, bestellt, abgeschaltet, Entwurf, das erste s.projekt = projects.find((p) => p.projectId === auswahl.projectId) - || projects.find((p) => zustand(p) === 'active') || projects.find((p) => zustand(p) === 'disabled') || projects[0] + || projects.find((p) => zustandVon(p) === 'active') || projects.find((p) => istBestellt(zustandVon(p))) + || projects.find((p) => zustandVon(p) === 'disabled') || projects.find(hatEntwurf) || projects[0] auswahl.projectId = s.projekt.projectId + // „Danke!“ nur nach der Zahlung (bestellt=1) und nur fuer das Projekt, fuer das bezahlt wurde + s.danke = rueckkehr.bestellt && (!rueckkehr.projekt || rueckkehr.projekt === s.projekt.projectId) s.buehne = h('div', { id: 'termine-buehne' }) root.replaceChildren(...(projects.length > 1 ? [projektwahl(s)] : []), s.buehne) await zeigeProjekt(s) @@ -55,6 +85,7 @@ function projektwahl(s) { auswahl.projectId = select.value s.projekt = s.projects.find((p) => p.projectId === select.value) s.tag = heuteBerlin() + s.danke = false // der Dank gilt dem Projekt, mit dem man von Stripe zurueckkam zeigeProjekt(s) }) return h('div', { class: 'fn-project kb-panel' }, h('label', { for: 'termine-project' }, 'Website'), select) @@ -62,6 +93,18 @@ function projektwahl(s) { const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`) +/** + * Liste der Funktionen neu holen (GET /api/funktionen): Projekte, das aktuelle Projekt darin, Preis, Einrichtungsdauer und Abo-Hinweis des + * Servers. Wirft bei einem Fehler und aendert dann nichts. + */ +async function ladeFunktionen(s) { + const antwort = await request('/api/funktionen') + const projects = Array.isArray(antwort.projects) ? antwort.projects : [] + s.projects = projects + s.termine = antwort.termine || null + s.projekt = projects.find((p) => p.projectId === s.projekt?.projectId) || s.projekt +} + /** * Hinweis fuer abgeschaltete Funktionen: Grund, Loeschdatum und (ausser vor der Einrichtung) der Export-Link, der einzige Link im Hinweis. * abgeschaltet: { grund, seit, loeschungAm } aus GET /einstellungen (fehlt bei einer aelteren Antwort: dann der bisherige Satz). @@ -73,19 +116,13 @@ function hinweisAbgeschaltet(projectId, abgeschaltet) { } /** - * Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort + * Je nach Zustand der Funktion die Ansicht zeigen (siehe Kopf der Datei). Jeder Aufruf zaehlt s.lauf hoch: die Antwort * einer frueheren Website (langsam, dann zur anderen gewechselt) wird verworfen und erscheint nie unter der neuen. */ -async function zeigeProjekt(s) { +async function zeigeProjekt(s, optionen = {}) { const projekt = s.projekt const lauf = ++s.lauf const aktuell = () => S === s && s.lauf === lauf - const zustand = projekt.features?.termine?.state || 'off' - s.nurLesen = zustand !== 'active' - if (zustand !== 'active' && zustand !== 'disabled') { - s.buehne.replaceChildren(infoKarte('Der Terminplaner ist für diese Website noch nicht freigeschaltet', 'Wir schalten den Terminplaner für dich frei.', { chat: true })) - return - } s.buehne.replaceChildren(h('p', { class: 'kb-empty' }, 'Lade…')) let daten try { @@ -95,8 +132,39 @@ async function zeigeProjekt(s) { return } if (!aktuell()) return + await zeichneAnsicht(s, { daten, aktuell, ...optionen }) +} + +/** + * Ansicht nach einer gescheiterten Aktion der Statuskarte neu aufbauen (Ruling R110): Liste und Daten frisch holen und erst dann zeichnen. + * Scheitert das Laden, wirft die Funktion und die Karte bleibt mit ihrer Meldung stehen. optionen: bestellformular (ordered zeigt das + * Bestellformular statt der Karte), meldung ({ text, art }, nur im Zustand ordered noch von Belang). + */ +async function ladeNeu(s, optionen = {}) { + const lauf = s.lauf + await ladeFunktionen(s) + const daten = await ladeDaten(s.projekt.projectId) + if (S !== s || s.lauf !== lauf) return // inzwischen hat ein Wechsel der Website die Ansicht ersetzt + const nr = ++s.lauf + await zeichneAnsicht(s, { daten, aktuell: () => S === s && s.lauf === nr, ...optionen }) +} + +/** Die Ansicht zum Zustand aus den frisch geladenen Daten (daten.state; faellt auf den Eintrag der Liste zurueck). */ +async function zeichneAnsicht(s, { daten, aktuell, bestellformular = false, meldung = null }) { + const projekt = s.projekt s.daten = daten - if (daten.einstellung) zeigeReiter(s, { projekt, aktuell }) + const zustand = daten.state || zustandVon(projekt) + s.nurLesen = zustand !== 'active' && zustand !== 'off' + if (zustand === 'off') { + if (daten.abo?.ok === true) zeigeFragebogen(s, { projekt, aktuell }) + else await zeigeAboHinweis(s, { aktuell }) + return + } + if (istBestellt(zustand)) { + await zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) + return + } + if (daten.einstellung) zeigeReiter(s, { projekt, aktuell, zustand }) else if (s.nurLesen) { s.buehne.replaceChildren( hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet), @@ -104,6 +172,82 @@ async function zeigeProjekt(s) { } else zeigeVorlagen(s, { projekt, aktuell }) } +const neueMeldung = () => h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + +/* ----------------------------------------------- Zustand off: Hinweis, Fragebogen --- */ +/** + * Ohne laufendes Wartungs-Abo gibt es keinen Fragebogen, nur den Hinweis wie in der Kachel. Der Text kommt ausschliesslich vom Server + * (GET /api/funktionen, termine.hinweis = aboMeldung in server/services/featureOrder.js), nie aus einer Kopie im Browser; bei + * kein_abo kommt der Link zu den Vertraegen dazu (Quelle: vertraege-link.js). Fehlt der Text, weil sich das Abo seit dem Laden der Liste + * geaendert hat, wird die Liste noch einmal geholt. + */ +async function zeigeAboHinweis(s, { aktuell }) { + if (typeof s.termine?.hinweis !== 'string' || s.termine.hinweis === '') { + try { + await ladeFunktionen(s) + } catch { + // ohne Text bleibt der Hinweis bei der Ueberschrift (und dem Link bei kein_abo) + } + if (!aktuell()) return + } + const hinweis = typeof s.termine?.hinweis === 'string' ? s.termine.hinweis : '' + const link = s.termine?.bestellbar === false && s.termine.grund === 'kein_abo' + ? h('button', { type: 'button', class: ZU_VERTRAEGE.klasse, 'data-view': ZU_VERTRAEGE.view }, ZU_VERTRAEGE.text) + : null + s.buehne.replaceChildren(h('div', { class: 'kb-panel tm-info', 'data-hinweis': 'abo' }, + h('h3', null, 'Terminplaner einrichten'), + hinweis || link ? h('p', null, hinweis, hinweis && link ? ' ' : null, link) : null)) +} + +/** Der Fragebogen mit der Bestellseite danach (termine-fragebogen.js); die Meldung ueber ihm ist s.meldungEl (ctx.meldung). */ +function zeigeFragebogen(s, { projekt, aktuell }) { + s.meldungEl = neueMeldung() + const wurzel = h('div', { class: 'tm-fb-wurzel' }) + s.buehne.replaceChildren(s.meldungEl, wurzel) + renderFragebogen(wurzel, baueCtx(s, { projekt, aktuell })) +} + +/* ----------------------------------------------- Zustand ordered, setup --- */ +const GESPERRT = 'Deine Angaben sind seit der Bestellung gesperrt. Änderungen laufen über den Chat.' + +/** + * Statuskarte (Zahlung offen | In Einrichtung) und darunter die Reiter zum Lesen. Der Eintrag der Liste liefert Bestelldatum, Ticketnummer und + * Rechnung; stimmt sein Zustand nicht mit dem der Daten ueberein (die Liste ist aelter, z. B. nach der Zahlung), wird sie neu geholt. + * Die Karte meldet ueber weiter(), wenn „Zahlung fortsetzen“ scheitert: ladeNeu zeichnet die Ansicht dann neu (bei 409 zustimmung_fehlt + * mit dem Bestellformular statt der Karte). Das Bestellformular (daten.bestellung, R110) zeigt dieselbe Seite wie der Fragebogen. + */ +async function zeigeBestellung(s, { zustand, aktuell, bestellformular, meldung }) { + if (s.projekt.features?.termine?.state !== zustand) { + try { + await ladeFunktionen(s) + } catch { + // es gilt der Zustand der Daten; was nur die Liste kennt (Ticketnummer, Rechnung), fehlt dann in der Karte + } + if (!aktuell()) return + } + const projekt = s.projekt + const eintrag = { ...projekt.features?.termine, state: zustand } + const danke = s.danke + s.danke = false // nur beim ersten Zeichnen nach der Rueckkehr + const formular = bestellformular && zustand === 'ordered' && Boolean(s.daten.bestellung) + const kopf = (ctx) => { + if (formular) { + const seite = h('div', { class: 'tm-fb-bestellen' }) + renderBestellen(seite, { ctx, daten: s.daten, zumSchritt: () => ctx.meldung(GESPERRT, 'fehler') }) + return seite + } + return statusKarte({ + projekt, + bestellung: eintrag, + einrichtung: s.termine?.einrichtung, + danke, + kundenansicht: s.daten.kundenansicht === true, + meldung: zustand === 'ordered' && meldung ? meldung.text : '', // die Meldung gilt nur, solange die Bestellung noch offen ist + }, { weiter: (fall) => ladeNeu(s, { bestellformular: fall?.bestellen === true, meldung: fall?.meldung ? { text: fall.meldung } : null }) }) + } + zeigeReiter(s, { projekt, aktuell, zustand, kopf, meldung: formular && meldung ? { text: meldung.text, art: 'ok' } : null }) +} + /* ---------------------------------------------------- Vorlage wählen --- */ function zeigeVorlagen(s, { projekt, aktuell }) { const fehler = h('p', { class: 'tm-fehler', role: 'alert' }) @@ -201,19 +345,27 @@ export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = return ctx } -function zeigeReiter(s, { projekt, aktuell }) { +/** + * Die Reiter (Hinweisleiste, Meldung, Reiterleiste, Panel). zustand steuert, was dazugehoert: disabled hat den Banner der Abschaltung, + * active den Hinweis auf fehlende Pflichtangaben, ordered und setup keinen der beiden und keinen Reiter „Termine“ (der Server antwortet + * dort mit 409). kopf(ctx) liefert ein Element, das ueber der Meldung steht (die Statuskarte), meldung ({ text, art }) wird nach dem + * ersten Zeichnen angezeigt. Ohne Einstellung gibt es keine Reiter, dann bleibt es beim Kopf. + */ +function zeigeReiter(s, { projekt, aktuell, zustand = s.nurLesen ? 'disabled' : 'active', kopf = null, meldung = null }) { const projectId = projekt.projectId const nurLesen = s.nurLesen - s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' }) + s.meldungEl = neueMeldung() const ctx = baueCtx(s, { projekt, aktuell, hinweise: zeichneHinweise, zumReiter: waehleReiter }) + const reiterListe = istBestellt(zustand) ? REITER.filter((r) => r.key !== 'termine') : REITER + const kopfEl = kopf ? kopf(ctx) : null // Hinweisleiste: abgeschaltet, oder Pflichtangaben fuer Gaeste fehlen (GET /einstellungen: fehlt). Sie folgt s.daten. const hinweisBox = h('div', { class: 'tm-hinweise' }) function zeichneHinweise() { const hinweise = [] - if (nurLesen) { + if (zustand === 'disabled') { hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet)) - } else if ((s.daten.fehlt || []).length) { + } else if (!nurLesen && (s.daten.fehlt || []).length) { hinweise.push(h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, `Noch nicht buchbar: Es fehlt ${fehltText(s.daten.fehlt) || 'eine Angabe zu deinem Betrieb'}. Solange das fehlt, können Gäste über deine Website nicht buchen.`), h('button', { type: 'button', class: 'kb-btn-outline', onclick: () => waehleReiter('einstellungen') }, 'Zu den Einstellungen'))) @@ -222,24 +374,33 @@ function zeigeReiter(s, { projekt, aktuell }) { } zeichneHinweise() + if (!s.daten.einstellung) { + s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl].filter(Boolean)) + if (meldung) zeigeMeldung(s, meldung.text, meldung.art) + return + } + const panel = h('div', { class: 'tm-panel', role: 'tabpanel', id: 'tm-panel', tabindex: '0' }) - const knoepfe = REITER.map((r) => h('button', { + let aktiv = null // Schluessel des gezeigten Reiters + const knoepfe = reiterListe.map((r) => h('button', { type: 'button', role: 'tab', class: 'tm-reiter', id: `tm-tab-${r.key}`, 'aria-controls': 'tm-panel', 'data-reiter': r.key, onclick: () => waehleReiter(r.key), }, r.label)) const leiste = h('div', { class: 'tm-reiterleiste', role: 'tablist', 'aria-label': 'Bereiche des Terminplaners' }, knoepfe) leiste.addEventListener('keydown', (ev) => { - const i = REITER.findIndex((r) => r.key === auswahl.reiter) - const ziel = { ArrowRight: (i + 1) % REITER.length, ArrowLeft: (i + REITER.length - 1) % REITER.length, Home: 0, End: REITER.length - 1 }[ev.key] + const i = reiterListe.findIndex((r) => r.key === aktiv) + const ziel = { ArrowRight: (i + 1) % reiterListe.length, ArrowLeft: (i + reiterListe.length - 1) % reiterListe.length, Home: 0, End: reiterListe.length - 1 }[ev.key] if (ziel === undefined) return ev.preventDefault() - waehleReiter(REITER[ziel].key) + waehleReiter(reiterListe[ziel].key) knoepfe[ziel].focus() }) - function waehleReiter(key) { - const reiter = REITER.find((r) => r.key === key) || REITER[0] - auswahl.reiter = reiter.key + function waehleReiter(key, { merken = true } = {}) { + // ein Reiter, den es in diesem Zustand nicht gibt (Termine in ordered und setup), wird nicht gemerkt: die Auswahl gilt fuer die naechste Website weiter + const reiter = reiterListe.find((r) => r.key === key) || reiterListe[0] + if (merken && reiter.key === key) auswahl.reiter = reiter.key + aktiv = reiter.key for (const k of knoepfe) { const an = k.dataset.reiter === reiter.key k.setAttribute('aria-selected', String(an)) @@ -253,7 +414,7 @@ function zeigeReiter(s, { projekt, aktuell }) { .catch((err) => { if (aktuell()) panel.replaceChildren(fehlerKarte(err.message, () => waehleReiter(reiter.key))) }) } - s.buehne.replaceChildren(hinweisBox, s.meldungEl, leiste, panel) - waehleReiter(auswahl.reiter) + s.buehne.replaceChildren(...[hinweisBox, kopfEl, s.meldungEl, leiste, panel].filter(Boolean)) // replaceChildren(null) schriebe „null“ in die Seite + waehleReiter(auswahl.reiter, { merken: false }) + if (meldung) zeigeMeldung(s, meldung.text, meldung.art) } - diff --git a/public/vertraege-link.js b/public/vertraege-link.js new file mode 100644 index 0000000..b258dd0 --- /dev/null +++ b/public/vertraege-link.js @@ -0,0 +1,5 @@ +/* Der Link „Zu Verträge“ in den Hinweisen ohne Wartungs-Abo (Grund kein_abo): die einzige Quelle für Text, Ziel und Klasse. Gelesen von + funktionen.js (Kachel Analyse, Analyse-Seite, Kachel Terminplaner; baut daraus HTML) und von termine.js (Hinweis in der Ansicht #termine; + baut daraus ein Element). Reine Daten ohne Import: beide laden das Modul relativ, funktionen.js behält seine zwei absoluten Importe. + Die Hinweistexte selbst stehen nicht hier: sie kommen vom Server (aboMeldung in server/services/featureOrder.js). */ +export const ZU_VERTRAEGE = Object.freeze({ text: 'Zu Verträge', view: 'vertraege', klasse: 'kb-link' }) diff --git a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html index ba7197b..b4245cb 100644 --- a/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html +++ b/public/vertragsunterlagen/funktionen-leistungsbeschreibung.html @@ -40,15 +40,15 @@
    WEBKLAR

    Leistungsbeschreibung: Funktionen – News-Pinnwand

    -

    Stand 30.09.2026

    +

    Stand 05.10.2026

    Das Wichtigste auf einen Blick - + - +
    LeistungEinbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal
    Preis30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten
    Preis30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten
    Dauer des Einbausmeist innerhalb von 1–2 Wochen (keine verbindliche Zusage)
    WiderrufVerbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)
    WiderrufVerbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 5 Nr. 2 BGB erlöschen (Abschnitt 6)
    @@ -57,7 +57,7 @@

    Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.

    2. Preis

    -

    Der Einbau kostet 30,00 € einmalig, inklusive Umsatzsteuer. Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.

    +

    Der Einbau kostet 30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG). Die Nutzung der Pinnwand danach kostet nichts; es entstehen keine laufenden Kosten und kein Abo. Sie bezahlen bei der Bestellung im Kundenportal über Stripe und erhalten eine Rechnung.

    3. Einbau und Freischaltung

    Mit Ihrer Bestellung legen wir einen Auftrag für den Einbau an. Der Einbau ist erfahrungsgemäß meist innerhalb von 1–2 Wochen abgeschlossen. Das ist eine Einschätzung und keine verbindliche Zusage eines Termins. Nach Abschluss des Einbaus schalten wir die Funktion für Sie frei; ab dann erscheinen Ihre Beiträge auf der Website.

    @@ -70,7 +70,7 @@

    6. Widerrufsrecht

    Sind Sie Verbraucher, können Sie diesen Vertrag innerhalb von 14 Tagen ohne Angabe von Gründen widerrufen. Die Frist beginnt mit dem Vertragsschluss. Zum Widerruf genügt eine eindeutige Erklärung an uns, zum Beispiel über project.webklar.com/widerrufen.html.

    -

    Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen. Das Widerrufsrecht erlischt nach § 356 Abs. 4 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.

    +

    Verlangen Sie ausdrücklich, dass wir vor Ablauf der Widerrufsfrist mit dem Einbau beginnen, müssen Sie uns bei einem Widerruf einen angemessenen Betrag für die bis dahin erbrachte Leistung zahlen (§ 357a Abs. 2 BGB). Das Widerrufsrecht erlischt nach § 356 Abs. 5 Nr. 2 BGB, wenn wir die Leistung vollständig erbracht haben und Sie vorher ausdrücklich zugestimmt haben, dass wir vor Ablauf der Widerrufsfrist beginnen, und Ihre Kenntnis bestätigt haben, dass Sie Ihr Widerrufsrecht bei vollständiger Vertragserfüllung verlieren.

    Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die Vertragsbedingungen und die Datenschutzerklärung.

    diff --git a/public/widerrufen.html b/public/widerrufen.html index 3086da5..c8c1193 100644 --- a/public/widerrufen.html +++ b/public/widerrufen.html @@ -21,6 +21,7 @@ + diff --git a/scripts/build-legal-html.mjs b/scripts/build-legal-html.mjs new file mode 100755 index 0000000..0a250fd --- /dev/null +++ b/scripts/build-legal-html.mjs @@ -0,0 +1,325 @@ +#!/usr/bin/env node +// HTML-Generator der Rechtstexte: rendert die Markdown-Quellen unter docs/rechtliches/ deterministisch in die Seiten unter +// public/rechtliches/-.html (dieselbe Seitenhülle wie die Seiten der Website-Analyse). Die PDFs entstehen +// danach aus diesen Seiten (scripts/build-legal-pdfs.sh termine). +// +// node scripts/build-legal-html.mjs termine schreibt die drei Seiten des Terminplaners +// node scripts/build-legal-html.mjs termine --check vergleicht mit den eingecheckten Seiten (Exit 1 bei Abweichung, schreibt nichts) +// +// Fassung = config.termine.avvVersion. Die Ausgabe enthält keine Datumsangabe außer der Fassung und hängt nur vom Markdown und +// von der Fassung ab (zweimal erzeugen ergibt dieselben Bytes). Unterstützte Teilmenge von Markdown: Überschriften # bis ### +// (# nur als erste Zeile), Absätze, Listen mit "- ", **fett**, `code` und Tabellen (Kopfzeile, Trennzeile |---|---|, Zeilen). +// Jede andere Syntax (Codeblöcke, Zitate, Bilder, Links, rohes HTML, nummerierte Listen, Einrückungen, Trennlinien) bricht laut ab, +// damit nie eine Seite mit falsch dargestelltem Rechtstext entsteht. +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { basename, join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { config } from '../server/config.js' +import { legalPaths } from '../server/services/legalDocs.js' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const FASSUNG_RE = /^\d{4}-\d{2}-\d{2}$/ + +export class MarkdownFehler extends Error { + constructor(zeile, was) { + super(`Markdown, Zeile ${zeile}: nicht unterstützt (${was})`) + this.name = 'MarkdownFehler' + } +} + +const ESCAPES = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' } +/** HTML-Escape für Text und Attributwerte. */ +export const esc = (text) => String(text).replace(/[&<>"']/g, (z) => ESCAPES[z]) + +/** Zeichenkette für ein CSS-content (in doppelten Anführungszeichen). */ +const cssText = (text) => String(text).replace(/[\\"\n]/g, (z) => (z === '\n' ? '\\A ' : `\\${z}`)) + +// ---------------------------------------------------------------------------------------------------------- Markdown + +/** Text außerhalb von `code`: rohes HTML (ein "<" unmittelbar vor Buchstabe, "/", "!" oder "?") bricht ab, alles andere wird maskiert. */ +function klartext(text, zeile) { + if (/<[A-Za-z/!?]/.test(text)) throw new MarkdownFehler(zeile, 'rohes HTML') + return esc(text) +} + +/** Inline-Teil: **fett**, `code`, sonst Text. Nur Text ohne Verschachtelung; alles andere wird escaped oder bricht ab. */ +function inline(text, zeile) { + if (/!\[|\]\(/.test(text)) throw new MarkdownFehler(zeile, 'Bild oder Link') + if (text.includes('***')) throw new MarkdownFehler(zeile, '***') + let aus = '' + let i = 0 + while (i < text.length) { + if (text.startsWith('**', i)) { + const ende = text.indexOf('**', i + 2) + if (ende < 0) throw new MarkdownFehler(zeile, 'offenes **') + const innen = text.slice(i + 2, ende) + if (innen === '' || innen.includes('`')) throw new MarkdownFehler(zeile, 'leeres oder verschachteltes **fett**') + aus += `${klartext(innen, zeile)}` + i = ende + 2 + } else if (text[i] === '`') { + const ende = text.indexOf('`', i + 1) + if (ende < 0) throw new MarkdownFehler(zeile, 'offenes `') + if (ende === i + 1) throw new MarkdownFehler(zeile, 'leerer Code') + aus += `${esc(text.slice(i + 1, ende))}` + i = ende + 1 + } else { + let ende = i + while (ende < text.length && text[ende] !== '`' && !text.startsWith('**', ende)) ende += 1 + aus += klartext(text.slice(i, ende), zeile) + i = ende + } + } + return aus +} + +/** Zerlegt Markdown in Blöcke { art: 'h'|'p'|'ul'|'table', … }; unbekannte Syntax wirft MarkdownFehler. */ +export function parse(md) { + if (typeof md !== 'string') throw new TypeError('parse: Markdown als Text erwartet') + const zeilen = md.replace(/\r/g, '').split('\n') + const bloecke = [] + let offen = null // der Block, an den die nächste Zeile anschließt (p | ul | table) + const schliesse = () => { + offen = null + } + zeilen.forEach((roh, index) => { + const nr = index + 1 + if (roh.trim() === '') return schliesse() + if (/^\s/.test(roh)) throw new MarkdownFehler(nr, 'Einrückung') + if (/^(```|~~~)/.test(roh)) throw new MarkdownFehler(nr, 'Codeblock') + if (roh.startsWith('>')) throw new MarkdownFehler(nr, 'Zitat') + if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(roh)) throw new MarkdownFehler(nr, 'Trennlinie') + if (/^[*+]\s/.test(roh)) throw new MarkdownFehler(nr, 'Liste mit * oder +') + if (/^\d+[.)]\s/.test(roh)) throw new MarkdownFehler(nr, 'nummerierte Liste') + if (roh.startsWith('#')) { + const m = /^(#{1,3}) (\S.*)$/.exec(roh) + if (!m) throw new MarkdownFehler(nr, /^#{4,}/.test(roh) ? 'Überschrift ab Ebene 4' : 'Überschrift ohne Leerzeichen') + schliesse() + bloecke.push({ art: 'h', ebene: m[1].length, html: inline(m[2].trim(), nr), zeile: nr }) + return + } + if (roh.startsWith('- ')) { + const text = roh.slice(2).trim() + if (text === '') throw new MarkdownFehler(nr, 'leerer Listenpunkt') + if (offen?.art !== 'ul') { + offen = { art: 'ul', punkte: [], zeile: nr } + bloecke.push(offen) + } + offen.punkte.push(inline(text, nr)) + return + } + if (roh.startsWith('|')) { + if (!roh.trimEnd().endsWith('|')) throw new MarkdownFehler(nr, 'Tabellenzeile ohne schließendes |') + const zellen = roh.trim().slice(1, -1).split('|').map((z) => z.trim()) + if (offen?.art !== 'table') { + offen = { art: 'table', kopf: zellen.map((z) => inline(z, nr)), trenn: false, zeilen: [], zeile: nr } + bloecke.push(offen) + return + } + if (!offen.trenn) { + if (zellen.length !== offen.kopf.length || !zellen.every((z) => /^:?-{3,}:?$/.test(z)) || zellen.some((z) => z.includes(':'))) { + throw new MarkdownFehler(nr, 'Tabelle: zweite Zeile muss die Trennzeile |---|---| sein (ohne Ausrichtung)') + } + offen.trenn = true + return + } + if (zellen.length !== offen.kopf.length) throw new MarkdownFehler(nr, 'Tabellenzeile mit anderer Spaltenzahl') + offen.zeilen.push(zellen.map((z) => inline(z, nr))) + return + } + // Absatz (aufeinanderfolgende Zeilen gehören zusammen) + if (offen?.art === 'p') { + offen.html += ` ${inline(roh.trim(), nr)}` + return + } + offen = { art: 'p', html: inline(roh.trim(), nr), zeile: nr } + bloecke.push(offen) + }) + for (const b of bloecke) { + if (b.art === 'table' && !b.trenn) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Trennzeile') + if (b.art === 'table' && b.zeilen.length === 0) throw new MarkdownFehler(b.zeile, 'Tabelle ohne Zeilen') + } + return bloecke +} + +// ---------------------------------------------------------------------------------------------------------- Seitenhülle + +const STYLE = (seitenkopf) => ` :root { color-scheme: light; } + * { box-sizing: border-box; } + body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; } + main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; } + .brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; } + h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; } + h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; } + h3 { font-size: 16.5px; margin: 22px 0 6px; } + p, li { margin: 6px 0; } + ul { padding-left: 24px; } + .meta { color: #6b6b70; font-size: 14px; } + .box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; } + .box.widerspruch { border: 2px solid #6d1fd1; } + table { border-collapse: collapse; width: 100%; font-size: 15px; } + td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; } + th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; } + a { color: #6d1fd1; } + nav.top { font-size: 14px; margin-bottom: 22px; } + .offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; } + @media print { + @page { size: A4; margin: 18mm 17mm 18mm; + @top-left { content: "WEBklar · ${cssText(seitenkopf)}"; font: 8pt sans-serif; color: #888; } + @bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } } + body { background: #fff; font-size: 10pt; line-height: 1.45; } + main { max-width: none; padding: 0; } + nav.top { display: none; } + h1 { font-size: 20pt; } + h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; } + a { color: inherit; text-decoration: none; } + .box, tr, ol.abs > li { break-inside: avoid; } + } +` + +/** Adresse eines Links der Navigation: relativ zur Wurzel ("/…", nicht "//") oder https. */ +function pruefeHref(href) { + if (typeof href !== 'string' || !(/^\/(?!\/)/.test(href) || /^https:\/\/[^\s"'<>]+$/.test(href)) || /[\s"'<>]/.test(href)) { + throw new Error(`render: ungültige Adresse in der Navigation: ${String(href).slice(0, 60)}`) + } + return href +} + +function bloeckeHtml(bloecke) { + const aus = [] + bloecke.forEach((b, i) => { + if (b.art === 'h') { + if (b.ebene === 1) { + if (i !== 0) throw new MarkdownFehler(b.zeile, '# nur als erste Zeile') + aus.push(`
    WEBKLAR
    \n

    ${b.html}

    `) + } else { + aus.push(`${b.html}`) + } + } else if (b.art === 'p') { + aus.push(`${b.html}

    `) + } else if (b.art === 'ul') { + aus.push(`
      \n${b.punkte.map((p) => `
    • ${p}
    • `).join('\n')}\n
    `) + } else { + aus.push( + [ + '', + `${b.kopf.map((z) => ``).join('')}`, + '', + ...b.zeilen.map((zeile) => `${zeile.map((z) => ``).join('')}`), + '', + '
    ${z}
    ${z}
    ', + ].join('\n '), + ) + } + }) + return aus.join('\n\n ') +} + +/** + * Rendert ein Dokument in die Seitenhülle der Rechtstexte. + * @param {string} md Markdown (Teilmenge, siehe Kopf); erste Zeile "# Titel", danach der Absatz "**Fassung JJJJ-MM-TT**[ · Zusatz]" + * @param {{ fassung: string, titel: string, seitenkopf: string, nav: Array<{ href: string, text: string }> }} meta + * titel = Seitentitel (vor " – WEBklar"), seitenkopf = Kopfzeile jeder gedruckten Seite hinter "WEBklar · " + * @returns {string} HTML mit abschließendem Zeilenumbruch + */ +export function render(md, meta) { + if (!meta || !FASSUNG_RE.test(String(meta.fassung))) throw new Error('render: meta.fassung muss JJJJ-MM-TT sein') + for (const feld of ['titel', 'seitenkopf']) { + if (typeof meta[feld] !== 'string' || meta[feld].trim() === '') throw new Error(`render: meta.${feld} fehlt`) + } + if (!Array.isArray(meta.nav) || meta.nav.length === 0) throw new Error('render: meta.nav fehlt') + const bloecke = parse(md) + if (bloecke[0]?.art !== 'h' || bloecke[0].ebene !== 1) throw new MarkdownFehler(1, 'die erste Zeile muss "# Titel" sein') + if (bloecke.filter((b) => b.art === 'h' && b.ebene === 1).length !== 1) throw new MarkdownFehler(1, 'genau eine Überschrift #') + if (bloecke[1]?.art !== 'p' || !bloecke[1].html.startsWith(`Fassung ${meta.fassung}`)) { + throw new MarkdownFehler(3, `der Absatz nach der Überschrift muss mit **Fassung ${meta.fassung}** beginnen`) + } + const nav = meta.nav.map((l) => `${esc(l.text)}`).join(' · ') + return ` + + + + + + ${esc(meta.titel)} – WEBklar + + + +
    + + + ${bloeckeHtml(bloecke)} +
    + + +` +} + +// ---------------------------------------------------------------------------------------------------------- Dokumentsätze + +/** Namen je Dokument des Terminplaners (Schlüssel wie in legalPaths: lb, avv, widerruf). */ +const TERMINE_NAMEN = { + lb: { titel: 'Leistungsbeschreibung Terminplaner', seitenkopf: 'Leistungsbeschreibung Terminplaner' }, + avv: { titel: 'Auftragsverarbeitungsvertrag Terminplaner', seitenkopf: 'AVV Terminplaner' }, + widerruf: { titel: 'Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)', seitenkopf: 'Widerrufsbelehrung Funktionen' }, +} +const NAV_NAMEN = { lb: 'Leistungsbeschreibung Terminplaner', avv: 'AVV Terminplaner', widerruf: 'Widerrufsbelehrung Funktionen' } + +/** + * Die drei Dokumente des Terminplaners in der Fassung: { doc, md, html, meta } mit Pfaden relativ zur Wurzel des Projekts. + * Quelle docs/rechtliches/.md, Ziel public/rechtliches/-.html (Slugs und Adressen aus legalDocs.js). + */ +export function termineDokumente(fassung = config.termine.avvVersion) { + const adressen = legalPaths('termine', fassung) + const nav = [ + { href: '/dashboard.html', text: '← Kundenportal' }, + { href: '/abo-bedingungen.html', text: 'Abo-Bedingungen' }, + ...Object.keys(adressen).map((doc) => ({ href: adressen[doc], text: NAV_NAMEN[doc] })), + { href: '/datenschutz.html', text: 'Datenschutzerklärung' }, + { href: 'https://webklar.com/impressum', text: 'Impressum' }, + ] + return Object.entries(adressen).map(([doc, adresse]) => ({ + doc, + md: `docs/rechtliches/${basename(adresse, `-${fassung}.html`)}.md`, + html: `public${adresse}`, + meta: { fassung, ...TERMINE_NAMEN[doc], nav }, + })) +} + +// ---------------------------------------------------------------------------------------------------------- Aufruf + +/** + * Kommandozeile: `termine [--check]`. root = Wurzel des Projekts (Tests übergeben ein Verzeichnis mit Kopien). + * => Exit-Code: 0 ok, 1 mindestens eine Seite weicht ab (nur --check), 2 falscher Aufruf + */ +export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) { + const [satz, ...optionen] = argv + const pruefen = optionen.includes('--check') + if (satz !== 'termine' || optionen.some((o) => o !== '--check')) { + fehler('Aufruf: node scripts/build-legal-html.mjs termine [--check]') + return 2 + } + let abweichungen = 0 + for (const d of termineDokumente()) { + const html = render(readFileSync(join(root, d.md), 'utf8'), d.meta) + const ziel = join(root, d.html) + if (pruefen) { + const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === html + log(`${gleich ? 'ok ' : 'ABWEICHT'} ${d.html}`) + if (!gleich) abweichungen += 1 + } else { + writeFileSync(ziel, html) + log(`-> ${d.html}`) + } + } + return abweichungen > 0 ? 1 : 0 +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + process.exitCode = lauf(process.argv.slice(2)) + } catch (err) { + console.error(err.message) + process.exitCode = 1 + } +} diff --git a/scripts/build-legal-pdfs.sh b/scripts/build-legal-pdfs.sh index 950a8b6..55cb367 100755 --- a/scripts/build-legal-pdfs.sh +++ b/scripts/build-legal-pdfs.sh @@ -1,15 +1,21 @@ #!/usr/bin/env bash # Erzeugt die Druck-/PDF-Fassungen der Vertragsunterlagen aus den HTML-Quellen. # Laeuft auf dem Host (braucht Chrome/Chromium), die PDFs werden eingecheckt. -# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse] +# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse|termine|datenschutz] # analyse = nur AVV und Leistungsbeschreibung der Website-Analyse (Fassung ANALYSE_VERSION), andere PDFs bleiben unveraendert +# termine = Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners (Fassung TERMINE_VERSION, Standard: config.termine.avvVersion); +# die HTML-Seiten kommen vorher aus `node scripts/build-legal-html.mjs termine`. Neben jedem PDF liegt .sha256 mit der +# SHA-256 der HTML-Seite, aus der es gedruckt wurde (test/buildLegalHtml.test.js prueft, dass beide zusammenpassen). Ein PDF, +# dessen Pruefsumme noch stimmt, wird nicht neu gedruckt (FORCE=1 druckt alle neu). +# datenschutz = nur die Datenschutzerklaerung (docs/WEBklar-Datenschutzerklaerung.pdf) +# Vorher `free -m` pruefen und mit `nice` aufrufen; jeder Chrome-Aufruf laeuft hoechstens 120 Sekunden (timeout), die PDFs nacheinander. set -euo pipefail cd "$(dirname "$0")/.." CHROME="${CHROME:-$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome 2>/dev/null | tail -1)}" [ -x "$CHROME" ] || { echo "Chrome nicht gefunden (CHROME=...)"; exit 1; } pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF) - "$CHROME" --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer \ + timeout 120 "$CHROME" --headless=new --no-sandbox --disable-gpu --no-pdf-header-footer \ --virtual-time-budget=3000 --print-to-pdf="$2" "file://$PWD/$1" 2>/dev/null echo "-> $2" } @@ -17,11 +23,39 @@ pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF) MODE="${1:-all}" ANALYSE_VERSION="${ANALYSE_VERSION:-2026-10-02}" +# Fassung des Terminplaners: TERMINE_VERSION, sonst die Konstante des Servers (kein zweites Datum im Skript) +termine_version() { + local v="${TERMINE_VERSION:-}" + if [ -z "$v" ]; then + v="$(timeout 60 node --max-old-space-size=256 --input-type=module -e "const { config } = await import('./server/config.js'); console.log(config.termine.avvVersion)")" + fi + [[ "$v" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]] || { echo "Ungueltige Fassung: $v" >&2; exit 1; } + echo "$v" +} + case "$MODE" in analyse) pdf "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.pdf" pdf "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.pdf" ;; +termine) + TERMINE_VERSION="$(termine_version)" + for slug in leistungsbeschreibung-terminplaner avv-terminplaner widerrufsbelehrung-funktionen; do + [ -f "public/rechtliches/$slug-$TERMINE_VERSION.html" ] || { echo "HTML fehlt: public/rechtliches/$slug-$TERMINE_VERSION.html (zuerst node scripts/build-legal-html.mjs termine)"; exit 1; } + quelle="public/rechtliches/$slug-$TERMINE_VERSION.html" + ziel="public/rechtliches/$slug-$TERMINE_VERSION.pdf" + summe="$(sha256sum "$quelle" | cut -d' ' -f1)" + if [ -z "${FORCE:-}" ] && [ -f "$ziel" ] && [ "$(cat "$ziel.sha256" 2>/dev/null)" = "$summe" ]; then + echo "= $ziel (unveraendert)" + else + pdf "$quelle" "$ziel" + echo "$summe" > "$ziel.sha256" + fi + done + ;; +datenschutz) + pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf + ;; all) pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf # Fassung 3 (public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf, Archiv unter public/vertragsunterlagen/v3/) wird nicht neu erzeugt @@ -29,6 +63,6 @@ all) pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf ;; *) - echo "Usage: $0 [all|analyse]"; exit 1 + echo "Usage: $0 [all|analyse|termine|datenschutz]"; exit 1 ;; esac diff --git a/scripts/termine-texte-archiv.mjs b/scripts/termine-texte-archiv.mjs new file mode 100644 index 0000000..844e89c --- /dev/null +++ b/scripts/termine-texte-archiv.mjs @@ -0,0 +1,162 @@ +#!/usr/bin/env node +// Archiv der Gästetexte des Terminplaners: schreibt den Wortlaut der Texte, die ein Gast in der aktuellen Fassung +// (RECHTSTEXTE_VERSION in server/services/termine/rechtstexte.js) sieht, nach +// docs/rechtliches/archiv/termine-gaestetexte-.md (AVV Terminplaner Nr. 8 Abs. 5: der Nachweis einer Buchung speichert +// nur Hashes, das Archiv hält den Wortlaut zu jeder Fassung fest). +// +// node scripts/termine-texte-archiv.mjs schreibt die Datei der aktuellen Fassung +// node scripts/termine-texte-archiv.mjs --check vergleicht mit der eingecheckten Datei (Exit 1 bei Abweichung, schreibt nichts) +// node scripts/termine-texte-archiv.mjs --force überschreibt eine abweichende Datei der aktuellen Fassung (ohne --force: Exit 1) +// +// Die Betriebsangaben stehen als Platzhalter im Text ({{Name des Betriebs}} usw.); die Ausgabe enthält kein Datum und hängt nur von +// rechtstexte.js und einstellungen.js ab (zweimal erzeugen ergibt dieselben Bytes). Die Datei einer früheren Fassung wird von diesem +// Skript nie angefasst: sie bleibt, wie sie war (test/termineRechtstexteArchiv.test.js pinnt ihren Hash). +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { knopfText } from '../server/services/termine/einstellungen.js' +import { + GESUNDHEIT_HINWEIS, + RECHTSTEXTE_VERSION, + ZUSTIMMUNG_ERINNERUNG, + ZUSTIMMUNG_STORNO, + ZUSTIMMUNG_VORZEITIG, + datenschutzAbsatz, + formularHinweis, + widerrufsbelehrung, +} from '../server/services/termine/rechtstexte.js' + +const ROOT = fileURLToPath(new URL('..', import.meta.url)) + +/** Betriebsangaben als Platzhalter (wie in docs/rechtliches/termine-widerrufsbelehrung-muster.md). */ +export const PLATZHALTER_BETRIEB = Object.freeze({ name: '{{Name des Betriebs}}', adresse: '{{Anschrift}}', telefon: '{{Telefon}}', email: '{{E-Mail}}' }) +const PLATZHALTER_STUNDEN = '{{Stunden}}' + +/** Pfad der Archivdatei einer Fassung, relativ zur Wurzel des Projekts. */ +export const archivPfad = (version = RECHTSTEXTE_VERSION) => `docs/rechtliches/archiv/termine-gaestetexte-${version}.md` + +/** Einstellung mit Platzhalter-Betrieb und allen Schaltern an: der Datenschutz-Absatz erscheint mit allen bedingten Sätzen. */ +function einstellung(anrede) { + return { + anrede, + aufbewahrungTage: 90, + betrieb: PLATZHALTER_BETRIEB, + gastFelder: { telefon: 'optional', nachricht: 'optional', zusatz: [] }, + erinnerung: { anbieten: true, stundenVorher: 24 }, + recht: { gaesteSind: 'verbraucher', freizeit: false }, + ressourcen: [{ id: 'r1', aktiv: true }], + leistungen: [{ id: 'l1', aktiv: true, ressourcen: ['r1'] }], + } +} + +/** Text als Blockquote: jede Zeile mit "> ", Leerzeilen als ">". */ +const zitat = (text) => text.split('\n').map((zeile) => (zeile === '' ? '>' : `> ${zeile}`)).join('\n') + +const SOFORT = { buchungsart: 'sofort', preisArt: 'keiner', preisCent: 0 } +const MIT_PREIS = { buchungsart: 'sofort', preisArt: 'fest', preisCent: 1 } +const ANFRAGE = { buchungsart: 'anfrage', preisArt: 'keiner', preisCent: 0 } + +function knopfTabelle() { + const zeilen = [ + ['Sofort buchbar, ohne Preis', SOFORT], + ['Sofort buchbar, mit Preis', MIT_PREIS], + ['Terminanfrage', ANFRAGE], + ] + return [ + '| Leistung | Knopf im Widget | Knopf auf der Bestätigungsseite |', + '|---|---|---|', + ...zeilen.map(([name, leistung]) => `| ${name} | ${knopfText(leistung)} | ${knopfText(leistung, { bestaetigen: true })} |`), + ].join('\n') +} + +/** Der ganze Inhalt der Archivdatei der aktuellen Fassung. */ +export function baueArchiv() { + const teile = [ + `# Gästetexte des Terminplaners, Fassung ${RECHTSTEXTE_VERSION}`, + [ + 'Dieses Archiv hält den Wortlaut der Gästetexte fest, die ein Gast in dieser Fassung sieht (AVV Terminplaner Nr. 8 Abs. 5). ' + + 'Der Nachweis einer Buchung speichert die Fassung, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes (SHA-256) der übrigen Texte, nicht die Texte selbst.', + `Fassung der Gästetexte: **${RECHTSTEXTE_VERSION}** (\`RECHTSTEXTE_VERSION\` in \`server/services/termine/rechtstexte.js\`). ` + + 'Der Zeitraum der Verwendung ergibt sich aus „gültig ab“ dieser Fassung und der nächsten Fassung in der Tabelle „Terminplaner-Gästetexte“ in `README.md` dieses Ordners.', + 'Erzeugt mit `node scripts/termine-texte-archiv.mjs`, geprüft mit `--check`. Nicht von Hand ändern: eine Textänderung bekommt eine neue Fassung und eine neue Datei; diese Datei bleibt, wie sie war.', + 'Die Betriebsangaben stehen als Platzhalter: `{{Name des Betriebs}}`, `{{Anschrift}}`, `{{Telefon}}` und `{{E-Mail}}` stammen aus der Einstellung des Betriebs, `{{Stunden}}` ist die gewählte Vorlaufzeit der Erinnerung (der Nachweis speichert sie), ' + + 'die Aufbewahrung nach dem Termin ist mit der Vorgabe von 90 Tagen dargestellt (einstellbar von 14 bis 365 Tagen).', + ].join('\n\n'), + + '## 1. Hinweis unter dem Buchungsformular (Datenschutz)', + ['### Sie-Fassung', zitat(formularHinweis(einstellung('sie'))), '### Du-Fassung', zitat(formularHinweis(einstellung('du')))].join('\n\n'), + + '## 2. Haken', + [ + 'Die Haken stehen immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen.', + '### Vorzeitiger Beginn (Pflicht bei einer Leistung mit Preis für Verbraucher, wenn der Termin vor Ablauf der Widerrufsfrist beginnt, bei Terminanfragen immer)', + zitat(ZUSTIMMUNG_VORZEITIG(PLATZHALTER_BETRIEB.name)), + '### Stornobedingungen (Pflicht, wenn der Betrieb Stornobedingungen hat; der Text der Bedingungen steht beim Betrieb und trägt eine eigene Version)', + zitat(ZUSTIMMUNG_STORNO), + '### Erinnerung (freiwillig, nie vorangekreuzt, nur wenn der Betrieb Erinnerungen anbietet)', + zitat(ZUSTIMMUNG_ERINNERUNG(PLATZHALTER_STUNDEN)), + ].join('\n\n'), + + '## 3. Hinweis zu Gesundheitsangaben', + ['Steht über dem Nachrichtenfeld des Formulars.', zitat(GESUNDHEIT_HINWEIS)].join('\n\n'), + + '## 4. Knopftexte', + [knopfTabelle(), 'Der Text des Buchungsknopfs im Widget steht im Nachweis (`knopf`).'].join('\n\n'), + + '## 5. Widerrufsbelehrung mit Muster-Widerrufsformular für Gäste', + ['Sie-Form, mit den Betriebsangaben als Platzhalter.', zitat(widerrufsbelehrung(PLATZHALTER_BETRIEB))].join('\n\n'), + + '## 6. Datenschutz-Absatz „Online-Terminbuchung“ für die Datenschutzerklärung der Website des Betriebs', + [ + 'Hier mit allen bedingten Sätzen (Telefon-Feld, Nachricht oder Zusatzfelder, Erinnerung, Widerruf, Telefonnummer des Betriebs); ein Betrieb ohne die jeweilige Funktion hat den Satz nicht.', + '### Sie-Fassung', + zitat(datenschutzAbsatz(einstellung('sie'), 'sie')), + '### Du-Fassung', + zitat(datenschutzAbsatz(einstellung('du'), 'du')), + ].join('\n\n'), + + '## 7. Nachweis', + 'Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt (⚖ Q26).', + ] + return `${teile.join('\n\n')}\n` +} + +/** + * Kommandozeile: `[--check] [--force]` (nicht beides). root = Wurzel des Projekts (Tests übergeben ein Verzeichnis). + * Eine Datei, die es schon gibt und deren Inhalt abweicht, wird nur mit --force überschrieben: ist die Fassung ausgeliefert, + * darf ihr Wortlaut nicht still ersetzt werden (neue Fassung, neue Datei). Eine neue oder identische Datei schreibt das Skript immer. + * => Exit-Code: 0 ok, 1 die Datei weicht ab oder fehlt (--check) bzw. weicht ab und --force fehlt, 2 falscher Aufruf + */ +export function lauf(argv, { root = ROOT, log = console.log, fehler = console.error } = {}) { + if (argv.some((o) => o !== '--check' && o !== '--force') || (argv.includes('--check') && argv.includes('--force'))) { + fehler('Aufruf: node scripts/termine-texte-archiv.mjs [--check] [--force] (nicht beides)') + return 2 + } + const pfad = archivPfad() + const ziel = join(root, pfad) + const inhalt = baueArchiv() + if (argv.includes('--check')) { + const gleich = existsSync(ziel) && readFileSync(ziel, 'utf8') === inhalt + log(`${gleich ? 'ok ' : 'ABWEICHT'} ${pfad}`) + return gleich ? 0 : 1 + } + if (!argv.includes('--force') && existsSync(ziel) && readFileSync(ziel, 'utf8') !== inhalt) { + fehler( + `${pfad} weicht ab (von den Texten in rechtstexte.js) und wird nicht überschrieben. Ist die Fassung ausgeliefert, bekommt eine Textänderung ` + + 'eine neue Fassung (neues Datum, neue Datei). Nur wenn die Fassung noch nicht ausgeliefert ist: mit --force überschreiben.', + ) + return 1 + } + writeFileSync(ziel, inhalt) + log(`-> ${pfad}`) + return 0 +} + +if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) { + try { + process.exitCode = lauf(process.argv.slice(2)) + } catch (err) { + console.error(err.message) + process.exitCode = 1 + } +} diff --git a/server/config.js b/server/config.js index 38773dc..d47afd7 100644 --- a/server/config.js +++ b/server/config.js @@ -165,6 +165,82 @@ const ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG' const ticketProjektName = (project) => project?.projectName || project?.subdomain || '' +/** + * Rechtsunterlage des Terminplaners mit Fassung und Adresse aus config.termine.avvVersion (bei jedem Zugriff gelesen). + * Tests duerfen sie ueberschreiben (Zuweisung); wird der aktuelle Standardwert zurueckgeschrieben (Wiederherstellen nach + * dem Test) oder undefined zugewiesen, gilt wieder der Standard und die Unterlage folgt der Konstante. null gilt ebenso, + * ausser bei nullIstWert (Widerrufsbelehrung): dort heisst null "keine Unterlage" (so setzen Tests eine Bestellung ohne sie). + */ +function termineRechtsunterlage(slug, { nullIstWert = false } = {}) { + let ueberschrieben // undefined = Standard + const standard = () => ({ version: config.termine.avvVersion, url: `/rechtliches/${slug}-${config.termine.avvVersion}.html` }) + return { + get: () => (ueberschrieben === undefined ? standard() : ueberschrieben), + set(wert) { + const std = standard() + const zurueck = wert === undefined || (wert === null && !nullIstWert) || (wert !== null && wert.version === std.version && wert.url === std.url) + ueberschrieben = zurueck ? undefined : wert + }, + } +} +const termineLb = termineRechtsunterlage('leistungsbeschreibung-terminplaner') +const termineAvv = termineRechtsunterlage('avv-terminplaner') +const termineWiderruf = termineRechtsunterlage('widerrufsbelehrung-funktionen', { nullIstWert: true }) + +const TERMINE_PREIS = '149 €' +const TERMINE_DAUER = 'meist innerhalb von 1–2 Wochen' + +/** + * Bestelltexte des Terminplaners (Task F7): vier Haken, alle Besteller (D1), ohne Adressen im Text (die versionierten Links + * zeigt die Bestellseite daneben aus bestellung.dokumente). Die Fassung (consent.version) ist config.termine.avvVersion; die + * dynamischen Teile (Fassung, Preis, Umsatzsteuer-Hinweis, Dauer) stehen fertig im Text, gespeichert wird immer dieser Text. + * Die Haken werden je Fassung einmal gebaut und danach unveraendert herausgegeben (Tests aendern einzelne Texte und stellen sie + * zurueck); aendert sich die Fassung zur Laufzeit, entstehen sie neu. Bei Textaenderung neues Fassungsdatum und neuen Hash im + * Pin von test/featureConsentTexte.test.js. Zitat und Wertersatz-Satz von "beginn" und die Pflichtinformationen sind Entwurf bis + * zur Kanzlei (Kanzlei-Paket, Fragen Q3, Q5, Q19, Q20). + */ +function termineConsent() { + let versionGesetzt // undefined = config.termine.avvVersion + let gebaut = null // { version, items } + const version = () => versionGesetzt ?? config.termine.avvVersion + const items = (v) => [ + { + id: 'unterlagen', + haken: true, + text: `Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${v} zu.`, + }, + { + id: 'gesundheit', + haken: true, + text: 'Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).', + }, + { + id: 'beginn', + haken: true, + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', + }, + { + id: 'information', + haken: true, + text: `Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis ${TERMINE_PREIS} einmalig (${ustHinweis}), voraussichtliche Dauer ${TERMINE_DAUER}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.`, + }, + ] + return { + get version() { + return version() + }, + set version(wert) { + versionGesetzt = wert === undefined || wert === null || wert === config.termine.avvVersion ? undefined : wert + }, + knopf: `Zahlungspflichtig bestellen · ${TERMINE_PREIS}`, + get items() { + const v = version() + if (gebaut?.version !== v) gebaut = { version: v, items: items(v) } + return gebaut.items + }, + } +} + /** * Register der kaufbaren Funktionen (News-Pinnwand, Terminplaner): Preis, Zustimmungen, Rechtsunterlagen, * Ticket- und Mail-Texte je Funktion; reine Daten, config.js importiert keine Dienste. Die Helfer (featureConfig, @@ -181,19 +257,30 @@ const features = { label: { name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }, priceLabel: '30 €', setupEstimate: 'meist innerhalb von 1–2 Wochen', - // Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 4 BGB) + // Wortlaut der Zustimmung; wird mit Zeitstempel am Feature-Dokument gespeichert (Widerruf, § 356 Abs. 5 Nr. 2 BGB, + // Wertersatz § 357a Abs. 2 BGB). Aufbau wie der Gaestetext ZUSTIMMUNG_VORZEITIG; Zitat und Wertersatz-Satz sind + // Entwurf bis zur Kanzlei (Kanzlei-Paket, Frage Q3). Die Fassung folgt config.termine.avvVersion (); bestehende + // Bestellungen behalten Text und Fassung, die am Dokument und im Nachweis gespeichert sind. consent: { - version: '2026-09-30', + get version() { + return config.termine.avvVersion + }, knopf: 'Zahlungspflichtig bestellen · 30 €', items: [ { id: 'beginn', haken: true, - text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht erlischt, sobald der Einbau vollständig erbracht ist (§ 356 Abs. 4 BGB).', + text: 'Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', }, ], }, - leistungsbeschreibung: { version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }, + // Stand der Leistungsbeschreibung (public/vertragsunterlagen/funktionen-leistungsbeschreibung.html) = + leistungsbeschreibung: { + get version() { + return config.termine.avvVersion + }, + url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html', + }, avv: null, widerruf: null, returnHash: 'news', @@ -220,7 +307,7 @@ const features = { knopf: 'Beitrag schreiben', danach: 'Danach kannst Du Beiträge im Portal schreiben.', bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.', - preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo', + preisHinweis: `einmalig, ${ustHinweis}; die Nutzung ist kostenlos, es läuft kein Abo`, anleitung: false, }, requiresWartungsAbo: false, @@ -233,30 +320,29 @@ const features = { key: 'termine', product: 'termineSetup', label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' }, - priceLabel: '149 €', - setupEstimate: 'meist innerhalb von 1–2 Wochen', - // Platzhalter bis Block F (Rechtsrunde): Struktur und IDs stehen, Texte und Fassung ersetzt Block F - consent: { - version: 'ENTWURF-2026-10-03', - knopf: 'Zahlungspflichtig bestellen · 149 €', - items: [ - { id: 'unterlagen', haken: true, text: 'ENTWURF (Block F): Zustimmung zu Leistungsbeschreibung und Auftragsverarbeitungsvertrag (AVV) Terminplaner.' }, - { id: 'gesundheit', haken: true, text: 'ENTWURF (Block F): Bestätigung, dass der Terminplaner nicht im Gesundheitsbereich eingesetzt wird.' }, - { - id: 'beginn', - haken: true, - text: 'ENTWURF (Block F): Ausdrückliches Verlangen, dass vor Ablauf der Widerrufsfrist mit dem Einbau begonnen wird, mit Hinweis auf das Erlöschen des Widerrufsrechts und den Wertersatz.', - }, - { - id: 'information', - haken: true, - text: 'ENTWURF (Block F): Pflichtinformationen zu Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo und Einrichtungsdauer.', - }, - ], + priceLabel: TERMINE_PREIS, + setupEstimate: TERMINE_DAUER, + consent: termineConsent(), + // Fassung und Adresse folgen config.termine.avvVersion bei jedem Zugriff (Slugs wie in services/legalDocs.js; config.js + // importiert keine Dienste, test/legalDocs.test.js gleicht beide ab) + get leistungsbeschreibung() { + return termineLb.get() + }, + set leistungsbeschreibung(wert) { + termineLb.set(wert) + }, + get avv() { + return termineAvv.get() + }, + set avv(wert) { + termineAvv.set(wert) + }, + get widerruf() { + return termineWiderruf.get() + }, + set widerruf(wert) { + termineWiderruf.set(wert) }, - leistungsbeschreibung: { version: 'ENTWURF', url: '' }, - avv: { version: 'ENTWURF', url: '' }, - widerruf: null, returnHash: 'termine', returnProjekt: true, ticket: { @@ -617,6 +703,10 @@ export const config = { .split(',') .map((s) => s.trim().toLowerCase()) .filter(Boolean), + // Fassung der Terminplaner-Bestellunterlagen (Leistungsbeschreibung, AVV, Widerrufsbelehrung, Bestelltexte, News- + // Zustimmung): Berliner Kalendertag des Fassungs-Commits (JJJJ-MM-TT). Eine ausgelieferte Fassung wird nie + // ueberschrieben: nach Uebergabe an die Kanzlei oder Livegang heisst jede Textaenderung neues Datum und neue Dateien. + avvVersion: '2026-10-05', holdMinutes: 30, zelleMin: 5, mailsProStunde: 200, diff --git a/server/index.js b/server/index.js index 2462b31..55e52a2 100644 --- a/server/index.js +++ b/server/index.js @@ -41,7 +41,7 @@ import publicNewsRoutes from './routes/publicNews.js' import publicTermineRoutes from './routes/publicTermine.js' import publicZeitenRoutes from './routes/publicZeiten.js' import adminAnalyseRoutes from './routes/admin/analyse.js' -import { analyseLegalRouter } from './services/analyseLegal.js' +import { legalRouter } from './services/legalDocs.js' import adminFinanzenRoutes from './routes/admin/finanzen.js' import teamRoutes from './routes/team/index.js' import legacyAdminRedirect from './routes/legacyAdminRedirect.js' @@ -115,8 +115,8 @@ app.use('/embed/', (_req, res, next) => { res.set('Access-Control-Allow-Origin', '*') next() }) -// Adressen ohne Fassung: aktuelle geprüfte Fassung der Analyse-Rechtstexte (vor express.static) -app.use('/rechtliches', analyseLegalRouter()) +// Adressen ohne Fassung: aktuelle Fassung der Rechtstexte (Analyse und Terminplaner; vor express.static) +app.use('/rechtliches', legalRouter()) // Abgeschaltetes Admin-Panel und Rechnungsanalyse: Links aus alten Team-Mails ins Ticketsystem app.use('/admin.html', legacyAdminRedirect.admin) app.use('/kalkulation.html', legacyAdminRedirect.kalkulation) diff --git a/server/mail-templates/K04 Eingang Widerruf.html b/server/mail-templates/K04 Eingang Widerruf.html index 543bd11..f7580ec 100644 --- a/server/mail-templates/K04 Eingang Widerruf.html +++ b/server/mail-templates/K04 Eingang Widerruf.html @@ -9,7 +9,7 @@ Empfänger: E-Mail aus dem Formular · BCC intern Platzhalter: {{name}}, {{produkt}}, {{bestellung}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}} Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abschluss, wechsel, zusatzspeicher, funktion, pruefen - produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist). + produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); beim Widerruf einer Funktion ihr Name aus dem Register (News-Pinnwand, Terminplaner). Folgen je Bestellung wie in den Hinweisen zum Widerruf (Fassung 4): abschluss (Wertersatz bei Sofortbeginn, Zusatzspeicher voll erstattet), wechsel (Hosting-Abo läuft weiter, Mehrbetrag abzüglich Wertersatz), zusatzspeicher (volle Erstattung, kein Wertersatz), pruefen (keine offene Frist). --> @@ -51,7 +51,7 @@ a{color:#111111;} - + diff --git a/server/mail-templates/K15 Bestellung Funktion.html b/server/mail-templates/K15 Bestellung Funktion.html index 48b807e..e7503de 100644 --- a/server/mail-templates/K15 Bestellung Funktion.html +++ b/server/mail-templates/K15 Bestellung Funktion.html @@ -7,13 +7,15 @@ Betreff: Deine Bestellung: {{funktion}} Absender: „WEBklar“ Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern - Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, behalten, neu + Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_gen}}, {{funktion_akk}}, {{funktion_nom}}, {{funktion_pron}}, {{preis}}, {{preis_hinweis}}, {{zeit}}, {{danach}}, {{portal_url}}, {{rechnung_url}}, {{fassung}}, {{lb_url}}, {{avv_url}}, {{widerruf_url}}, {{zustimmungen_text}} + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung, behalten, neu, anlagen, anhang, zustimmungen funktion = Name der Funktion; funktion_gen/_akk/_nom/_pron = ihre Formen im Satz (Genitiv, Akkusativ, Nominativ mit Artikel, Pronomen im Akkusativ), aus dem Register (mails.formen). preis = Preis der Einrichtung aus dem Katalog der Funktion; preis_hinweis = Zusatz in Klammern dahinter (mails.preisHinweis). danach = Satz, was der Kunde nach der Freischaltung tun kann (mails.danach). portal_url = Adresse der Funktion im Kundenportal. rechnung = Link zur Stripe-Rechnung (nur wenn Stripe die Rechnung schon geliefert hat). behalten = der Kunde behält die Funktion nach dem Ende seines Wartungs-Abos für einmalig 30 € (Öffnungszeiten live): nichts wird neu eingerichtet. neu = der Normalfall, die Einrichtung läuft jetzt los; genau einer der beiden ist an (bestellMail setzt neu = nicht behalten). + anlagen = Vertragsunterlagen der Funktion (nur Funktionen mit Rechtsdokumenten): fassung = Fassung der Texte, lb_url/avv_url/widerruf_url = versionierte Adressen von Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular. anhang (innerhalb von anlagen) = die drei PDFs hängen an der Mail (dauerhafter Datenträger); fehlt der Anhang, entfällt der Satz. + zustimmungen = Wortlaut und Zeitpunkt der erteilten Zustimmungen (zustimmungen_text, mehrzeilig) aus dem Nachweis zur bezahlten Bestellung. --> @@ -42,6 +44,12 @@ a{color:#111111;} Hallo {{kunde_name}},
    vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein. Du behältst {{funktion_akk}} für Dein Projekt, auch ohne Wartungs-Abo.
    Funktion{{funktion}} – Einrichtung – behalten
    Projekt{{projekt}}
    Einmalig bezahlt{{preis}} ({{preis_hinweis}})
    Einrichtung{{zeit}}
    + + + + + +
    Rechnung ansehen →
    diff --git a/server/mail-templates/T09 Widerruf eingegangen.html b/server/mail-templates/T09 Widerruf eingegangen.html index f86d414..23dccc2 100644 --- a/server/mail-templates/T09 Widerruf eingegangen.html +++ b/server/mail-templates/T09 Widerruf eingegangen.html @@ -7,7 +7,8 @@ Betreff: [Abo] WIDERRUF eingegangen: {{email}} Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com - Platzhalter: {{email}}, {{name}}, {{produkt}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} + Platzhalter: {{email}}, {{name}}, {{produkt}}, {{preis}}, {{kundennummer}}, {{bestellung}}, {{stripe_abo_id}}, {{abo_beginn}}, {{fristende}}, {{rechtzeitig}}, {{ereignisse}}, {{pruefhinweis}} + preis = Preis der widerrufenen Funktion aus dem Register (nur im Block funktion, sonst leer). Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, abo_gefunden, auf_kuendigung_gesetzt, kein_abo_gefunden, kuendigung_fehlgeschlagen, abschluss, wechsel, zusatzspeicher, funktion, keine_frist, nachweis_fehlt, wechsel_offen, abschluss_verspaetet, nicht_pruefbar, bereits_widerrufen --> @@ -61,7 +62,7 @@ a{color:#111111;} - + diff --git a/server/mail-templates/T16 Funktion bestellt.html b/server/mail-templates/T16 Funktion bestellt.html index 106ffad..ec26318 100644 --- a/server/mail-templates/T16 Funktion bestellt.html +++ b/server/mail-templates/T16 Funktion bestellt.html @@ -8,7 +8,7 @@ Absender: „WEBklar Kundenportal“ Empfänger: kenso@webklar.com, andrej@webklar.com Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{funktion}}, {{woid}}, {{sitekey}}, {{sitzung}}, {{zustand}}, {{anleitung_url}}, {{gesundheit_text}} - Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, abo, team + Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit, abo, team, anhang_fehlt funktion = Name der Funktion aus dem Register (zum Beispiel News-Pinnwand, Terminplaner); ohne bekannte Funktion steht „Funktion“. ungeklaert = eine bezahlte Stripe-Sitzung passt zu keiner offenen Bestellung (Betreff und Inhalt: von Hand pruefen); sitzung nennt Sitzungs-ID, Kunde und Projekt. zahlung_fehlgeschlagen = die Zahlung (SEPA) ist nicht eingegangen; es wurde nichts bestellt, woid und sitekey sind dann leer. @@ -18,6 +18,7 @@ gesundheit = Warnzeile „Gesundheitsbereich prüfen“ mit gesundheit_text (Hinweise zur Branche des Betriebs). abo = die Funktion wurde ohne Zahlung eingeschaltet, weil sie im Wartungs-Abo des Kunden enthalten ist (Gratisweg, der Kunde hat selbst eingeschaltet); es gibt keine Rechnung. team = das Team hat die Funktion ohne Zahlung freigeschaltet („Aktivieren ohne Zahlung“); es gibt keine Rechnung. + anhang_fehlt = die Vertragsunterlagen (PDF) sind nicht beim Kunden angekommen (Anhang nicht lesbar, Mail nicht versandt oder Unterlagen einer anderen Fassung als bestellt); das Team sendet sie von Hand nach. --> @@ -65,6 +66,9 @@ a{color:#111111;} + + +
    Im Ticketsystem öffnen →
    diff --git a/server/routes/abo.js b/server/routes/abo.js index 5d66c34..72527c4 100644 --- a/server/routes/abo.js +++ b/server/routes/abo.js @@ -19,6 +19,7 @@ import { switchToWartung, } from '../services/planSwitch.js' import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js' +import { featureConfig } from '../services/featureRegistry.js' import { PRODUCT_KEYS, contractAttachmentFor, describeItems, isTermsV4OrNewer, productLabel, productView, termsDocFor } from '../services/products.js' import { ACTIVE_STATUSES, @@ -705,13 +706,15 @@ export function publicRateLimitState() { // Auswahl im Formular: Vertrag (Kuendigung) und Bestellung (Widerruf), Standard jeweils der erste Wert const CONTRACTS = ['abo', 'zusatzspeicher'] -const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion'] +const ORDERS = ['abschluss', 'wechsel', 'zusatzspeicher', 'funktion', 'termine'] +// Bestellung einer Funktion -> Funktionsschluessel im Register ('funktion' meint die News-Pinnwand; jede Funktion hat ihre eigene Frist) +const FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' } // Bestellung -> Ereignistyp mit Widerrufsfrist (F03); beim Zusatzspeicher zaehlt nur ein Plus (openWithdrawals) const ORDER_EVENT_TYPE = { abschluss: 'abschluss', wechsel: 'wechsel', zusatzspeicher: 'speicher' } /** * Erklaerung aus dem Formular: null ohne Name oder gueltige E-Mail; { error } bei einer unzulaessigen Paketzahl. - * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion'; packages (nur beim + * contract 'abo' | 'zusatzspeicher', order 'abschluss' | 'wechsel' | 'zusatzspeicher' | 'funktion' | 'termine'; packages (nur beim * Zusatzspeicher) ganze Zahl von 1 bis maxUnits, null heisst alle gebuchten (Rechtsrunde 28.09.2026). */ function validDeclaration(body) { @@ -784,6 +787,7 @@ function orderLabel(order, sub, { customer = false } = {}) { if (order === 'wechsel') return 'Wechsel ins Wartungs-Abo' if (order === 'zusatzspeicher') return 'E-Mail-Zusatzspeicher' if (order === 'funktion') return 'Funktion (z. B. News-Pinnwand)' + if (order === 'termine') return 'Terminplaner (Einrichtung)' const abo = productNameOf(sub) return `${customer ? 'Abschluss Ihres Abos' : 'Abschluss des Abos'}${abo ? ` (${abo})` : ''}` } @@ -1092,19 +1096,29 @@ async function withdrawOrder(sub, d, receivedAt) { const FEATURE_STATE_LABEL = { setup: 'in Einrichtung', active: 'aktiv', disabled: 'abgeschaltet' } /** - * Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig): gehoert zu keinem Abo und hat kein Vertrags- - * ereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; das Team - * bekommt in T09 die bezahlten Funktionen mit Fristende und erstattet/schaltet ab von Hand. + * Widerruf einer Funktion (News-Pinnwand 30 € oder Terminplaner-Einrichtung 149 €, je einmalig): gehoert zu keinem Abo + * und hat kein Vertragsereignis, darum aendert sich nichts automatisch. Gesucht wird der Kunde ueber die E-Mail-Adresse; + * das Team bekommt in T09 die bezahlten Bestellungen der gewaehlten Funktion mit Fristende und erstattet/schaltet ab von + * Hand. Jede Funktion hat ihre eigene Widerrufsfrist: gefiltert wird auf featureKey, der Anker ist deren vertragAt + * (Zeit des Klicks der bezahlten Sitzung, ohne Nachweis orderedAt); das spaeteste Fristende entscheidet ueber "rechtzeitig". + * Dokumente ohne featureKey stammen aus der Zeit, als es nur die News gab. */ -async function withdrawFeature(d, receivedAt) { +async function withdrawFeature(d, receivedAt, featureKey) { const customer = (await getCustomerByEmail(d.email).catch(() => null)) || (await getCustomerByEmail(d.email.toLowerCase()).catch(() => null)) - const orders = customer ? await paidFeatureOrders(customer.$id) : [] - // Anker der Frist ist die Zeit des Klicks der bezahlten Sitzung (vertragAt), ohne Nachweis orderedAt - const ends = orders.filter((o) => o.vertragAt || o.orderedAt).map((o) => withdrawalDeadline(o.vertragAt || o.orderedAt).endsAt) - const deadline = ends.length ? new Date(Math.max(...ends.map((e) => new Date(e).getTime()))) : null + const alle = customer ? await paidFeatureOrders(customer.$id) : [] + const orders = alle.filter((o) => (o.featureKey || 'news') === featureKey) + const endOf = (o) => { + const anker = o.vertragAt || o.orderedAt + return anker ? new Date(withdrawalDeadline(anker).endsAt) : null + } + const ends = orders.map(endOf).filter(Boolean) + const deadline = ends.length ? new Date(Math.max(...ends.map((e) => e.getTime()))) : null const lines = orders - .map((o) => `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}`) + .map((o) => { + const ende = endOf(o) + return `${o.projekt || 'Projekt'}: ${FEATURE_STATE_LABEL[o.state] || o.state}, bestellt am ${fmtDate(o.vertragAt || o.orderedAt)}${ende ? `, Fristende ${fmtDate(ende)}` : ''}${o.ticketWoid ? `, Ticket #${o.ticketWoid}` : ''}` + }) .join('\n') return { customer, orders, deadline, lines, inTime: Boolean(deadline) && receivedAt <= deadline } } @@ -1127,12 +1141,14 @@ router.post('/public/withdraw', async (req, res) => { let sub = null let result = { inTime: false, deadline: null, withdrawn: [], canceled: false, cancelFailed: false, event: null, unreadable: false, startMissing: false, alreadyWithdrawn: false, late: false, openSwitchWithdrawn: false } - // Funktion: kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) - const feature = d.order === 'funktion' + // Funktion (News oder Terminplaner): kein Abo, keine Vertragsereignisse; nur Nachschlagen fuer das Team (withdrawFeature) + const featureKey = FEATURE_OF_ORDER[d.order] ?? null + const feature = featureKey !== null + const funktion = feature ? featureConfig(featureKey) : null let fn = { customer: null, orders: [], deadline: null, lines: '', inTime: false } try { if (feature) { - fn = await withdrawFeature(d, receivedAt) + fn = await withdrawFeature(d, receivedAt, featureKey) result = { ...result, deadline: fn.deadline, inTime: fn.inTime, late: !fn.inTime && Boolean(fn.deadline) } } else { sub = await findActiveSubscriptionByEmail(d.email) @@ -1153,7 +1169,7 @@ router.post('/public/withdraw', async (req, res) => { values: { name: d.name, bestellung: orderLabel(d.order, sub, { customer: true }), - produkt: feature ? 'News-Pinnwand' : productLabelOf(sub), + produkt: feature ? funktion.label.name : productLabelOf(sub), eingang_zeitpunkt: fmtDateTime(receivedAt), email: d.email, kundennummer: d.customerNumber, @@ -1176,7 +1192,8 @@ router.post('/public/withdraw', async (req, res) => { name: d.name, kundennummer: d.customerNumber, bestellung: orderLabel(d.order, sub), - produkt: feature ? 'News-Pinnwand' : productLabelOf(sub), + produkt: feature ? funktion.label.name : productLabelOf(sub), + preis: feature ? funktion.priceLabel : '', stripe_abo_id: sub?.stripeSubscriptionId || '', abo_beginn: fmtDate(sub?.startedAt), fristende: result.deadline ? `${fmtDate(result.deadline)}, 24 Uhr` : 'keine Frist gefunden', diff --git a/server/routes/admin/funktionenAdmin.js b/server/routes/admin/funktionenAdmin.js index 42121ce..b9d36fd 100644 --- a/server/routes/admin/funktionenAdmin.js +++ b/server/routes/admin/funktionenAdmin.js @@ -1,24 +1,31 @@ import { Router } from 'express' import { config } from '../../config.js' +import { aboMemo } from '../../services/aboStatus.js' import { getDocument } from '../../services/appwriteAdmin.js' +import { featureConfig, featureHaken } from '../../services/featureRegistry.js' import { withLocks } from '../../services/keyedLock.js' import { FEATURE_KEYS, FEATURE_LABELS, adminActivate, assertOwnProject, + customerOf, disable, + freischaltenNachAbnahme, getFeatureDoc, listCustomerFeatureDocs, listCustomerFeatures, + ticketNachlegen, } from '../../services/siteFeatures.js' -import { featureHaken } from '../../services/featureRegistry.js' import { nachAbschaltung } from '../../services/zeiten/abschalten.js' import { einschaltenZeiten } from '../../services/zeiten/einschalten.js' import { ersatzHtml } from '../../services/zeiten/ersatz.js' import { normalisiereTeam } from '../../services/zeiten/pruefen.js' import { ladeZeiten, speichereTeam } from '../../services/zeiten/speicher.js' import { hauptplan, planNachKurz } from '../../services/zeiten/zeitlogik.js' +import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js' +import { freischaltHindernis } from '../../services/termine/freischaltung.js' +import { ladeStand } from '../../services/termine/kontext.js' /** * Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen"). @@ -42,13 +49,13 @@ export function composeAdminNote(previous, { by, action, note }, now = new Date( return older ? `${entry} | ${older}`.slice(0, NOTE_MAX) : entry } -async function ticketExists(ticketId) { - if (!ticketId) return false +/** Das Ticket oder null (keine ticketId, Ticket geloescht); jeder andere Lesefehler wird weitergereicht. */ +async function readTicket(ticketId) { + if (!ticketId) return null try { - await getDocument(config.collections.workorders, ticketId) - return true + return await getDocument(config.collections.workorders, ticketId) } catch (err) { - if (err.status === 404) return false + if (err.status === 404) return null throw err } } @@ -76,16 +83,33 @@ async function zeitenZusatz(doc) { } } -// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet +/** Name des Kunden fuer die Anleitung; wie im Ticket (termineTicketDetails) die Kunden-ID, wenn er fehlt oder nicht lesbar ist (nur Kosmetik, kein Fehler). */ +async function customerName(customerId) { + try { + return (await customerOf(customerId))?.name || customerId + } catch (err) { + console.error(`[funktionen-admin] Kunde ${customerId} fuer die Anleitung nicht lesbar:`, err.message) + return customerId + } +} + +const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null + +// Die Funktionsdokumente des Kunden werden einmal gelesen und fuer alle Projekte und Funktionen verwendet. +// hindernis (freischaltHindernis, termine/freischaltung.js): was die Freischaltung eines Terminplaners in setup noch hindert +// ('kein_abo' | 'keine_einstellung' | 'abo_unbekannt'), null = nichts; ausserhalb von setup und bei News immer null. Das Abo wird +// je Anfrage einmal gelesen (ein gemeinsamer aboMemo fuer alle Projekte des Kunden), portalFeatures nicht erneut. async function adminView(customerId) { const docs = await listCustomerFeatureDocs(customerId) const projects = await listCustomerFeatures(customerId, { docs }) + const abo = aboMemo() for (const p of projects) { for (const key of FEATURE_KEYS) { const view = p.features[key] const doc = docs.find((d) => d.projectId === p.projectId && d.featureKey === key) view.adminNote = doc?.adminNote || '' view.ticketMissing = view.state === 'setup' && !(doc?.ticketId && (await ticketExists(doc.ticketId))) + view.hindernis = key === 'termine' && doc?.state === 'setup' ? await freischaltHindernis(doc, { abo }) : null if (key === 'zeiten') Object.assign(view, await zeitenZusatz(doc)) } } @@ -129,6 +153,55 @@ funktionenAdminRouter.get('/:customerId/zeiten/ersatz', async (req, res) => { } }) +/** + * Einbauanleitung des Terminplaners eines Projekts fuer das WOMS-Panel (Task D8): die volle Fassung des Generators ohne `kurz` (die geht nur + * ins Ticket), nur lesend. Gilt in jedem Zustand und bei jedem Modus (kein TERMINE_ENABLED-Gate) und ist kein Kundenzugriff (kein + * customerViewLog, kein Pfad im Kundenrouter). Rechnung und Preis kommen live aus dem Dokument. + * state: Zustand des Dokuments. ticketWoid, ticketVersion: Ticket des Dokuments und der Stand, auf dem es beruht (null ohne Ticket oder + * ohne Standzeile). hindernis: nur in setup, was die Freischaltung noch hindert (siehe adminView), sonst null. + * Ein gespeicherter Stand, der neuer ist als das Ticket, haengt eine Warnung ans Ende von warnungen (driftWarnung); ein geloeschtes + * Ticket (404) ist keine Warnung und kein Fehler, jeder andere Lesefehler ist einer (keine stille Luecke). + */ +funktionenAdminRouter.get('/:customerId/termine/anleitung', async (req, res) => { + try { + const { customerId } = req.params + const { projectId } = req.query + if (typeof projectId !== 'string' || !projectId) return res.status(400).json({ error: 'Projekt fehlt' }) + const project = await assertOwnProject(customerId, projectId) + const doc = await getFeatureDoc(customerId, projectId, 'termine') + if (!doc) { + throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS.termine.keine}`), { status: 409 }) + } + const stand = await ladeStand(doc.$id) + const ticket = await readTicket(doc.ticketId) + const ticketVersion = ticket ? ticketStand(ticket.details) : null + const { stand: anleitungsStand, warnungen, abschnitte, text } = einbauanleitung({ + feature: doc, + project, + einstellung: stand?.einstellung ?? null, + fragebogen: stand?.fragebogen ?? null, + version: stand?.version ?? 0, + updatedAt: stand?.updatedAt ?? null, + kunde: { name: await customerName(customerId) }, + now: new Date(), + }) + const drift = driftWarnung(ticketVersion, anleitungsStand.version) + // Felder einzeln benannt: `kurz` (nur fuers Ticket) und alles, was der Generator spaeter dazubekommt, geht nie unbesehen nach aussen + res.json({ + stand: anleitungsStand, + warnungen: drift ? [...warnungen, drift] : warnungen, + abschnitte, + text, + state: doc.state, + ticketWoid: ticket?.woid || null, + ticketVersion, + hindernis: doc.state === 'setup' ? await freischaltHindernis(doc) : null, + }) + } catch (err) { + sendError(res, err) + } +}) + /** * Oeffnungszeiten live einschalten (Team, ohne Zahlung): derselbe Pfad wie der Gratisweg des Kunden (Spec 5), mit unlockedBy 'team' * und ohne Zustimmungsnachweis und K24: Zustand setup, Neubau, Einbau-Ticket; das Ticket gibt spaeter frei. Die Notiz am Dokument @@ -162,31 +235,44 @@ async function zeitenAbschalten({ customerId, projectId, by, note }) { return nachAbschaltung(doc) } -// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu, /zeiten ueber den Gratis-Pfad; jeder andere Schluessel -> 404 +// Je Funktion (/news, /termine, /zeiten; jeder andere Schluessel -> 404): activate (ohne Zahlung; bei /zeiten ueber den Gratis-Pfad), +// disable, abnahme (Freischalten nach Abnahme, in setup, ohne das Ticket zu schliessen) und ticket (fehlendes Einrichtungs-Ticket +// nachlegen, in setup). abnahme und ticket gibt es nur fuer Funktionen mit ticketFreischaltung im Register. +const ACTIONS = ['activate', 'disable', 'abnahme', 'ticket'] +const TICKET_ACTIONS = ['abnahme', 'ticket'] + funktionenAdminRouter.post('/:customerId/:key', async (req, res) => { try { const { customerId, key } = req.params if (!FEATURE_KEYS.includes(key)) return res.status(404).json({ error: 'Unbekannte Funktion' }) const { projectId, action } = req.body || {} const note = typeof req.body?.note === 'string' ? req.body.note.trim().slice(0, 120) : '' - if (action !== 'activate' && action !== 'disable') { + if (!ACTIONS.includes(action)) { return res.status(400).json({ error: 'Unbekannte Aktion' }) } if (typeof projectId !== 'string' || !projectId) { return res.status(400).json({ error: 'Projekt fehlt' }) } + if (TICKET_ACTIONS.includes(action) && featureConfig(key)?.ticketFreischaltung !== true) { + return res.status(409).json({ error: 'Diese Aktion gibt es für diese Funktion nicht.' }) + } await assertOwnProject(customerId, projectId) const by = adminName(req) let ersatzTicket - if (key === 'zeiten') { - if (action === 'activate') await zeitenAktivieren({ customerId, projectId, by, note }) - else ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note }) + if (key === 'zeiten' && action === 'activate') { + await zeitenAktivieren({ customerId, projectId, by, note }) + } else if (key === 'zeiten' && action === 'disable') { + ersatzTicket = await zeitenAbschalten({ customerId, projectId, by, note }) } else { await withLocks([`feature-admin:${projectId}`], async () => { const doc = await getFeatureDoc(customerId, projectId, key) const adminNote = composeAdminNote(doc?.adminNote, { by, action, note }) if (action === 'activate') { await adminActivate(customerId, projectId, key, { by, adminNote }) + } else if (action === 'abnahme') { + await freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote }) + } else if (action === 'ticket') { + await ticketNachlegen({ customerId, projectId, key, by, adminNote }) } else { if (!doc) { throw Object.assign(new Error(`Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`), { status: 409 }) diff --git a/server/routes/funktionen.js b/server/routes/funktionen.js index 811b338..c74cfb1 100644 --- a/server/routes/funktionen.js +++ b/server/routes/funktionen.js @@ -15,6 +15,7 @@ import { listCustomerFeatures, markOrdered, runActivation, + unterlagenAdressen, } from '../services/siteFeatures.js' import { actingPerson, speichereNachweisAusAnfrage, zustimmungFuerSitzung } from '../services/featureConsents.js' import { termineFuerKunde } from '../services/termine/schalter.js' @@ -23,7 +24,7 @@ import { einschaltenDeps, einschaltenZeiten, zeitenProjektAnsicht } from '../ser import { starteBehalten } from '../services/zeiten/behalten.js' import { aboMemo } from '../services/aboStatus.js' import { featureConfig, featureHaken, featureKeys, featureSperre, freiweg, oeffentlicheAnsicht } from '../services/featureRegistry.js' -import { bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' +import { aboMeldung, bestellFehler, istFortsetzen, oeffentlicheConsent, pruefeAbo, pruefeInklusive, pruefeZustimmung } from '../services/featureOrder.js' import { ELIGIBILITY_MESSAGES, @@ -93,8 +94,10 @@ const ABO_GRUENDE = ['kein_abo', 'gekuendigt', 'nicht_aktiv'] /** * Terminplaner-Teil der Antwort von GET /api/funktionen. Nicht verfuegbar (Schalter, Pilotliste): genau { verfuegbar: false }, * das Abo wird dann nicht gelesen. Verfuegbar: dazu bestellbar (true | false | null), grund (kein_abo | gekuendigt | nicht_aktiv | - * null), preis und einrichtung aus dem Register. Ein Lesefehler im Abo ergibt bestellbar: null und kostet die Liste nichts; im - * Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen oder IDs enthalten). + * null), preis und einrichtung aus dem Register sowie hinweis, der Abo-Text fuer den Kunden (aboMeldung, einzige Quelle der + * Abo-Hinweise; der Browser zeigt ihn nur an). hinweis ist null, solange bestellbar true ist. Ein Lesefehler im Abo ergibt + * bestellbar: null und kostet die Liste nichts; im Log stehen nur Art und Status, nie der Text (Appwrite-Texte koennten Namen + * oder IDs enthalten). */ async function termineAngebot(email, customerId, abos = aboMemo()) { if (!termineFuerKunde(email)) return { verfuegbar: false } @@ -108,7 +111,33 @@ async function termineAngebot(email, customerId, abos = aboMemo()) { } catch (err) { console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim()) } - return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '' } + const hinweis = bestellbar === true ? null : aboMeldung(register, grund ?? 'unbekannt') + return { verfuegbar: true, bestellbar, grund, preis: register?.priceLabel ?? '', einrichtung: register?.setupEstimate ?? '', hinweis } +} + +/** + * Vertragsunterlagen je Funktion fuer die Liste (GET /api/funktionen, Feld legal): Funktionen mit Rechtsdokumenten (Terminplaner) + * { version, links: { lb, avv, widerruf } (absolut, versioniert), belehrung } mit der Widerrufsbelehrung samt Muster-Formular aus + * dem Haken belehrung (derselbe Text wie auf der Bestellseite und im Nachweis), alle anderen (News) null. Kann der Haken die + * Belehrung nicht liefern, steht belehrung null: die Liste faellt dafuer nicht aus. + */ +async function rechtlichesJeFunktion() { + const legal = {} + for (const key of featureKeys()) { + const unterlagen = unterlagenAdressen(featureConfig(key)) + if (!unterlagen) { + legal[key] = null + continue + } + let belehrung = null + try { + belehrung = (await featureHaken(key).belehrung?.()) ?? null + } catch (err) { + console.error(`[funktionen] Widerrufsbelehrung (${key}) nicht lesbar: ${err?.name ?? 'Fehler'}`) + } + legal[key] = { version: unterlagen.fassung, links: { lb: unterlagen.lb, avv: unterlagen.avv, widerruf: unterlagen.widerruf }, belehrung } + } + return legal } /** @@ -187,6 +216,8 @@ router.get('/', requireSession, async (req, res) => { features: Object.fromEntries(featureKeys().map((key) => [key, oeffentlicheAnsicht(key)])), // Preishinweis fuer alle Bestellseiten: die einzige Stelle, an der er in die Oberflaeche gelangt ustHinweis: config.ustHinweis, + // Vertragsunterlagen und Widerrufsbelehrung je Funktion (Terminplaner: Fassung, versionierte Links, Belehrung; News: null) + legal: await rechtlichesJeFunktion(), setupEstimate: config.funktionen.setupEstimate, consentText: config.funktionen.consentText, // Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch diff --git a/server/routes/termine.js b/server/routes/termine.js index aba52d0..28ea5dd 100644 --- a/server/routes/termine.js +++ b/server/routes/termine.js @@ -12,6 +12,7 @@ import { createWriteLimiter } from '../services/writeLimiter.js' import { verschiebeZeiten } from '../services/termine/ablauf.js' import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js' import { EXPORT_SEITE, exportSeite, hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.js' +import { einbauSnippet } from '../services/termine/einbauanleitung.js' import { fehlendePflichtangaben, neuerFeedToken, normalisiereEinstellung } from '../services/termine/einstellungen.js' import { loeschTag, loeschzeit } from '../services/termine/frist.js' import { BRANCHEN, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js' @@ -278,20 +279,6 @@ function feedLinks(ctx) { return links } -const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z]) - -/** Einbau-Code für die Website (Spezifikation Abschnitt 7): Platzhalter mit Ausweichtext, bleibt ohne JavaScript stehen. */ -function einbauSnippet(ctx) { - const telefon = ctx.einstellung?.betrieb?.telefon - const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.' - return [ - `
    `, - `

    ${ausweich}

    `, - '
    ', - ``, - ].join('\n') -} - router.use((_req, res, next) => { res.set('Cache-Control', 'no-store') next() @@ -311,7 +298,9 @@ async function aboFuerVorschau(customerId) { * Einstellungen und Entwurf für das Portal. Liest in jedem Zustand und auch ohne Funktionsdokument (entwurf: true). * Neben der Einstellung liefert die Antwort den Entwurf des Fragebogens (branchen, fragebogen, kundenansicht, betriebVorschlag * ohne Einstellung) und in `off` die Sicht der Bestellung: abo (bestellbar() oder null bei Lesefehler), offen (Punkte der - * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). Einbau-Code und Kalender-Links + * Bestellprüfung inklusive Abo-Punkt (h)) und bestellung (Texte und Preis des Registers). In `ordered` liefert sie bestellung und + * offen ebenfalls, nur lesend (Ruling R110: „Zahlung fortsetzen“ ohne Nachweis zeigt dort wieder das Bestellformular); das Abo wird + * dort nicht gelesen (abo null), offen hat keinen Abo-Punkt (h). Einbau-Code und Kalender-Links * gibt es nur, wo die Funktion läuft oder lief (active, disabled; Kalender-Links nur in active). */ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), route(async (req, res) => { @@ -322,6 +311,7 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou const customerId = getSessionCustomerId(req) const register = featureConfig('termine') const abo = state === 'off' ? await aboFuerVorschau(customerId) : null + const bestellbar = state === 'off' || state === 'ordered' // die Sicht der Bestellung (offen, bestellung) gibt es nur vor der Zahlung res.json({ state, abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null, @@ -334,11 +324,11 @@ router.get('/einstellungen', requireSession, withTermine({ entwurf: true }), rou kundenansicht: isKundenansicht(req.session), ...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }), abo, - offen: state === 'off' ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo }) : null, - bestellung: state === 'off' ? await bestellungAnsicht(register) : null, + offen: bestellbar ? bestellpruefung({ einstellung: e, fragebogen: ctx.fragebogen, feature: register, abo, mitAbo: state === 'off' }) : null, + bestellung: bestellbar ? await bestellungAnsicht(register) : null, feeds: e && state === 'active' ? feedLinks(ctx) : {}, siteKey: ctx.feature.siteKey || null, - einbau: laeuft ? { snippet: einbauSnippet(ctx) } : {}, + einbau: laeuft ? { snippet: einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }) } : {}, anfragenOffen: laeuft ? await zaehleAnfragen(ctx.feature.$id) : 0, fehlt: e ? fehlendePflichtangaben(e) : [], // Pflichtangaben für Gäste (Ruling R41): solange etwas fehlt, ist die Funktion nicht buchbar }) diff --git a/server/services/analyseLegal.js b/server/services/analyseLegal.js index ee23143..22fe836 100644 --- a/server/services/analyseLegal.js +++ b/server/services/analyseLegal.js @@ -1,54 +1,13 @@ -// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/) -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { join } from 'node:path' -import { Router } from 'express' -import { config } from '../config.js' +// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/): +// duenner Wrapper um die Dokument-Registry legalDocs.js (Satz 'analyse') +import { legalPaths as registryPaths, legalPdfAttachments as registryAttachments, legalPdfFiles as registryFiles, legalRouter } from './legalDocs.js' -const publicDir = fileURLToPath(new URL('../../public/', import.meta.url)) -const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/ +export const legalPaths = (version) => registryPaths('analyse', version) -function checkVersion(version) { - if (!VERSION_RE.test(String(version))) throw new Error('Analyse: ungültige Fassung') - return String(version) -} - -export function legalPaths(version) { - const v = checkVersion(version) - return { - avv: `/rechtliches/avv-website-analyse-${v}.html`, - lb: `/rechtliches/leistungsbeschreibung-website-analyse-${v}.html`, - } -} - -export function legalPdfFiles(version) { - const v = checkVersion(version) - return { - avv: { filename: `AVV-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `avv-website-analyse-${v}.pdf`) }, - lb: { filename: `Leistungsbeschreibung-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `leistungsbeschreibung-website-analyse-${v}.pdf`) }, - } -} +export const legalPdfFiles = (version) => registryFiles('analyse', version) /** Beide PDFs als Mail-Anhaenge; wirft, wenn eine Datei fehlt oder nicht lesbar ist. */ -export async function legalPdfAttachments(version) { - const files = legalPdfFiles(version) - return Promise.all( - [files.avv, files.lb].map(async (f) => ({ - filename: f.filename, - content: await readFile(f.path), - contentType: 'application/pdf', - })) - ) -} +export const legalPdfAttachments = (version) => registryAttachments('analyse', version) /** Adressen ohne Fassung liefern immer die aktuelle geprüfte Fassung (config.analyse.avvVersion). */ -export function analyseLegalRouter() { - const router = Router() - const send = (key) => (_req, res, next) => { - const file = join(publicDir, legalPaths(config.analyse.avvVersion)[key]) - res.sendFile(file, (err) => err && next(err.code === 'ENOENT' ? undefined : err)) - } - router.get('/avv-website-analyse.html', send('avv')) - router.get('/leistungsbeschreibung-website-analyse.html', send('lb')) - return router -} +export const analyseLegalRouter = () => legalRouter({ saetze: ['analyse'] }) diff --git a/server/services/featureRegistry.js b/server/services/featureRegistry.js index 27c8b0f..ac4b565 100644 --- a/server/services/featureRegistry.js +++ b/server/services/featureRegistry.js @@ -28,14 +28,15 @@ async function termineBestellpruefung({ customerId, projectId, feature }) { /** * Freischaltpruefung des Terminplaners (Spec 4.5: die Aktivierung bleibt gesperrt, bis ein Abo laeuft): die Einrichtung - * ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Fail-closed: ein Lesefehler - * (Abo, Einstellung) wirft, activateClosedTickets haelt das Dokument dann in 'setup'. Der Aufrufer liest das Dokument - * unter der Sperre frisch und uebergibt es; auch das Abo wird hier frisch gelesen, nie aus einem Zwischenspeicher. + * ist erst freizugeben, wenn ein Wartungs-Abo laeuft und eine Einstellung gespeichert ist. Das entscheidet + * freischaltHindernis (termine/freischaltung.js, auch die Gruende dort); dieser Haken gibt nur bei null frei. Fail-closed: + * ein nicht lesbares Abo ('abo_unbekannt') gibt nie frei, ein Lesefehler der Einstellung wirft, activateClosedTickets haelt + * das Dokument dann in 'setup'. Der Aufrufer liest das Dokument unter der Sperre frisch und uebergibt es; auch das Abo wird + * hier frisch gelesen (freischaltHindernis ohne eingereichten Zwischenspeicher), nie aus einem Zwischenspeicher. */ async function termineFreischaltbar(doc) { - const [{ ladeEinstellung }, { laeuft }] = await Promise.all([import('./termine/kontext.js'), import('./aboStatus.js')]) - if (!(await laeuft(doc.customerId))) return false - return (await ladeEinstellung(doc.$id)) !== null + const { freischaltHindernis } = await import('./termine/freischaltung.js') + return (await freischaltHindernis(doc)) === null } /** @@ -68,14 +69,34 @@ async function zeitenNachFreischaltung(doc) { } /** - * Platzhalter der Widerrufsbelehrung mit Muster-Widerrufsformular, die die Bestellseite vor dem Knopf zeigt und der - * Zustimmungsnachweis speichert. Block F (Task F7) ersetzt den Haken belehrung durch widerrufsbelehrungFunktion(). + * Platzhalter der Widerrufsbelehrung der Oeffnungszeiten (Haken belehrung von zeiten). Der Terminplaner liefert seine echte + * Belehrung mit termineBelehrung (Task F7); fuer die Oeffnungszeiten bleibt dieser Platzhalter, bis ihr Belehrungstext feststeht. */ export const BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.' +/** + * Vertragsunterlagen des Terminplaners abrufbar? (Stufe 5b des Checkouts, vor jeder Stripe-Sitzung): HTML-Seiten und PDFs der + * aktuellen Fassung sind da und lesbar (legalDocs.featureLegalReady, Task F8; fail-closed, wirft nie). Geladen wird legalDocs erst + * beim Aufruf. + */ +async function termineUnterlagenBereit() { + const { featureLegalReady } = await import('./legalDocs.js') + return featureLegalReady('termine') +} + +/** + * Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen (Gesetzesmuster, Sie-Form), die die Bestellseite vor dem Knopf + * zeigt und der Zustimmungsnachweis speichert: dieselbe Zeichenkette an beiden Stellen (Task F7, Text aus Task F3). Geladen + * wird sie erst beim Aufruf (kein Importzyklus). + */ +async function termineBelehrung() { + const { widerrufsbelehrungFunktion } = await import('./termine/rechtstexte.js') + return widerrufsbelehrungFunktion() +} + /** * Standard-Haken je Funktion (Funktionen, nicht Daten). B6 fuellt verfuegbar, bestellpruefung und unterlagenBereit - * (Standard immer bereit; F8 haengt die Rechtsunterlagen ein), B7 belehrung (Text der Belehrung, der vor dem Knopf steht + * (Terminplaner: featureLegalReady, F8), B7 belehrung (Text der Belehrung, der vor dem Knopf steht * und im Nachweis gespeichert wird) und B11 freischaltbar. News hat keine Standard-Haken, also auch keinen belehrung-Eintrag * im Nachweis. Feste Funktionen, damit featureHaken bei jedem Aufruf dieselben liefert. */ @@ -84,8 +105,8 @@ const STANDARD_HAKEN = { termine: { verfuegbar: (email) => termineFuerKunde(email), bestellpruefung: termineBestellpruefung, - unterlagenBereit: async () => true, - belehrung: async () => BELEHRUNG_PLATZHALTER, + unterlagenBereit: termineUnterlagenBereit, + belehrung: termineBelehrung, freischaltbar: termineFreischaltbar, }, // Oeffnungszeiten live: keine Bestellpruefung (die Einstellung entsteht erst im Editor); belehrung und diff --git a/server/services/legalDocs.js b/server/services/legalDocs.js new file mode 100644 index 0000000..460ccd5 --- /dev/null +++ b/server/services/legalDocs.js @@ -0,0 +1,161 @@ +// Dokument-Registry der veroeffentlichten Rechtstexte (je Fassung feste Dateien unter public/rechtliches/): +// Saetze 'analyse' (AVV, Leistungsbeschreibung der Website-Analyse) und 'termine' (Leistungsbeschreibung, AVV und +// Widerrufsbelehrung mit Muster-Widerrufsformular des Terminplaners). analyseLegal.js ist ein duenner Wrapper darum. +// Die Fassung ist der Berliner Kalendertag der Texte (JJJJ-MM-TT); eine ausgelieferte Fassung wird nie ueberschrieben. +import { access, open, readFile, stat } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import { Router } from 'express' +import { config } from '../config.js' + +const publicDir = fileURLToPath(new URL('../../public/', import.meta.url)) +/** Wurzel der ausgelieferten Dateien (Standard: public/); nur Tests tauschen sie gegen ein Verzeichnis mit Kopien aus. */ +let basisDir = publicDir +/** Nur fuer Tests: Verzeichnis, in dem legalPdfFiles und featureLegalReady die Dateien suchen (null = public/). */ +export function setLegalDirForTests(dir) { + basisDir = dir ?? publicDir +} +const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/ + +/** + * Dokumente je Satz in Anhangsreihenfolge: slug = Dateiname der HTML-Seite und des PDFs (-.html|pdf), + * pdf = Dateiname des Mail-Anhangs (-.pdf). config.js baut die Adressen des Terminplaners aus denselben + * Slugs (config.js darf diese Datei nicht importieren); test/legalDocs.test.js gleicht beide ab. + */ +const SAETZE = { + analyse: { + avv: { slug: 'avv-website-analyse', pdf: 'AVV-Website-Analyse' }, + lb: { slug: 'leistungsbeschreibung-website-analyse', pdf: 'Leistungsbeschreibung-Website-Analyse' }, + }, + termine: { + lb: { slug: 'leistungsbeschreibung-terminplaner', pdf: 'Leistungsbeschreibung-Terminplaner' }, + avv: { slug: 'avv-terminplaner', pdf: 'AVV-Terminplaner' }, + widerruf: { slug: 'widerrufsbelehrung-funktionen', pdf: 'Widerrufsbelehrung-Muster-Widerrufsformular' }, + }, +} + +/** Satz, dessen Dokumente eine Funktion (Registerschluessel) mit der Bestellung bekommt; News hat keine. */ +const FUNKTION_SATZ = { news: null, termine: 'termine' } + +/** Aktuelle Fassung je Satz, bei jedem Aufruf aus config gelesen (Tests aendern sie zur Laufzeit). */ +const aktuelleFassung = (satz) => (satz === 'analyse' ? config.analyse.avvVersion : config.termine.avvVersion) + +function satzDokumente(satz) { + if (typeof satz !== 'string' || !Object.hasOwn(SAETZE, satz)) throw new Error('Rechtstexte: unbekannter Satz') + return SAETZE[satz] +} + +function pruefeFassung(version) { + if (!VERSION_RE.test(String(version))) throw new Error('Rechtstexte: ungültige Fassung') + return String(version) +} + +/** Adressen der versionierten HTML-Seiten: { [doc]: '/rechtliches/-.html' }. */ +export function legalPaths(satz, version) { + const dokumente = satzDokumente(satz) + const v = pruefeFassung(version) + return Object.fromEntries(Object.entries(dokumente).map(([doc, d]) => [doc, `/rechtliches/${d.slug}-${v}.html`])) +} + +/** PDF je Dokument: { [doc]: { filename (Anhangsname), path (Datei unter public/rechtliches/) } }. */ +export function legalPdfFiles(satz, version) { + const dokumente = satzDokumente(satz) + const v = pruefeFassung(version) + return Object.fromEntries( + Object.entries(dokumente).map(([doc, d]) => [doc, { filename: `${d.pdf}-${v}.pdf`, path: join(basisDir, 'rechtliches', `${d.slug}-${v}.pdf`) }]) + ) +} + +/** + * PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine + * Datei fehlt oder nicht lesbar ist. ohne: Dokumente, die nicht angehaengt werden (sie muessen nicht vorhanden sein). + */ +export async function legalPdfAttachments(satz, version, { ohne = [] } = {}) { + const dateien = legalPdfFiles(satz, version) + for (const doc of ohne) { + if (!Object.hasOwn(dateien, doc)) throw new Error('Rechtstexte: unbekanntes Dokument') + } + return Promise.all( + Object.entries(dateien) + .filter(([doc]) => !ohne.includes(doc)) + .map(async ([, f]) => ({ filename: f.filename, content: await readFile(f.path), contentType: 'application/pdf' })) + ) +} + +function funktionSatz(featureKey) { + if (typeof featureKey !== 'string' || !Object.hasOwn(FUNKTION_SATZ, featureKey)) throw new Error('Rechtstexte: unbekannte Funktion') + return FUNKTION_SATZ[featureKey] +} + +/** + * Anhaenge der Bestellmail einer Funktion in der aktuellen Fassung: termine => Satz 'termine', news => []. Wirft bei + * unbekannter Funktion und bei fehlender Datei (der Aufrufer entscheidet, was dann geschieht). opts.fassung waehlt eine andere + * (ausgelieferte) Fassung, etwa die, der ein Kunde bei der Bestellung zugestimmt hat; sonst gilt die aktuelle. + */ +export async function featureLegalAttachments(featureKey, { fassung, ...opts } = {}) { + const satz = funktionSatz(featureKey) + return satz ? legalPdfAttachments(satz, fassung ?? aktuelleFassung(satz), opts) : [] +} + +/** + * Adressen der HTML-Seiten der Funktion ({ lb, avv, widerruf }, versioniert) in der aktuellen oder der gewaehlten Fassung + * (opts.fassung); news => null. Wirft bei unbekannter Funktion oder ungueltiger Fassung. + */ +export function featureLegalPaths(featureKey, { fassung } = {}) { + const satz = funktionSatz(featureKey) + return satz ? legalPaths(satz, fassung ?? aktuelleFassung(satz)) : null +} + +/** + * Ist die Datei ein lesbares PDF? Grösse > 0 (stat) und die ersten fünf Bytes "%PDF-"; nur diese fünf Bytes werden gelesen, nie die + * ganze Datei (featureLegalReady läuft bei jeder Bestellung, vor jeder Stripe-Sitzung). Wirft bei jedem Lesefehler. + */ +async function istLesbaresPdf(pfad) { + const info = await stat(pfad) + if (!info.isFile() || info.size <= 0) return false + const datei = await open(pfad, 'r') + try { + const kopf = Buffer.alloc(5) + const { bytesRead } = await datei.read(kopf, 0, 5, 0) + return bytesRead === 5 && kopf.toString('latin1') === '%PDF-' + } finally { + await datei.close() + } +} + +/** + * true, wenn alle Rechtsdokumente der Funktion in der aktuellen Fassung vorliegen: HTML-Seiten (die die Bestellseite + * verlinkt) und PDFs (die Mail anhaengt; je PDF stat und die ersten fuenf Bytes, nicht der ganze Inhalt). Unbekannte + * Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf. opts.fassung prueft eine andere Fassung als die aktuelle. + */ +export async function featureLegalReady(featureKey, { fassung } = {}) { + try { + const satz = funktionSatz(featureKey) + if (!satz) return true + const v = fassung ?? aktuelleFassung(satz) + await Promise.all(Object.values(legalPaths(satz, v)).map((adresse) => access(join(basisDir, adresse)))) + const lesbar = await Promise.all(Object.values(legalPdfFiles(satz, v)).map((f) => istLesbaresPdf(f.path))) + return lesbar.every(Boolean) + } catch { + return false + } +} + +/** + * Adressen ohne Fassung (/rechtliches/.html) liefern die Datei der aktuellen Fassung aus config. Nur fuer + * Menschen und die WOMS-Karten: keine Mail und kein Zustimmungstext darf sie verlinken, denn sie aendern sich mit der + * Fassung. Gehoert vor express.static; fehlt die Datei, geht die Anfrage weiter (404). saetze: welche Saetze bedient + * werden, dir: Verzeichnis der Dateien (Standard public/rechtliches, Tests uebergeben ein eigenes). + */ +export function legalRouter({ saetze = Object.keys(SAETZE), dir = join(publicDir, 'rechtliches') } = {}) { + const router = Router() + for (const satz of saetze) { + for (const d of Object.values(satzDokumente(satz))) { + router.get(`/${d.slug}.html`, (_req, res, next) => { + const datei = join(dir, `${d.slug}-${pruefeFassung(aktuelleFassung(satz))}.html`) + res.sendFile(datei, (err) => err && next(err.code === 'ENOENT' ? undefined : err)) + }) + } + } + return router +} diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index c11446c..22df7ba 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -4,9 +4,10 @@ import { createDocument, getDocument, listDocuments, updateDocument, Query } fro import { withLocks } from './keyedLock.js' import { sendAdminNotice, sendCustomerMail } from './mailer.js' import { getStripe, retrieveCheckoutSession } from './stripeBilling.js' -import { anleitungsLink, featureConfig, featureHaken, featureKeys, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js' +import { anleitungsLink, featureConfig, featureHaken, featureKeys, kuerze, ticketFreischaltungKeys, ticketTexte } from './featureRegistry.js' import { zustimmungFuerSitzung } from './featureConsents.js' import { aboLage, laeuft } from './aboStatus.js' +import { berlinDateTime } from './berlinTime.js' import { loeschTag } from './termine/frist.js' import { createFeatureTicket } from './ticketAdmin.js' @@ -416,20 +417,34 @@ export async function markPaid({ sessionId, invoiceId, invoiceUrl }) { // Oeffnungszeiten live: erst der Neubau (Zustand ist schon gespeichert), dann das Ticket mit dem frischen einbau (Spec 4.6) if (mitAboKauf) setup = await baueZeitenEin(setup, project) const customer = await customerOf(doc.customerId).catch(() => null) - const feature = featureConfig(doc.featureKey) - const texte = feature ? await ticketTexte({ feature, doc: setup, project, customer }) : {} - const { ticketId } = await createFeatureTicket({ - customerId: doc.customerId, - project, - featureKey: doc.featureKey, - siteKey: doc.siteKey, - ...texte, - }) + const ticketId = await legeTicketAn(setup, project, customer) const updated = await updateDocument(config.collections.portalFeatures, setup.$id, { ticketId }) return { doc: updated, created: true, fall: null } }) } +/** + * Das Einrichtungs-Ticket einer bezahlten Funktion anlegen (Betreff und Details aus dem Register, ticketTexte) und seine ID liefern. + * Die einzige Stelle, die das Ticket anlegt: markPaid (Zahlung) und ticketNachlegen (b, geloeschtes Ticket ersetzen). Schreibt nichts ins + * Funktionsdokument (die ticketId traegt der Aufrufer ein) und sendet keine Mail. + * @param {object} doc portalFeatures-Dokument (customerId, featureKey, siteKey) + * @param {object} project websiteProjects-Dokument + * @param {object|null} customer Kundendokument, best effort + * @returns {Promise} ID des neuen Tickets + */ +async function legeTicketAn(doc, project, customer) { + const feature = featureConfig(doc.featureKey) + const texte = feature ? await ticketTexte({ feature, doc, project, customer }) : {} + const { ticketId } = await createFeatureTicket({ + customerId: doc.customerId, + project, + featureKey: doc.featureKey, + siteKey: doc.siteKey, + ...texte, + }) + return ticketId +} + /** * Zahlung abgebrochen oder fehlgeschlagen: nur eine noch offene Bestellung faellt auf 'off' zurueck. * Unter demselben Schluessel wie markPaid, damit ein spaetes 'expired' nie eine gerade bezahlte @@ -648,6 +663,192 @@ export async function activateClosedTickets({ now = new Date(), customerId = nul return activated } +// --------------------------------------------------------------------------- +// Team-Aktionen "Freischalten (Abnahme)" und "Ticket nachlegen" (WOMS, Kunden-Reiter Funktionen) +// --------------------------------------------------------------------------- + +const ABNAHME_NUR_SETUP = 'Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.' +const TICKET_NUR_SETUP = 'Ein Ticket nachlegen gibt es nur für Funktionen in Einrichtung.' +/** Klartext und Status je Grund aus freischaltHindernis (termine/freischaltung.js); ein unbekannter Grund sperrt mit einem allgemeinen 409. */ +const ABNAHME_HINDERNIS = { + kein_abo: [409, 'Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.'], + keine_einstellung: [409, 'Es sind noch keine Einstellungen gespeichert.'], + abo_unbekannt: [503, 'Das Abo konnte gerade nicht geprüft werden.'], + // Gruende des Registerhakens freischaltbar anderer Funktionen (Oeffnungszeiten live: eingetragene Zeiten), siehe freischaltHindernis oben + 'Voraussetzungen noch nicht erfüllt': [409, 'Die Voraussetzungen für das Freischalten sind noch nicht erfüllt.'], + 'Prüfung nicht möglich': [503, 'Die Voraussetzungen konnten gerade nicht geprüft werden.'], +} + +/** Funktionsdokument zu (Kunde, Projekt, Funktion) fuer eine Team-Aktion; ohne Dokument 409 "Für dieses Projekt gibt es noch {keine}". */ +async function dokumentFuerTeamAktion(customerId, projectId, key) { + assertKey(key) + const doc = await getFeatureDoc(customerId, projectId, key) + if (!doc) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`) + return doc +} + +/** Das Dokument unter der Sperre frisch lesen; ist es inzwischen geloescht (Kunde geloescht), gilt dieselbe Meldung wie ohne Dokument. */ +async function frischesDokument(vorab, key) { + try { + return await getDocument(config.collections.portalFeatures, vorab.$id) + } catch (err) { + if (err.status === 404) throw httpError(409, `Für dieses Projekt gibt es noch ${FEATURE_LABELS[key].keine}`) + throw err + } +} + +/** + * Was hindert die Abnahme? null = nichts. Nur Funktionen mit Haken freischaltbar im Register haben Voraussetzungen; fehlt der Haken + * (News), gibt es keine Pruefung und kein Abo wird gelesen. Terminplaner: laufendes Wartungs-Abo und gespeicherte Einstellung, die + * Gruende liefert freischaltHindernis (termine/freischaltung.js, erst im Aufruf geladen, weil es dieses Modul importiert). Jede andere + * Funktion mit Haken (Oeffnungszeiten live) fragt wie der 5-Minuten-Job ihren Haken (freischaltHindernis oben, fail-closed): ihre + * Voraussetzung hat nichts mit Abo und Einstellung des Terminplaners zu tun. + */ +async function abnahmeHindernis(doc) { + if (typeof featureHaken(doc.featureKey).freischaltbar !== 'function') return null + if (doc.featureKey !== 'termine') return freischaltHindernis(doc) + const { freischaltHindernis: pruefe } = await import('./termine/freischaltung.js') + return pruefe(doc) +} + +/** + * Nacharbeit nach der Freischaltung: Haken nachFreischaltung der Funktion, falls das Register einen hat (Oeffnungszeiten: Ersatz-Hash + * merken). Laeuft vor der K16; ein Fehler wird geloggt und bricht nichts ab (die Freischaltung ist dann schon geschehen). + */ +async function nacharbeitNachFreischaltung(doc) { + const { nachFreischaltung } = featureHaken(doc.featureKey) + if (typeof nachFreischaltung !== 'function') return + try { + await nachFreischaltung(doc) + } catch (err) { + console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message) + } +} + +/** + * "Freischalten (Abnahme)": eine Funktion in Einrichtung ('setup') wird freigeschaltet, ohne dass das Einrichtungs-Ticket geschlossen + * wird. Gesetzt werden nur state/enabled 'active', activatedAt und adminNote; ticketId, Ticket und unlockedByPurchase bleiben. + * Danach K16 (meldeFreigeschaltet, Fehler werden dort geloggt). Unter derselben Sperre feature-activate: wie der Freischalt-Job + * (activateClosedTickets) nach frischem Lesen: wer zuerst kommt, aktiviert und sendet; der andere sieht 'active' und tut nichts. + * - Dokument fehlt: 409 "Für dieses Projekt gibt es noch {keine}". + * - schon 'active': Erfolg ohne Schreibzugriff und ohne Mail, activated false. + * - 'off' | 'ordered' | 'disabled': 409. Ein Hindernis (kein Wartungs-Abo, keine Einstellung): 409; Abo nicht lesbar: 503. + * @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string, now?: Date }} args + * @returns {Promise<{ doc: object, activated: boolean }>} + */ +export async function freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now = new Date() }) { + const vorab = await dokumentFuerTeamAktion(customerId, projectId, key) + const ergebnis = await withLocks([`feature-activate:${vorab.$id}`], async () => { + const doc = await frischesDokument(vorab, key) + if (doc.state === 'active') return { doc, activated: false } + if (doc.state !== 'setup') throw httpError(409, ABNAHME_NUR_SETUP) + const grund = await abnahmeHindernis(doc) + if (grund) { + const [status, text] = ABNAHME_HINDERNIS[grund] ?? [409, 'Freischalten ist noch nicht möglich.'] + throw httpError(status, text) + } + const note = adminNote ?? byNote(by, 'Freigeschaltet (Abnahme)') + const updated = await updateDocument(config.collections.portalFeatures, doc.$id, { + ...stateFields('active'), + activatedAt: now.toISOString(), + ...(note ? { adminNote: note } : {}), + }) + return { doc: updated, activated: true } + }) + if (ergebnis.activated) { + await nacharbeitNachFreischaltung(ergebnis.doc) + const { meldeFreigeschaltet } = await import('./termine/freischaltung.js') + await meldeFreigeschaltet(ergebnis.doc) + } + return ergebnis +} + +/** Ob das Ticket noch existiert; nur 404 heisst "geloescht", jeder andere Fehler wird weitergereicht. */ +async function ticketBesteht(ticketId) { + try { + await getDocument(config.collections.workorders, ticketId) + return true + } catch (err) { + if (err.status === 404) return false + throw err + } +} + +/** Die bezahlte Stripe-Sitzung des Dokuments; nicht bezahlt: 409, Stripe nicht erreichbar oder nicht konfiguriert: 503 (Ursache im Log). */ +async function bezahlteSitzung(doc) { + if (!doc.stripeSessionId) throw httpError(409, 'Zu dieser Funktion ist keine Zahlung vermerkt.') + let session + try { + session = await retrieveCheckoutSession(doc.stripeSessionId) + } catch (err) { + console.error(`[funktionen] Stripe-Sitzung ${doc.stripeSessionId} nicht abrufbar:`, err.message) + throw httpError(503, 'Stripe konnte gerade nicht gefragt werden. Bitte später erneut versuchen.') + } + if (session?.payment_status !== 'paid') throw httpError(409, 'Die Zahlung ist noch nicht eingegangen.') + return session +} + +/** + * Ticket nachlegen (a): 'setup' ohne ticketId, das Ticket entstand nie. Die Zahlung wird bei Stripe geprueft und dann derselbe Weg wie + * im Webhook gegangen (handleFeatureCheckoutEvent): Ticket, K15 und T16 entstehen genau einmal, ein spaeterer Stripe-Retry findet das + * Ticket und sendet nichts. Keine Sperre feature-paid: hier, markPaid nimmt sie selbst (withLocks ist nicht wiederbetretbar). + */ +async function ticketNachZahlung(doc, note) { + const session = await bezahlteSitzung(doc) + await handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } }) + const nachher = await getDocument(config.collections.portalFeatures, doc.$id) + if (!nachher.ticketId) return { doc: nachher, created: false } + if (!note) return { doc: nachher, created: true } + try { + return { doc: await updateDocument(config.collections.portalFeatures, doc.$id, { adminNote: note }), created: true } + } catch (err) { + // Ticket und Mails sind raus; die Notiz ist nur ein Vermerk + console.error(`[funktionen] Notiz zum nachgelegten Ticket von ${doc.$id} nicht gespeichert:`, err.message) + return { doc: nachher, created: true } + } +} + +/** + * Ticket nachlegen (b): ticketId gesetzt, das Ticket aber geloescht. Ein neues Ticket ersetzt die ticketId; K15 und T16 gingen beim + * ersten Mal raus, darum keine Mail. Laeuft unter der Sperre der Zahlung (feature-paid:), damit kein Stripe-Retry daneben + * schreibt; das Dokument wird darunter erneut gelesen. + */ +async function ersetzeGeloeschtesTicket(doc, note) { + return withLocks([`feature-paid:${doc.stripeSessionId || doc.$id}`], async () => { + const frisch = await getDocument(config.collections.portalFeatures, doc.$id) + if (frisch.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP) + if (frisch.ticketId !== doc.ticketId) return { doc: frisch, created: false } + const project = await getDocument(config.collections.websiteProjects, frisch.projectId) + const customer = await customerOf(frisch.customerId).catch(() => null) + const ticketId = await legeTicketAn(frisch, project, customer) + const updated = await updateDocument(config.collections.portalFeatures, frisch.$id, { ticketId, ...(note ? { adminNote: note } : {}) }) + return { doc: updated, created: true } + }) +} + +/** + * "Ticket nachlegen": nur fuer eine Funktion in Einrichtung ('setup'; sonst 409, ohne Dokument 409). + * (a) ticketId leer: Zahlung bei Stripe pruefen (nicht bezahlt 409, Stripe nicht erreichbar 503) und den Weg des Webhooks gehen, + * Ticket + K15 + T16 genau einmal (ticketNachZahlung); + * (b) ticketId gesetzt, Ticket geloescht: neues Ticket, ticketId ersetzt, keine Mails (ersetzeGeloeschtesTicket); + * (c) Ticket vorhanden: nichts, created false. + * Eine Sperre feature-ticket: je Dokument: gleichzeitige Aufrufe laufen nacheinander und sehen das Ergebnis des ersten + * (ein Ticket, created nur einmal true). + * @param {{ customerId: string, projectId: string, key: string, by?: string, adminNote?: string }} args + * @returns {Promise<{ doc: object, created: boolean }>} + */ +export async function ticketNachlegen({ customerId, projectId, key, by, adminNote }) { + const vorab = await dokumentFuerTeamAktion(customerId, projectId, key) + const note = adminNote ?? byNote(by, 'Ticket nachgelegt') + return withLocks([`feature-ticket:${vorab.$id}`], async () => { + const doc = await frischesDokument(vorab, key) + if (doc.state !== 'setup') throw httpError(409, TICKET_NUR_SETUP) + if (!doc.ticketId) return ticketNachZahlung(doc, note) + if (await ticketBesteht(doc.ticketId)) return { doc, created: false } + return ersetzeGeloeschtesTicket(doc, note) + }) +} + /** * Aktive Funktion zu einem siteKey; ohne featureKey 'news' (Pinnwand), unbekannte Funktion oder siteKey => null. * states ersetzt die Vorgabe ['active'] (die Oeffnungszeiten antworten schon in 'setup', damit das Team die Leisten @@ -679,14 +880,29 @@ function einrichtungsPreis(feature) { return eur(amountCents) } +/** + * Absolute, versionierte Adressen der Vertragsunterlagen einer Funktion aus dem Register (Fassung der Leistungsbeschreibung und + * die Adressen von Leistungsbeschreibung, AVV und Widerrufsbelehrung); null bei Funktionen ohne Rechtsdokumente (News). + * @returns {{ fassung: string, lb: string, avv: string, widerruf: string } | null} + */ +export function unterlagenAdressen(feature) { + const { leistungsbeschreibung: lb, avv, widerruf } = feature ?? {} + if (!lb?.url || !avv?.url || !widerruf?.url) return null + const absolut = (pfad) => `${config.publicBaseUrl}${pfad}` + return { fassung: lb.version, lb: absolut(lb.url), avv: absolut(avv.url), widerruf: absolut(widerruf.url) } +} + /** * K15 (Bestellung eingegangen) einer Funktion: Vorlage, Platzhalterwerte und Bloecke ausschliesslich aus dem Register-Eintrag * (mails.*, setupEstimate, returnHash) und dem Katalogpreis. Die Werte decken genau die Platzhalter der Vorlage ab. - * Ohne Anhaenge: die Rechtsunterlagen der Termine-Bestellung haengt F8 direkt in onFeaturePaid an. + * Funktionen mit Rechtsdokumenten (Terminplaner): `unterlagen` (unterlagenAdressen) schaltet den Block anlagen ein und fuellt + * fassung und die drei Adressen; `anhang` sagt, dass die PDFs an der Mail haengen (Block anhang in anlagen); `zustimmungenText` + * (zustimmungenText) schaltet den Block zustimmungen ein. Die PDFs selbst gibt onFeaturePaid als `attachments` mit; es gibt keinen + * Registerhaken dafuer. * behalten: der Kunde behaelt die Funktion nach dem Abo-Ende fuer einmalig 30 EUR (Oeffnungszeiten live): die Mail sagt, dass nichts * neu eingerichtet wird. Die Vorlage kennt dafuer zwei Gegenstuecke, behalten und neu (Normalfall); genau eins ist an. */ -export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten = false }) { +export function bestellMail(feature, { kundeName, projekt, invoiceUrl, unterlagen = null, anhang = false, zustimmungenText = '', behalten = false }) { const { funktion, formen, preisHinweis, danach } = feature.mails return { template: 'K15', @@ -704,11 +920,47 @@ export function bestellMail(feature, { kundeName, projekt, invoiceUrl, behalten danach, portal_url: portalUrl(feature), rechnung_url: invoiceUrl || '', + fassung: unterlagen?.fassung ?? '', + lb_url: unterlagen?.lb ?? '', + avv_url: unterlagen?.avv ?? '', + widerruf_url: unterlagen?.widerruf ?? '', + zustimmungen_text: zustimmungenText, + }, + blocks: { + rechnung: Boolean(invoiceUrl), + anlagen: unterlagen !== null, + anhang: unterlagen !== null && anhang === true, + zustimmungen: zustimmungenText !== '', + behalten: Boolean(behalten), + neu: !behalten, }, - blocks: { rechnung: Boolean(invoiceUrl), behalten: Boolean(behalten), neu: !behalten }, } } +/** + * Wortlaut und Zeitpunkt der erteilten Zustimmungen fuer K15 (Du-Form): die Haken-Texte aus dem Nachweis zur bezahlten Sitzung, der + * Zeitpunkt des Klicks (Europe/Berlin) und der Knopf, mit dem bestellt wurde. '' ohne verwertbaren Nachweis. + * @param {{ texte?: string, acceptedAt?: string } | null} nachweis featureConsents-Dokument (JSON-Text im Feld texte) + */ +export function zustimmungenText(nachweis) { + let texte + try { + texte = JSON.parse(nachweis?.texte ?? '') + } catch { + return '' + } + if (!Array.isArray(texte)) return '' + const haken = texte.filter((t) => t?.haken === true && typeof t.text === 'string' && t.text !== '') + if (haken.length === 0) return '' + const am = new Date(nachweis.acceptedAt) + const zeit = Number.isNaN(am.getTime()) ? '' : (({ date, time }) => ` am ${date} um ${time.slice(0, 2)}:${time.slice(2)} Uhr`)(berlinDateTime(am)) + const knopf = texte.find((t) => t?.id === 'knopf' && typeof t.text === 'string' && t.text !== '') + return [ + `Du hast${zeit} im Kundenportal zugestimmt${knopf ? ` und mit „${knopf.text}“ bestellt` : ''}:`, + ...haken.map((t) => `– ${t.text}`), + ].join('\n') +} + /** K16 (Funktion bereit) einer Funktion, Werte wie bei bestellMail aus dem Register-Eintrag. */ export function bereitMail(feature, { kundeName, projekt }) { const { funktion, formen, bereit, knopf } = feature.mails @@ -780,7 +1032,7 @@ async function ticketWoidOf(doc) { /** * T16 an das Team (Funktion bestellt): alle Platzhalter und Bloecke der Vorlage; was nicht genannt wird, bleibt leer bzw. aus. * Ohne bekannte Funktion heisst sie in Betreff und Text "Funktion". Liefert das Ergebnis von sendAdminNotice ({ sent, reason? }); der - * Block gesundheit bleibt hier aus (D4 fuellt ihn). + * Der Block gesundheit (Terminplaner) steht nur, wenn der Aufrufer ihn mit gesundheit_text einschaltet (teamNotice). */ function sendT16({ funktion, ...werte }, bloecke) { return sendAdminNotice({ @@ -798,15 +1050,66 @@ function sendT16({ funktion, ...werte }, bloecke) { gesundheit_text: '', ...werte, }, - blocks: { zahlung_fehlgeschlagen: false, ungeklaert: false, kein_abo: false, bereits_aktiv: false, anleitung: false, gesundheit: false, abo: false, team: false, ...bloecke }, + blocks: { + zahlung_fehlgeschlagen: false, + ungeklaert: false, + kein_abo: false, + bereits_aktiv: false, + anleitung: false, + gesundheit: false, + anhang_fehlt: false, + abo: false, + team: false, + ...bloecke, + }, }) } -/** keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). */ -async function teamNotice(doc, customer, { failed = false, keinAbo = false } = {}) { +/** Der Warntext des T16-Blocks gesundheit hat hoechstens so viele Zeichen (ganze Meldungen, danach "… und N weitere"). */ +const GESUNDHEIT_TEXT_MAX = 500 + +/** Meldungen als ein Text: durch "; " getrennt; passt es nicht, so viele ganze Meldungen wie moeglich und "… und N weitere" (nie mitten in einer Meldung). */ +function gesundheitText(meldungen) { + const alle = meldungen.join('; ') + if (alle.length <= GESUNDHEIT_TEXT_MAX) return alle + let text = '' + let n = 0 + for (const meldung of meldungen) { + const naechster = text ? `${text}; ${meldung}` : meldung + if (`${naechster} … und ${meldungen.length - n - 1} weitere`.length > GESUNDHEIT_TEXT_MAX) break + text = naechster + n += 1 + } + return text ? `${text} … und ${meldungen.length - n} weitere` : kuerze(meldungen[0], GESUNDHEIT_TEXT_MAX, { endzeile: '…' }) +} + +/** + * T16-Hinweis "Gesundheitsbereich pruefen" (nur Terminplaner): die Treffer der Stichwortpruefung ueber die gespeicherte Einstellung des Betriebs + * (gesundheitsMeldungen: wohlgeformt, einzeilig, Klartext; renderMail maskiert sie fuer HTML), hoechstens 500 Zeichen. '' ohne Treffer und bei + * jedem Lesefehler: T16 geht dann ohne Block raus (nie Geld ohne Spur); das Log nennt keine Daten des Betriebs. Dynamische Importe, weil + * termine/kontext.js dieses Modul importiert. + */ +async function gesundheitHinweis(doc) { + if (doc.featureKey !== 'termine') return '' + try { + const [{ ladeEinstellung }, { gesundheitsMeldungen }] = await Promise.all([import('./termine/kontext.js'), import('./termine/einbauanleitung.js')]) + const meldungen = gesundheitsMeldungen((await ladeEinstellung(doc.$id))?.einstellung) + return meldungen.length ? gesundheitText(meldungen) : '' + } catch (err) { + console.error(`[funktionen] Gesundheitshinweis fuer T16 (Funktion ${doc.$id}) nicht lesbar, der Block entfaellt:`, err.message) + return '' + } +} + +/** + * keinAbo: die Zahlung ist da, der Kunde hat aber kein laufendes Wartungs-Abo (Block kein_abo, nur bei Funktionen mit requiresWartungsAbo). + * anhangFehlt: die Vertragsunterlagen sind nicht beim Kunden angekommen, das Team sendet sie von Hand nach (Block anhang_fehlt). + */ +async function teamNotice(doc, customer, { failed = false, keinAbo = false, anhangFehlt = false } = {}) { const mails = featureConfig(doc.featureKey)?.mails // Knopf zur Einbauanleitung nur bei Funktionen mit Anleitung und nur, wenn bestellt und ein Ticket angelegt wurde const anleitung = Boolean(mails?.anleitung) && !failed + const gesundheit = failed ? '' : await gesundheitHinweis(doc) await sendT16( { kunde_name: customer?.name || customer?.email || doc.customerId, @@ -816,8 +1119,9 @@ async function teamNotice(doc, customer, { failed = false, keinAbo = false } = { woid: failed ? '' : await ticketWoidOf(doc), sitekey: failed ? '' : doc.siteKey || '', anleitung_url: anleitung ? anleitungsLink(doc.customerId, doc.projectId) : '', + gesundheit_text: gesundheit, }, - { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo } + { zahlung_fehlgeschlagen: failed, anleitung, kein_abo: keinAbo, gesundheit: gesundheit !== '', anhang_fehlt: anhangFehlt } ) } @@ -986,6 +1290,62 @@ async function onBehaltenPaid(session) { await bucheBehalten(session) } +/** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */ +const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false }) + +/** + * Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs und Adressen der Fassung, der der Kunde bei der Bestellung + * zugestimmt hat (Fassung des Nachweises zur bezahlten Sitzung), und der Wortlaut seiner Zustimmungen. Das ist meist die aktuelle + * Fassung (legalDocs.featureLegalAttachments, erst beim Aufruf geladen; kein Registerhaken). Wurde die Fassung zwischen Klick und + * Zahlung angehoben, gehen die Unterlagen der vereinbarten Fassung raus, wenn es sie gibt; fehlen sie, geht K15 ohne PDFs und Links + * raus (bei der aktuellen Fassung bleiben die Links, nur die PDFs fehlen). Ist der Nachweis nicht lesbar oder seine Fassung nicht bestimmbar, ist die vereinbarte Fassung unbekannt: nie die aktuelle + * ungeprueft anhaengen, K15 geht ohne PDFs und Links raus. Ohne Nachweis (Altbestand) gilt die aktuelle Fassung. In allen Faellen ohne + * Unterlagen ist fehlt true (T16 sagt "von Hand nachsenden") und der Grund steht im Log. Wirft nie. + * @returns {Promise<{ attachments: object[], unterlagen: { fassung: string, lb: string, avv: string, widerruf: string } | null, anhang: boolean, zustimmungen: string, fehlt: boolean }>} + */ +async function k15Rechtsunterlagen(doc, feature, session) { + const ohne = (zustimmungen) => ({ attachments: [], unterlagen: null, anhang: false, zustimmungen, fehlt: true }) + let nachweis = null + try { + nachweis = await zustimmungFuerSitzung(session.id) + } catch (err) { + console.error(`[funktionen] K15 (Funktion ${doc.$id}): Zustimmungsnachweis zu Sitzung ${session.id} nicht lesbar, die vereinbarte Fassung ist unbekannt - ohne Unterlagen, das Team sendet sie von Hand:`, err.message) + return ohne('') + } + const zustimmungen = zustimmungenText(nachweis) + const aktuell = feature.consent.version + let vereinbart = aktuell + if (nachweis) { + try { + vereinbart = JSON.parse(nachweis.fassungen)?.zustimmung + } catch { + vereinbart = undefined + } + if (typeof vereinbart !== 'string' || vereinbart === '') { + console.error(`[funktionen] K15 (Funktion ${doc.$id}): Fassung im Zustimmungsnachweis nicht bestimmbar - ohne Unterlagen, das Team sendet sie von Hand`) + return ohne(zustimmungen) + } + } + try { + const legal = await import('./legalDocs.js') + const eigene = vereinbart === aktuell ? {} : { fassung: vereinbart } + if (vereinbart !== aktuell) { + console.error(`[funktionen] K15 (Funktion ${doc.$id}): Der Kunde hat der Fassung ${vereinbart} zugestimmt, aktuell ist ${aktuell} - die Unterlagen der vereinbarten Fassung gehen raus, wenn es sie gibt`) + if (!(await legal.featureLegalReady(doc.featureKey, eigene))) throw new Error(`Unterlagen der Fassung ${vereinbart} fehlen`) + } + const attachments = await legal.featureLegalAttachments(doc.featureKey, eigene) + if (attachments.length === 0) throw new Error('keine Anhaenge fuer diese Funktion') + const adressen = legal.featureLegalPaths(doc.featureKey, eigene) + const absolut = (pfad) => `${config.publicBaseUrl}${pfad}` + const unterlagen = { fassung: vereinbart, lb: absolut(adressen.lb), avv: absolut(adressen.avv), widerruf: absolut(adressen.widerruf) } + return { attachments, unterlagen, anhang: true, zustimmungen, fehlt: false } + } catch (err) { + console.error(`[funktionen] K15 (Funktion ${doc.$id}): Unterlagen nicht lesbar, K15 geht ohne PDFs raus:`, err.message) + // die Seiten der aktuellen (= vereinbarten) Fassung bleiben verlinkt; Links auf eine andere Fassung gibt es nur, wenn deren Dateien da sind + return { ...ohne(zustimmungen), unterlagen: vereinbart === aktuell ? unterlagenAdressen(feature) : null } + } +} + async function onFeaturePaid(session) { if (session.metadata?.zweck === 'behalten') return onBehaltenPaid(session) const { invoiceId, invoiceUrl } = await invoiceOf(session.invoice) @@ -1005,16 +1365,34 @@ async function onFeaturePaid(session) { } catch (err) { console.error('[funktionen] Kunde fuer K15/T16 nicht lesbar:', err.message) } + // K15 mit den Vertragsunterlagen (Terminplaner: drei PDFs als dauerhafter Datentraeger, § 312f BGB). Die Bestaetigung geht vor dem + // Anhang: sind die PDFs nicht lesbar oder kommt die Mail nicht an, bleibt die Zahlung nicht ohne Spur, T16 sagt dem Team + // "Unterlagen von Hand nachsenden" (Block anhang_fehlt). + const mitRechtsdokumenten = feature ? unterlagenAdressen(feature) !== null : false + let anhangFehlt = false if (customer?.email) { try { if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) - await sendCustomerMail( - customer.email, - bestellMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId), invoiceUrl }) - ) + const rechtlich = mitRechtsdokumenten ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN + anhangFehlt = rechtlich.fehlt + await sendCustomerMail(customer.email, { + ...bestellMail(feature, { + kundeName: customer.name || '', + projekt: await projectName(doc.projectId), + invoiceUrl, + unterlagen: rechtlich.unterlagen, + anhang: rechtlich.anhang, + zustimmungenText: rechtlich.zustimmungen, + }), + attachments: [...rechtlich.attachments], + }) } catch (err) { console.error('[funktionen] K15 fehlgeschlagen:', err.message) + if (mitRechtsdokumenten) anhangFehlt = true } + } else if (mitRechtsdokumenten) { + console.error(`[funktionen] K15 (Funktion ${doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt - die Vertragsunterlagen muessen von Hand nachgesendet werden`) + anhangFehlt = true } // Funktionen, die ein laufendes Wartungs-Abo voraussetzen: endete es vor der Zahlung, sagt T16 es dem Team (Block kein_abo). // Nach der Verbuchung geprueft; ein Lesefehler blockiert nichts (Log, Block bleibt aus), der Webhook antwortet nie deswegen 500. @@ -1027,7 +1405,7 @@ async function onFeaturePaid(session) { } } try { - await teamNotice(doc, customer, { keinAbo }) + await teamNotice(doc, customer, { keinAbo, anhangFehlt }) } catch (err) { console.error('[funktionen] T16 fehlgeschlagen:', err.message) } @@ -1094,32 +1472,18 @@ async function heileZeiten({ now }) { /** * Freischalt-Job: geschlossene Einrichtungs-Tickets -> 'active' (je Funktion nur, wenn ihr Haken freischaltbar es erlaubt; Oeffnungszeiten: * erst mit eingetragenen Zeiten), danach je Dokument der Haken nachFreischaltung (Oeffnungszeiten: Ersatz-Hash) und K16 an jeden - * betroffenen Kunden. Zum Schluss, nur im Lauf des Jobs (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die - * Projektliste eines Kunden (GET /api/funktionen, mit customerId) schaltet nur frei und baut, bestellt und sendet nichts nach. + * betroffenen Kunden (meldeFreigeschaltet: Fehler werden geloggt und halten weder die Aktivierung noch die uebrigen Mails auf). + * termine/freischaltung.js importiert diese Datei, darum wird es hier erst im Aufruf geladen. Zum Schluss, nur im Lauf des Jobs + * (ohne customerId), die Selbstheilung der Oeffnungszeiten (heileZeiten): die Projektliste eines Kunden (GET /api/funktionen, mit + * customerId) schaltet nur frei und baut, bestellt und sendet nichts nach. */ export async function runActivation({ now = new Date(), customerId = null } = {}) { const activated = await activateClosedTickets({ now, customerId }) - for (const doc of activated) { - // Nacharbeit vor der K16 und vor dem Adress-Check: auch ein Kunde ohne Adresse bekommt sie; ein Fehler bricht nichts ab - const { nachFreischaltung } = featureHaken(doc.featureKey) - if (typeof nachFreischaltung === 'function') { - try { - await nachFreischaltung(doc) - } catch (err) { - console.error(`[funktionen] Nacharbeit nach der Freischaltung ${doc.featureKey} (${doc.$id}) fehlgeschlagen:`, err.message) - } - } - try { - const customer = await customerOf(doc.customerId) - if (!customer?.email) continue - const feature = featureConfig(doc.featureKey) - if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) - await sendCustomerMail( - customer.email, - bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) }) - ) - } catch (err) { - console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) + if (activated.length > 0) { + const { meldeFreigeschaltet } = await import('./termine/freischaltung.js') + for (const doc of activated) { + await nacharbeitNachFreischaltung(doc) + await meldeFreigeschaltet(doc) } } if (!customerId) await heileZeiten({ now }) diff --git a/server/services/termine/ablauf.js b/server/services/termine/ablauf.js index 67c6a98..5678000 100644 --- a/server/services/termine/ablauf.js +++ b/server/services/termine/ablauf.js @@ -228,7 +228,8 @@ export function pruefeEingabe(e, roh, now, { manuell = false, bestaetigung = fal // Zustimmungen const zustimmungen = istObjekt(roh.zustimmungen) ? roh.zustimmungen : {} - const stornoNoetig = !manuell && typeof e.storno?.text === 'string' && e.storno.text !== '' + // Stornobedingungen gelten nur fuer Leistungen mit Preis: bei kostenlosen Leistungen kein Pflichthaken und kein Nachweis + const stornoNoetig = !manuell && hatPreis(leistung) && typeof e.storno?.text === 'string' && e.storno.text !== '' if (stornoNoetig && zustimmungen.storno !== true) fehl('storno', MELDUNG.storno) const vorzeitigNoetig = !manuell && leistung && start ? brauchtZustimmungVorzeitig(e, leistung, start, now) : false if (vorzeitigNoetig && zustimmungen.vorzeitig !== true) fehl('vorzeitig', MELDUNG.vorzeitig) diff --git a/server/services/termine/einbauanleitung.js b/server/services/termine/einbauanleitung.js new file mode 100644 index 0000000..cb879b8 --- /dev/null +++ b/server/services/termine/einbauanleitung.js @@ -0,0 +1,856 @@ +import { config } from '../../config.js' +import { TICKET_DETAILS_MAX, TICKET_GEKUERZT, anleitungsLink, kuerze } from '../featureRegistry.js' +import { gesundheitsWarnungen } from './fragebogen.js' +import { + VORGABE_FARBE, fehlendePflichtangaben, hatPreis, preisHinweis, preisText, sichtbareLeistungen, text as bereinigterText, zahlungText, zeigtWiderruf, +} from './einstellungen.js' +import { RECHTSTEXTE_ENTWURF, datenschutzAbsatz } from './rechtstexte.js' +import { datumKurz, heute } from './zeit.js' + +/** + * Einbau des Terminplaners auf der Website des Kunden (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt + * Einbauanleitung; Anleitung fuer das Team: docs/funktionen/termine-einbau.md). + * + * Zwei Teile in einer Datei: + * - Bausteine (esc, einbauSnippet, Farbfunktionen, cssVorschlag): reine Funktionen ohne Appwrite und ohne config, die Adresse + * des Portals (`apiBase`) und alle Werte kommen als Parameter. Der Einbau-Code stand bis TP2 in routes/termine.js; + * GET /api/termine/einstellungen ruft ihn von hier auf (Modus inline, die Kundenansicht bleibt unveraendert). + * - Generator einbauanleitung() (ganz unten): setzt aus den Einstellungen des Betriebs die Anleitung fuer das Team zusammen. + * Auch er ist rein (kein I/O, keine Zufallswerte); config wird nur beim Aufruf gelesen (Portal-Adresse, Haltezeit, WOMS-Adresse, + * Preis). Er nutzt die reinen Hilfen gesundheitsWarnungen (fragebogen.js) und datenschutzAbsatz (rechtstexte.js). + * + * Die Farbfunktionen (luminanz, textFarbeAuf) sind 1:1 die aus public/embed/termine.js (Marken @farben-anfang/@farben-ende), + * bewusst kopiert und nicht importiert: das Widget ist ein eigenstaendiges Browser-Skript. test/termineEmbedStatic.test.js + * haelt beide fuer 4096 Farben gleich; wer eine Seite aendert, aendert die andere mit. + */ + +const FARBE_RE = /^#[0-9a-fA-F]{6}$/ +const KNOPF_MAX = 40 +const MIN_RAHMEN = 3 // WCAG 2.1 AA fuer Bedienelemente und Rahmen (1.4.11): der Rahmen freier Tage steht in der Akzentfarbe + +// ---------------------------------------------------------------- Text + +/** Maskiert &, <, >, " fuer Text und Attributwerte in doppelten Anfuehrungszeichen. null und undefined werden zu ''. */ +export function esc(text) { + return String(text ?? '').replace(/[&<>"]/g, (z) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[z]) +} + +/** Knopf-Text fuer data-knopf: Zeilenumbrueche und Steuerzeichen zu Leerzeichen, hoechstens 40 Zeichen (nie mitten in einem Zeichen). */ +function knopfTextBereinigt(roh) { + if (typeof roh !== 'string') return '' + const eine = roh.replace(/\r\n/g, ' ').replace(/[\p{Cc}\p{Zl}\p{Zp}]/gu, ' ').trim() + return Array.from(eine).slice(0, KNOPF_MAX).join('') +} + +// ---------------------------------------------------------------- Einbau-Code + +/** + * Einbau-Code fuer die Website: Platzhalter mit Ausweichtext (bleibt ohne JavaScript stehen) und das Skript. + * modus: 'inline' (Standard, die Strecke steht in der Seite) | 'knopf' (ein Knopf oeffnet einen Dialog); jeder andere Wert gilt + * als 'inline'. knopfText gilt nur im Modus knopf und nur, wenn er nicht leer ist; sonst nimmt das Widget "Termin buchen". + */ +export function einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText } = {}) { + const knopf = modus === 'knopf' ? knopfTextBereinigt(knopfText) : '' + const ausweich = telefon ? `Termine telefonisch unter ${esc(telefon)}.` : 'Termine telefonisch vereinbaren.' + const attribute = [ + `data-webklar-termine="${esc(siteKey)}"`, + `data-api="${esc(apiBase)}"`, + `data-modus="${modus === 'knopf' ? 'knopf' : 'inline'}"`, + ...(knopf ? [`data-knopf="${esc(knopf)}"`] : []), + ] + return [ + `
    `, + `

    ${ausweich}

    `, + '
    ', + ``, + ].join('\n') +} + +// ---------------------------------------------------------------- Farben + +/** Relative Leuchtdichte nach WCAG 2.1 fuer '#rrggbb' (wie im Widget; erwartet eine gueltige Farbe). */ +export function luminanz(farbe) { + const rgb = [1, 3, 5].map((i) => { + const c = parseInt(farbe.slice(i, i + 2), 16) / 255 + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4) + }) + return 0.2126 * rgb[0] + 0.7152 * rgb[1] + 0.0722 * rgb[2] +} + +/** Kontrastverhaeltnis zweier Leuchtdichten (1 bis 21), wie kontrast() im Widget. */ +const verhaeltnis = (a, b) => (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05) + +/** Kontrastverhaeltnis zweier Farben '#rrggbb' (1 bis 21, Reihenfolge egal). */ +export function kontrast(hexA, hexB) { + return verhaeltnis(luminanz(hexA), luminanz(hexB)) +} + +/** + * Schrift auf der Betriebsfarbe: Weiss oder das dunkle Grau, je nachdem was mehr Kontrast gibt; erreicht keines 4,5 : 1 + * (mittlere Toene), nimmt sie Schwarz (mindestens 4,58 : 1 fuer jede Farbe). 1:1 aus dem Widget. + */ +export function textFarbeAuf(farbe) { + const l = luminanz(farbe) + const weiss = verhaeltnis(l, 1) + const dunkel = verhaeltnis(l, luminanz('#111827')) + const beste = Math.max(weiss, dunkel) + if (beste < 4.5 && verhaeltnis(l, 0) > beste) return '#000000' + return weiss >= dunkel ? '#ffffff' : '#111827' +} + +/** Kontrast mit einer Nachkommastelle und deutschem Komma, abgerundet ("unter 3 : 1" bleibt auch im Text unter 3). */ +const kontrastAnzeige = (wert) => (Math.floor(wert * 10) / 10).toFixed(1).replace('.', ',') + +/** + * CSS-Vorschlag fuer die Website aus der Betriebsfarbe (Anleitung Abschnitt 3): Kommentar plus + * `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`. Nur eine gueltige '#rrggbb' (gross oder klein) wird uebernommen; + * alles andere ersetzt VORGABE_FARBE, der Rohtext kommt nie in die Ausgabe. + * Rueckgabe: css, akzent (die verwendete Farbe, klein), akzentText (Schrift darauf), kontrastText (Akzent gegen Schrift), + * kontrastSeite (Akzent gegen Weiss), hinweise (Warnungen, leer wenn alles passt). Der Rahmen der freien Tage steht in der + * Akzentfarbe und braucht gegen den Hintergrund mindestens 3 : 1; das Widget hat standardmaessig einen weissen Hintergrund. + */ +export function cssVorschlag(farbe) { + const akzent = typeof farbe === 'string' && FARBE_RE.test(farbe) ? farbe.toLowerCase() : VORGABE_FARBE + const akzentText = textFarbeAuf(akzent) + const textKontrast = kontrast(akzent, akzentText) + const seitenKontrast = kontrast(akzent, '#ffffff') + const hinweise = [] + if (seitenKontrast < MIN_RAHMEN) { + hinweise.push( + `Kontrast gegen Weiß: nur ${kontrastAnzeige(seitenKontrast)} : 1. Der Rahmen der freien Tage steht in der Akzentfarbe ${akzent} und braucht ` + + `mindestens ${MIN_RAHMEN} : 1 gegen den Hintergrund. Auf weißem Grund wähle eine dunklere Farbe, auf dunklem Grund setze --wkt-hintergrund passend.` + ) + } + const css = [ + `/* Terminplaner: Farbe der Knöpfe und Schrift darauf, aus der Betriebsfarbe. Kontrast der Schrift: ${kontrastAnzeige(textKontrast)} : 1. */`, + '[data-webklar-termine]{', + ` --wkt-akzent:${akzent};`, + ` --wkt-akzent-text:${akzentText};`, + '}', + ].join('\n') + return { css, akzent, akzentText, kontrastText: textKontrast, kontrastSeite: seitenKontrast, hinweise } +} + +// ================================================================ Generator einbauanleitung() +// Task D3a: Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Task D3b: Einbau, Snippet, CSS, Datenschutz-Absaetze, +// Checkliste, Abrechnung, die Warnungen und der Volltext. Task D4: die Kurzfassung fuer das Einrichtungs-Ticket (`kurz`), ticketStand und driftWarnung. +// +// Feldweissliste: gelesen werden nur ausdruecklich benannte Felder, nie ganze Objekte. Nie ausgegeben werden feedToken (Kalender- +// Abo der Ressource), die Betriebs-E-Mail (sie steht nur in den Datenschutz-Absaetzen), Beschreibungen der Leistungen und alles, +// was sonst an den Objekten haengt. Kundentexte (Namen, Titel, Storno-Text, Wuensche zum Einbau) stehen unmaskiert als Text; wer sie +// anzeigt, setzt sie als Text ein. Einzeilige Werte werden auf eine Zeile gebracht, mehrzeilige eingerueckt, damit ein Kundentext +// keine Ueberschrift vortaeuschen kann. Wuensche des Kunden stehen nie im Snippet und nie im CSS (nur der Knopf-Text, maskiert, +// als data-knopf). + +/** Kennzeichnung der Gaestetexte als Entwurf; erste Warnung, solange `entwurf` gilt (Standard RECHTSTEXTE_ENTWURF). */ +export const ENTWURF_BANNER = 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen' + +const WOCHENTAGE = ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'] +const TAG_RE = /^\d{4}-\d{2}-\d{2}$/ +const MAX_ABWESENHEITEN = 20 +const MAX_SONDEROEFFNUNGEN = 20 +const NICHT_ANGEGEBEN = 'nicht angegeben' +const GAESTE = { verbraucher: 'Privatpersonen (Verbraucher)', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' } +const BUCHUNGSART = { sofort: 'sofort buchbar', anfrage: 'nur als Anfrage' } +const GAST_FELD = { aus: 'aus', optional: 'optional', pflicht: 'Pflicht' } + +const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert) +const liste = (wert) => (Array.isArray(wert) ? wert : []) +const anzahl = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}` +const imWort = (map, schluessel, vorgabe) => (typeof schluessel === 'string' && Object.hasOwn(map, schluessel) ? map[schluessel] : vorgabe) + +/** Einzeiliger Text: Leerraum, Umbrueche und Steuerzeichen werden zu einem Leerzeichen, Rand getrimmt; Nicht-Text = ''. */ +const zeile = (wert) => (typeof wert === 'string' ? wert.replace(/[\p{Cc}\p{Zl}\p{Zp}\s]+/gu, ' ').trim() : '') + +/** '2026-10-05' => '05.10.2026'. */ +const tagText = (tag) => tag.split('-').reverse().join('.') + +/** [['09:00','12:30'],['14:00','18:00']] => '09:00–12:30, 14:00–18:00'; Unbrauchbares faellt weg. */ +function fensterText(fenster) { + return liste(fenster) + .filter((f) => Array.isArray(f) && f.length === 2 && typeof f[0] === 'string' && typeof f[1] === 'string') + .map(([von, bis]) => `${zeile(von)}–${zeile(bis)}`) + .join(', ') +} + +/** Wochenplan { 1: [['09:00','12:30'], …], … } (ISO-Wochentage) als Zeilen 'Mo 09:00–12:30, 14:00–18:00', Montag bis Sonntag, ohne leere Tage. */ +export function wochenplanText(plan) { + const aus = [] + for (let tag = 1; tag <= 7; tag += 1) { + const text = fensterText(istObjekt(plan) ? plan[tag] : null) + if (text) aus.push(`${WOCHENTAGE[tag - 1]} ${text}`) + } + return aus +} + +/** Die ersten `max` Eintraege, bei mehr als `max` gefolgt von '… und N weitere'. Aendert die Liste nicht. */ +export function kuerzeListe(eintraege, max) { + if (eintraege.length <= max) return [...eintraege] + return [...eintraege.slice(0, max), `… und ${eintraege.length - max} weitere`] +} + +const neuerAbschnitt = (id, titel, text, kopierbar = false) => ({ id, titel, text, kopierbar }) + +/** Zeilen als Aufzaehlung: '- Eintrag'. */ +const aufzaehlung = (eintraege) => eintraege.map((eintrag) => `- ${eintrag}`) + +// ---------------------------------------------------------------- Abschnitt Modell + +function standText(d) { + if (!Number.isInteger(d.version) || d.version < 1) return 'keine gespeicherte Version' + return `Version ${d.version}${d.geaendert ? `, zuletzt geändert ${d.geaendert}` : ''}` +} + +function abschnittModell(d) { + const { e, betrieb: b } = d + const aktive = (eintraege) => eintraege.filter((x) => x.aktiv).length + const angabe = (wert) => zeile(wert) || NICHT_ANGEGEBEN + const gaeste = imWort(GAESTE, e.recht?.gaesteSind, GAESTE.verbraucher) + (e.recht?.freizeit === true ? ', Freizeitleistung' : '') + return neuerAbschnitt('modell', 'Modell', [ + `Erzeugt am ${datumKurz(d.now)}`, + `Stand der Einstellungen: ${standText(d)}`, + ...(d.kunde ? [`Kunde: ${d.kunde}`] : []), + ...(d.projekt ? [`Projekt: ${d.projekt}`] : []), + `Betrieb: ${angabe(b.name)}`, + `Anschrift: ${angabe(b.adresse)}`, + `Telefon: ${angabe(b.telefon)}`, + `Impressum: ${angabe(b.impressumUrl)}`, + `Datenschutzerklärung: ${angabe(b.datenschutzUrl)}`, + ...(zeile(b.agbUrl) ? [`AGB: ${zeile(b.agbUrl)}`] : []), + ...(zeile(b.websiteUrl) ? [`Website: ${zeile(b.websiteUrl)}`] : []), + `Farbe: ${d.farbe}`, + `Anrede der Gäste: ${e.anrede === 'du' ? 'Du' : 'Sie'}`, + `Gäste: ${gaeste}`, + `Umfang: ${anzahl(d.ressourcen.length, 'Person oder Platz', 'Personen und Plätze')} (${aktive(d.ressourcen)} aktiv), ` + + `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktive(d.leistungen)} aktiv)`, + `Portal-Adresse (data-api): ${d.portal}`, + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Leistungen + +/** 'Personen: Anna, Ben' | 'Plätze: …' | 'Personen und Plätze: …'; unbekannte Zuordnungen fallen weg. */ +function zuordnungText(ids, nachId) { + const treffer = liste(ids).map((id) => nachId.get(id)).filter(Boolean) + if (treffer.length === 0) return 'keine Person und kein Platz zugeordnet' + const plaetze = treffer.filter((r) => r.typ === 'platz').length + const label = plaetze === 0 ? 'Personen' : plaetze === treffer.length ? 'Plätze' : 'Personen und Plätze' + return `${label}: ${treffer.map((r) => r.name).join(', ')}` +} + +function leistungsZeile(l, nachId) { + const dauer = Number.isFinite(l.dauer) ? `${l.dauer} Min.` : 'Dauer fehlt' + const pause = Number.isFinite(l.pufferNach) && l.pufferNach > 0 ? ` (+ ${l.pufferNach} Min. Pause danach)` : '' + return `${zeile(l.name) || 'ohne Namen'}${l.aktiv ? '' : ' (inaktiv)'}: ${dauer}${pause}, ${preisText(l) || 'kein Preis'}, ` + + `${imWort(BUCHUNGSART, l.buchungsart, zeile(l.buchungsart) || BUCHUNGSART.sofort)}, ${zuordnungText(l.ressourcen, nachId)}` +} + +function abschnittLeistungen(d) { + if (d.leistungen.length === 0) return neuerAbschnitt('leistungen', 'Leistungen', 'Keine Leistungen angelegt.') + const aktiv = d.leistungen.filter((l) => l.aktiv).length + return neuerAbschnitt('leistungen', 'Leistungen', [ + `${anzahl(d.leistungen.length, 'Leistung', 'Leistungen')} (${aktiv} aktiv):`, + ...aufzaehlung(d.leistungen.map((l) => leistungsZeile(l, d.nachId))), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Ressourcen + +function ressourcenBlock(r) { + const plan = wochenplanText(r.plan) + return [ + `- ${zeile(r.name) || 'ohne Namen'} (${r.typ === 'platz' ? 'Platz' : 'Person'}${r.aktiv ? '' : ', inaktiv'})`, + ...(plan.length ? plan : ['keine Zeiten eingetragen']).map((z) => ` ${z}`), + ].join('\n') +} + +/** Abwesenheiten, die heute oder spaeter enden (laufende bleiben), nach Beginn sortiert. */ +function abwesenheitsZeilen(d) { + const eintraege = liste(d.e.abwesenheiten) + .filter((a) => istObjekt(a) && TAG_RE.test(a.von) && TAG_RE.test(a.bis) && a.bis >= d.heuteTag) + .sort((a, b) => (a.von < b.von ? -1 : a.von > b.von ? 1 : a.bis < b.bis ? -1 : a.bis > b.bis ? 1 : 0)) + .map((a) => { + const zeitraum = a.von === a.bis ? tagText(a.von) : `${tagText(a.von)}–${tagText(a.bis)}` + const wer = a.ressourceId ? d.nachId.get(a.ressourceId)?.name ?? 'unbekannt' : 'alle' + return `${zeitraum}: ${zeile(a.titel) || 'Abwesenheit'} (${wer})` + }) + return aufzaehlung(kuerzeListe(eintraege, MAX_ABWESENHEITEN)) +} + +/** Sonderoeffnungszeiten ab heute ([] = an diesem Tag geschlossen), nach Tag sortiert. */ +function sonderoeffnungsZeilen(d) { + const roh = istObjekt(d.e.sonderoeffnungen) ? d.e.sonderoeffnungen : {} + const eintraege = Object.keys(roh) + .filter((tag) => TAG_RE.test(tag) && tag >= d.heuteTag) + .sort() + .map((tag) => `${tagText(tag)}: ${fensterText(roh[tag]) || 'geschlossen'}`) + return aufzaehlung(kuerzeListe(eintraege, MAX_SONDEROEFFNUNGEN)) +} + +function abschnittRessourcen(d) { + const abwesenheiten = abwesenheitsZeilen(d) + const sonder = sonderoeffnungsZeilen(d) + return neuerAbschnitt('ressourcen', 'Personen und Plätze', [ + d.ressourcen.length ? d.ressourcen.map(ressourcenBlock).join('\n') : 'Keine Personen oder Plätze angelegt.', + ...(abwesenheiten.length ? [['Abwesenheiten (ab heute):', ...abwesenheiten].join('\n')] : []), + ...(sonder.length ? [['Sonderöffnungszeiten (ab heute, für alle):', ...sonder].join('\n')] : []), + ].join('\n\n')) +} + +// ---------------------------------------------------------------- Abschnitt Regeln + +/** Vorlauf in der groessten glatten Einheit: '45 Minuten', '2 Stunden', '1 Tag'; 0 = kein Vorlauf. */ +function vorlaufText(minuten) { + if (!Number.isFinite(minuten)) return NICHT_ANGEGEBEN + if (minuten === 0) return 'kein Vorlauf' + if (minuten % 1440 === 0) return anzahl(minuten / 1440, 'Tag', 'Tage') + if (minuten % 60 === 0) return anzahl(minuten / 60, 'Stunde', 'Stunden') + return anzahl(minuten, 'Minute', 'Minuten') +} + +function stornoZeilen(storno) { + const text = typeof storno?.text === 'string' ? storno.text.trim() : '' + if (!text) return ['Stornobedingungen: keine hinterlegt'] + return [`Stornobedingungen (Fassung ${zeile(storno.version) || 'ohne Angabe'}):`, ...text.split(/\r\n|\r|\n/).map((z) => ` ${z}`)] +} + +function abschnittRegeln(d) { + const { e } = d + const r = istObjekt(e.regeln) ? e.regeln : {} + const mit = (wert, f) => (Number.isFinite(wert) ? f(wert) : NICHT_ANGEGEBEN) + return neuerAbschnitt('regeln', 'Regeln', [ + `Raster: ${mit(r.raster, (n) => anzahl(n, 'Minute', 'Minuten'))}`, + `Vorlauf: ${vorlaufText(r.vorlaufMin)}`, + `Buchungszeitraum: ${mit(r.horizontTage, (n) => `${anzahl(n, 'Tag', 'Tage')} im Voraus`)}`, + `Absage durch Gäste: ${mit(r.absageBisStd, (n) => (n === 0 ? 'bis zum Terminbeginn' : `bis ${anzahl(n, 'Stunde', 'Stunden')} vor dem Termin`))}`, + `Offene Termine je E-Mail-Adresse: ${mit(r.maxOffeneJeEmail, (n) => `höchstens ${n}`)}`, + `Person wählbar: ${r.personWaehlbar === false ? 'nein' : 'ja'}`, + `Feiertage: ${e.feiertageGeschlossen === false ? 'geöffnet wie im Wochenplan' : 'geschlossen'} (Bundesland ${zeile(e.bundesland) || NICHT_ANGEGEBEN})`, + zahlungText(e), + `Preishinweis: ${preisHinweis(e)}`, + zeigtWiderruf(e) + ? 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt' + : 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)', + ...stornoZeilen(e.storno), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Gastfelder + +function abschnittGastfelder(d) { + const g = istObjekt(d.e.gastFelder) ? d.e.gastFelder : {} + const zusatz = liste(g.zusatz).filter(istObjekt).map((z) => { + const art = z.typ === 'auswahl' ? `Auswahl: ${liste(z.optionen).map(zeile).filter(Boolean).join(', ')}` : 'Text' + return `${zeile(z.label) || 'ohne Beschriftung'} (${art}; ${z.pflicht === true ? 'Pflicht' : 'optional'})` + }) + return neuerAbschnitt('gastfelder', 'Angaben der Gäste', [ + 'Name: Pflicht', + 'E-Mail-Adresse: Pflicht', + `Telefon: ${imWort(GAST_FELD, g.telefon, NICHT_ANGEGEBEN)}`, + `Nachricht: ${imWort(GAST_FELD, g.nachricht, NICHT_ANGEGEBEN)}`, + ...(zusatz.length ? [`Zusatzfelder (${zusatz.length}):`, ...aufzaehlung(zusatz)] : ['Zusatzfelder: keine']), + ].join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Benachrichtigungen + +function abschnittBenachrichtigungen(d) { + const { e } = d + const sichtbar = sichtbareLeistungen(e) + const sofort = sichtbar.some((l) => l.buchungsart !== 'anfrage') + const anfrage = sichtbar.some((l) => l.buchungsart === 'anfrage') + const widerruf = zeigtWiderruf(e) + const erinnerung = e.erinnerung?.anbieten === true && Number.isFinite(e.erinnerung.stundenVorher) + const haltezeit = Number.isFinite(d.haltezeit) ? ` (gültig ${anzahl(d.haltezeit, 'Minute', 'Minuten')})` : '' + return neuerAbschnitt('benachrichtigungen', 'Benachrichtigungen', [ + `Gäste werden mit „${e.anrede === 'du' ? 'Du' : 'Sie'}“ angesprochen. Absender ist der Betriebsname (${zeile(d.betrieb.name) || NICHT_ANGEGEBEN}), ` + + 'Antworten gehen an den Betrieb.', + ['Mails an Gäste:', ...aufzaehlung([ + `Bestätigungslink zur E-Mail-Adresse${haltezeit}`, + ...(sofort ? ['Terminbestätigung (sofort buchbare Leistungen)'] : []), + ...(anfrage ? ['Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)'] : []), + ...(erinnerung ? [`Erinnerung ${anzahl(e.erinnerung.stundenVorher, 'Stunde', 'Stunden')} vor dem Termin (nur wenn der Gast sie ankreuzt)`] : []), + 'Absage und Verschiebung', + ...(widerruf ? ['Eingangsbestätigung des Widerrufs'] : []), + ])].join('\n'), + ['Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', ...aufzaehlung([ + ...(sofort ? ['Neue Buchung'] : []), + ...(anfrage ? ['Neue Terminanfrage'] : []), + 'Absage oder Verschiebung durch den Gast', + ...(widerruf ? ['Widerruf'] : []), + ])].join('\n'), + 'Kalender-Abo (ICS): je Person oder Platz im Portal unter Termine. Der Link ist geheim und steht nicht in dieser Anleitung.', + ].join('\n\n')) +} + +// ---------------------------------------------------------------- Wuensche des Kunden (Fragebogen, Schritt Einbau) + +const EINBAU_MODI = ['inline', 'knopf'] +const MODUS_TEXT = { + inline: 'inline – die Buchungsstrecke steht direkt in der Seite', + knopf: 'knopf – ein Knopf öffnet die Buchungsstrecke in einem Dialog', +} +// Grenzen wie in fragebogen.js (TEXTGRENZEN): hier erneut angewandt, die Anleitung traut dem gespeicherten Fragebogen nicht blind +const WUNSCH_GRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 } +const WUNSCH_MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung']) + +/** Modus (ohne Fragebogen oder ohne gueltige Wahl: inline) und die vier Wunschtexte, bereinigt, gekuerzt und wohlgeformt. */ +function kundenwunsch(fragebogen) { + const einbau = istObjekt(fragebogen) && istObjekt(fragebogen.einbau) ? fragebogen.einbau : {} + const modusGewaehlt = EINBAU_MODI.includes(einbau.modus) + const wunsch = { modus: modusGewaehlt ? einbau.modus : 'inline', modusGewaehlt } + for (const [feld, max] of Object.entries(WUNSCH_GRENZEN)) { + wunsch[feld] = bereinigterText(einbau[feld], max, { mehrzeilig: WUNSCH_MEHRZEILIG.has(feld) }).toWellFormed() + } + return wunsch +} + +/** ' Stelle: erste Zeile' und weitere Zeilen um vier Leerzeichen eingerueckt (nie am Zeilenanfang, also nie eine Ueberschrift). */ +function wunschZeilen(bezeichnung, wert) { + if (!wert) return [] + const [erste, ...weitere] = wert.split('\n') + return [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => (z === '' ? '' : ` ${z}`))] +} + +function kundenwunschBlock(wunsch) { + const zeilen = [ + ...wunschZeilen('Knopf-Text', wunsch.knopfText), + ...wunschZeilen('Stelle', wunsch.stelle), + ...wunschZeilen('Gestaltung', wunsch.gestaltung), + ...wunschZeilen('Bemerkung', wunsch.bemerkung), + ] + return zeilen.length ? ['Kundenwunsch (Text des Kunden):', ...zeilen].join('\n') : 'Kundenwunsch (Text des Kunden): keine Angaben im Fragebogen' +} + +// ---------------------------------------------------------------- Abschnitte Einbau, Snippet, CSS + +function abschnittEinbau(d) { + const w = d.wunsch + const quelle = w.modusGewaehlt ? 'Wahl des Kunden im Fragebogen' : 'Standard, der Fragebogen nennt keinen Modus' + const telefon = zeile(d.betrieb.telefon) || NICHT_ANGEGEBEN + const impressum = zeile(d.betrieb.impressumUrl) || NICHT_ANGEGEBEN + const datenschutz = zeile(d.betrieb.datenschutzUrl) || NICHT_ANGEGEBEN + return neuerAbschnitt('einbau', 'Einbau', [ + `siteKey: ${d.siteKey || NICHT_ANGEGEBEN}`, + `Modus: ${MODUS_TEXT[w.modus]} (${quelle})`, + ['So wird eingebaut:', ...aufzaehlung([ + 'Platzhalter und Skript aus dem Abschnitt „Einbau-Code (Snippet)“ in die Seite setzen: den Platzhalter an die Stelle, die der Kunde nennt ' + + '(siehe Kundenwunsch), das Skript einmal pro Seite, am besten vor dem schließenden body-Tag. Das Skript hat keine Abhängigkeiten, ' + + 'setzt keine Cookies und lädt nichts von Dritten.', + 'Der Ausweichtext im Platzhalter gehört der Website: Er bleibt ohne JavaScript, bei falschem siteKey, abgeschalteter Funktion, fehlenden ' + + `Pflichtangaben und Netzfehlern stehen. Telefon des Betriebs: ${telefon}. Gestalte ihn im Stil der Seite.`, + 'Aussehen: den Abschnitt „Aussehen (CSS)“ in die CSS der Website übernehmen. Weitere Variablen stehen in docs/funktionen/termine-einbau.md, Abschnitt 3.', + 'Datenschutz: den Absatz „Online-Terminbuchung“ (Abschnitte „Datenschutz-Absatz für die Website“) in die Datenschutzerklärung der Website aufnehmen, ' + + 'bevor das Widget freigeschaltet wird.', + `Impressum und Datenschutzerklärung: Die URLs stehen in den Betriebsangaben im Portal (Impressum: ${impressum}; Datenschutzerklärung: ${datenschutz}), ` + + 'das Widget verlinkt sie im Fuß.', + ])].join('\n'), + kundenwunschBlock(w), + ].join('\n\n')) +} + +/** Einbau-Code aus den Angaben des Betriebs und dem Modus des Fragebogens; Wuensche ausser dem Knopf-Text kommen nie hinein. */ +const baueSnippet = (d) => + einbauSnippet({ siteKey: d.siteKey, apiBase: d.portal, telefon: d.telefon, modus: d.wunsch.modus, knopfText: d.wunsch.knopfText }) + +const abschnittSnippet = (d) => neuerAbschnitt('snippet', 'Einbau-Code (Snippet)', baueSnippet(d), true) + +const abschnittCss = (d) => neuerAbschnitt('css', 'Aussehen (CSS)', cssVorschlag(d.farbe).css, true) + +// ---------------------------------------------------------------- Abschnitte Datenschutz-Absatz (Sie, Du) + +const DATENSCHUTZ_HINWEIS = + 'Der Datenschutz-Absatz konnte nicht erzeugt werden: In den Einstellungen fehlt der Name oder die E-Mail-Adresse des Betriebs. ' + + 'Beides im Portal eintragen lassen und die Anleitung neu laden.' + +/** { sie, du } oder null, wenn Name oder E-Mail-Adresse des Betriebs fehlen (datenschutzAbsatz wirft dann einen TypeError). */ +function datenschutzTexte(einstellung) { + try { + return { sie: datenschutzAbsatz(einstellung, 'sie'), du: datenschutzAbsatz(einstellung, 'du') } + } catch (err) { + if (err instanceof TypeError) return null + throw err + } +} + +/** Nur ein echter Absatz ist kopierbar; der Hinweis, dass er fehlt, nie (er gehoert nicht in eine Datenschutzerklaerung). */ +const abschnittDatenschutz = (anrede) => (d) => { + const id = `datenschutz_${anrede}` + const titel = `Datenschutz-Absatz für die Website (${anrede === 'du' ? 'Du' : 'Sie'}-Form)` + const absatz = d.datenschutz?.[anrede] + return absatz ? neuerAbschnitt(id, titel, absatz, true) : neuerAbschnitt(id, titel, DATENSCHUTZ_HINWEIS) +} + +// ---------------------------------------------------------------- Abschnitt Abnahme-Checkliste + +// Wer den Datenschutz-Absatz einfuegt: die Regel der Leistungsbeschreibung (Nr. 4 Abs. 9); Checkliste und Schritt 4 der Kurzfassung teilen sie +const DATENSCHUTZ_REGEL = + 'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; ' + + 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt' + +function abschnittCheckliste(d) { + const { e } = d + const widerruf = zeigtWiderruf(e) + const preis = sichtbareLeistungen(e).some(hatPreis) + const knopf = d.wunsch.modus === 'knopf' + const impressum = zeile(d.betrieb.impressumUrl) || 'fehlt' + const datenschutz = zeile(d.betrieb.datenschutzUrl) || 'fehlt' + const wo = d.anleitungsLink ? ` (Kunde → Reiter Funktionen, Zeile Terminplaner: ${d.anleitungsLink})` : '' + const punkte = [ + // vor der Freischaltung + 'Einbau-Code eingefügt (Abschnitt „Einbau-Code (Snippet)“). Das Widget bleibt unsichtbar bis zur Freischaltung: Bis dahin steht nur der Ausweichtext auf der Seite.', + ...(knopf ? ['Modus knopf: --wkt-akzent immer setzen (Abschnitt „Aussehen (CSS)“). Der Knopf kennt die Betriebsfarbe erst nach dem Klick und nimmt sonst #1d4ed8.'] : []), + 'Ausweichtext geprüft: JavaScript aus oder falscher siteKey: Der Text steht, nichts ist kaputt.', + `Datenschutz-Absatz „Online-Terminbuchung“ steht in der Datenschutzerklärung der Website (Fassung ${e.anrede === 'du' ? 'Du' : 'Sie'}; ${DATENSCHUTZ_REGEL}).`, + `URLs von Impressum (${impressum}) und Datenschutzerklärung (${datenschutz}) stimmen und beginnen mit https://, sonst fehlen die Links im Fuß des Widgets.`, + `„Freischalten (Abnahme)“ im Ticketsystem ausgelöst${wo}. Erst danach antwortet die öffentliche Schnittstelle (nur im Zustand active), und das Widget erscheint.`, + // am sichtbaren Widget + 'Probebuchung bis „Fast geschafft“ durchgespielt: Die Bestätigungs-E-Mail kommt an, der Link bestätigt, die zweite E-Mail (mit Kalenderdatei) kommt an, im Portal steht der Termin. ' + + 'Danach den Testtermin stornieren (Verwaltungslink aus der E-Mail oder im Portal).', + ...(widerruf ? ['Widerrufsbelehrung ist im Widget aufklappbar (Verbraucher-Betrieb, auch bei nur Anfrage-Leistungen).'] : []), + ...(widerruf && preis ? ['Der Haken für den vorzeitigen Beginn erscheint bei Leistungen mit Preis für Termine in den nächsten 14 Tagen.'] : []), + ...(widerruf ? ['Der Knopf „Vertrag widerrufen“ steht im Fuß, das Formular funktioniert.'] : []), + ...(widerruf && knopf + ? ['Modus knopf: Widerrufsknopf dauerhaft neben dem Buchungsknopf (nicht erst im Dialog); er öffnet den Dialog gleich beim Widerrufsformular.'] + : []), + 'Kontrast geprüft: Text auf dem Hintergrund und Schrift auf dem Akzent mindestens 4,5 : 1, Rand gegen den Hintergrund mindestens 3 : 1; globale Regeln des Themes (button, input) stören nicht.', + 'Handy (390 px Breite): kein seitliches Scrollen, Knöpfe gut treffbar, der Dialog passt in den Bildschirm; Zoom 200 %: nichts wird abgeschnitten.', + 'Nur mit der Tastatur bedienen: Fokus immer sichtbar, Monatsraster mit Pfeilen, Pos1/Ende und Bild ↑/↓, Esc schließt den Dialog.', + ] + return neuerAbschnitt('checkliste', 'Abnahme-Checkliste', punkte.map((p) => `- [ ] ${p}`).join('\n')) +} + +// ---------------------------------------------------------------- Abschnitt Abrechnung + +const RECHNUNG_RE = /^https:\/\/\S+$/i + +/** Preis der Einrichtung aus dem Register (config.features.termine.priceLabel), sonst „149 €“. */ +function einrichtungsPreis() { + const preis = zeile(config.features?.termine?.priceLabel) + return preis || '149 €' +} + +function abschnittAbrechnung(d) { + if (d.feature?.unlockedByPurchase !== true) { + return neuerAbschnitt('abrechnung', 'Abrechnung', 'Abrechnung: ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung.') + } + const rechnung = zeile(d.feature.invoiceUrl) + return neuerAbschnitt('abrechnung', 'Abrechnung', [ + `Abrechnung: Einrichtung bezahlt (${einrichtungsPreis()}, Rechnung ${RECHNUNG_RE.test(rechnung) ? rechnung : 'folgt im Portal'}), Zeit im Arbeitsblatt, nicht auf Abo-Stunden.`, + 'Leistungsumfang: ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen.', + ].join('\n')) +} + +// ---------------------------------------------------------------- Warnungen + +const WARNUNG_PFLICHT = { + 'betrieb.telefon': 'Telefonnummer des Betriebs fehlt: Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.', + 'betrieb.adresse': + 'Anschrift des Betriebs fehlt (sie steht in der Widerrufsbelehrung): Gäste können nicht buchen, das Widget zeigt nur den Ausweichtext.', +} +const WARNUNG_OHNE_URL = 'fehlt: Im Fuß des Widgets fehlt der Link.' +const WARNUNG_OHNE_DATENSCHUTZ = 'Datenschutz-Absatz nicht erzeugbar: Name oder E-Mail-Adresse des Betriebs fehlt in den Einstellungen.' + +/** + * Meldungen aus gesundheitsWarnungen (Block C) tragen Namen des Kunden: gekuerzt in UTF-16-Einheiten (ein halbes Zeichenpaar ist moeglich) + * und ungemaskiert. Hier werden sie wohlgeformt und einzeilig; sie bleiben Klartext, der Anzeiger setzt sie als Text ein (nie als HTML). + */ +const meldungText = (wert) => zeile(typeof wert === 'string' ? wert.toWellFormed() : '') + +/** + * Die Gesundheitsmeldungen einer Einstellung (hoechstens 10, Pruefung aus Block C) in der Form, in der sie im Panel, im Ticket und in T16 stehen: + * wohlgeformt, einzeilig, Klartext. Ohne Einstellung (null) und ohne Treffer leer. + */ +export function gesundheitsMeldungen(einstellung) { + return gesundheitsWarnungen(einstellung).map(meldungText) +} + +/** + * Warnungen fuer das Team in fester Reihenfolge, in drei Teilen: davor (Banner, Pflichtangaben, Impressum, Datenschutz-URL), die Gesundheits- + * meldungen, danach (Kontrast, Datenschutz-Absatz). warnungen = davor + gesundheit + danach; die Kurzfassung (Ticket) setzt die Gesundheit + * in einen eigenen Absatz und zaehlt sie nicht zu ihren fuenf Warnungen. + */ +function warnungsTeile(d) { + const davor = [] + if (d.entwurf) davor.push(ENTWURF_BANNER) + for (const feld of fehlendePflichtangaben(d.e)) davor.push(WARNUNG_PFLICHT[feld] ?? `Pflichtangabe fehlt: ${feld}`) + if (!zeile(d.betrieb.impressumUrl)) davor.push(`Impressum-URL ${WARNUNG_OHNE_URL}`) + if (!zeile(d.betrieb.datenschutzUrl)) davor.push(`Datenschutz-URL ${WARNUNG_OHNE_URL}`) + const danach = [...cssVorschlag(d.farbe).hinweise] + if (!d.datenschutz) danach.push(WARNUNG_OHNE_DATENSCHUTZ) + return { davor, gesundheit: gesundheitsMeldungen(d.e), danach } +} + +// ---------------------------------------------------------------- Kurzfassung fuer das Einrichtungs-Ticket (Task D4) + +// `kurz` steht in ticket.details (Klartext, das WOMS zeigt es mit pre-wrap) und ist die einzige Fassung, die ins Ticket geht: ohne Datenschutz-Absatz +// (4,8 k Zeichen je Variante), ohne Token. Absaetze durch eine Leerzeile; der erste ist der Kopf und endet mit der Konfigurationsstand-Zeile, die +// ticketStand liest. Platz: TICKET_DETAILS_MAX. Statt mitten im Satz abzuschneiden, ist es eine Budgetliste: Kopf, Gesundheitsabsatz, Snippet, +// Schritte und Verweis bleiben immer (eine Gesundheitsmeldung verschwindet nie unter Platzdruck); passt die Summe nicht, fallen erst die Zaehlungen +// weg, dann die uebrigen Warnungen (die letzte zuerst), dann der Kundenwunsch-Block. Nur wenn selbst die Pflichtteile nicht passen (ein unge- +// pruefter Wert sprengt sie), kuerzt kuerze() am Ende an einer Zeilengrenze mit der Schlusszeile des Tickets. + +const KURZ_NAMEN = 8 // so viele Namen je Zaehlung, danach "… und N weitere" +const KURZ_NAME_MAX = 60 +const KURZ_KOPF_MAX = 120 +const KURZ_WARNUNGEN = 5 +const GESUNDHEIT_ABSATZ = 'Gesundheitsbereich prüfen:' +const KURZ_OHNE_LINK = 'Verweis: im WOMS unter Kunden → Funktionen, Anleitung des Projekts' + +const kopfWert = (wert) => kuerze(zeile(wert), KURZ_KOPF_MAX, { endzeile: '…' }) +const kurzName = (name) => kuerze(zeile(name) || 'ohne Namen', KURZ_NAME_MAX, { endzeile: '…' }) + +/** 'Konfigurationsstand: Version N, TT.MM.JJJJ' | '… Version N' (ohne Datum) | '… Version 0 (noch keine Einstellungen gespeichert)' | '… (Einstellungen nicht lesbar)'. */ +function standZeile(version, geaendert, hatEinstellung) { + const n = Number.isInteger(version) && version >= 1 ? version : 0 + if (n === 0) return 'Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)' + if (!hatEinstellung) return `Konfigurationsstand: Version ${n} (Einstellungen nicht lesbar)` + return `Konfigurationsstand: Version ${n}${geaendert ? `, ${geaendert}` : ''}` +} + +/** Kopf: Funktion, Projekt, Domain (wie createFeatureTicket: liveDomain, sonst previewUrl, sonst subdomain), Kunde, siteKey, Stand. */ +function kurzKopf(k) { + const domain = zeile(k.project?.liveDomain) || zeile(k.project?.previewUrl) || zeile(k.project?.subdomain) + return [ + 'Funktion: Terminplaner', + `Projekt: ${kopfWert(k.project?.projectName) || kopfWert(k.project?.subdomain)}`, + `Domain: ${kopfWert(domain)}`, + `Kunde: ${kopfWert(k.kunde)}`, + `siteKey: ${k.siteKey}`, + standZeile(k.version, k.geaendert, k.hatEinstellung), + ].join('\n') +} + +/** ' Stelle: erste Zeile' und die weiteren Zeilen um vier Leerzeichen eingerueckt; Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext). */ +function kurzWunschZeilen(bezeichnung, wert) { + const [erste, ...weitere] = wert.split('\n').filter((z) => z !== '') + return erste === undefined ? [] : [` ${bezeichnung}: ${erste}`, ...weitere.map((z) => ` ${z}`)] +} + +/** Kundenwunsch: Modus (ohne Wahl: Standard) und die vier Wunschtexte des Kunden (Text des Kunden, nie im Snippet ausser dem Knopf-Text). */ +function kurzWunsch(w) { + const zeilen = [ + ...kurzWunschZeilen('Knopf-Text', w.knopfText), + ...kurzWunschZeilen('Stelle', w.stelle), + ...kurzWunschZeilen('Gestaltung', w.gestaltung), + ...kurzWunschZeilen('Bemerkung', w.bemerkung), + ] + return [ + 'Kundenwunsch (Text des Kunden):', + ` Modus: ${w.modus}${w.modusGewaehlt ? '' : ' (Standard)'}`, + ...(zeilen.length ? zeilen : [' Sonst keine Angaben im Fragebogen']), + ].join('\n') +} + +/** '- 12 Leistungen (11 aktiv): A, B, … und 4 weitere': Anzahl, aktive (nur wenn es welche gibt und gezaehlt wird) und die ersten 8 Namen. */ +function kurzZaehlung(eintraege, einzahl, mehrzahl, { mitAktiv = false } = {}) { + const n = eintraege.length + const aktiv = mitAktiv && n > 0 ? ` (${eintraege.filter((x) => x.aktiv).length} aktiv)` : '' + const namen = kuerzeListe(eintraege.map((x) => kurzName(x.name)), KURZ_NAMEN).join(', ') + return `- ${anzahl(n, einzahl, mehrzahl)}${aktiv}${n > 0 ? `: ${namen}` : ''}` +} + +function kurzUmfang(k) { + return [ + 'Umfang:', + kurzZaehlung(k.leistungen, 'Leistung', 'Leistungen', { mitAktiv: true }), + kurzZaehlung(k.ressourcen, 'Person oder Platz', 'Personen und Plätze', { mitAktiv: true }), + kurzZaehlung(k.zusatzfelder.map((z) => ({ name: z.label })), 'Zusatzfeld', 'Zusatzfelder'), + ].join('\n') +} + +/** Die sechs Schritte des Teams; Schritt 6 nennt den Preis nur, wenn die Einrichtung bezahlt ist (unlockedByPurchase === true). */ +function kurzSchritte(k) { + const form = !k.hatEinstellung ? 'Sie- oder Du-Form nach der Anrede der Gäste' : k.anrede === 'du' ? 'Du-Form' : 'Sie-Form' + const bezahlt = k.feature?.unlockedByPurchase === true ? ` (Einrichtung bezahlt, ${einrichtungsPreis()})` : '' + return [ + 'Schritte:', + '1. WOMS-Panel öffnen (Kunde → Reiter Funktionen → Anleitung des Projekts): dort stehen alle Einstellungen, die Kopierknöpfe und die Abnahme-Checkliste.', + '2. Einbau-Code einfügen: den Code oben an der Stelle einsetzen, die der Kunde nennt (siehe Kundenwunsch), das Skript einmal pro Seite.', + '3. Gestaltung und Kontrast: das CSS aus der Anleitung (Abschnitt „Aussehen (CSS)“) in die Website übernehmen und den Kontrast prüfen.', + `4. Datenschutz-Absatz (${form}) aus der Anleitung in die Datenschutzerklärung der Website aufnehmen, bevor freigeschaltet wird (${DATENSCHUTZ_REGEL}).`, + '5. „Freischalten (Abnahme)“ im WOMS auslösen, danach eine Probebuchung durchspielen und den Testtermin wieder stornieren (Checkliste in der Anleitung).', + `6. Ticket erledigen und die Zeit im Arbeitsblatt buchen. Nicht auf das Stundenkontingent anrechnen${bezahlt}.`, + ].join('\n') +} + +/** + * Die Kurzfassung aus den vorbereiteten Teilen. k: feature, siteKey, project, kunde, version, geaendert, hatEinstellung, anrede, wunsch, leistungen, + * ressourcen, zusatzfelder, warnungen (ohne Gesundheit), gesundheit (Meldungen), snippet, anleitungsLink. + */ +function kurzfassung(k) { + const pflicht = { + kopf: kurzKopf(k), + gesundheit: k.gesundheit.length ? [GESUNDHEIT_ABSATZ, ...k.gesundheit.map((m) => `- ${m}`)].join('\n') : '', + snippet: `Einbau-Code (Snippet):\n${k.snippet}`, + schritte: kurzSchritte(k), + verweis: k.anleitungsLink ? `Verweis: ${k.anleitungsLink}` : KURZ_OHNE_LINK, + } + const umfang = k.hatEinstellung ? kurzUmfang(k) : '' + const wunsch = kurzWunsch(k.wunsch) + const warnungen = k.warnungen.slice(0, KURZ_WARNUNGEN).map((w) => `- ${w}`) + let mitUmfang = Boolean(umfang) + let mitWunsch = true + + const zusammen = () => + [ + pflicht.kopf, + mitWunsch ? wunsch : '', + mitUmfang ? umfang : '', + warnungen.length ? ['Warnungen:', ...warnungen].join('\n') : '', + pflicht.gesundheit, + pflicht.snippet, + pflicht.schritte, + pflicht.verweis, + ].filter(Boolean).join('\n\n').toWellFormed() + + let text = zusammen() + if (text.length > TICKET_DETAILS_MAX) { + mitUmfang = false + text = zusammen() + } + while (text.length > TICKET_DETAILS_MAX && warnungen.length > 0) { + warnungen.pop() + text = zusammen() + } + if (text.length > TICKET_DETAILS_MAX) { + mitWunsch = false + text = zusammen() + } + return text.length > TICKET_DETAILS_MAX ? kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT }) : text +} + +const STAND_RE = /^Konfigurationsstand: Version (\d{1,9})(?!\d)/m + +/** + * Die Version der Einstellungen, auf der ein Ticket beruht: die erste Zeile "Konfigurationsstand: Version N" am Zeilenanfang (die Wuensche des + * Kunden stehen eingerueckt und koennen sie nicht nachahmen). Ohne die Zeile, mit mehr als neun Stellen oder bei Nicht-Text null; Version 0 ist ein Stand. + */ +export function ticketStand(details) { + if (typeof details !== 'string') return null + const treffer = STAND_RE.exec(details) + return treffer ? Number(treffer[1]) : null +} + +/** + * Hinweis fuers Team, wenn die Einstellungen seit der Bestellung gespeichert wurden: ticketVersion aus ticketStand, jetzt die aktuelle Version. + * Nur bei zwei ganzen Zahlen und jetzt > ticketVersion, sonst null (auch ohne Stand im Ticket). + */ +export function driftWarnung(ticketVersion, jetzt) { + if (!Number.isInteger(ticketVersion) || !Number.isInteger(jetzt) || jetzt <= ticketVersion) return null + return `Einstellungen seit der Bestellung geändert (Ticket Version ${ticketVersion}, jetzt ${jetzt}).` +} + +// ---------------------------------------------------------------- Zusammenbau + +const ABSCHNITTE = [ + abschnittModell, abschnittLeistungen, abschnittRessourcen, abschnittRegeln, abschnittGastfelder, abschnittBenachrichtigungen, + abschnittEinbau, abschnittSnippet, abschnittCss, abschnittDatenschutz('sie'), abschnittDatenschutz('du'), abschnittCheckliste, abschnittAbrechnung, +] + +/** Alle Abschnitte mit Ueberschrift '## Titel', durch eine Leerzeile getrennt. */ +const volltext = (abschnitte) => abschnitte.map((a) => `## ${a.titel}\n${a.text}`).join('\n\n') + +/** 'Projektname (Domain)' | 'Projektname' | 'Domain' | '' (Domain = liveDomain, sonst previewUrl, sonst subdomain). */ +function projektText(project) { + const name = zeile(project?.projectName) || zeile(project?.subdomain) + const domain = zeile(project?.liveDomain) || zeile(project?.previewUrl) || zeile(project?.subdomain) + return name && domain && name !== domain ? `${name} (${domain})` : name || domain +} + +/** + * Anleitung fuer den Einbau des Terminplaners bei einem Betrieb (Team-Sicht): rein, kein I/O, gleiche Eingabe und gleiches `now` + * ergeben gleiche Ausgabe (deepEqual). config wird beim Aufruf gelesen. + * + * @param {object} p + * @param {object} [p.feature] portalFeatures-Dokument der Funktion (gelesen: siteKey, customerId, projectId, unlockedByPurchase, + * invoiceUrl; der Registereintrag config.features.termine wird nicht gebraucht) + * @param {object} [p.project] websiteProjects-Dokument (gelesen: projectName, subdomain, liveDomain, previewUrl) + * @param {object|null} p.einstellung gespeicherte Einstellung (termineConfig.einstellung); null = noch keine + * @param {object|null} [p.fragebogen] geparster Fragebogen oder null (gelesen: einbau.modus, knopfText, stelle, gestaltung, bemerkung) + * @param {number} p.version Version der Einstellungen (0 = noch nie gespeichert) + * @param {Date} p.now Zeitpunkt der Erzeugung; ein ungueltiges now wirft einen TypeError + * @param {string|null} [p.updatedAt] ISO-Zeitpunkt der letzten Aenderung der Einstellungen + * @param {{name?: string}|null} [p.kunde] gelesen wird nur der Name + * @param {boolean} [p.entwurf] Rechtstexte sind noch ENTWURF (erste Warnung ENTWURF_BANNER); Standard RECHTSTEXTE_ENTWURF + * @returns {{stand: {version: number, updatedAt: string|null}, warnungen: string[], abschnitte: Array<{id: string, titel: string, text: string, kopierbar: boolean}>, text: string, kurz: string}} + * abschnitte: 13 Stueck in fester Reihenfolge (modell bis abrechnung), kopierbar nur snippet, css und die beiden Datenschutz-Absaetze. + * text: alle Abschnitte mit Ueberschrift, aber nur die Datenschutz-Fassung, die zu einstellung.anrede passt ('du', sonst Sie). + * Ohne Einstellung (null): nur der Abschnitt snippet und die Warnung "Keine Einstellungen gespeichert". + * kurz: die Kurzfassung fuer ticket.details (Klartext, hoechstens TICKET_DETAILS_MAX Zeichen, erster Absatz = Kopf mit der Konfigurationsstand-Zeile, + * ohne Datenschutz-Absatz und ohne Token); auch ohne Einstellung gefuellt (Kopf mit Version 0, Snippet, Schritte, Verweis). + */ +export function einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) { + if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError('einbauanleitung: now fehlt') + const stand = { version, updatedAt } + const banner = entwurf ? [ENTWURF_BANNER] : [] + const geaendertAm = updatedAt ? new Date(updatedAt) : null + const gemeinsam = { + feature, + project, + siteKey: zeile(feature?.siteKey), + portal: zeile(config.publicBaseUrl), + wunsch: kundenwunsch(fragebogen), + anleitungsLink: feature && zeile(feature.customerId) && zeile(feature.projectId) ? anleitungsLink(zeile(feature.customerId), zeile(feature.projectId)) : '', + version, + geaendert: geaendertAm && !Number.isNaN(geaendertAm.getTime()) ? datumKurz(geaendertAm) : '', + } + + if (!istObjekt(einstellung)) { + const abschnitte = [abschnittSnippet({ ...gemeinsam, telefon: '' })] + const warnungen = [...banner, 'Keine Einstellungen gespeichert'] + const kurz = kurzfassung({ + ...gemeinsam, + kunde: zeile(kunde?.name), + hatEinstellung: false, + anrede: null, + leistungen: [], + ressourcen: [], + zusatzfelder: [], + warnungen, + gesundheit: [], + snippet: abschnitte[0].text, + }) + return { stand, warnungen, abschnitte, text: volltext(abschnitte), kurz } + } + + const ressourcen = liste(einstellung.ressourcen).filter(istObjekt) + const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {} + const daten = { + ...gemeinsam, + e: einstellung, + now, + entwurf: Boolean(entwurf), + heuteTag: heute(now), + betrieb, + telefon: zeile(betrieb.telefon), + kunde: zeile(kunde?.name), + projekt: projektText(project), + farbe: typeof einstellung.farbe === 'string' && FARBE_RE.test(einstellung.farbe) ? einstellung.farbe.toLowerCase() : VORGABE_FARBE, + haltezeit: config.termine?.holdMinutes, + ressourcen, + leistungen: liste(einstellung.leistungen).filter(istObjekt), + nachId: new Map(ressourcen.map((r) => [r.id, { typ: r.typ, name: zeile(r.name) || 'ohne Namen' }])), + datenschutz: datenschutzTexte(einstellung), + } + const abschnitte = ABSCHNITTE.map((baue) => baue(daten)) + // der Volltext fuehrt nur die Datenschutz-Fassung, die zur Anrede der Gaeste passt + const andere = einstellung.anrede === 'du' ? 'datenschutz_sie' : 'datenschutz_du' + const teile = warnungsTeile(daten) + const kurz = kurzfassung({ + ...daten, + hatEinstellung: true, + anrede: einstellung.anrede, + zusatzfelder: liste(istObjekt(einstellung.gastFelder) ? einstellung.gastFelder.zusatz : null).filter(istObjekt), + warnungen: [...teile.davor, ...teile.danach], + gesundheit: teile.gesundheit, + snippet: abschnitte.find((a) => a.id === 'snippet').text, + }) + return { + stand, + warnungen: [...teile.davor, ...teile.gesundheit, ...teile.danach], + abschnitte, + text: volltext(abschnitte.filter((a) => a.id !== andere)), + kurz, + } +} diff --git a/server/services/termine/einstellungen.js b/server/services/termine/einstellungen.js index 5478dae..1796bec 100644 --- a/server/services/termine/einstellungen.js +++ b/server/services/termine/einstellungen.js @@ -12,7 +12,7 @@ import { heute, zuUtc } from './zeit.js' * Fehler sind { [pfad]: text } mit Pfaden wie 'betrieb.name' oder 'ressourcen.0.plan.1'; Texte in Du-Form (Portal). */ -const VORGABE_FARBE = '#7c3aed' +export const VORGABE_FARBE = '#7c3aed' const GRENZEN = { ressourcen: 20, leistungen: 50, abwesenheiten: 100, sonderoeffnungen: 200, zusatzfelder: 5, optionen: 10, json: 200000 } const RASTER = [5, 10, 15, 20, 30, 60] const ERINNERUNG_STUNDEN = [2, 12, 24, 48] diff --git a/server/services/termine/freischaltung.js b/server/services/termine/freischaltung.js new file mode 100644 index 0000000..126660f --- /dev/null +++ b/server/services/termine/freischaltung.js @@ -0,0 +1,57 @@ +import { aboMemo } from '../aboStatus.js' +import { featureConfig } from '../featureRegistry.js' +import { sendCustomerMail } from '../mailer.js' +import { bereitMail, customerOf, projectName } from '../siteFeatures.js' +import { ladeEinstellung } from './kontext.js' + +// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (TP2 D6, aus B11 herausgeloest). Beide Funktionen stehen hier, +// damit der Standardhaken freischaltbar (featureRegistry.js), der Freischalt-Job (siteFeatures.runActivation) und die +// Staff-Aktivierung (F13a) dieselbe Antwort und denselben Mailweg nutzen. siteFeatures.js und featureRegistry.js laden diese +// Datei erst im Aufruf (dynamisches import()), weil sie selbst siteFeatures.js importiert. + +/** + * Was hindert die Freischaltung dieses Termine-Dokuments? null = nichts; sonst der Grund: + * 'kein_abo' kein laufendes Wartungs-Abo (inkl. beendet, Wechsel-Widerruf E1; past_due und vorgemerkte Kuendigung laufen noch) + * 'keine_einstellung' Abo laeuft, aber es ist noch keine Einstellung gespeichert + * 'abo_unbekannt' das Abo war nicht lesbar: nie freischalten, wenn etwas nicht lesbar war (der Fehler wird geloggt) + * Reihenfolge: Abo zuerst, dann Einstellung; ohne Abo wird die Einstellung gar nicht gelesen. Ein Lesefehler bei der + * Einstellung wirft (anders als beim Abo steht dafuer kein Grundcode bereit): der Aufrufer, activateClosedTickets, haelt das + * Dokument dann in 'setup'. + * abo: Fragen an das Abo, { laeuft(customerId) }; Standard aboMemo() je Aufruf, also frisch gelesen. Wer viele Dokumente + * im Listenlauf prueft, uebergibt einen gemeinsamen aboMemo(); Pruefungen unter einer Sperre lesen frisch (Standard). + * @param {{ $id: string, customerId: string, featureKey?: string }} doc portalFeatures-Dokument + * @returns {Promise} + */ +export async function freischaltHindernis(doc, { abo = aboMemo() } = {}) { + let laeuft + try { + laeuft = await abo.laeuft(doc.customerId) + } catch (err) { + console.error(`[funktionen] Freischaltung ${doc.featureKey || 'termine'} (${doc.$id}) nicht prüfbar:`, err.message) + return 'abo_unbekannt' + } + if (!laeuft) return 'kein_abo' + if ((await ladeEinstellung(doc.$id)) === null) return 'keine_einstellung' + return null +} + +/** + * K16 (Funktion bereit) an den Kunden eines freigeschalteten Dokuments, Werte aus dem Register-Eintrag der Funktion (bereitMail). + * Fehler (Kunde, Register, Versand) werden geloggt und nie geworfen: die Freischaltung ist dann schon geschehen, und eine Mail, + * die nicht rausgeht, darf die uebrigen Dokumente des Laufs nicht aufhalten. Kunde ohne Adresse: keine Mail, kein Fehler. + * attachments: Anhaenge der Mail, z. B. die PDFs der Staff-Aktivierung (F13a). + * @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument + * @param {{ attachments?: object[] }} [optionen] + */ +export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) { + try { + const customer = await customerOf(doc.customerId) + if (!customer?.email) return + const feature = featureConfig(doc.featureKey) + if (!feature) throw new Error(`Funktion ${doc.featureKey} nicht im Register`) + const mail = bereitMail(feature, { kundeName: customer.name || '', projekt: await projectName(doc.projectId) }) + await sendCustomerMail(customer.email, { ...mail, attachments }) + } catch (err) { + console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message) + } +} diff --git a/server/services/termine/kontext.js b/server/services/termine/kontext.js index 335e094..7b1c37c 100644 --- a/server/services/termine/kontext.js +++ b/server/services/termine/kontext.js @@ -106,6 +106,44 @@ export async function ladeEinstellung(featureId) { return gelesen?.einstellung ? { ...gelesen, fragebogen: fragebogenAus(doc) } : null } +/** + * Der Fragebogen aus einem termineConfig-Dokument, so wie er gespeichert ist (geparstes Objekt) oder null. Anders als fragebogenAus kein + * leerer Fragebogen als Ersatz: fehlt das Attribut, ist es leer oder kein JSON-Objekt, ist das Ergebnis null (kaputtes JSON mit einer Logzeile). + */ +function fragebogenRoh(doc) { + const roh = doc.fragebogen + if (roh === undefined || roh === null || roh === '') return null + try { + const fragebogen = JSON.parse(roh) + if (!istObjekt(fragebogen)) throw new Error('kein Objekt') + return fragebogen + } catch (err) { + console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`) + return null + } +} + +/** + * Der gespeicherte Stand einer Funktion fuer das Einrichtungs-Ticket und die Anleitung (TP2 D4): Einstellung, Version, Zeitpunkt der letzten + * Aenderung und Fragebogen, ohne Modus- und Zustandspruefung. Anders als ladeEinstellung unterscheidet er "noch nie gespeichert" von "unlesbar": + * null nur ohne termineConfig-Dokument; unlesbares JSON macht nur das jeweilige Feld null (die Version des Dokuments bleibt). Fehlt Collection + * oder Datenbank, wird der Fehler weitergereicht. + * @param {string} featureId ID des portalFeatures-Dokuments + * @returns {Promise<{ einstellung: object|null, version: number, updatedAt: string|null, fragebogen: object|null }|null>} + */ +export async function ladeStand(featureId) { + pruefeText(featureId, 'featureId') + const doc = await holeOderNull(config.collections.termineConfig, featureId) + if (!doc) return null + const { einstellung, version } = liesDokument(doc) + return { + einstellung, + version, + updatedAt: typeof doc.updatedAt === 'string' && doc.updatedAt !== '' ? doc.updatedAt : null, + fragebogen: fragebogenRoh(doc), + } +} + async function baueKontext(feature) { const [project, doc] = await Promise.all([ feature.projectId ? holeOderNull(config.collections.websiteProjects, feature.projectId) : null, diff --git a/server/services/termine/rechtstexte.js b/server/services/termine/rechtstexte.js index 8b404ab..3b443b4 100644 --- a/server/services/termine/rechtstexte.js +++ b/server/services/termine/rechtstexte.js @@ -1,9 +1,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 Hilfsfunktionen aus einstellungen.js (zeigtWiderruf) und lesen + * config.termine.holdMinutes (Gueltigkeit des Bestaetigungslinks im Datenschutz-Absatz, bei jedem Aufruf gelesen), sonst keine Konfiguration. 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,10 +13,28 @@ * 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. */ -export const RECHTSTEXTE_VERSION = '2026-10-02' +import { config } from '../../config.js' +import { zeigtWiderruf } from './einstellungen.js' + +/** + * Fassung der Gaestetexte (steht im Nachweis jeder Buchung, archiviert in docs/rechtliches/archiv/termine-gaestetexte-.md). + * Gleich config.termine.avvVersion (test/termineRechtstexte.test.js haelt beide gleich); eine Textaenderung nach der Auslieferung + * bekommt ein neues Datum, ein neues Archiv und einen neuen Hash-Pin. Buchungen aus aelteren Fassungen behalten ihre Fassung. + */ +export const RECHTSTEXTE_VERSION = '2026-10-05' + +/** + * Kennzeichen, ob die Gaestetexte noch als Entwurf (nur Pilot, vor dem Einsatz bei fremden Betrieben zu pruefen) gelten. Ist es true, + * nennt die Einbauanleitung das als erste Warnung. Seit der Endfassung der Gaestetexte (Fassung 2026-10-05) false. + */ +export const RECHTSTEXTE_ENTWURF = false const ZEILENUMBRUCH = '\n' const ABSATZ = '\n\n' @@ -48,12 +66,18 @@ const verbinde = (teile) => teile.filter(Boolean).join(', ') const FRIST = 'des Vertragsabschlusses.' // Gestaltungshinweis a): Dienstleistungsvertrag // Widerrufsfunktion: Satzrahmen des Gesetzes wörtlich („Sie können Ihr Widerrufsrecht auch online unter … ausüben.“); in die -// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt ein geeigneter Hinweis statt einer Internetadresse. +// Lücke „… [Internetadresse oder anderen geeigneten Hinweis …]“ kommt bei den Gästen ein geeigneter Hinweis statt einer +// Internetadresse, bei der Bestellung einer Funktion (WEBklar als Unternehmer) die Adresse der Widerrufsseite. +const EINGANGSBESTAETIGUNG = + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) ' + + 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.' + const WIDERRUFSFUNKTION = 'Sie können Ihr Widerrufsrecht auch online unter „Vertrag widerrufen“ (Schaltfläche im Terminbereich auf unserer Website ' + 'sowie Link „Termin verwalten“ in Ihrer Bestätigungs-E-Mail) ausüben. ' - + 'Wenn Sie diese Online-Funktion nutzen, übermitteln wir Ihnen auf einem dauerhaften Datenträger (z. B. durch eine E-Mail) ' - + 'unverzüglich eine Eingangsbestätigung mit Informationen zum Inhalt der Widerrufserklärung sowie dem Datum und der Uhrzeit ihres Eingangs.' + + EINGANGSBESTAETIGUNG + +const WIDERRUFSFUNKTION_WEBKLAR = `Sie können Ihr Widerrufsrecht auch online unter https://project.webklar.com/widerrufen.html ausüben. ${EINGANGSBESTAETIGUNG}` // Wertersatz bei Dienstleistungen; „oder Lieferung von Wasser/Gas/Strom/Fernwärme [Unzutreffendes streichen]“ ist gestrichen. const WERTERSATZ = @@ -66,7 +90,7 @@ function kontakt({ name, adresse, telefon, email }) { return verbinde([name, adresse, `Telefon: ${telefon}`, `E-Mail: ${email}`]) } -function belehrungsAbsaetze(daten) { +function belehrungsAbsaetze(daten, widerrufsfunktion = WIDERRUFSFUNKTION) { return [ 'Widerrufsbelehrung', 'Widerrufsrecht', @@ -75,7 +99,7 @@ function belehrungsAbsaetze(daten) { `Um Ihr Widerrufsrecht auszuüben, müssen Sie uns (${kontakt(daten)}) mittels einer eindeutigen Erklärung ` + '(z. B. ein mit der Post versandter Brief oder eine E-Mail) über Ihren Entschluss, diesen Vertrag zu widerrufen, informieren. ' + 'Sie können dafür das beigefügte Muster-Widerrufsformular verwenden, das jedoch nicht vorgeschrieben ist.', - WIDERRUFSFUNKTION, + widerrufsfunktion, 'Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.', 'Folgen des Widerrufs', 'Wenn Sie diesen Vertrag widerrufen, haben wir Ihnen alle Zahlungen, die wir von Ihnen erhalten haben, einschließlich der Lieferkosten ' @@ -88,9 +112,9 @@ function belehrungsAbsaetze(daten) { ] } -function formularAbsaetze({ name, adresse, email }) { +function formularAbsaetze({ name, adresse, email }, anZeile = `An ${verbinde([name, adresse, email])}:`) { const zeilen = [ - `An ${verbinde([name, adresse, email])}:`, + anZeile, 'Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)', 'Bestellt am (*)/erhalten am (*)', 'Name des/der Verbraucher(s)', @@ -117,12 +141,41 @@ export function widerrufsbelehrung(betrieb) { return [...belehrungsAbsaetze(daten), ...formularAbsaetze(daten)].join(ABSATZ) } +/** WEBklar als Unternehmer (Bestellung einer Funktion im Kundenportal): Kontaktangaben der Widerrufsbelehrung. */ +export const WEBKLAR_KONTAKT = Object.freeze({ + name: 'WEBklar GbR', + adresse: 'Am Schwimmbad 10, 67722 Winnweiler', + telefon: '0176 23726355', + email: 'support@webklar.com', +}) + +/** + * Widerrufsbelehrung und Muster-Widerrufsformular fuer die Bestellung der Einrichtung einer Funktion (Terminplaner, News): + * WEBklar ist der Unternehmer, Sie-Form, Text mit Zeilenumbruechen wie widerrufsbelehrung. Gleiche Bausteine wie bei den + * Gaesten (Frist „des Vertragsabschlusses.“, Wertersatz fuer Dienstleistungen), aber die Widerrufsfunktion nennt die Adresse + * der Widerrufsseite (Wortlaut wie in den Abo-Bedingungen, Abschnitt Widerrufsbelehrung) und die „An“-Zeile des Formulars nennt + * die E-Mail-Adresse mit „E-Mail:“. Quelle des Dokuments docs/rechtliches/widerrufsbelehrung-funktionen.md; test/termineRechtstexte.test.js + * haelt beide gleich und gleicht die Belehrung mit public/abo-bedingungen.html ab. + */ +export function widerrufsbelehrungFunktion() { + const daten = betriebsdaten(WEBKLAR_KONTAKT, { vollstaendig: true }) + const an = `An ${verbinde([daten.name, daten.adresse, `E-Mail: ${daten.email}`])}:` + return [...belehrungsAbsaetze(daten, WIDERRUFSFUNKTION_WEBKLAR), ...formularAbsaetze(daten, an)].join(ABSATZ) +} + // ---------------------------------------------------------------- Hinweis unter dem Buchungsformular (recherche-recht.md 11.2) 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 +185,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 +200,180 @@ 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, haltMinuten, ...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 ${d.haltMinuten} 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, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. ' + + '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 ${d.haltMinuten} 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, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht. ' + + '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), + haltMinuten: config.termine.holdMinutes, // Gueltigkeit des Bestaetigungslinks, bei jedem Aufruf gelesen + } + return absaetze.map((absatz) => absatz(d)).join(ABSATZ) +} + // ---------------------------------------------------------------- Zustimmungen (Haken) und Hinweis zu Gesundheitsangaben /** @@ -162,8 +389,12 @@ export const ZUSTIMMUNG_VORZEITIG = (name) => /** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat. */ export const ZUSTIMMUNG_STORNO = 'Ich habe die Stornobedingungen gelesen.' -/** Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher. */ -export const ZUSTIMMUNG_ERINNERUNG = (stunden) => `Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.` +/** + * Freiwilliger Haken (nie vorangekreuzt), nur wenn der Betrieb Erinnerungen anbietet; stunden = einstellung.erinnerung.stundenVorher. + * Der zweite Satz weist auf das Widerrufsrecht der Einwilligung hin (Art. 7 Abs. 3 DSGVO); der Hash im Nachweis deckt beide Saetze. + */ +export const ZUSTIMMUNG_ERINNERUNG = (stunden) => + `Ich möchte ${stunden} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.` /** Hinweis ueber dem Nachrichtenfeld (Gesundheitsbereich ist ausgeschlossen, Spezifikation Abschnitt 4). */ export const GESUNDHEIT_HINWEIS = 'Bitte keine Gesundheitsangaben.' diff --git a/server/services/termine/ticketDetails.js b/server/services/termine/ticketDetails.js index bef1032..39082fb 100644 --- a/server/services/termine/ticketDetails.js +++ b/server/services/termine/ticketDetails.js @@ -1,10 +1,48 @@ -// STUB (Block B) – ersetzt Task D4 -import { minimaleTicketDetails } from '../featureRegistry.js' +import { TICKET_DETAILS_MAX, kuerze, minimaleTicketDetails } from '../featureRegistry.js' +import { einbauanleitung } from './einbauanleitung.js' +import { ladeStand } from './kontext.js' /** - * Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details). - * ctx = { feature, doc, project, customer }. Block D4 ersetzt den Inhalt durch einbauanleitung(...).kurz; Signatur und ctx bleiben. + * Details des Einrichtungs-Tickets fuer den Terminplaner (config.features.termine.ticket.details, TP2 Task D4). + * ctx = { feature, doc, project, customer } wie ihn markPaid uebergibt: feature = Registereintrag (der Generator braucht ihn nicht), + * doc = portalFeatures-Dokument (siteKey, $id, customerId, projectId, unlockedByPurchase kommen von dort), project = websiteProjects-Dokument, + * customer = Kunde oder null (best effort). + * + * Inhalt: einbauanleitung(...).kurz, gebaut aus dem gespeicherten Stand der Einstellungen (ladeStand). Die Funktion wirft nie: bezahlt ist bezahlt, + * das Ticket entsteht immer. Scheitert das Lesen oder der Generator, stehen die Kopfzeilen, der Hinweis, dass die Anleitung nicht erzeugt werden + * konnte, und der Verweis auf das WOMS im Ticket (notfallDetails). Das Log nennt keine Daten des Betriebs. */ export async function termineTicketDetails(ctx) { - return `${minimaleTicketDetails(ctx)}\nEinrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.` + try { + const { doc, project, customer } = ctx + const stand = await ladeStand(doc.$id) + const { kurz } = einbauanleitung({ + feature: doc, + project, + einstellung: stand?.einstellung ?? null, + fragebogen: stand?.fragebogen ?? null, + version: stand?.version ?? 0, + updatedAt: stand?.updatedAt ?? null, + kunde: { name: customer?.name || doc.customerId || '' }, + now: new Date(), + }) + if (typeof kurz !== 'string' || kurz.trim() === '') throw new Error('Kurzfassung leer') + return kurz + } catch (err) { + console.error(`[termine-ticket] Kurzfassung der Einbauanleitung nicht erzeugbar, Notfall-Details: ${err?.message}`) + return notfallDetails(ctx) + } +} + +const ANLEITUNG_FEHLT = 'Anleitung konnte nicht erzeugt werden, im WOMS öffnen (Verweis oben).' +const KONTINGENT = 'Nicht auf das Stundenkontingent anrechnen.' + +/** Kopfzeilen und Verweis (minimaleTicketDetails, Funktion wie im Kopf der Kurzfassung) plus Hinweis; wirft nie, auch nicht bei einem unbrauchbaren ctx. */ +function notfallDetails(ctx) { + try { + const kopf = minimaleTicketDetails({ ...ctx, feature: { key: 'Terminplaner' } }) + return kuerze(`${kopf}\n${ANLEITUNG_FEHLT}\n${KONTINGENT}`.toWellFormed(), TICKET_DETAILS_MAX) + } catch { + return `Funktion: Terminplaner\nAnleitung konnte nicht erzeugt werden, im WOMS öffnen (Kunden → Funktionen).\n${KONTINGENT}` + } } diff --git a/server/services/termine/vorlagen.js b/server/services/termine/vorlagen.js index 66ccaa4..615cde6 100644 --- a/server/services/termine/vorlagen.js +++ b/server/services/termine/vorlagen.js @@ -1,4 +1,4 @@ -import { neueId, normalisiereEinstellung } from './einstellungen.js' +import { hatPreis, neueId, normalisiereEinstellung } from './einstellungen.js' /** * Branchen-Vorlagen des Terminplaners (Spezifikation Abschnitt 9, Erstes Oeffnen): Daten, kein Code je Nische. @@ -206,7 +206,8 @@ export function ausVorlage(key, betrieb, now) { regeln: { ...v.regeln, maxOffeneJeEmail: 3, personWaehlbar: true }, gastFelder: structuredClone(v.gastFelder), erinnerung: { anbieten: true, stundenVorher: 24 }, - storno: { text: v.storno }, + // Stornobedingungen gelten nur fuer Leistungen mit Preis: hat keine Leistung der Vorlage einen, gibt es keinen Vorschlag + storno: { text: v.leistungen.some(hatPreis) ? v.storno : '' }, recht: { ...v.recht }, zahlung: { modus: 'vorort', vorOrtArten: [...ZAHLARTEN] }, } diff --git a/test/buildLegalHtml.test.js b/test/buildLegalHtml.test.js new file mode 100644 index 0000000..54b4d24 --- /dev/null +++ b/test/buildLegalHtml.test.js @@ -0,0 +1,361 @@ +import { test, after } from 'node:test' +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { spawnSync } from 'node:child_process' +import express, { Router } from 'express' +import { useTestEnv } from './support/testEnv.js' + +// HTML-Generator der Rechtstexte (scripts/build-legal-html.mjs) und die ausgelieferten Dateien des Terminplaners (Task F6). +// Die Markdown-Quellen stehen unter docs/rechtliches/, die Seiten und PDFs unter public/rechtliches/-.html|pdf. + +useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { startTestApp } = await import('./support/testApp.js') +const { legalPaths, legalPdfFiles, legalPdfAttachments, legalRouter } = await import('../server/services/legalDocs.js') +const { render, parse, lauf, termineDokumente, MarkdownFehler } = await import('../scripts/build-legal-html.mjs') + +const V = config.termine.avvVersion +const ROOT = fileURLToPath(new URL('..', import.meta.url)) +const lies = (rel) => readFileSync(join(ROOT, rel), 'utf8') +const DOKUMENTE = termineDokumente(V) + +const META = { fassung: '2026-10-05', titel: 'Prüfdokument', seitenkopf: 'Prüfdokument', nav: [{ href: '/dashboard.html', text: 'Portal' }] } +const KOPF = '# Titel\n\n**Fassung 2026-10-05**\n\n' + +/** HTML in Klartext: Tags weg, Entitäten zurück, Leerraum normalisiert. */ +const klartext = (html) => + html + .replace(/', '#12345g', ' #9d4b6f', '#9d4b6f\n']) { + const v = cssVorschlag(roh) + assert.equal(v.akzent, '#7c3aed', String(roh)) + assert.ok(v.css.includes('--wkt-akzent:#7c3aed;'), String(roh)) + assert.ok(v.css.includes(`--wkt-akzent-text:${textFarbeAuf('#7c3aed')};`), String(roh)) + const ausgabe = JSON.stringify(v) + for (const stueck of ['red', '', '12345g', '#9d4b6f', '#fff;']) { + if (typeof roh === 'string' && roh.includes(stueck)) assert.ok(!ausgabe.includes(stueck), `${JSON.stringify(roh)}: ${stueck} steht in der Ausgabe`) + } + assert.ok(!/[<>]/.test(v.css), 'kein HTML im CSS') + } + // Grossbuchstaben sind gueltig und werden klein geschrieben + assert.equal(cssVorschlag('#9D4B6F').akzent, '#9d4b6f') +}) + +test('cssVorschlag: der Hinweis nennt den Kontrast gegen Weiss, wenn der Akzent unter 3 : 1 liegt', () => { + const v = cssVorschlag('#f5c542') + assert.ok(v.kontrastSeite < 3, String(v.kontrastSeite)) + assert.equal(v.hinweise.length, 1) + assert.match(v.hinweise[0], /Weiß/) + assert.match(v.hinweise[0], /1,6 : 1/, 'Kontrast mit deutschem Komma') + assert.match(v.hinweise[0], /3 : 1/) + assert.match(v.hinweise[0], /Rahmen/) + // knapp ueber der Grenze: keine Warnung + const gut = cssVorschlag('#0f766e') + assert.ok(gut.kontrastSeite >= 3) + assert.deepEqual(gut.hinweise, []) + // Grenzfall: genau an 3 : 1 warnt nicht, knapp darunter schon + for (const farbe of alleFarben()) { + const k = kontrast(farbe, '#ffffff') + assert.equal(cssVorschlag(farbe).hinweise.length, k < 3 ? 1 : 0, `${farbe} (${k.toFixed(3)} : 1)`) + } +}) + +// ================================================================ Generator einbauanleitung() 1 (Task D3a) +// Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen. Rein: kein I/O, nur ausdruecklich benannte Felder. + +const NOW = new Date('2026-10-05T10:00:00Z') // Montag, 12:00 Berliner Zeit +const SPAETER = new Date('2026-10-06T10:00:00Z') // einen Tag spaeter: nur die "Erzeugt am"-Zeile aendert sich +const GEHEIM = 'XYZ-GEHEIM' +// Endregel (Leistungsbeschreibung Nr. 4 Abs. 9): wer den Datenschutz-Absatz einfügt; Checkliste und Schritt 4 der Kurzfassung nennen sie +const DATENSCHUTZ_ENDREGEL = + 'liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügen wir den Absatz beim Einbau ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; ' + + 'sonst den Absatz als Textbaustein per Chat an den Kunden senden, der ihn selbst einfügt' + +const BETRIEB = { + name: 'Salon Muster', + email: 'inhaber-intern@salon-muster.example', + telefon: '0631 123456', + adresse: 'Hauptstraße 1, 67655 Kaiserslautern', + kleinunternehmer: false, + impressumUrl: 'https://salon-muster.example/impressum', + datenschutzUrl: 'https://salon-muster.example/datenschutz', + bundesland: 'RP', +} + +/** Salon-Vorlage mit Farbe, zwei Personen und einem Platz, Wochenplan, Abwesenheiten, Sonderoeffnungen und einer Anfrage-Leistung. */ +function fixtureEinstellung() { + const e = ausVorlage('salon', BETRIEB, NOW) + e.farbe = '#9d4b6f' + const [anna] = e.ressourcen + anna.name = 'Anna' + anna.plan = { 1: [['09:00', '12:30'], ['14:00', '18:00']], 3: [['09:00', '12:30']], 7: [['10:00', '12:00']] } + e.ressourcen.push( + { id: 'r_ben00001', typ: 'person', name: 'Ben', aktiv: true, plan: { 2: [['08:00', '16:00']] }, feedToken: neuerFeedToken() }, + { id: 'r_stuhl001', typ: 'platz', name: 'Stuhl 2', aktiv: false, plan: {}, feedToken: neuerFeedToken() }, + ) + e.leistungen[0].ressourcen = [anna.id, 'r_ben00001'] + e.leistungen[1].aktiv = false // Damenschnitt + e.leistungen[2].buchungsart = 'anfrage' + e.leistungen[2].preisArt = 'keiner' + e.leistungen[2].preisCent = 0 + e.abwesenheiten = [ + { id: 'a_alt00001', ressourceId: anna.id, von: '2026-09-28', bis: '2026-10-02', titel: 'Urlaub vorbei' }, + { id: 'a_jetzt001', ressourceId: 'r_ben00001', von: '2026-10-05', bis: '2026-10-05', titel: 'Fortbildung' }, + { id: 'a_laeuft01', ressourceId: anna.id, von: '2026-10-01', bis: '2026-10-07', titel: 'Kur' }, + { id: 'a_zukunft1', ressourceId: null, von: '2026-12-24', bis: '2026-12-31', titel: 'Betriebsferien' }, + ] + e.sonderoeffnungen = { '2026-10-03': [['09:00', '12:00']], '2026-12-24': [], '2026-11-07': [['10:00', '14:00']] } + e.gastFelder = { + telefon: 'pflicht', + nachricht: 'optional', + zusatz: [ + { key: 'haare', label: 'Haarlänge', typ: 'auswahl', optionen: ['kurz', 'mittel', 'lang'], pflicht: true }, + { key: 'wunsch', label: 'Wunschfrisur', typ: 'text', optionen: [], pflicht: false }, + ], + } + return e +} + +const PROJEKT = { projectName: 'Salon Muster Website', subdomain: 'salon-muster', liveDomain: 'salon-muster.example', previewUrl: 'https://salon-muster.project.webklar.com' } + +/** Standard-Aufruf des Generators; Abweichungen ueber `zusatz`. */ +function erzeuge(zusatz = {}) { + return einbauanleitung({ + feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup' }, + project: PROJEKT, + einstellung: fixtureEinstellung(), + fragebogen: null, + version: 3, + updatedAt: '2026-10-03T08:15:00.000Z', + now: NOW, + kunde: { name: 'Salon Muster GbR' }, + ...zusatz, + }) +} + +const abschnitt = (ergebnis, id) => ergebnis.abschnitte.find((a) => a.id === id) +const zeilen = (text) => text.split('\n') + +test('D3a: Ergebnisform, die ersten sechs Abschnitte in fester Reihenfolge, keiner davon kopierbar', () => { + const r = erzeuge({ entwurf: false }) + assert.deepEqual(Object.keys(r).sort(), ['abschnitte', 'kurz', 'stand', 'text', 'warnungen']) + assert.deepEqual(r.abschnitte.slice(0, 6).map((a) => a.id), ['modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen']) + for (const a of r.abschnitte) { + assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id) + assert.ok(a.titel && typeof a.titel === 'string', a.id) + assert.ok(a.text && typeof a.text === 'string', a.id) + } + for (const a of r.abschnitte.slice(0, 6)) assert.equal(a.kopierbar, false, a.id) + assert.deepEqual(r.warnungen, [], 'ein vollstaendiger Betrieb ohne Entwurf-Kennzeichnung hat keine Warnungen') + assert.equal(typeof r.kurz, 'string', 'die Kurzfassung steht seit D4 im Ergebnis (Inhalt: Tests ab "D4")') + assert.ok(r.kurz.length > 0) + // der Volltext fuehrt jeden Abschnitt mit Ueberschrift, die Datenschutz-Fassung der anderen Anrede fehlt (D3b) + const ohneDu = r.abschnitte.filter((a) => a.id !== 'datenschutz_du') + assert.deepEqual(zeilen(r.text).filter((z) => z.startsWith('## ')), ohneDu.map((a) => `## ${a.titel}`)) + for (const a of ohneDu) assert.ok(r.text.includes(a.text), a.id) +}) + +test('D3a: stand ist genau { version, updatedAt }, updatedAt ist ohne Angabe null', () => { + assert.deepEqual(erzeuge().stand, { version: 3, updatedAt: '2026-10-03T08:15:00.000Z' }) + assert.deepEqual(erzeuge({ updatedAt: undefined }).stand, { version: 3, updatedAt: null }) + assert.deepEqual(erzeuge({ version: 0, updatedAt: undefined }).stand, { version: 0, updatedAt: null }) +}) + +test('D3a Modell: Betrieb, Kontakt, Adressen, Farbe, Anrede, Portal-Adresse, Erzeugt-am und Stand der Einstellungen', () => { + const modell = abschnitt(erzeuge(), 'modell').text + for (const erwartet of [ + 'Erzeugt am 05.10.2026', + 'Stand der Einstellungen: Version 3, zuletzt geändert 03.10.2026', + 'Kunde: Salon Muster GbR', + 'Projekt: Salon Muster Website (salon-muster.example)', + 'Betrieb: Salon Muster', + 'Anschrift: Hauptstraße 1, 67655 Kaiserslautern', + 'Telefon: 0631 123456', + 'Impressum: https://salon-muster.example/impressum', + 'Datenschutzerklärung: https://salon-muster.example/datenschutz', + 'Farbe: #9d4b6f', + 'Anrede der Gäste: Sie', + 'Gäste: Privatpersonen (Verbraucher)', + `Portal-Adresse (data-api): ${config.publicBaseUrl}`, + 'Umfang: 3 Personen und Plätze (2 aktiv), 4 Leistungen (3 aktiv)', + ]) assert.ok(zeilen(modell).includes(erwartet), `${erwartet}\n---\n${modell}`) +}) + +test('D3a Modell: fehlende Angaben stehen als nicht angegeben, Du-Anrede, Projekt ohne Domain, Version 0, keine Kundenzeile', () => { + const e = fixtureEinstellung() + e.betrieb.telefon = '' + e.betrieb.adresse = '' + e.betrieb.impressumUrl = null + e.betrieb.datenschutzUrl = null + e.anrede = 'du' + e.recht = { gaesteSind: 'gemischt', freizeit: true } + const modell = abschnitt(erzeuge({ einstellung: e, project: { projectName: 'Nur Name' }, kunde: null, version: 0, updatedAt: null }), 'modell').text + const z = zeilen(modell) + for (const erwartet of [ + 'Betrieb: Salon Muster', + 'Anschrift: nicht angegeben', + 'Telefon: nicht angegeben', + 'Impressum: nicht angegeben', + 'Datenschutzerklärung: nicht angegeben', + 'Anrede der Gäste: Du', + 'Gäste: Privatpersonen und Unternehmen, Freizeitleistung', + 'Projekt: Nur Name', + 'Stand der Einstellungen: keine gespeicherte Version', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${modell}`) + assert.ok(!z.some((x) => x.startsWith('Kunde:')), 'ohne Kunde keine Kundenzeile') +}) + +test('D3a Modell: AGB- und Website-Adresse nur, wenn vorhanden; die Portal-Adresse wird beim Aufruf gelesen', () => { + const ohne = abschnitt(erzeuge(), 'modell').text + assert.ok(!/AGB|Website:/.test(ohne), ohne) + const e = fixtureEinstellung() + e.betrieb.agbUrl = 'https://salon-muster.example/agb' + e.betrieb.websiteUrl = 'https://salon-muster.example/' + const mit = zeilen(abschnitt(erzeuge({ einstellung: e }), 'modell').text) + assert.ok(mit.includes('AGB: https://salon-muster.example/agb')) + assert.ok(mit.includes('Website: https://salon-muster.example/')) + const vorher = config.publicBaseUrl + try { + config.publicBaseUrl = 'https://portal.test.example' + assert.ok(zeilen(abschnitt(erzeuge(), 'modell').text).includes('Portal-Adresse (data-api): https://portal.test.example')) + } finally { + config.publicBaseUrl = vorher + } +}) + +test('D3a Leistungen: Name, Dauer, Preis, Buchungsart, Personen; inaktive und Anfrage-Leistungen markiert', () => { + const text = abschnitt(erzeuge(), 'leistungen').text + const z = zeilen(text) + assert.equal(z[0], '4 Leistungen (3 aktiv):') + assert.ok(z.includes('- Herrenschnitt: 30 Min. (+ 5 Min. Pause danach), 28,00 €, sofort buchbar, Personen: Anna, Ben'), text) + assert.ok(z.includes('- Damenschnitt (inaktiv): 45 Min. (+ 5 Min. Pause danach), ab 45,00 €, sofort buchbar, Personen: Anna'), text) + assert.ok(z.includes('- Kinderschnitt: 30 Min. (+ 5 Min. Pause danach), kein Preis, nur als Anfrage, Personen: Anna'), text) + assert.ok(z.includes('- Färben: 120 Min. (+ 10 Min. Pause danach), ab 75,00 €, sofort buchbar, Personen: Anna'), text) + assert.equal(z.filter((x) => x.includes('(inaktiv)')).length, 1, 'nur die inaktive Leistung ist markiert') +}) + +test('D3a Leistungen: Plaetze heissen Plaetze, gemischt Personen und Plaetze, ohne Pause keine Klammer, unbekannte Zuordnung faellt weg', () => { + const e = fixtureEinstellung() + e.leistungen[0].ressourcen = ['r_stuhl001'] + e.leistungen[1].ressourcen = [e.ressourcen[0].id, 'r_stuhl001', 'r_gibtsnicht'] + e.leistungen[3].pufferNach = 0 + e.leistungen[3].ressourcen = [] + const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'leistungen').text) + assert.ok(z.some((x) => x.startsWith('- Herrenschnitt:') && x.endsWith('Plätze: Stuhl 2')), z.join('\n')) + assert.ok(z.some((x) => x.startsWith('- Damenschnitt (inaktiv):') && x.endsWith('Personen und Plätze: Anna, Stuhl 2')), z.join('\n')) + assert.ok(z.includes('- Färben: 120 Min., ab 75,00 €, sofort buchbar, keine Person und kein Platz zugeordnet'), z.join('\n')) +}) + +test('D3a Ressourcen: Wochenplan je Tag, nur kuenftige Abwesenheiten, Sonderoeffnungen ab heute', () => { + const text = abschnitt(erzeuge(), 'ressourcen').text + const z = zeilen(text) + assert.ok(z.includes('- Anna (Person)'), text) + assert.ok(z.includes(' Mo 09:00–12:30, 14:00–18:00'), text) + assert.ok(z.includes(' Mi 09:00–12:30'), text) + assert.ok(z.includes(' So 10:00–12:00'), text) + assert.ok(z.includes('- Ben (Person)') && z.includes(' Di 08:00–16:00'), text) + assert.ok(z.includes('- Stuhl 2 (Platz, inaktiv)'), text) + assert.ok(z.includes(' keine Zeiten eingetragen'), text) + // Abwesenheiten: abgelaufene fehlen, heute endende und laufende bleiben, nach Beginn sortiert + const ab = z.slice(z.indexOf('Abwesenheiten (ab heute):') + 1).filter((x) => x.startsWith('- ') && /Fortbildung|Kur|Betriebsferien|Urlaub/.test(x)) + assert.deepEqual(ab, [ + '- 01.10.2026–07.10.2026: Kur (Anna)', + '- 05.10.2026: Fortbildung (Ben)', + '- 24.12.2026–31.12.2026: Betriebsferien (alle)', + ]) + assert.ok(!text.includes('Urlaub vorbei'), 'die abgelaufene Abwesenheit steht nirgends') + // Sonderoeffnungen: der 03.10. liegt vor heute und faellt weg, der 07.11. kommt vor dem 24.12. + const so = z.slice(z.indexOf('Sonderöffnungszeiten (ab heute, für alle):') + 1).filter((x) => x.startsWith('- ')) + assert.deepEqual(so.slice(-2), ['- 07.11.2026: 10:00–14:00', '- 24.12.2026: geschlossen']) + assert.ok(!text.includes('03.10.2026')) +}) + +test('D3a Ressourcen: ohne Abwesenheiten und Sonderoeffnungen entfallen die Ueberschriften; ohne Ressourcen steht ein Hinweis', () => { + const e = fixtureEinstellung() + e.abwesenheiten = [{ id: 'a_alt00001', ressourceId: null, von: '2026-01-01', bis: '2026-01-02', titel: 'alt' }] + e.sonderoeffnungen = { '2026-01-01': [] } + const text = abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text + assert.ok(!text.includes('Abwesenheiten'), text) + assert.ok(!text.includes('Sonderöffnungszeiten'), text) + e.ressourcen = [] + assert.equal(abschnitt(erzeuge({ einstellung: e }), 'ressourcen').text, 'Keine Personen oder Plätze angelegt.') +}) + +test('D3a Regeln: Raster, Vorlauf, Horizont, Absagefrist, Obergrenze, Storno-Text mit Fassung, Zahlung', () => { + const e = fixtureEinstellung() + const text = abschnitt(erzeuge({ einstellung: e }), 'regeln').text + const z = zeilen(text) + for (const erwartet of [ + 'Raster: 15 Minuten', + 'Vorlauf: 2 Stunden', + 'Buchungszeitraum: 60 Tage im Voraus', + 'Absage durch Gäste: bis 24 Stunden vor dem Termin', + 'Offene Termine je E-Mail-Adresse: höchstens 3', + 'Person wählbar: ja', + 'Feiertage: geschlossen (Bundesland RP)', + 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte', + 'Preishinweis: inkl. USt', + 'Widerruf: Widerrufsbelehrung und Widerrufsfunktion werden Gästen angezeigt', + `Stornobedingungen (Fassung ${e.storno.version}):`, + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + assert.match(e.storno.version, /^\d{4}-\d{2}-\d{2}-\d+$/) + assert.ok(text.includes(` ${e.storno.text}`), 'der Storno-Text steht vollstaendig, eingerueckt') +}) + +test('D3a Regeln: andere Werte, kein Storno-Text, Unternehmen ohne Widerruf, Kleinunternehmer, keine Zahlarten', () => { + const e = fixtureEinstellung() + e.regeln = { raster: 30, vorlaufMin: 0, horizontTage: 14, absageBisStd: 0, maxOffeneJeEmail: 1, personWaehlbar: false } + e.feiertageGeschlossen = false + e.storno = { text: '', version: '2026-10-01-2' } + e.recht = { gaesteSind: 'unternehmer', freizeit: false } + e.betrieb.kleinunternehmer = true + e.zahlung = { modus: 'vorort', vorOrtArten: [] } + const z = zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text) + for (const erwartet of [ + 'Raster: 30 Minuten', + 'Vorlauf: kein Vorlauf', + 'Buchungszeitraum: 14 Tage im Voraus', + 'Absage durch Gäste: bis zum Terminbeginn', + 'Offene Termine je E-Mail-Adresse: höchstens 1', + 'Person wählbar: nein', + 'Feiertage: geöffnet wie im Wochenplan (Bundesland RP)', + 'Bezahlung vor Ort', + 'Preishinweis: kein Ausweis der USt nach § 19 UStG', + 'Widerruf: keine Widerrufsbelehrung (Gäste sind keine Verbraucher oder es ist keine Leistung sichtbar)', + 'Stornobedingungen: keine hinterlegt', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${z.join('\n')}`) + assert.ok(!z.join('\n').includes('2026-10-01-2'), 'ohne Text keine Fassung im Text') +}) + +test('D3a Regeln: Vorlauf in Minuten, Stunden und Tagen, Absagefrist in Tagen', () => { + const vorlauf = (minuten) => { + const e = fixtureEinstellung() + e.regeln.vorlaufMin = minuten + return zeilen(abschnitt(erzeuge({ einstellung: e }), 'regeln').text).find((x) => x.startsWith('Vorlauf:')) + } + assert.equal(vorlauf(45), 'Vorlauf: 45 Minuten') + assert.equal(vorlauf(60), 'Vorlauf: 1 Stunde') + assert.equal(vorlauf(90), 'Vorlauf: 90 Minuten') + assert.equal(vorlauf(1440), 'Vorlauf: 1 Tag') + assert.equal(vorlauf(20160), 'Vorlauf: 14 Tage') +}) + +test('D3a Gastfelder: Name und E-Mail immer Pflicht, Telefon und Nachricht nach Einstellung, Zusatzfelder mit Auswahl', () => { + const text = abschnitt(erzeuge(), 'gastfelder').text + const z = zeilen(text) + for (const erwartet of [ + 'Name: Pflicht', + 'E-Mail-Adresse: Pflicht', + 'Telefon: Pflicht', + 'Nachricht: optional', + 'Zusatzfelder (2):', + '- Haarlänge (Auswahl: kurz, mittel, lang; Pflicht)', + '- Wunschfrisur (Text; optional)', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + const e = fixtureEinstellung() + e.gastFelder = { telefon: 'aus', nachricht: 'aus', zusatz: [] } + const leer = zeilen(abschnitt(erzeuge({ einstellung: e }), 'gastfelder').text) + assert.ok(leer.includes('Telefon: aus') && leer.includes('Nachricht: aus') && leer.includes('Zusatzfelder: keine'), leer.join('\n')) +}) + +test('D3a Benachrichtigungen: Mails an Gaeste nach Konfiguration, Mails an den Betrieb ohne Adresse, Kalender-Abo ohne Link', () => { + const text = abschnitt(erzeuge(), 'benachrichtigungen').text + const z = zeilen(text) + for (const erwartet of [ + 'Gäste werden mit „Sie“ angesprochen. Absender ist der Betriebsname (Salon Muster), Antworten gehen an den Betrieb.', + 'Mails an Gäste:', + '- Bestätigungslink zur E-Mail-Adresse (gültig 30 Minuten)', + '- Terminbestätigung (sofort buchbare Leistungen)', + '- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', + '- Erinnerung 24 Stunden vor dem Termin (nur wenn der Gast sie ankreuzt)', + '- Absage und Verschiebung', + '- Eingangsbestätigung des Widerrufs', + 'Mails an den Betrieb (Adresse aus den Einstellungen, steht nicht in dieser Anleitung):', + '- Neue Buchung', + '- Neue Terminanfrage', + '- Absage oder Verschiebung durch den Gast', + '- Widerruf', + ]) assert.ok(z.includes(erwartet), `${erwartet}\n---\n${text}`) + assert.ok(text.includes('Kalender-Abo (ICS)') && text.includes('im Portal'), text) + assert.ok(!text.includes(BETRIEB.email), 'die Betriebs-E-Mail steht nicht in der Anleitung') + // die Gueltigkeit des Bestaetigungslinks wird beim Aufruf aus der Konfiguration gelesen + const vorher = config.termine.holdMinutes + try { + config.termine.holdMinutes = 45 + assert.ok(zeilen(abschnitt(erzeuge(), 'benachrichtigungen').text).includes('- Bestätigungslink zur E-Mail-Adresse (gültig 45 Minuten)')) + } finally { + config.termine.holdMinutes = vorher + } +}) + +test('D3a Benachrichtigungen: ohne Erinnerung, ohne Anfrage-Leistung, ohne Widerruf und mit Du-Anrede entfallen die Zeilen', () => { + const e = fixtureEinstellung() + e.anrede = 'du' + e.erinnerung = { anbieten: false, stundenVorher: 24 } + e.leistungen[2].buchungsart = 'sofort' + e.recht = { gaesteSind: 'unternehmer', freizeit: false } + const text = abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text + const z = zeilen(text) + assert.ok(text.startsWith('Gäste werden mit „Du“ angesprochen.'), text) + for (const fehlt of ['- Eingang und Ablehnung von Terminanfragen (Leistungen mit Anfrage)', '- Neue Terminanfrage', '- Eingangsbestätigung des Widerrufs', '- Widerruf']) { + assert.ok(!z.includes(fehlt), `${fehlt}\n---\n${text}`) + } + assert.ok(!z.some((x) => x.startsWith('- Erinnerung')), text) + assert.ok(z.includes('- Neue Buchung') && z.includes('- Absage oder Verschiebung durch den Gast'), text) + // nur Anfrage-Leistungen: keine Terminbestaetigung und keine "Neue Buchung" + e.leistungen.forEach((l) => { l.buchungsart = 'anfrage' }) + const nurAnfrage = zeilen(abschnitt(erzeuge({ einstellung: e }), 'benachrichtigungen').text) + assert.ok(!nurAnfrage.includes('- Terminbestätigung (sofort buchbare Leistungen)') && !nurAnfrage.includes('- Neue Buchung')) + assert.ok(nurAnfrage.includes('- Neue Terminanfrage')) + // inaktive Leistungen zaehlen nicht + const f = fixtureEinstellung() + f.leistungen[2].aktiv = false + assert.ok(!zeilen(abschnitt(erzeuge({ einstellung: f }), 'benachrichtigungen').text).includes('- Neue Terminanfrage')) +}) + +test('D3a feedToken: jede Ressource hat einen echten Token, das Ergebnis enthaelt weder einen Token noch das Wort feedToken', () => { + const e = fixtureEinstellung() + assert.ok(e.ressourcen.length >= 3) + for (const r of e.ressourcen) assert.match(r.feedToken, /^[A-Za-z0-9_-]{43}$/) + const json = JSON.stringify(erzeuge({ einstellung: e })) + assert.ok(!json.includes('feedToken')) + assert.ok(!json.toLowerCase().includes('feedtoken')) + for (const r of e.ressourcen) assert.ok(!json.includes(r.feedToken), `Token von ${r.name} steht im Ergebnis`) +}) + +test('D3a Weissliste: Zusatzfelder an Betrieb, Ressource, Leistung, Regeln und Kopf tauchen nirgends auf', () => { + const e = fixtureEinstellung() + e.geheim = GEHEIM + e.betrieb.geheim = GEHEIM + e.regeln.geheim = GEHEIM + e.gastFelder.geheim = GEHEIM + e.gastFelder.zusatz[0].geheim = GEHEIM + e.storno.geheim = GEHEIM + e.ressourcen[0].geheim = GEHEIM + e.ressourcen[0].notiz = 'NOTIZ-INTERN' + e.leistungen[0].geheim = GEHEIM + e.leistungen[0].notiz = 'NOTIZ-INTERN' + e.leistungen[0].beschreibung = 'BESCHREIBUNG-NUR-FUER-GAESTE' + e.abwesenheiten[3].geheim = GEHEIM + const json = JSON.stringify(erzeuge({ + einstellung: e, + feature: { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, geheim: GEHEIM, feedToken: GEHEIM, adminNote: GEHEIM }, + project: { ...PROJEKT, geheim: GEHEIM, apiKey: GEHEIM }, + kunde: { name: 'Salon Muster GbR', email: GEHEIM, geheim: GEHEIM }, + fragebogen: { geheim: GEHEIM }, + })) + for (const verboten of [GEHEIM, 'NOTIZ-INTERN', 'BESCHREIBUNG-NUR-FUER-GAESTE']) assert.ok(!json.includes(verboten), verboten) + // die Betriebs-E-Mail steht nur in den beiden Datenschutz-Absaetzen (Verantwortlicher), sonst nirgends + const r = erzeuge({ einstellung: e }) + for (const a of r.abschnitte) assert.equal(a.text.includes(BETRIEB.email), a.id.startsWith('datenschutz_'), a.id) +}) + +test('D3a Kundentexte stehen unveraendert als Text und koennen keine Abschnitts-Ueberschrift vortaeuschen', () => { + const e = fixtureEinstellung() + e.betrieb.name = 'Evil\n## Regeln\nRaster: 1' + e.ressourcen[0].name = ' & "Anna"' + e.leistungen[0].name = 'Schnitt\r\n## Gastfelder' + e.abwesenheiten[3].titel = 'Ferien\n## Modell' + const r = erzeuge({ einstellung: e, kunde: { name: 'Kunde\n## Leistungen' }, project: { projectName: 'Projekt\n## Ressourcen' } }) + assert.equal(zeilen(r.text).filter((z) => z.startsWith('## ')).length, 12, r.text) // 13 Abschnitte, nur eine Datenschutz-Fassung + assert.ok(r.text.includes(' & "Anna"'), 'kein Maskieren: der Anzeiger setzt es als Text ein') + assert.ok(r.text.includes('Betrieb: Evil ## Regeln Raster: 1'), r.text) + assert.equal(zeilen(r.text).filter((z) => z.startsWith('Raster:')).length, 1) +}) + +test('D3a ist deterministisch und veraendert die Eingabe nicht', () => { + const e = fixtureEinstellung() + // Abwesenheiten absichtlich unsortiert: der Generator sortiert, darf dabei die Eingabe aber nicht umordnen + e.abwesenheiten.reverse() + const vorher = structuredClone(e) + const a = erzeuge({ einstellung: e }) + assert.deepEqual(e, vorher, 'nach dem ersten Aufruf: die Einstellung bleibt unveraendert (kein Sortieren oder Filtern in place)') + const b = erzeuge({ einstellung: e }) + assert.deepEqual(e, vorher, 'nach dem zweiten Aufruf ebenso') + assert.deepEqual(a, b) +}) + +test('D3a ein anderes now aendert nur die Erzeugt-am-Zeile und die Filterung vergangener Dinge', () => { + const normal = (r) => ({ ...r, text: r.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT'), abschnitte: r.abschnitte.map((a) => ({ ...a, text: a.text.replace(/^Erzeugt am .*$/m, 'ERZEUGT') })) }) + // einen Tag spaeter, ohne dass etwas ablaeuft (die Fortbildung liegt hier erst am 20.10.): nur die Erzeugt-am-Zeile unterscheidet + const spaeterEnde = fixtureEinstellung() + spaeterEnde.abwesenheiten[1].von = '2026-10-20' + spaeterEnde.abwesenheiten[1].bis = '2026-10-20' + const eins = erzeuge({ einstellung: spaeterEnde }) + const zwei = erzeuge({ now: SPAETER, einstellung: spaeterEnde }) + assert.notDeepEqual(eins, zwei) + assert.ok(abschnitt(eins, 'modell').text.includes('Erzeugt am 05.10.2026')) + assert.ok(abschnitt(zwei, 'modell').text.includes('Erzeugt am 06.10.2026')) + assert.deepEqual(normal(eins), normal(zwei), 'sonst bleibt alles gleich') + // mit der Fixture-Abwesenheit vom 05.10. faellt sie am Folgetag weg (bis < heute) + const a = erzeuge() + const b = erzeuge({ now: SPAETER }) + assert.ok(abschnitt(a, 'ressourcen').text.includes('Fortbildung')) + assert.ok(!abschnitt(b, 'ressourcen').text.includes('Fortbildung')) + assert.ok(abschnitt(b, 'ressourcen').text.includes('Kur'), 'die laufende Kur bleibt') + // zwei Monate spaeter: vergangene Abwesenheiten und Sonderoeffnungen fehlen, sonst bleibt alles gleich + const spaet = erzeuge({ now: new Date('2026-12-20T10:00:00Z') }) + const ressourcen = abschnitt(spaet, 'ressourcen').text + assert.ok(!ressourcen.includes('Kur') && !ressourcen.includes('Fortbildung') && !ressourcen.includes('07.11.2026'), ressourcen) + assert.ok(ressourcen.includes('Betriebsferien') && ressourcen.includes('24.12.2026: geschlossen'), ressourcen) + for (const id of ['leistungen', 'regeln', 'gastfelder', 'benachrichtigungen']) assert.equal(abschnitt(spaet, id).text, abschnitt(a, id).text, id) + // ein Abwesenheitstag, der in Berlin schon der naechste Tag ist, zaehlt nach Berliner Kalender (23:30 UTC = 01:30 Berlin am 06.10.) + const nachtgrenze = erzeuge({ now: new Date('2026-10-05T23:30:00Z') }) + assert.ok(!abschnitt(nachtgrenze, 'ressourcen').text.includes('Fortbildung'), 'der 05.10. ist in Berlin schon vorbei') + assert.ok(abschnitt(nachtgrenze, 'modell').text.includes('Erzeugt am 06.10.2026')) +}) + +test('D3a ohne Einstellung wirft nichts; ein ungueltiges now wirft', () => { + const r = erzeuge({ einstellung: null, version: 0, updatedAt: null }) + assert.deepEqual(r.abschnitte.map((a) => a.id), ['snippet'], 'D3b: nur der Einbau-Code') + assert.deepEqual(r.stand, { version: 0, updatedAt: null }) + for (const now of [undefined, null, 'heute', new Date('kaputt'), 1.7e12]) { + assert.throws(() => erzeuge({ now }), TypeError, String(now)) + } +}) + +test('D3a Hilfen: wochenplanText und kuerzeListe', () => { + assert.deepEqual(wochenplanText({ 1: [['09:00', '12:30'], ['14:00', '18:00']], 5: [['09:00', '13:00']] }), ['Mo 09:00–12:30, 14:00–18:00', 'Fr 09:00–13:00']) + assert.deepEqual(wochenplanText({ 7: [['10:00', '12:00']], 2: [] }), ['So 10:00–12:00'], 'leere Tage fehlen, Reihenfolge Montag bis Sonntag') + assert.deepEqual(wochenplanText({}), []) + assert.deepEqual(wochenplanText(undefined), []) + assert.deepEqual(wochenplanText({ 1: 'kaputt', 8: [['09:00', '10:00']], 3: [['09:00']] }), [], 'Unbrauchbares wird uebersprungen') + assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 3), ['a', 'b', 'c']) + assert.deepEqual(kuerzeListe(['a', 'b', 'c', 'd', 'e'], 2), ['a', 'b', '… und 3 weitere']) + assert.deepEqual(kuerzeListe(['a', 'b', 'c'], 2), ['a', 'b', '… und 1 weitere']) + assert.deepEqual(kuerzeListe([], 5), []) +}) + +test('D3a Kuerzen: lange Abwesenheits- und Sonderoeffnungslisten enden mit und N weitere, Leistungen stehen vollstaendig', () => { + const e = fixtureEinstellung() + e.abwesenheiten = Array.from({ length: 30 }, (_, i) => ({ + id: `a_x${String(i).padStart(7, '0')}`, ressourceId: null, von: `2027-01-${String(i + 1).padStart(2, '0')}`, bis: `2027-01-${String(i + 1).padStart(2, '0')}`, titel: `Tag ${i + 1}`, + })) + e.sonderoeffnungen = Object.fromEntries(Array.from({ length: 25 }, (_, i) => [`2027-02-${String(i + 1).padStart(2, '0')}`, [['10:00', '12:00']]])) + e.leistungen = Array.from({ length: 50 }, (_, i) => ({ ...e.leistungen[0], id: `s_l${String(i).padStart(6, '0')}`, name: `Leistung ${i + 1}` })) + const r = erzeuge({ einstellung: e }) + const z = zeilen(abschnitt(r, 'ressourcen').text) + assert.equal(z.filter((x) => /^- \d\d\.01\.2027/.test(x)).length, 20) + assert.ok(z.includes('- … und 10 weitere'), z.join('\n')) + assert.equal(z.filter((x) => /^- \d\d\.02\.2027/.test(x)).length, 20) + assert.ok(z.includes('- … und 5 weitere'), z.join('\n')) + const l = zeilen(abschnitt(r, 'leistungen').text) + assert.equal(l[0], '50 Leistungen (50 aktiv):') + assert.equal(l.filter((x) => x.startsWith('- Leistung ')).length, 50) +}) + +// ================================================================ Generator einbauanleitung() 2 (Task D3b) +// Einbau, Snippet, CSS, Datenschutz-Absaetze, Checkliste, Abrechnung, Warnungen, Volltext. + +const ALLE_IDS = [ + 'modell', 'leistungen', 'ressourcen', 'regeln', 'gastfelder', 'benachrichtigungen', + 'einbau', 'snippet', 'css', 'datenschutz_sie', 'datenschutz_du', 'checkliste', 'abrechnung', +] +const KOPIERBAR = ['snippet', 'css', 'datenschutz_sie', 'datenschutz_du'] +const KNOPF_FRAGEBOGEN = { + einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: 'Schlicht, wie die Seite', bemerkung: '' }, +} +const PAKET_FEATURE = { $id: 'f1', customerId: 'c1', projectId: 'p1', siteKey: SITE_KEY, state: 'setup', unlockedByPurchase: true } +const hatText = (ergebnis, ...teile) => teile.every((t) => ergebnis.text.includes(t)) + +test('D3b: die 13 Abschnitt-IDs in fester Reihenfolge, kopierbar nur Snippet, CSS und beide Datenschutz-Absaetze', () => { + const r = erzeuge() + assert.deepEqual(r.abschnitte.map((a) => a.id), ALLE_IDS) + for (const a of r.abschnitte) { + assert.deepEqual(Object.keys(a).sort(), ['id', 'kopierbar', 'text', 'titel'], a.id) + assert.equal(a.kopierbar, KOPIERBAR.includes(a.id), a.id) + assert.ok(a.titel && a.text, a.id) + } + assert.equal(new Set(r.abschnitte.map((a) => a.titel)).size, 13, 'jede Ueberschrift kommt einmal vor') +}) + +test('D3b: ENTWURF_BANNER hat den Wortlaut aus dem Brief', () => { + assert.equal(ENTWURF_BANNER, 'ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen') +}) + +test('D3b Volltext: genau eine Datenschutz-Fassung, die zur Anrede passt; abschnitte enthalten beide', () => { + const sie = fixtureEinstellung() + const du = fixtureEinstellung() + du.anrede = 'du' + const textSie = datenschutzAbsatz(sie, 'sie') + const textDu = datenschutzAbsatz(sie, 'du') + assert.notEqual(textSie, textDu) + + const a = erzeuge({ einstellung: sie }) + assert.equal(abschnitt(a, 'datenschutz_sie').text, textSie) + assert.equal(abschnitt(a, 'datenschutz_du').text, datenschutzAbsatz(sie, 'du')) + assert.ok(a.text.includes(textSie) && !a.text.includes(textDu), 'Sie-Betrieb: nur die Sie-Fassung') + assert.ok(a.text.includes(`## ${abschnitt(a, 'datenschutz_sie').titel}`) && !a.text.includes(`## ${abschnitt(a, 'datenschutz_du').titel}`)) + + const b = erzeuge({ einstellung: du }) + assert.deepEqual(b.abschnitte.map((x) => x.id), ALLE_IDS, 'die Abschnitte bleiben gleich, nur der Volltext waehlt') + assert.ok(b.text.includes(datenschutzAbsatz(du, 'du')) && !b.text.includes(datenschutzAbsatz(du, 'sie')), 'Du-Betrieb: nur die Du-Fassung') + assert.equal(abschnitt(b, 'datenschutz_sie').text, datenschutzAbsatz(du, 'sie')) + // jeder andere Abschnitt steht im Volltext, in der Reihenfolge der Abschnitte + const ids = b.abschnitte.filter((x) => x.id !== 'datenschutz_sie').map((x) => `## ${x.titel}`) + assert.deepEqual(zeilen(b.text).filter((z) => z.startsWith('## ')), ids) + // eine unbekannte Anrede gilt wie in D3a als Sie + const seltsam = fixtureEinstellung() + seltsam.anrede = 'Herr' + assert.ok(erzeuge({ einstellung: seltsam }).text.includes(datenschutzAbsatz(seltsam, 'sie'))) +}) + +test('D3b Datenschutz-Absatz: kein ENTWURF-Banner im kopierbaren Text, die Betriebs-E-Mail steht nur dort', () => { + const r = erzeuge({ entwurf: true }) + for (const id of ['datenschutz_sie', 'datenschutz_du']) { + const t = abschnitt(r, id).text + assert.ok(!t.includes('ENTWURF'), id) + assert.ok(t.startsWith('Online-Terminbuchung'), id) + assert.ok(t.includes(BETRIEB.email), id) + } +}) + +test('D3b Snippet und CSS: genau das, was die Bausteine liefern, ohne Fragebogen im Modus inline', () => { + const r = erzeuge() + const erwartet = einbauSnippet({ siteKey: SITE_KEY, apiBase: config.publicBaseUrl, telefon: BETRIEB.telefon, modus: 'inline' }) + assert.equal(abschnitt(r, 'snippet').text, erwartet) + assert.ok(erwartet.includes('data-modus="inline"') && !erwartet.includes('data-knopf')) + assert.ok(erwartet.includes(`data-webklar-termine="${SITE_KEY}"`) && erwartet.includes(`data-api="${config.publicBaseUrl}"`)) + assert.ok(erwartet.includes('

    Termine telefonisch unter 0631 123456.

    '), 'der Ausweichtext nennt die Telefonnummer des Betriebs') + assert.equal(abschnitt(r, 'css').text, cssVorschlag('#9d4b6f').css) + assert.ok(abschnitt(r, 'css').text.includes('--wkt-akzent:#9d4b6f;')) + // Fragebogen ohne Modus oder mit unbekanntem Modus: inline + for (const fragebogen of [{ einbau: { modus: null } }, { einbau: { modus: 'dialog' } }, { einbau: 'kaputt' }, {}, 'text']) { + assert.equal(abschnitt(erzeuge({ fragebogen }), 'snippet').text, erwartet, JSON.stringify(fragebogen)) + } + // die Portal-Adresse wird beim Aufruf gelesen + const vorher = config.publicBaseUrl + try { + config.publicBaseUrl = 'https://portal.test.example' + assert.ok(abschnitt(erzeuge(), 'snippet').text.includes('data-api="https://portal.test.example"')) + } finally { + config.publicBaseUrl = vorher + } +}) + +test('D3b Snippet: Telefonnummer und siteKey sind maskiert (kein Ausbrechen aus dem Platzhalter)', () => { + const e = fixtureEinstellung() + e.betrieb.telefon = '0631 12' + const r = erzeuge({ einstellung: e, feature: { ...PAKET_FEATURE, siteKey: 'a">']) { + assert.ok(einbau.includes(wunsch), `${wunsch}\n---\n${einbau}`) + } + assert.equal((einbau.match(/Kundenwunsch \(Text des Kunden\)/g) || []).length, 1, 'ein einziger Block') + // Stelle, Gestaltung und Bemerkung stehen in keinem anderen Abschnitt (der Knopf-Text nur zusaetzlich im Snippet) + for (const a of r.abschnitte.filter((x) => x.id !== 'einbau')) { + for (const wunsch of ['Startseite unter dem Hero', 'Schlicht, wie die Seite', 'alert(1)']) assert.ok(!a.text.includes(wunsch), `${wunsch} in ${a.id}`) + } + assert.ok(!snippet.includes('alert(1)') && !abschnitt(r, 'css').text.includes('alert(1)')) + assert.equal((snippet.match(/', + ]) + assert.ok(mit.kurz.includes('data-modus="knopf" data-knopf="Termin sichern"')) + assert.equal(mit.kurz.split('alert(1)').length - 1, 1, 'der Wunsch steht nur im Kundenwunsch-Block, nie im Snippet') + + const ohne = absatzMit(erzeuge({ fragebogen: null }).kurz, 'Kundenwunsch').split('\n') + assert.deepEqual(ohne, ['Kundenwunsch (Text des Kunden):', ' Modus: inline (Standard)', ' Sonst keine Angaben im Fragebogen']) + + // mehrzeilig: weitere Zeilen eingerueckt, Leerzeilen fallen weg (nie ein Absatzumbruch im Kundentext), keine Ueberschrift, kein falscher Stand + const mehrKurz = erzeuge({ fragebogen: { einbau: { modus: 'inline', bemerkung: 'Eins\n\n\nZwei\nKonfigurationsstand: Version 99' } } }).kurz + assert.deepEqual(absatzMit(mehrKurz, 'Kundenwunsch').split('\n').slice(2), [' Bemerkung: Eins', ' Zwei', ' Konfigurationsstand: Version 99']) + assert.ok(!/^Konfigurationsstand: Version 99/m.test(mehrKurz), 'der Wunsch kann den Stand nicht faelschen') + assert.equal(ticketStand(mehrKurz), 3) + assert.equal(absaetze(mehrKurz).length, absaetze(erzeuge().kurz).length, 'die Absatzzahl aendert sich durch den Wunsch nicht') +}) + +test('D4 kurz Zaehlungen: Anzahl, aktive und die ersten 8 Namen, danach "und N weitere"', () => { + const umfang = absatzMit(erzeuge().kurz, 'Umfang:').split('\n') + assert.deepEqual(umfang, [ + 'Umfang:', + '- 4 Leistungen (3 aktiv): Herrenschnitt, Damenschnitt, Kinderschnitt, Färben', + '- 3 Personen und Plätze (2 aktiv): Anna, Ben, Stuhl 2', + '- 2 Zusatzfelder: Haarlänge, Wunschfrisur', + ]) + const e = fixtureEinstellung() + e.leistungen = Array.from({ length: 12 }, (_, i) => ({ ...e.leistungen[0], id: `s_z${i}`, name: `Leistung ${i + 1}`, aktiv: i !== 3 })) + const zeile12 = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1] + assert.equal(zeile12, '- 12 Leistungen (11 aktiv): Leistung 1, Leistung 2, Leistung 3, Leistung 4, Leistung 5, Leistung 6, Leistung 7, Leistung 8, … und 4 weitere') + e.leistungen = e.leistungen.slice(0, 8) + assert.ok(!absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').includes('weitere'), 'genau 8: nichts uebrig') + e.leistungen = e.leistungen.slice(0, 1) + e.ressourcen = [] + e.gastFelder = { zusatz: [] } + assert.deepEqual(absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n').slice(1), ['- 1 Leistung (1 aktiv): Leistung 1', '- 0 Personen und Plätze', '- 0 Zusatzfelder']) + // sehr lange Namen: je Name hoechstens 60 Zeichen, Umbrueche werden zu Leerzeichen + e.leistungen = [{ ...e.leistungen[0], name: `${'N'.repeat(100)}\nzwei` }] + const lang = absatzMit(erzeuge({ einstellung: e }).kurz, 'Umfang:').split('\n')[1] + assert.ok(lang.endsWith(`${'N'.repeat(59)}…`), lang) + assert.ok(!lang.includes('zwei') && !lang.includes('N'.repeat(60))) +}) + +test('D4 kurz Warnungen: hoechstens 5 in der Reihenfolge von warnungen, das Banner nur einmal, ohne Warnung kein Absatz', () => { + const r = erzeuge({ einstellung: mitSechsWarnungen(), entwurf: true }) + assert.equal(r.warnungen.length, 6, r.warnungen.join('\n')) + const block = absatzMit(r.kurz, 'Warnungen:').split('\n') + assert.deepEqual(block, ['Warnungen:', ...r.warnungen.slice(0, 5).map((w) => `- ${w}`)], 'die ersten fuenf, die sechste (Kontrast) bleibt dem Panel') + assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1, 'der Banner-Text steht genau einmal im Ticket') + assert.equal(block[1], `- ${ENTWURF_BANNER}`) + const ohne = erzeuge({ entwurf: false }) + assert.deepEqual(ohne.warnungen, []) + assert.ok(!ohne.kurz.includes('Warnungen:')) + assert.equal(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).warnungen.length, 5) + assert.equal(absatzMit(erzeuge({ einstellung: mitSechsWarnungen(), entwurf: false }).kurz, 'Warnungen:').split('\n').length, 6) +}) + +test('D4 kurz Gesundheit: eigener Absatz, zaehlt nicht zu den 5 Warnungen, wohlgeformt, einzeilig und als Klartext', () => { + const e = mitSechsWarnungen() + e.leistungen[0].name = 'Physiotherapie' + e.leistungen[1].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}` + e.leistungen[1].beschreibung = 'Therapie' + e.leistungen[2].name = 'Zeile1\n## Zeile2 x' + e.leistungen[2].beschreibung = 'Physiotherapie' + const r = erzeuge({ einstellung: e, entwurf: true }) + const gesundheit = absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n') + const meldungen = gesundheitsMeldungen(e) + assert.equal(meldungen.length, 3, meldungen.join('\n')) + assert.deepEqual(gesundheit, [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)]) + assert.equal(absatzMit(r.kurz, 'Warnungen:').split('\n').length, 6, 'die fuenf Warnungen bleiben voll, die Gesundheit kommt dazu') + for (const m of meldungen) { + assert.ok(m.isWellFormed() && !/[\r\n]/.test(m), JSON.stringify(m)) + assert.ok(!r.warnungen.every((w) => w !== m), 'dieselben Meldungen stehen auch in warnungen') + } + assert.ok(meldungen.some((m) => m.includes('x')), 'Klartext, nichts wird maskiert') + assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz)) + // keine Treffer: kein Absatz + assert.ok(!erzeuge().kurz.includes(GESUNDHEIT_KOPF)) + // Reihenfolge der Absaetze + const stellen = ['Funktion:', 'Kundenwunsch', 'Umfang:', 'Warnungen:', GESUNDHEIT_KOPF, 'Einbau-Code (Snippet):', 'Schritte:', 'Verweis:'].map((a) => absaetze(erzeuge({ einstellung: e, entwurf: true, fragebogen: KNOPF_FRAGEBOGEN }).kurz).findIndex((x) => x.startsWith(a))) + assert.ok(stellen.every((i) => i >= 0), JSON.stringify(stellen)) + assert.deepEqual(stellen, [...stellen].sort((a, b) => a - b)) +}) + +test('D4 gesundheitsMeldungen: ohne Treffer und ohne Einstellung leer, sonst die bereinigten Meldungen der Pruefung aus C', () => { + assert.deepEqual(gesundheitsMeldungen(null), []) + assert.deepEqual(gesundheitsMeldungen(undefined), []) + assert.deepEqual(gesundheitsMeldungen(fixtureEinstellung()), []) + const e = fixtureEinstellung() + e.leistungen[0].name = 'Physiotherapie' + assert.deepEqual(gesundheitsMeldungen(e), ['Leistungsname enthält „Physiotherapie“']) + const halb = fixtureEinstellung() + halb.leistungen[0].name = `${'x'.repeat(38)}😀${'y'.repeat(10)}` + halb.leistungen[0].beschreibung = 'Physiotherapie' + for (const m of gesundheitsMeldungen(halb)) assert.ok(m.isWellFormed(), JSON.stringify(m)) +}) + +test('D4 kurz ohne Einstellungen: Kopf mit Version 0, Warnung, Snippet, Schritte und Verweis', () => { + const r = erzeuge({ einstellung: null, version: 0, updatedAt: null, entwurf: true }) + const [kopf] = absaetze(r.kurz) + assert.ok(kopf.endsWith('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), kopf) + assert.ok(r.kurz.includes('noch keine Einstellungen')) + assert.deepEqual(absatzMit(r.kurz, 'Warnungen:').split('\n'), ['Warnungen:', `- ${ENTWURF_BANNER}`, '- Keine Einstellungen gespeichert']) + assert.ok(r.kurz.includes(`Einbau-Code (Snippet):\n${r.abschnitte[0].text}`)) + assert.ok(!r.kurz.includes('Umfang:') && !r.kurz.includes(GESUNDHEIT_KOPF)) + assert.equal(absatzMit(r.kurz, 'Schritte:').split('\n').length, 7) + assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`)) + assert.ok(r.kurz.length <= TICKET_DETAILS_MAX) + // der Wunsch des Kunden (Fragebogen) steht auch ohne Einstellungen im Ticket + assert.ok(erzeuge({ einstellung: null, version: 0, fragebogen: KNOPF_FRAGEBOGEN }).kurz.includes(' Knopf-Text: Termin sichern')) +}) + +test('D4 kurz Maximalbefuellung: hoechstens TICKET_DETAILS_MAX, Kopf, Snippet, Verweis, Stundenkontingent und alle 10 Gesundheitswarnungen bleiben', () => { + const e = maximalEinstellung() + const meldungen = gesundheitsMeldungen(e) + assert.equal(meldungen.length, 10, 'die Pruefung aus C liefert hoechstens 10') + const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true }) + assert.ok(r.kurz.length <= TICKET_DETAILS_MAX, `kurz ${r.kurz.length}`) + assert.ok(r.kurz.length > 3000, 'es steht etwas Sinnvolles da, nicht nur ein Rumpf') + assert.equal(absaetze(r.kurz)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026') + assert.ok(r.kurz.includes(abschnitt(r, 'snippet').text)) + assert.ok(r.kurz.includes('Nicht auf das Stundenkontingent anrechnen')) + assert.ok(r.kurz.endsWith(`Verweis: ${LINK}`)) + assert.deepEqual(absatzMit(r.kurz, GESUNDHEIT_KOPF).split('\n'), [GESUNDHEIT_KOPF, ...meldungen.map((m) => `- ${m}`)]) + assert.ok(!r.kurz.includes('Online-Terminbuchung')) + for (const x of e.ressourcen) assert.ok(!r.kurz.includes(x.feedToken)) + assert.ok(r.kurz.isWellFormed()) + assert.equal(r.kurz.split(ENTWURF_BANNER).length - 1, 1) +}) + +test('D4 kurz Platzdruck: erst fallen die Zaehlungen, dann die Warnungen, dann der Kundenwunsch weg; Pflichtteile und Gesundheit nie', () => { + const gesehen = new Set() + let zuletzt = null + // das Snippet waechst mit dem (hier absichtlich nicht normalisierten) Telefon und treibt die Pflichtteile nach oben + for (let laenge = 1; laenge <= 3200; laenge += 8) { + const e = maximalEinstellung() + e.betrieb.telefon = '0'.repeat(laenge) + e.betrieb.adresse = '' + const r = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, feature: PAKET_FEATURE, entwurf: true }) + const k = r.kurz + assert.ok(k.length <= TICKET_DETAILS_MAX, `Telefon ${laenge}: kurz ${k.length}`) + // Pflichtteile + assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026') + assert.ok(k.includes(abschnitt(r, 'snippet').text), `Telefon ${laenge}: Snippet`) + assert.ok(k.includes('Nicht auf das Stundenkontingent anrechnen') && k.endsWith(`Verweis: ${LINK}`)) + assert.deepEqual(absatzMit(k, GESUNDHEIT_KOPF).split('\n').slice(1), gesundheitsMeldungen(e).map((m) => `- ${m}`), `Telefon ${laenge}: Gesundheit`) + // Stufen + const umfang = k.includes('\n\nUmfang:') + const warnungen = k.includes('\n\nWarnungen:') + const wunsch = k.includes('\n\nKundenwunsch (Text des Kunden):') + if (umfang) assert.ok(warnungen && wunsch, `Telefon ${laenge}: Zaehlungen nur mit Warnungen und Wunsch`) + if (warnungen) assert.ok(wunsch, `Telefon ${laenge}: Warnungen nur mit Wunsch`) + if (warnungen) { + const anzahl = absatzMit(k, 'Warnungen:').split('\n').length - 1 + assert.deepEqual(absatzMit(k, 'Warnungen:').split('\n').slice(1), r.warnungen.filter((w) => !gesundheitsMeldungen(e).includes(w)).slice(0, anzahl).map((w) => `- ${w}`), 'es fallen die letzten Warnungen zuerst weg') + } + gesehen.add(`${umfang ? 'Umfang' : '-'}/${warnungen ? 'Warnungen' : '-'}/${wunsch ? 'Wunsch' : '-'}`) + zuletzt = k + } + assert.ok(gesehen.has('Umfang/Warnungen/Wunsch'), [...gesehen].join(' ')) + assert.ok(gesehen.has('-/Warnungen/Wunsch'), [...gesehen].join(' ')) + assert.ok(gesehen.has('-/-/Wunsch'), [...gesehen].join(' ')) + assert.ok(gesehen.has('-/-/-'), [...gesehen].join(' ')) + assert.ok(zuletzt.length > 0) +}) + +test('D4 kurz: selbst ein riesiges Snippet sprengt die Grenze nicht, die Gesundheit steht vor dem Schnitt', () => { + const e = maximalEinstellung() + e.betrieb.telefon = '9'.repeat(20000) + const k = erzeuge({ einstellung: e, fragebogen: MAX_FRAGEBOGEN, entwurf: true }).kurz + assert.ok(k.length <= TICKET_DETAILS_MAX, `kurz ${k.length}`) + assert.ok(k.isWellFormed() && !HALBES_PAAR.test(k)) + assert.ok(k.includes(GESUNDHEIT_KOPF)) + assert.ok(k.endsWith('… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen')) + assert.equal(absaetze(k)[0].split('\n').at(-1), 'Konfigurationsstand: Version 3, 03.10.2026') +}) + +test('D4 kurz ist wohlgeformt: halbe Zeichenpaare in Namen, Wuenschen, Projekt und Kunde werden ersetzt, Emojis bleiben', () => { + const e = fixtureEinstellung() + e.leistungen[0].name = `Schnitt \uD83D${'x'.repeat(10)}😀` + e.ressourcen[0].name = `Anna\uDE00` + const fb = { einbau: { modus: 'inline', bemerkung: `halb \uD83D und ganz 😀`, stelle: '\uDE00' } } + const r = erzeuge({ einstellung: e, fragebogen: fb, project: { projectName: `Salon \uD83D`, liveDomain: 'x.de' }, kunde: { name: `K\uDE00unde` } }) + assert.ok(r.kurz.isWellFormed() && !HALBES_PAAR.test(r.kurz), JSON.stringify(r.kurz.match(HALBES_PAAR))) + assert.ok(r.kurz.includes('😀'), 'ganze Zeichenpaare bleiben') + assert.ok(JSON.parse(JSON.stringify(r.kurz)) === r.kurz) +}) + +test('D4 kurz ist deterministisch, unabhaengig von now und aendert die Eingaben nicht', () => { + const e = maximalEinstellung() + const fb = structuredClone(MAX_FRAGEBOGEN) + const vorher = structuredClone({ e, fb }) + const a = erzeuge({ einstellung: e, fragebogen: fb }) + assert.equal(erzeuge({ einstellung: e, fragebogen: fb }).kurz, a.kurz) + assert.equal(erzeuge({ einstellung: e, fragebogen: fb, now: SPAETER }).kurz, a.kurz, 'in der Kurzfassung steht kein Erzeugungsdatum') + assert.deepEqual(structuredClone({ e, fb }), vorher) +}) + +// ---------------------------------------------------------------- ticketStand, driftWarnung + +test('D4 ticketStand liest Konfigurationsstand: Version N am Zeilenanfang, sonst null', () => { + assert.equal(ticketStand('Funktion: Terminplaner\nKonfigurationsstand: Version 7, 03.10.2026\n\nrest'), 7) + assert.equal(ticketStand('Konfigurationsstand: Version 7, 03.10.2026'), 7) + assert.equal(ticketStand('Konfigurationsstand: Version 0 (noch keine Einstellungen gespeichert)'), 0) + assert.equal(ticketStand('x\r\nKonfigurationsstand: Version 12'), 12) + assert.equal(ticketStand('Konfigurationsstand: Version 999999999'), 999999999, 'neun Stellen sind erlaubt') + assert.equal(ticketStand('Konfigurationsstand: Version 99999999999'), null, 'elf Stellen: kein Stand') + assert.equal(ticketStand('Konfigurationsstand: Version 1234567890'), null, 'zehn Stellen: kein Stand') + for (const ohne of ['', 'Funktion: Terminplaner', 'Konfigurationsstand: Version', 'Konfigurationsstand: Version x', ' Konfigurationsstand: Version 7', 'Der Konfigurationsstand: Version 7', 'konfigurationsstand: Version 7', 'Konfigurationsstand: Version -7']) { + assert.equal(ticketStand(ohne), null, JSON.stringify(ohne)) + } + for (const nichtText of [undefined, null, 7, {}, ['Konfigurationsstand: Version 7']]) assert.equal(ticketStand(nichtText), null, String(nichtText)) +}) + +test('D4 driftWarnung: nur wenn die Einstellungen jetzt eine hoehere Version haben als das Ticket', () => { + assert.equal(driftWarnung(7, 9), 'Einstellungen seit der Bestellung geändert (Ticket Version 7, jetzt 9).') + assert.equal(driftWarnung(0, 1), 'Einstellungen seit der Bestellung geändert (Ticket Version 0, jetzt 1).', 'Version 0 im Ticket ist ein gueltiger Stand') + assert.equal(driftWarnung(9, 9), null) + assert.equal(driftWarnung(9, 7), null) + assert.equal(driftWarnung(null, 9), null) + assert.equal(driftWarnung(7, null), null) + assert.equal(driftWarnung(undefined, 9), null) + assert.equal(driftWarnung('7', 9), null) + assert.equal(driftWarnung(7.5, 9), null) +}) diff --git a/test/termineEinstellungen.test.js b/test/termineEinstellungen.test.js index a07586c..c4c400d 100644 --- a/test/termineEinstellungen.test.js +++ b/test/termineEinstellungen.test.js @@ -12,6 +12,7 @@ import { zahlungText, fehlendePflichtangaben, zeigtWiderruf, + hatPreis, } from '../server/services/termine/einstellungen.js' import { VORLAGEN, vorlagenListe, ausVorlage } from '../server/services/termine/vorlagen.js' import { BUNDESLAENDER } from '../server/services/termine/feiertage.js' @@ -116,8 +117,12 @@ test('Vorlagen: Anfrage bei tattoo/handwerk/fotograf, sonst sofort; Werkstatt mi assert.ok(werkstatt.ressourcen.every((r) => r.typ === 'platz')) }) -test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt', () => { - for (const key of Object.keys(VORLAGEN)) { +const MIT_PREIS = (key) => VORLAGEN[key].leistungen.some((l) => l.preisArt !== 'keiner' && l.preisCent > 0) + +test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, Widerrufssatz bleibt (Vorlagen mit Preis)', () => { + const mitPreis = Object.keys(VORLAGEN).filter(MIT_PREIS) + assert.ok(mitPreis.length >= 7, 'die meisten Vorlagen haben Preise') + for (const key of mitPreis) { const e = ausVorlage(key, BETRIEB, NOW) const text = e.storno.text assert.ok(text.startsWith(`Sie können Ihren Termin bis ${e.regeln.absageBisStd} Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail).`), key) @@ -129,6 +134,26 @@ test('Vorlagen: Stornotext Variante 1 mit Frist aus den Regeln und 50 Prozent, W } }) +test('vorlage_ohne_preis_ohne_storno: eine Vorlage, deren Leistungen alle kostenlos sind, bringt keinen Stornotext mit; Vorlagen mit Preisen bleiben unverändert', () => { + const ohnePreis = Object.keys(VORLAGEN).filter((key) => !MIT_PREIS(key)) + assert.deepEqual(ohnePreis.sort(), ['handwerk', 'sonstiges'], 'diese beiden Vorlagen haben nur kostenlose Leistungen') + for (const key of ohnePreis) { + const e = ausVorlage(key, BETRIEB, NOW) + assert.ok(e.leistungen.every((l) => !hatPreis(l)), key) + assert.equal(e.storno.text, '', `${key}: kein Stornotext`) + assert.equal(e.storno.version, '', `${key}: und keine Fassungsnummer`) + assert.deepEqual(gueltig(e, { bisher: e }), e, `${key}: stabil`) + } + // mit Preisen: der Vorschlag steht wie bisher im Entwurf (50 % / 24 h für Salon) + for (const key of Object.keys(VORLAGEN).filter(MIT_PREIS)) { + const e = ausVorlage(key, BETRIEB, NOW) + assert.equal(e.storno.text, VORLAGEN[key].storno, key) + assert.equal(e.storno.version, '2026-10-02-1', key) + } + // die Vorlagendaten selbst bleiben unverändert (der Text steht dort weiter, ausVorlage entscheidet) + assert.ok(VORLAGEN.handwerk.storno.includes('50 %')) +}) + test('Vorlagen: zwei Aufrufe ergeben verschiedene IDs und Feed-Token; Kleinunternehmer wird uebernommen', () => { const a = ausVorlage('salon', BETRIEB, NOW) const b = ausVorlage('salon', { ...BETRIEB, kleinunternehmer: true }, NOW) diff --git a/test/termineEmbedStatic.test.js b/test/termineEmbedStatic.test.js index d1b0524..994da27 100644 --- a/test/termineEmbedStatic.test.js +++ b/test/termineEmbedStatic.test.js @@ -2,6 +2,7 @@ import { test } from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import vm from 'node:vm' +import { luminanz as serverLuminanz, textFarbeAuf as serverTextFarbeAuf } from '../server/services/termine/einbauanleitung.js' // Statische Pruefung des Einbau-Skripts public/embed/termine.js (Aufgabe 19, Rulings R3 und R4): // - Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers (Kalender, Wochentage, Uhrzeiten und Datumstexte kommen @@ -196,6 +197,57 @@ test('Textfarbe: kein Farbton faellt unter 4,5 : 1 (alle Grautoene und ein Raste } }) +test('Farbparitaet: Widget und Farbvorschlag des Servers (einbauanleitung.js) waehlen fuer alle 4096 Farben dieselbe Schrift', () => { + const { textFarbeAuf, luminanz } = farbFunktionen() + const stufen = Array.from({ length: 16 }, (_, i) => i * 17) // 0, 17, ... 255 + const hex = (c) => c.toString(16).padStart(2, '0') + let anzahl = 0 + for (const r of stufen) { + for (const g of stufen) { + for (const b of stufen) { + const farbe = `#${hex(r)}${hex(g)}${hex(b)}` + assert.equal(serverTextFarbeAuf(farbe), textFarbeAuf(farbe), `${farbe}: Schriftfarbe weicht ab`) + assert.equal(serverLuminanz(farbe), luminanz(farbe), `${farbe}: Leuchtdichte weicht ab`) + anzahl += 1 + } + } + } + assert.equal(anzahl, 4096) +}) + +// ---------------------------------------------------------------- Stornobedingungen nur bei Leistungen mit Preis (TP2 F11) + +/** Die reine Funktion stornoGilt des Skripts (zwischen den Marken @storno-anfang und @storno-ende) einzeln ausfuehren. */ +function stornoFunktion() { + const m = /\/\/ @storno-anfang[^\n]*\n([\s\S]*?)\/\/ @storno-ende/.exec(QUELLE) + assert.ok(m, 'Marken @storno-anfang/@storno-ende fehlen') + return vm.runInNewContext(`${m[1]}; stornoGilt`, {}) +} + +test('widget_storno_je_leistung: Box, Haken und Pruefung der Stornobedingungen haengen am Preis der gewaehlten Leistung', () => { + const stornoGilt = stornoFunktion() + const storno = { text: 'Absage bis 24 Stunden vorher.', version: '2026-10-02-1' } + // nur mit Stornotext des Betriebs UND einer Leistung mit Preis (l.preis ist der Preistext des Servers, '' ohne Preis) + assert.equal(stornoGilt(storno, { id: 's1', preis: '45,00 €' }), true) + assert.equal(stornoGilt(storno, { id: 's1', preis: 'ab 45,00 €' }), true) + assert.equal(stornoGilt(storno, { id: 's2', preis: '' }), false, 'kostenlos: weder Box noch Haken noch Pruefung') + assert.equal(stornoGilt(storno, { id: 's2' }), false) + assert.equal(stornoGilt(storno, { id: 's2', preis: null }), false) + assert.equal(stornoGilt(storno, null), false) + assert.equal(stornoGilt(storno, undefined), false) + assert.equal(stornoGilt(null, { id: 's1', preis: '45,00 €' }), false, 'kein Stornotext beim Betrieb') + assert.equal(stornoGilt(undefined, { id: 's1', preis: '45,00 €' }), false) + // im Zusammenfassungs-Schritt: Box, Haken und Pruefung fragen alle stornoGilt(…, l) – nie mehr nur c.storno + assert.match(OHNE_KOMMENTARE, /var stornoAn = stornoGilt\(c\.storno, l\)/) + assert.match(OHNE_KOMMENTARE, /if \(stornoAn && istText\(c\.storno\.text\)\) \{\s*sb\.appendChild\(el\('h3', '', 'Stornobedingungen'\)\)/, 'Box nur mit Stornotext und Preis') + assert.match(OHNE_KOMMENTARE, /if \(\(stornoAn && istText\(c\.storno\.text\)\) \|\| agbUrl\) \{/, 'der AGB-Link bleibt unabhaengig vom Preis') + assert.match(OHNE_KOMMENTARE, /if \(stornoAn\) form\.appendChild\(haken\(refs, 'storno', c\.texte\.storno, dd\.storno, true,/) + assert.match(OHNE_KOMMENTARE, /if \(stornoAn && !dd\.storno\) f\.storno = /) + assert.doesNotMatch(OHNE_KOMMENTARE, /if \(c\.storno\)|if \(c\.storno &&|c\.storno && !dd\.storno/, 'kein Haken und keine Pruefung allein nach c.storno') + // gesendet wird der Haken nur, wenn er gezeigt wurde + assert.match(OHNE_KOMMENTARE, /zustimmungen: \{ storno: stornoGilt\(S\.cfg\.storno, aktuelleLeistung\(\)\) && !!dd\.storno, vorzeitig: !!dd\.vorzeitig \}/) +}) + // ---------------------------------------------------------------- Demo und Einbauanleitung test('Demo-Seite: noindex, Ausweichtext, Skript per defer, beide Modi', () => { @@ -216,7 +268,7 @@ test('Einbauanleitung: Snippet, Variablen, drei Stile, Datenschutz-Absatz, Abnah 'data-modus', 'data-api', 'data-knopf', 'data-leistung', '--wkt-akzent', '--wkt-akzent-text', '--wkt-hintergrund', '--wkt-text', '--wkt-rand', '--wkt-radius', '--wkt-schrift', '--wkt-schrift-titel', '@layer wkt', 'wkt-style', - 'docs/rechtliches/termine-datenschutz-absatz.md', 'ENTWURF', + 'docs/rechtliches/termine-datenschutz-absatz.md', 'Fassung', 'Abnahme', '/api/public/termine/v1', '/config', '/tage', '/zeiten', '/buchungen', '/widerruf', '/kalender/', 'termine-demo.html', 'termine-widget.ui.mjs', ]) { diff --git a/test/termineFragebogen.test.js b/test/termineFragebogen.test.js index 5f2f82e..7032699 100644 --- a/test/termineFragebogen.test.js +++ b/test/termineFragebogen.test.js @@ -7,7 +7,8 @@ import { } from '../server/services/termine/fragebogen.js' import { MELDUNG_PFLICHT, sichtbareLeistungen } from '../server/services/termine/einstellungen.js' import { aboMeldung } from '../server/services/featureOrder.js' -import { oeffentlicheAnsicht, featureHaken, BELEHRUNG_PLATZHALTER } from '../server/services/featureRegistry.js' +import { oeffentlicheAnsicht, featureHaken } from '../server/services/featureRegistry.js' +import { widerrufsbelehrungFunktion } from '../server/services/termine/rechtstexte.js' import { config } from '../server/config.js' import { bestellbarerStand, entwurfDoc } from './support/fragebogenStand.js' @@ -724,7 +725,7 @@ test('bestellungAnsicht: Abbildung von oeffentlicheAnsicht – Preis, Hinweis, E test('bestellungAnsicht: belehrung ist das Ergebnis des Hakens (derselbe Text, den der Nachweis speichert)', async () => { const ansicht = await bestellungAnsicht(T) assert.equal(ansicht.belehrung, await featureHaken('termine').belehrung()) - assert.equal(ansicht.belehrung, BELEHRUNG_PLATZHALTER) + assert.equal(ansicht.belehrung, widerrufsbelehrungFunktion()) // ein Test-Haken im Register ueberschreibt den Standard T.belehrung = async () => 'Belehrung Test' try { diff --git a/test/termineFragebogenRoutes.test.js b/test/termineFragebogenRoutes.test.js index cb2cb86..7886e6a 100644 --- a/test/termineFragebogenRoutes.test.js +++ b/test/termineFragebogenRoutes.test.js @@ -322,8 +322,8 @@ test('einbau.snippet und feeds nur in active (feeds) und active/disabled (snippe } }) -test('abo, offen und bestellung gibt es nur in off; sonst null', async () => { - for (const zustand of ['ordered', 'setup', 'active', 'disabled']) { +test('abo gibt es nur in off; offen und bestellung nur in off und ordered (R110); in setup, active und disabled null', async () => { + for (const zustand of ['setup', 'active', 'disabled']) { await deleteDocument('portalFeatures', FEATURE).catch(() => {}) await deleteDocument('termineConfig', FEATURE).catch(() => {}) setzeZustand(zustand) @@ -332,11 +332,57 @@ test('abo, offen und bestellung gibt es nur in off; sonst null', async () => { assert.equal(body.offen, null, zustand) assert.equal(body.bestellung, null, zustand) } - setzeZustand('off') + for (const zustand of ['off', 'ordered']) { + await deleteDocument('portalFeatures', FEATURE).catch(() => {}) + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + setzeZustand(zustand) + const { body } = await send('GET', einstellungenUrl(), { cookie }) + assert.notEqual(body.offen, null, zustand) + assert.notEqual(body.bestellung, null, zustand) + assert.equal(body.abo === null, zustand === 'ordered', `${zustand}: das Abo liest die Route nur in off`) + } +}) + +// ---------------------------------------------------------------- ordered: bestellung und offen nur lesend (R110) +// "Zahlung fortsetzen" ohne Nachweis (409 zustimmung_fehlt) zeigt im Zustand ordered wieder das Bestellformular; dafuer braucht der +// Client bestellung und offen auch dort. Das Abo gehoert nicht dazu: es wird in ordered nicht gelesen, offen hat keinen Abo-Punkt (h). + +test('ordered: bestellung ist die Sicht des Registers wie in off, offen die Bestellprüfung ohne Abo-Punkt, abo null, nichts wird geschrieben', async () => { + const stand = setzeZustand('ordered') + aw.resetListCalls() + const vorher = groessen() + const { status, body } = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(status, 200) + assert.equal(body.state, 'ordered') + assert.deepEqual(body.bestellung, JSON.parse(JSON.stringify(await bestellungAnsicht(T)))) + assert.equal(body.bestellung.consent.knopf, T.consent.knopf) + assert.deepEqual(body.offen, [], 'bestellbarer Entwurf: keine offenen Punkte, auch ohne Abo-Punkt') + assert.deepEqual(body.offen, bestellpruefung({ einstellung: stand.einstellung, fragebogen: stand.fragebogen, feature: T, mitAbo: false })) + assert.equal(body.abo, null) + assert.deepEqual(aw.listCalls('subscriptions'), [], 'in ordered wird das Abo nicht gelesen') + assert.deepEqual(groessen(), vorher, 'read-only: nichts angelegt, nichts protokolliert') +}) + +test('ordered: ein unvollständiger Entwurf zeigt seine offenen Punkte (nie den Abo-Punkt), ohne Einstellung den Punkt „Branche“', async () => { + setzeZustand('ordered', { stand: { ...bestellbarerStand({ now: JETZT }), fragebogen: leerFragebogen() } }) const { body } = await send('GET', einstellungenUrl(), { cookie }) - assert.notEqual(body.abo, null) - assert.notEqual(body.offen, null) + assert.ok(body.offen.length > 0) + assert.ok(body.offen.some((p) => p.schritt === 'leistungen')) + assert.ok(body.offen.every((p) => p.schritt !== 'bestellen'), 'kein Abo-Punkt (h)') + await deleteDocument('termineConfig', FEATURE) + const ohne = await send('GET', einstellungenUrl(), { cookie }) + assert.equal(ohne.status, 200) + assert.deepEqual(ohne.body.offen, [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]) + assert.notEqual(ohne.body.bestellung, null) +}) + +test('ordered in der Kundenansicht: bestellung und offen ebenfalls da, kundenansicht true', async () => { + setzeZustand('ordered') + const { status, body } = await send('GET', einstellungenUrl(), kv()) + assert.equal(status, 200) + assert.equal(body.kundenansicht, true) assert.notEqual(body.bestellung, null) + assert.deepEqual(body.offen, []) }) test('E11 bleibt: disabled liefert das Feld abgeschaltet mit Grund, Zeitpunkt und Löschtag; Schreiben ist weiter 409 "abgeschaltet"', async () => { diff --git a/test/termineFragebogenView.test.js b/test/termineFragebogenView.test.js index 245fc5b..1e1e274 100644 --- a/test/termineFragebogenView.test.js +++ b/test/termineFragebogenView.test.js @@ -1443,35 +1443,72 @@ test('statusKarte ordered: „Zahlung fortsetzen“ sendet fortsetzenKoerper und assert.equal(fx.window.location.href, KNOPF_URL) })) -test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter() lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({ +test('statusKarte ordered: 409 zustimmung_fehlt ⇒ weiter({ bestellen: true, meldung }) lädt neu und zeigt das Bestellformular, keine Navigation', mitDom({ antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }], }, async (fx) => { - let weiter = 0 - const wurzel = karte({}, {}, { weiter: async () => { weiter += 1 } }) + const aufrufe = [] + const wurzel = karte({}, {}, { weiter: async (fall) => { aufrufe.push(fall) } }) await fx.klick(fx.finde(wurzel, '[data-aktion="fortsetzen"]')) - assert.equal(weiter, 1) + assert.deepEqual(aufrufe, [{ bestellen: true, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }]) assert.equal(fx.window.location.href, 'http://localhost/') })) -test('statusKarte ordered: ohne weiter() oder bei anderem Fehler steht die Meldung am Knopf, der Knopf ist wieder frei', mitDom({ - antworten: [{ status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, { body: { url: 'http://unsicher.example/x' } }], +test('statusKarte ordered: jeder andere Fehler ruft weiter({ bestellen: false, meldung }) (R110), die Karte bleibt nie veraltet stehen', mitDom({ + antworten: [ + { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, + { status: 500, body: {} }, + ], }, async (fx) => { - let weiter = 0 + const aufrufe = [] + // wie im Browser zeichnet sich die Ansicht nach weiter() neu: der Knopf der alten Karte bleibt gesperrt, jeder Fall hat seine Karte + for (let i = 0; i < 3; i += 1) await fx.klick(fx.finde(karte({}, {}, { weiter: (fall) => { aufrufe.push(fall) } }), '[data-aktion="fortsetzen"]')) + assert.deepEqual(aufrufe, [ + { bestellen: false, meldung: 'Bitte stimme der Erklärung zu, um zu bestellen.' }, + { bestellen: false, meldung: 'Der Terminplaner ist für dieses Projekt schon bestellt.' }, + { bestellen: false, meldung: 'Fehler 500' }, + ], 'auch bei 400 (Zustand inzwischen setup oder active) und 409 ohne zustimmung_fehlt') +})) + +test('statusKarte ordered: scheitert weiter() (Neuladen nicht möglich), bleibt die Karte mit dem Fehler am Knopf; ohne weiter() steht die Meldung des Servers dort', mitDom({ + antworten: [ + { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, + { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + { status: 409, body: { error: 'Der Terminplaner ist für dieses Projekt schon bestellt.' } }, + { body: { url: 'http://unsicher.example/x' } }, + ], +}, async (fx) => { + const kaputt = () => { throw new Error('Keine Verbindung. Bitte versuch es gleich noch einmal.') } + const mit = karte({}, {}, { weiter: kaputt }) + const meldungMit = fx.finde(mit, '[data-meldung="fortsetzen"]') + const knopfMit = fx.finde(mit, '[data-aktion="fortsetzen"]') + await fx.klick(knopfMit) + assert.equal(fx.text(meldungMit), 'Keine Verbindung. Bitte versuch es gleich noch einmal.', 'bei fehlender Zustimmung der Grund des Neuladens') + assert.equal(knopfMit.disabled, false) + await fx.klick(knopfMit) + assert.equal(fx.text(meldungMit), 'Der Terminplaner ist für dieses Projekt schon bestellt.', 'sonst der Fehler des Versuchs') const ohne = karte() const knopf = fx.finde(ohne, '[data-aktion="fortsetzen"]') await fx.klick(knopf) assert.equal(fx.text(fx.finde(ohne, '[data-meldung="fortsetzen"]')), 'Bitte stimme der Erklärung zu, um zu bestellen.') assert.equal(knopf.disabled, false) - const mit = karte({}, {}, { weiter: () => { weiter += 1 } }) - await fx.klick(fx.finde(mit, '[data-aktion="fortsetzen"]')) - assert.equal(weiter, 0, 'weiter() nur bei zustimmung_fehlt') - assert.equal(fx.text(fx.finde(mit, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.') + const ohneZwei = karte() + await fx.klick(fx.finde(ohneZwei, '[data-aktion="fortsetzen"]')) + assert.equal(fx.text(fx.finde(ohneZwei, '[data-meldung="fortsetzen"]')), 'Der Terminplaner ist für dieses Projekt schon bestellt.') const unsicher = karte() await fx.klick(fx.finde(unsicher, '[data-aktion="fortsetzen"]')) assert.equal(fx.window.location.href, 'http://localhost/', 'keine http-Navigation') assert.match(fx.text(fx.finde(unsicher, '[data-meldung="fortsetzen"]')), /Zahlungsseite/) })) +test('statusKarte: meldung steht beim Zeichnen am Knopf „Zahlung fortsetzen“ (als Text)', mitDom(undefined, (fx) => { + const wurzel = karte({}, { meldung: 'Deine Zahlung läuft schon. x' }) + assert.equal(fx.text(fx.finde(wurzel, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft schon. x') + assert.equal(fx.alle(wurzel, 'b').length, 0) + assert.equal(fx.text(fx.finde(karte(), '[data-meldung="fortsetzen"]')), '') +})) + test('statusKarte ordered: in der Kundenansicht ist „Zahlung fortsetzen“ gesperrt, mit Hinweis und ohne Anfrage', mitDom(undefined, async (fx) => { const wurzel = karte({}, { kundenansicht: true }) const knopf = fx.finde(wurzel, '[data-aktion="fortsetzen"]') @@ -2965,3 +3002,519 @@ test('termine.css: Klassen der Schritte 4 bis 8 (Person, Ausnahmen, Sammelknopf, for (const klasse of ['tm-fb-person', 'tm-fb-ausnahmen', 'tm-fb-sammel', 'tm-fb-einbau']) assert.match(css, new RegExp(`\\.${klasse}[\\s{,.:\\[]`), `.${klasse} ist gestaltet`) assert.match(css.match(/\.tm-fb-sammel\s*\{[^}]*\}/)?.[0] ?? '', /flex-wrap:\s*wrap/, 'die Knöpfe brechen am Handy um') }) + +// ------------------------------------------------------- Ansicht #termine nach Zustand und Rückkehr von Stripe (TP2, Task C10b) +// initTermine in public/termine.js. Der Fake-fetch antwortet hier nach Adresse statt der Reihe nach: Antworten von GET /api/funktionen (dort +// steht der Abo-Hinweis, R111) und GET /api/termine/einstellungen liefert der Test. Ein Array in der Tabelle sind aufeinanderfolgende Antworten +// (die letzte gilt weiter). Der Zustand der Huelle (Auswahl des Projekts und des Reiters) bleibt zwischen den Tests im Modul: jeder Test nennt +// sein Projekt (projectId), und kein Test verlangt einen bestimmten Reiter, ohne ihn zu waehlen. +const AN_FUNKTIONEN = 'GET /api/funktionen' +const AN_CHECKOUT = 'POST /api/funktionen/termine/checkout' +const an = (projectId) => `GET /api/termine/einstellungen?projectId=${projectId}` +const SERVER_HINWEIS = 'Dieser Satz steht nur beim Server, so wie der Server ihn schreibt.' +const AUFRUFE = (fx) => fx.fetchLog.map((a) => `${a.method} ${a.url}`) + +function routen(fx, tabelle) { + globalThis.fetch = async (url, optionen = {}) => { + const aufruf = { method: optionen.method || 'GET', url: String(url), body: optionen.body ? JSON.parse(optionen.body) : undefined } + fx.fetchLog.push(aufruf) + let eintrag = tabelle[`${aufruf.method} ${aufruf.url}`] + if (eintrag === undefined && aufruf.method === 'GET' && aufruf.url.startsWith('/api/termine/buchungen')) eintrag = { body: { buchungen: [] } } + if (Array.isArray(eintrag)) eintrag = eintrag.length > 1 ? eintrag.shift() : eintrag[0] + if (typeof eintrag === 'function') eintrag = eintrag(aufruf) + const { status = 200, body = {} } = eintrag ?? { status: 404, body: { error: `nicht vorgesehen: ${aufruf.method} ${aufruf.url}` } } + return { ok: status >= 200 && status < 300, status, json: async () => structuredClone(body) } + } +} + +/** Ansicht öffnen (Adresse mit hash, Tabelle der Antworten) und warten, bis auch die Reiter gezeichnet sind. => Wurzel der Ansicht. */ +async function oeffneAnsicht(fx, { projectId = 'p1', hash = '', tabelle }) { + routen(fx, tabelle) + fx.window.location.hash = hash + const wurzel = h('div') + document.body.append(wurzel) + await huelle.initTermine(wurzel, { projectId }) + await fx.ruhe() + return wurzel +} + +const projektEintrag = (id, state, extra = {}) => ({ projectId: id, projectName: `Website ${id}`, liveUrl: '', features: { news: { state: 'off' }, termine: { state, ...extra } } }) +const GEORDERT = { orderedAt: '2026-10-05T10:00:00.000Z', ticketWoid: null, invoiceUrl: null } +const funktionenAntwort = (projekte, termine = {}) => ({ + body: { projects: projekte, termine: { verfuegbar: true, bestellbar: true, grund: null, preis: '149 €', einrichtung: EINRICHTUNG, hinweis: null, ...termine } }, +}) +const ohneAbo = (grund, hinweis = SERVER_HINWEIS) => ({ bestellbar: false, grund, hinweis }) +const ABGESCHALTET = { grund: 'abo_ende', seit: '2026-10-04T10:00:00.000Z', loeschungAm: '2026-11-03' } + +/** Antwort von GET /api/termine/einstellungen im jeweiligen Zustand (off: Entwurf mit Abo, ordered: dazu die Sicht der Bestellung ohne Abo). */ +async function ansichtDaten(state, extra = {}) { + const basis = { ...fbDaten({ state }), abo: null, offen: null, bestellung: null, feeds: {}, anfragenOffen: 0, aktivSeit: null, abgeschaltet: null } + if (state === 'off') return { ...basis, ...fbDaten(), ...extra } + if (state === 'ordered') return { ...basis, offen: [], bestellung: await bestellungTest(), ...extra } + if (state === 'active') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', einbau: { snippet: '' }, ...extra } + if (state === 'disabled') return { ...basis, aktivSeit: '2026-10-01T09:00:00.000Z', abgeschaltet: ABGESCHALTET, ...extra } + return { ...basis, ...extra } +} +const reiterTexte = (fx, wurzel) => fx.alle(wurzel, '[role="tab"]').map((tab) => fx.text(tab)) +const meldungDerHuelle = (fx, wurzel) => fx.text(fx.finde(wurzel, '.tm-meldung')) +/** Antwort-Tabelle für ein Projekt p1 im Zustand state mit den Daten daten. */ +async function einProjekt(state, { eintrag = {}, daten = {}, termine = {}, tabelle = {} } = {}) { + return { + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', state, eintrag)], termine), + [an('p1')]: { body: await ansichtDaten(state, daten) }, + ...tabelle, + } +} + +test('Ansicht off mit laufendem Abo: der Fragebogen (Fortschrittsleiste im Baum), weder Hinweis noch Reiter', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { fragebogen: fbBis(1) } }) }) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt ol.tm-schritte'), 'renderFragebogen hat gezeichnet') + assert.equal(fbSchritt(fx, wurzel), 'wer', 'Start beim ersten offenen Schritt des Entwurfs') + assert.ok(fx.finde(wurzel, '[data-hinweis="abo"]') === null) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')]) +})) + +test('Ansicht off: ohne Entwurf die Branchenwahl; die Meldung der Hülle (ctx.meldung) steht über dem Fragebogen', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { + daten: { einstellung: null, version: 0, fragebogen: leerFragebogen(), betriebVorschlag: BETRIEB_KONTO }, + tabelle: { + 'POST /api/termine/einstellungen/vorlage': { status: 201, body: { einstellung: salon(), version: 1, fehlt: [], fragebogen: fbBis(0) } }, + [an('p1')]: [{ body: fbStart() }, { body: fbDaten({ version: 1, fragebogen: fbBis(0) }) }], + 'PUT /api/termine/fragebogen': fbAntwort(1), + }, + }) }) + assert.equal(fbSchritt(fx, wurzel), 'branche') + assert.equal(meldungDerHuelle(fx, wurzel), '') + await fx.klick(fbRadio(fx, wurzel, 'salon')) + await fx.klick(fx.finde(wurzel, '[data-aktion="branche-starten"]')) + assert.equal(fbSchritt(fx, wurzel), 'wer') + assert.match(meldungDerHuelle(fx, wurzel), /^Die Branche „Friseur und Salon“ ist angelegt\./, 'ohne meldungEl bliebe die Meldung stumm') + assert.equal(fx.finde(wurzel, '.tm-meldung').getAttribute('role'), 'status') +})) + +test('Ansicht off ohne laufendes Abo: der Hinweis vom Server (kein_abo mit Link zu Verträge), kein Fragebogen, keine weitere Abfrage', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'kein_abo' } }, termine: ohneAbo('kein_abo') }) }) + const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') + assert.ok(karteEl) + assert.ok(fx.text(karteEl).includes(SERVER_HINWEIS), 'der Text des Servers, unverändert') + const link = fx.alle(karteEl, 'button') + assert.deepEqual(link.map((b) => [fx.text(b), b.getAttribute('data-view'), b.getAttribute('class'), b.getAttribute('type')]), [['Zu Verträge', 'vertraege', 'kb-link', 'button']]) + assert.equal(fx.text(fx.finde(karteEl, 'p')), `${SERVER_HINWEIS} Zu Verträge`, 'Text, Leerzeichen, Link wie in der Kachel') + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen') + assert.equal(fx.alle(wurzel, 'input').length, 0) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')]) +})) + +test('Ansicht off ohne Abo: gekündigt, nicht aktiv, nicht lesbar und fehlendes abo zeigen nur den Text des Servers, ohne Link', mitDom(undefined, async (fx) => { + const faelle = [ + ['gekuendigt', { ok: false, grund: 'gekuendigt' }, ohneAbo('gekuendigt', 'Server: Dein Wartungs-Abo ist gekündigt.')], + ['nicht_aktiv', { ok: false, grund: 'nicht_aktiv' }, ohneAbo('nicht_aktiv', 'Server: Dein Wartungs-Abo ist gerade nicht aktiv.')], + ['Lesefehler (abo null)', null, { bestellbar: null, grund: null, hinweis: 'Server: Das Abo konnte gerade nicht geprüft werden.' }], + ['abo fehlt in der Antwort', undefined, ohneAbo('gekuendigt', 'Server: ohne abo.')], + ] + for (const [name, abo, termine] of faelle) { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo }, termine }) }) + const karteEl = fx.finde(wurzel, '[data-hinweis="abo"]') + assert.ok(karteEl, name) + assert.equal(fx.text(fx.finde(karteEl, 'p')), termine.hinweis, name) + assert.equal(fx.alle(karteEl, 'button').length, 0, `${name}: kein Link`) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, name) + } +})) + +test('Ansicht off ohne Abo: der Hinweis kommt maskiert in die Seite und der Browser kennt keinen eigenen Text', mitDom(undefined, async (fx) => { + const bose = ' & "Anführungszeichen"' + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt', bose) }) }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), bose) +})) + +test('Ansicht off ohne Abo: fehlt der Text in der Liste (das Abo hat sich seit dem Laden geändert), wird die Liste noch einmal geholt', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { + daten: { abo: { ok: false, grund: 'kein_abo' } }, + tabelle: { [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'off')]), funktionenAntwort([projektEintrag('p1', 'off')], ohneAbo('kein_abo'))] }, + }) }) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN]) + assert.equal(fx.text(fx.finde(wurzel, '[data-hinweis="abo"] p')), `${SERVER_HINWEIS} Zu Verträge`) + // bleibt der Text auch dann aus, steht nur die Überschrift (kein erfundener Ersatztext) + const leer = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { abo: { ok: false, grund: 'gekuendigt' } } }) }) + assert.ok(fx.finde(leer, '[data-hinweis="abo"] p') === null) + assert.equal(fx.text(fx.finde(leer, '[data-hinweis="abo"] h3')), 'Terminplaner einrichten') +})) + +test('Ansicht off: in der Kundenansicht gilt dasselbe (Fragebogen mit Hinweis der Kundenansicht, ohne Abo der Hinweis)', mitDom(undefined, async (fx) => { + const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true } }) }) + assert.match(fx.text(fx.finde(mit, '.tm-box[role="note"]')), /Kundenansicht/) + const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('off', { daten: { kundenansicht: true, abo: { ok: false, grund: 'gekuendigt' } }, termine: ohneAbo('gekuendigt') }) }) + assert.ok(fx.finde(ohne, '[data-hinweis="abo"]')) + assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null) +})) + +test('Ansicht active ohne Einstellung: weiter die Vorlagenauswahl von TP1 (kein Fragebogen), mit Einstellung die vier Reiter', mitDom(undefined, async (fx) => { + const ohne = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { einstellung: null, version: 0, fragebogen: null } }) }) + assert.equal(fx.alle(ohne, 'input[name="termine-vorlage"]').length, 1) + assert.ok(fx.text(ohne).includes('Womit möchtest du starten?')) + assert.ok(fx.finde(ohne, 'nav.tm-fortschritt') === null) + assert.ok(fx.finde(ohne, '.tm-reiterleiste') === null) + const mit = await oeffneAnsicht(fx, { tabelle: await einProjekt('active') }) + assert.deepEqual(reiterTexte(fx, mit), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(mit, '.tm-statuskarte') === null) + assert.ok(fx.finde(mit, '.tm-hinweise .tm-box-warn') === null, 'weder Abschaltung noch fehlende Angaben') +})) + +test('Ansicht active: fehlende Pflichtangaben zeigen den Hinweis „Noch nicht buchbar“ weiter wie bisher', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('active', { daten: { fehlt: ['betrieb.telefon'] } }) }) + assert.match(fx.text(fx.finde(wurzel, '.tm-hinweise .tm-box-warn')), /Noch nicht buchbar/) +})) + +for (const [state, pille, ticket] of [['ordered', 'Zahlung offen', null], ['setup', 'In Einrichtung', 100342]]) { + test(`Ansicht ${state}: Statuskarte „${pille}“ und die Reiter Leistungen, Team & Plätze, Einstellungen nur zum Lesen, ohne Reiter „Termine“`, mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt(state, { + eintrag: { ...GEORDERT, ticketWoid: ticket, invoiceUrl: state === 'setup' ? 'https://invoice.stripe.test/i/in_1' : null }, + daten: { fehlt: ['betrieb.telefon'], aktivSeit: null }, + }) }) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl) + assert.equal(karteEl.getAttribute('data-state'), state) + assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), pille) + assert.match(fx.text(karteEl), /Bestellt am\s*05\.10\.2026/, 'Bestelldatum aus dem Eintrag der Liste') + if (state === 'setup') { + assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/) + assert.match(fx.text(karteEl), new RegExp(`Zeiteinschätzung\\s*${EINRICHTUNG}`), 'Einrichtungsdauer aus GET /api/funktionen') + } + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null, 'kein Fragebogen') + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(wurzel, '#tm-tab-termine') === null) + assert.ok(fx.finde(wurzel, '.tm-hinweise .tm-box-warn') === null, 'weder Banner der Abschaltung noch „Noch nicht buchbar“') + // jeder Reiter ist schreibgeschützt: Felder gesperrt, keine Knöpfe zum Ändern + for (const [key, ohneKnopf] of [['leistungen', '[data-werkzeug="leistung-neu"]'], ['team', '[data-werkzeug="ressource-neu"]'], ['einstellungen', 'button[type="submit"]']]) { + await fx.klick(fx.finde(wurzel, `#tm-tab-${key}`)) + await fx.ruhe() + const panel = fx.finde(wurzel, '#tm-panel') + assert.ok(fx.finde(panel, ohneKnopf) === null, `${key}: nichts zum Ändern`) + assert.ok(fx.finde(panel, 'fieldset[disabled]') || key !== 'einstellungen', `${key}: gesperrt`) + assert.ok(!fx.text(panel).includes('null'), `${key}: kein „null“ als Text (replaceChildren mit null)`) + } + // „Export und Status“ des Reiters Einstellungen sagt vor der Freischaltung nicht „Aktiv“ + assert.ok(fx.finde(fx.finde(wurzel, '#tm-panel'), '.tm-status') === null) + assert.equal(fx.alle(fx.finde(wurzel, '#tm-panel'), 'a').length, 0, 'noch nichts zum Exportieren') + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1')], 'der Reiter „Termine“ wird nicht gelesen (der Server antwortete mit 409)') + })) +} + +test('Ansicht ordered/setup: die Pfeiltasten wandern nur durch die drei Reiter, ein gemerkter Reiter „Termine“ fällt auf den ersten zurück und bleibt für andere Websites gemerkt', mitDom(undefined, async (fx) => { + const tabelle = { + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'active'), projektEintrag('p2', 'ordered', GEORDERT)]), + [an('p1')]: { body: await ansichtDaten('active') }, + [an('p2')]: { body: await ansichtDaten('ordered') }, + } + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', tabelle }) + await fx.klick(fx.finde(wurzel, '#tm-tab-termine')) + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2') + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'der erste Reiter, den es gibt') + await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowLeft' }) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'einstellungen', 'Pfeil links vom ersten Reiter springt zum letzten') + await fx.sende(fx.finde(wurzel, '.tm-reiterleiste'), 'keydown', { key: 'ArrowRight' }) + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen') + // zurück zur aktiven Website: der zuvor gewählte Reiter „Termine“ ist wieder da + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.equal(fx.finde(wurzel, '[role="tab"][aria-selected="true"]').dataset.reiter, 'leistungen', 'die Wahl in p2 gilt ab jetzt') +})) + +test('Ansicht ordered in der Kundenansicht: „Zahlung fortsetzen“ ist gesperrt', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { eintrag: GEORDERT, daten: { kundenansicht: true } }) }) + assert.equal(fx.finde(wurzel, '[data-aktion="fortsetzen"]').disabled, true) +})) + +test('Ansicht ordered/setup ohne Einstellung: nur die Statuskarte, keine Reiter', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('setup', { eintrag: GEORDERT, daten: { einstellung: null, version: 0 } }) }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) + assert.ok(!fx.text(wurzel).includes('null')) +})) + +test('Ansicht disabled: Banner der Abschaltung (hinweisAbgeschaltet) mit Löschdatum aus abgeschaltet.loeschungAm und Export-Link, alle vier Reiter, unverändert (E11)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled') }) + const text = ansicht.abschaltText(ABGESCHALTET) + const banner = fx.finde(wurzel, '.tm-hinweise .tm-box-warn') + assert.ok(banner) + assert.equal(fx.text(banner), `${text.davor}exportieren${text.danach}`) + assert.match(fx.text(banner), /03\.11\.2026|2026-11-03/, 'das Löschdatum aus loeschungAm') + assert.deepEqual(fx.alle(banner, 'a').map((a) => [fx.text(a), a.getAttribute('href')]), [['exportieren', '/api/termine/export.csv?projectId=p1']]) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt') === null) + await fx.klick(fx.finde(wurzel, '#tm-tab-einstellungen')) + await fx.ruhe() + assert.match(fx.text(fx.finde(wurzel, '.tm-status')), /Abgeschaltet/, 'Export und Status bleiben in disabled') +})) + +test('Ansicht disabled ohne Einstellung: Banner und „Keine Termine eingerichtet“ wie bisher', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('disabled', { daten: { einstellung: null, version: 0 } }) }) + assert.ok(fx.finde(wurzel, '.tm-box-warn[role="note"]')) + assert.ok(fx.text(wurzel).includes('Keine Termine eingerichtet')) + assert.ok(fx.finde(wurzel, '.tm-reiterleiste') === null) +})) + +test('Ansicht: der Zustand der Daten gilt, auch wenn die Liste einen anderen kennt (Liste: off, Daten: active)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: { [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off')]), [an('p1')]: { body: await ansichtDaten('active') } } }) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) +})) + +test('Ansicht ordered: kennt die Liste einen älteren Zustand als die Daten (nach der Zahlung), wird sie neu geholt und die Karte zeigt die Ticketnummer', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: { + [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])], + [an('p1')]: { body: await ansichtDaten('setup') }, + } }) + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_FUNKTIONEN]) + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung') + assert.match(fx.text(fx.finde(wurzel, '.tm-statuskarte')), /Ticketnummer\s*#100342/) + // lässt sich die Liste nicht holen, gilt der Zustand der Daten, ohne Ticket + const ohne = await oeffneAnsicht(fx, { tabelle: { + [AN_FUNKTIONEN]: [funktionenAntwort([projektEintrag('p1', 'ordered', GEORDERT)]), { status: 500, body: {} }], + [an('p1')]: { body: await ansichtDaten('setup') }, + } }) + assert.equal(fx.text(fx.finde(ohne, '.tm-statuskarte .tm-pille')), 'In Einrichtung') + assert.match(fx.text(fx.finde(ohne, '.tm-statuskarte')), /Wir legen dein Ticket gerade an\./) + assert.ok(fx.finde(ohne, '[data-aktion="fortsetzen"]') === null, 'kein „Zahlung fortsetzen“ für eine bezahlte Bestellung') +})) + +test('Ansicht: ein Fehler beim Laden zeigt die Fehlerkarte mit „Noch einmal versuchen“, das lädt neu', mitDom(undefined, async (fx) => { + const tabelle = await einProjekt('off', { tabelle: { [an('p1')]: [{ status: 500, body: { error: 'Der Server hat gerade Pause.' } }, { body: await ansichtDaten('off') }] } }) + const wurzel = await oeffneAnsicht(fx, { tabelle }) + assert.equal(fx.text(fx.finde(wurzel, '[role="alert"] p')), 'Der Server hat gerade Pause.') + await fx.klick(fx.finde(wurzel, '[role="alert"] button')) + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt')) +})) + +// ------------------------------------------------------- Rückkehr von Stripe +const zweiProjekte = async (zustandP2 = 'ordered', extraP2 = GEORDERT) => ({ + [AN_FUNKTIONEN]: funktionenAntwort([projektEintrag('p1', 'off'), projektEintrag('p2', zustandP2, extraP2)]), + [an('p1')]: { body: await ansichtDaten('off') }, + [an('p2')]: { body: await ansichtDaten(zustandP2) }, +}) +/** Aufrufe von history.replaceState mitschreiben. */ +function merkeAdressen(fx) { + const adressen = [] + const echt = fx.window.history.replaceState + fx.window.history.replaceState = (zustand, titel, url) => { adressen.push(url); echt(zustand, titel, url) } + return adressen +} + +test('Rückkehr nach der Zahlung: #termine?bestellt=1&projekt=p2 wählt p2, zeigt „Danke!“, bereinigt die Adresse (einmal gelesen)', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2', 'das Projekt der Rückkehr (vor dem gemerkten p1)') + assert.equal(fx.text(fx.finde(wurzel, '[data-danke]')), 'Danke! Sobald die Zahlung da ist, legen wir los.') + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'Zahlung offen') + assert.deepEqual(adressen, ['/dashboard.html#termine'], 'Pfad und Query bleiben, der Hash bis auf #termine weg') + assert.equal(fx.window.location.hash, '#termine') + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p2')], 'nur das Projekt der Rückkehr wird geladen') + // ein zweiter Aufruf (Hash ist bereinigt) kennt weder Rückkehr noch Danke, behält aber die Auswahl + const wieder = await oeffneAnsicht(fx, { projectId: '', hash: fx.window.location.hash, tabelle: await zweiProjekte() }) + assert.ok(fx.finde(wieder, '[data-danke]') === null) + assert.equal(fx.finde(wieder, '#termine-project').value, 'p2') +})) + +test('Rückkehr nach der Zahlung, wenn der Webhook schon durch ist (setup): „Danke!“ auch dann', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('setup', { ...GEORDERT, ticketWoid: 100342 }) }) + assert.ok(fx.finde(wurzel, '[data-danke]')) + assert.equal(fx.text(fx.finde(wurzel, '.tm-statuskarte .tm-pille')), 'In Einrichtung') +})) + +test('Rückkehr nach dem Abbruch: #termine?projekt=p2 wählt p2 ohne „Danke!“, die Adresse ist bereinigt; „Zahlung fortsetzen“ ist da', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?projekt=p2', tabelle: await zweiProjekte() }) + assert.equal(fx.finde(wurzel, '#termine-project').value, 'p2') + assert.ok(fx.finde(wurzel, '[data-danke]') === null) + assert.ok(!fx.text(wurzel).includes('Danke!')) + assert.ok(fx.finde(wurzel, '[data-aktion="fortsetzen"]')) + assert.deepEqual(adressen, ['/dashboard.html#termine']) + assert.equal(fx.window.location.hash, '#termine') +})) + +test('Rückkehr: bestellt=1 ohne Projekt gilt dem gewählten Projekt; ein unbekanntes Projekt wird ignoriert (kein „Danke!“ für ein anderes)', mitDom(undefined, async (fx) => { + const ohneProjekt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(ohneProjekt, '[data-danke]')) + assert.equal(fx.window.location.hash, '#termine') + const unbekannt = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=gibtsnicht', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(unbekannt, '[data-danke]') === null) + assert.equal(fx.finde(unbekannt, '#termine-project').value, 'p2', 'es bleibt bei der gewählten Website') + assert.equal(fx.window.location.hash, '#termine') +})) + +test('Rückkehr: „Danke!“ nur für das Projekt der Zahlung und nur beim ersten Zeichnen, nicht nach einem Wechsel der Website', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(wurzel, '[data-danke]')) + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 ist die Website im Zustand off') + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p2') + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '[data-danke]') === null, 'der Dank war einmal') +})) + +test('Rückkehr: bestellt=1 für ein Projekt, das nicht bestellt ist (active, off), zeigt kein „Danke!“; fremde Hashes werden nicht angefasst', mitDom(undefined, async (fx) => { + const adressen = merkeAdressen(fx) + const aktiv = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#termine?bestellt=1&projekt=p2', tabelle: await zweiProjekte('active', {}) }) + assert.ok(fx.finde(aktiv, '[data-danke]') === null) + assert.ok(!fx.text(aktiv).includes('Danke!')) + assert.equal(adressen.length, 1) + const fremd = await oeffneAnsicht(fx, { projectId: 'p2', hash: '#news?bestellt=1&projekt=p2', tabelle: await zweiProjekte() }) + assert.ok(fx.finde(fremd, '[data-danke]') === null, 'die Rückkehr der News gehört nicht hierher') + assert.equal(adressen.length, 1, 'und ihr Hash bleibt stehen') + assert.equal(fx.window.location.hash, '#news?bestellt=1&projekt=p2') +})) + +test('Rückkehr: Projekt-ID aus der Adresse landet nur als Wert in der Anfrage (codiert), nie im Baum', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { projectId: 'p1', hash: '#termine?bestellt=1&projekt=%3Cimg%20src%3Dx%3E', tabelle: await zweiProjekte() }) + assert.equal(fx.alle(wurzel, 'img').length, 0) + assert.ok(!AUFRUFE(fx).some((a) => a.includes(' projektEintrag('p1', 'ordered', GEORDERT) +const fortsetzenKnopf = (fx, wurzel) => fx.finde(wurzel, '[data-aktion="fortsetzen"]') + +test('R110: 409 zustimmung_fehlt ⇒ Liste und Daten werden neu geholt und das Bestellformular steht an der Stelle der Karte', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } } }, + }) }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte')) + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')]) + assert.deepEqual(fx.fetchLog[2].body, { projectId: 'p1', fortsetzen: true }) + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null, 'die Karte ist weg') + const seite = fx.finde(wurzel, '.tm-bestellen') + assert.ok(seite, 'das Bestellformular') + assert.equal(fx.text(bestellKnopf(fx, wurzel)), (await bestellungTest()).consent.knopf, 'Knopftext aus der Antwort des Servers') + assert.equal(bestellKnopf(fx, wurzel).disabled, true, 'gesperrt, bis alle Haken stehen') + assert.equal(hakenFelder(fx, seite).length, registerHaken().length) + assert.equal(meldungDerHuelle(fx, wurzel), 'Bitte stimme der Erklärung zu, um zu bestellen.') + assert.deepEqual(reiterTexte(fx, wurzel), ['Leistungen', 'Team & Plätze', 'Einstellungen'], 'die Reiter bleiben, nur lesbar') + // „Ändern“ führt nicht in den (gesperrten) Fragebogen, sondern erklärt, warum nicht + await fx.klick(fx.finde(wurzel, '[data-aktion="aendern"]')) + assert.match(meldungDerHuelle(fx, wurzel), /seit der Bestellung gesperrt/) + // nach dem Setzen aller Haken geht die neue Zustimmung an den Checkout und es folgt die Zahlungsseite + routen(fx, { [AN_CHECKOUT]: { body: { url: KNOPF_URL } } }) + await alleAnhaken(fx, seite) + assert.equal(bestellKnopf(fx, wurzel).disabled, false) + await fx.klick(bestellKnopf(fx, wurzel)) + const bestellt = fx.fetchLog.at(-1) + assert.equal(bestellt.url, '/api/funktionen/termine/checkout') + assert.equal(bestellt.body.zustimmung.version, (await bestellungTest()).consent.version) + assert.equal(fx.window.location.href, KNOPF_URL) +})) + +test('R110: 400 (die Bestellung ist inzwischen bezahlt, der Server kennt sie nicht mehr als offen) ⇒ die Ansicht folgt dem neuen Zustand, die Karte steht nicht veraltet da', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'setup', { ...GEORDERT, ticketWoid: 100342 })])], + [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('setup') }], + }, + }) }) + const alteKarte = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl !== alteKarte, 'neu gezeichnet') + assert.equal(fx.text(fx.finde(karteEl, '.tm-pille')), 'In Einrichtung') + assert.match(fx.text(karteEl), /Ticketnummer\s*#100342/) + assert.ok(fortsetzenKnopf(fx, wurzel) === null) + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null, 'kein Bestellformular für eine bezahlte Bestellung') + assert.equal(meldungDerHuelle(fx, wurzel), '', 'der Fehler des alten Zustands steht nicht in der neuen Karte') + assert.ok(!fx.text(wurzel).includes('stimme der Erklärung')) +})) + +test('R110: ist die Funktion nach dem Webhook aktiv, zeigt die Ansicht die vier Reiter statt der Karte', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: { status: 400, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }, + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), funktionenAntwort([projektEintrag('p1', 'active')])], + [an('p1')]: [{ body: await ansichtDaten('ordered') }, { body: await ansichtDaten('active') }], + }, + }) }) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) + assert.deepEqual(reiterTexte(fx, wurzel), ['Termine', 'Leistungen', 'Team & Plätze', 'Einstellungen']) + assert.equal(meldungDerHuelle(fx, wurzel), '') +})) + +test('R110: ein anderer Fehler bei noch offener Bestellung (409, die Sitzung läuft schon) ⇒ die Karte wird neu gezeichnet und der Fehler steht am Knopf', mitDom(undefined, async (fx) => { + const FEHLER = 'Deine Zahlung läuft gerade oder ist schon da. Wir richten den Terminplaner ein, sobald sie bei uns ist.' + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { [AN_CHECKOUT]: { status: 409, body: { error: FEHLER } } }, + }) }) + const alteKarte = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.deepEqual(AUFRUFE(fx), [AN_FUNKTIONEN, an('p1'), AN_CHECKOUT, AN_FUNKTIONEN, an('p1')]) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + assert.ok(karteEl !== alteKarte) + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), FEHLER) + assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false, 'der Knopf ist frei') + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) +})) + +test('R110: lässt sich die Ansicht nicht neu laden, bleibt die Karte stehen und der Fehler steht am Knopf (bei fehlender Zustimmung der Grund des Neuladens)', mitDom(undefined, async (fx) => { + const wurzel = await oeffneAnsicht(fx, { tabelle: await einProjekt('ordered', { + eintrag: GEORDERT, + tabelle: { + [AN_CHECKOUT]: [{ status: 409, body: { error: 'Deine Zahlung läuft gerade.' } }, { status: 409, body: { error: 'Bitte stimme der Erklärung zu, um zu bestellen.', code: 'zustimmung_fehlt' } }], + [AN_FUNKTIONEN]: [funktionenAntwort([ORDERED_P1()]), { status: 500, body: { error: 'Der Server hat gerade Pause.' } }], + }, + }) }) + const karteEl = fx.finde(wurzel, '.tm-statuskarte') + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl, 'dieselbe Karte') + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Deine Zahlung läuft gerade.') + assert.equal(fortsetzenKnopf(fx, wurzel).disabled, false) + await fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === karteEl) + assert.equal(fx.text(fx.finde(karteEl, '[data-meldung="fortsetzen"]')), 'Der Server hat gerade Pause.') + assert.ok(fx.finde(wurzel, '.tm-bestellen') === null) +})) + +test('R110: wechselt der Nutzer die Website, während neu geladen wird, zeichnet die späte Antwort nichts mehr', mitDom(undefined, async (fx) => { + let freigeben + const spaet = new Promise((fertig) => { freigeben = fertig }) + const tabelle = { ...(await zweiProjekte()), [AN_CHECKOUT]: { status: 409, body: { error: 'Deine Zahlung läuft gerade.' } } } + const wurzel = await oeffneAnsicht(fx, { projectId: 'p2', tabelle }) + // ab jetzt hängt das Laden der Daten von p2, bis der Test es freigibt + const antwort = globalThis.fetch + globalThis.fetch = async (url, optionen) => { + if (String(url) !== '/api/termine/einstellungen?projectId=p2') return antwort(url, optionen) + fx.fetchLog.push({ method: 'GET', url: String(url), body: undefined }) + await spaet + return { ok: true, status: 200, json: async () => structuredClone(await ansichtDaten('setup')) } + } + const klick = fx.klick(fortsetzenKnopf(fx, wurzel)) + await fx.ruhe() + await fx.setze(fx.finde(wurzel, '#termine-project'), 'p1') + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'p1 steht da') + freigeben() + await klick + await fx.ruhe() + assert.ok(fx.finde(wurzel, 'nav.tm-fortschritt'), 'die späte Antwort für p2 hat p1 nicht überschrieben') + assert.ok(fx.finde(wurzel, '.tm-statuskarte') === null) +})) diff --git a/test/termineFreischaltung.test.js b/test/termineFreischaltung.test.js new file mode 100644 index 0000000..7bff585 --- /dev/null +++ b/test/termineFreischaltung.test.js @@ -0,0 +1,353 @@ +import { test, after, beforeEach } from 'node:test' +import assert from 'node:assert/strict' +import { startAppwriteMock } from './support/appwriteMock.js' +import { useTestEnv } from './support/testEnv.js' +import { schemaKeys } from './support/schema.js' + +// Freischalt-Hindernisse und Freigabe-Mail des Terminplaners (Task D6, aus B11 herausgeloest): +// freischaltHindernis(doc, { abo }) => null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt' +// (Abo zuerst, dann Einstellung; ein Abo-Lesefehler gibt nie frei), +// meldeFreigeschaltet(doc, { attachments }) = K16 an den Kunden (Fehler geloggt, nie geworfen), +// der Standardhaken freischaltbar(doc) ist (await freischaltHindernis(doc)) === null, +// runActivation ruft meldeFreigeschaltet fuer jedes aktivierte Dokument. +// Die Tests von B11 (featureFreischaltung.test.js) laufen unveraendert weiter. +const aw = await startAppwriteMock({ + schema: { + portalFeatures: schemaKeys('portalFeatures'), + subscriptions: schemaKeys('subscriptions'), + termineConfig: schemaKeys('termineConfig'), + contractEvents: schemaKeys('contractEvents'), + }, +}) +after(() => aw.close()) +useTestEnv({ appwriteUrl: aw.url, stripeUrl: 'http://127.0.0.1:1' }) +const { config } = await import('../server/config.js') +const { seedFixtures, subscriptionDoc, wartungDoc } = await import('./support/fixtures.js') +const { deleteDocument } = await import('../server/services/appwriteAdmin.js') +const { setMailTransportForTests } = await import('../server/services/mailer.js') +const { mailSink } = await import('./support/mailSink.js') +const { aboMemo } = await import('../server/services/aboStatus.js') +const reg = await import('../server/services/featureRegistry.js') +const sf = await import('../server/services/siteFeatures.js') +const { freischaltHindernis, meldeFreigeschaltet } = await import('../server/services/termine/freischaltung.js') + +const mails = mailSink() +setMailTransportForTests(mails) +after(() => setMailTransportForTests(null)) + +const BASE = 'https://project.webklar.com' +const JETZT = new Date('2026-10-05T08:00:00.000Z') + +// seedFixtures gibt c1 nur ein Hosting-Abo (sub_h): Termine-Faelle ueberschreiben es mit einem Wartungs-Abo +const wartung = (patch = {}) => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, ...patch })) +const nurHosting = () => aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc, productKey: 'hosting' })) + +let nr = 0 +/** Ein portalFeatures-Dokument im Zustand 'setup' mit Projekt und geschlossenem Ticket; mit einstellung: true dazu die gespeicherte Einstellung. */ +function einrichtung(key = 'termine', { customerId = 'c1', einstellung = false } = {}) { + nr += 1 + const id = `f_${key}_${nr}` + const projectId = `p_${key}_${nr}` + aw.seed('websiteProjects', { $id: projectId, customerId, projectName: `Projekt ${nr}` }) + aw.seed('workorders', { $id: `t_${id}`, woid: `WO-${nr}`, status: 'Closed', topic: 'x' }) + aw.seed('portalFeatures', { + $id: id, + customerId, + projectId, + featureKey: key, + state: 'setup', + enabled: false, + siteKey: `siteke${String(nr).padStart(10, 'a')}`.slice(0, 16), + ticketId: `t_${id}`, + purchaseStatus: 'paid', + unlockedByPurchase: true, + }) + if (einstellung) speichere(id, customerId, projectId) + return aw.docs('portalFeatures').get(id) +} + +function speichere(featureId, customerId = 'c1', projectId = 'p') { + aw.seed('termineConfig', { + $id: featureId, + customerId, + projectId, + einstellung: JSON.stringify({ betrieb: { name: 'Salon' } }), + version: 1, + updatedAt: JETZT.toISOString(), + updatedBy: 'test', + }) +} + +const k16 = (subject) => mails.sent.filter((m) => m.subject === subject) +const geloggt = () => console.error.mock.calls.map((c) => c.arguments.join(' ')).join('\n') +const STUMM = (t) => { + t.mock.method(console, 'error', () => {}) + t.mock.method(console, 'warn', () => {}) +} + +beforeEach(async () => { + for (const col of ['portalFeatures', 'workorders', 'termineConfig', 'subscriptions', 'websiteProjects', 'contractEvents', 'customers']) { + for (const id of aw.docs(col).keys()) await deleteDocument(col, id) + } + seedFixtures(aw) + wartung() + mails.sent.length = 0 + config.termine.mode = 'pilot' + config.termine.pilotEmails = ['*'] +}) + +// ------------------------------------------------------------------------------ freischaltHindernis --- +test('freischaltHindernis: Wartungs-Abo da + Einstellung da => null', async () => { + const doc = einrichtung('termine', { einstellung: true }) + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: kein Wartungs-Abo => kein_abo, auch ohne Einstellung (Abo zuerst)', async () => { + nurHosting() + const mit = einrichtung('termine', { einstellung: true }) + const ohne = einrichtung('termine') + assert.equal(await freischaltHindernis(mit), 'kein_abo') + assert.equal(await freischaltHindernis(ohne), 'kein_abo', 'Abo zuerst: ohne Abo und ohne Einstellung steht kein_abo da') + // c3 hat gar kein Abo + const c3 = einrichtung('termine', { customerId: 'c3' }) + assert.equal(await freischaltHindernis(c3), 'kein_abo') +}) + +test('freischaltHindernis: Abo da, keine Einstellung => keine_einstellung', async () => { + const doc = einrichtung('termine') + assert.equal(await freischaltHindernis(doc), 'keine_einstellung') + speichere(doc.$id, 'c1', doc.projectId) + assert.equal(await freischaltHindernis(doc), null, 'mit gespeicherter Einstellung frei') +}) + +test('freischaltHindernis: Abo-Lesefehler (failNext auf subscriptions) => abo_unbekannt, geloggt, nie null', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'subscriptions', status: 500, message: 'Nachbau: Abo kaputt' }) + assert.equal(await freischaltHindernis(doc), 'abo_unbekannt') + assert.match(geloggt(), new RegExp(`Freischaltung termine \\(${doc.$id}\\) nicht prüfbar: .*Nachbau: Abo kaputt`)) + // der Fehler war einmalig: die naechste Frage liest wieder und gibt frei + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: Abo past_due zaehlt als laufend => null', async () => { + wartung({ status: 'past_due' }) + const doc = einrichtung('termine', { einstellung: true }) + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: Wechsel-Widerruf (E1: Spiegel Wartung active, juengstes wechsel-Ereignis widerrufen) => kein_abo; ein neueres erledigtes gibt frei', async () => { + const wechsel = (n, status) => + aw.seed('contractEvents', { + $id: `ev_w${n}`, + $createdAt: new Date(Date.UTC(2026, 8, 1, 0, 0, n)).toISOString(), + customerId: 'c1', + subscriptionId: 'sub_h', + type: 'wechsel', + productKey: 'wartung', + details: '{}', + status, + }) + const doc = einrichtung('termine', { einstellung: true }) + wechsel(1, 'widerrufen') + assert.equal(await freischaltHindernis(doc), 'kein_abo') + wechsel(2, 'erledigt') + assert.equal(await freischaltHindernis(doc), null) +}) + +test('freischaltHindernis: Abo beendet (canceled) => kein_abo', async () => { + wartung({ status: 'canceled' }) + const doc = einrichtung('termine', { einstellung: true }) + assert.equal(await freischaltHindernis(doc), 'kein_abo') +}) + +test('freischaltHindernis: ohne Abo wird die Einstellung gar nicht gelesen (ihr Lesefehler stoert dann nicht)', async () => { + nurHosting() + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'termineConfig', status: 500 }) + assert.equal(await freischaltHindernis(doc), 'kein_abo') + // der eingeplante Fehler ist noch offen (nichts hat termineConfig gelesen); er trifft jetzt die Einstellungs-Pruefung + wartung() + await assert.rejects(() => freischaltHindernis(doc)) +}) + +test('freischaltHindernis: Lesefehler bei der Einstellung wirft (kein Freigeben, der Aufrufer faengt es)', async () => { + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'termineConfig', status: 500 }) + await assert.rejects(() => freischaltHindernis(doc)) +}) + +test('freischaltHindernis: fragt das Abo des Kunden des Dokuments; ein eingereichtes abo ersetzt den Standard', async () => { + const doc = einrichtung('termine', { customerId: 'c2', einstellung: true }) + const gefragt = [] + const stub = (antwort) => ({ + laeuft: async (customerId) => { + gefragt.push(customerId) + if (antwort instanceof Error) throw antwort + return antwort + }, + }) + assert.equal(await freischaltHindernis(doc, { abo: stub(true) }), null) + assert.equal(await freischaltHindernis(doc, { abo: stub(false) }), 'kein_abo') + assert.deepEqual(gefragt, ['c2', 'c2']) +}) + +test('freischaltHindernis: abo.laeuft wirft => abo_unbekannt (auch bei einem eingereichten abo)', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + const abo = { + laeuft: async () => { + throw new Error('Memo: Ablehnung bleibt') + }, + } + assert.equal(await freischaltHindernis(doc, { abo }), 'abo_unbekannt') + assert.match(geloggt(), /Memo: Ablehnung bleibt/) +}) + +test('freischaltHindernis: ein gemeinsamer aboMemo() liest das Abo je Kunde nur einmal; ohne abo liest jeder Aufruf frisch', async () => { + const doc = einrichtung('termine', { einstellung: true }) + aw.resetListCalls() + const memo = aboMemo() + assert.equal(await freischaltHindernis(doc, { abo: memo }), null) + assert.equal(await freischaltHindernis(doc, { abo: memo }), null) + assert.equal(aw.listCalls('subscriptions').length, 1, 'Memo: ein Lesen') + + // ohne abo: frisch gelesen, ein Abo, das dazwischen endet, gibt nicht mehr frei + assert.equal(await freischaltHindernis(doc), null) + wartung({ status: 'canceled' }) + assert.equal(await freischaltHindernis(doc), 'kein_abo') +}) + +// ---------------------------------------------------------------------------- Delegation des Standardhakens --- +test('Standardhaken freischaltbar: true nur bei null, false bei kein_abo / keine_einstellung / abo_unbekannt', async (t) => { + STUMM(t) + const { freischaltbar } = reg.featureHaken('termine') + assert.equal(typeof freischaltbar, 'function') + const doc = einrichtung('termine') + assert.equal(await freischaltbar(doc), false, 'keine_einstellung') + speichere(doc.$id, 'c1', doc.projectId) + assert.equal(await freischaltbar(doc), true, 'null') + aw.failNext({ collection: 'subscriptions', status: 500 }) + assert.equal(await freischaltbar(doc), false, 'abo_unbekannt: fail-closed, kein Wurf') + wartung({ status: 'canceled' }) + assert.equal(await freischaltbar(doc), false, 'kein_abo') +}) + +// ------------------------------------------------------------------------------------ meldeFreigeschaltet --- +test('meldeFreigeschaltet: K16 mit "Terminplaner" im Betreff und Link #termine, genau eine Mail an den Kunden', async () => { + const doc = einrichtung('termine', { einstellung: true }) + await meldeFreigeschaltet(doc) + assert.equal(mails.sent.length, 1, mails.sent.map((m) => m.subject).join(' / ')) + const [mail] = k16('Dein Terminplaner ist bereit') + assert.ok(mail, mails.sent.map((m) => m.subject).join(' / ')) + assert.match(mail.subject, /Terminplaner/) + assert.deepEqual(mail.to.map((t) => t.address), ['k@example.invalid']) + assert.match(mail.text, /Hallo Kunde Eins,/) + assert.match(mail.text, /Funktion: Terminplaner\nProjekt: Projekt \d+/) + assert.ok(mail.text.includes(`Termine öffnen: ${BASE}/dashboard.html#termine`), mail.text) + assert.match(mail.html, /href="https:\/\/project\.webklar\.com\/dashboard\.html#termine"/) + assert.deepEqual(mail.attachments ?? [], [], 'ohne attachments kein Anhang') +}) + +test('meldeFreigeschaltet: attachments (die PDFs der Staff-Aktivierung) stehen in der gesendeten Mail', async () => { + const doc = einrichtung('termine', { einstellung: true }) + const pdf = (filename) => ({ filename, content: Buffer.from(`%PDF-1.4\n% ${filename}\n`), contentType: 'application/pdf' }) + await meldeFreigeschaltet(doc, { attachments: [pdf('Leistungsbeschreibung.pdf'), pdf('AVV.pdf')] }) + assert.equal(mails.sent.length, 1) + const [mail] = mails.sent + assert.equal(mail.subject, 'Dein Terminplaner ist bereit') + assert.deepEqual(mail.attachments.map((a) => [a.filename, a.contentType]), [ + ['Leistungsbeschreibung.pdf', 'application/pdf'], + ['AVV.pdf', 'application/pdf'], + ]) + for (const a of mail.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF') +}) + +test('meldeFreigeschaltet gilt fuer jede Funktion des Registers: News => K16 der News-Pinnwand mit Link #news', async () => { + const doc = einrichtung('news') + await meldeFreigeschaltet(doc) + const [mail] = k16('Deine News-Pinnwand ist bereit') + assert.ok(mail, mails.sent.map((m) => m.subject).join(' / ')) + assert.ok(mail.text.includes(`${BASE}/dashboard.html#news`), mail.text) + assert.equal(mails.sent.length, 1) +}) + +test('meldeFreigeschaltet: Versandfehler => kein Wurf, console.error mit Kunde und Grund', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + setMailTransportForTests({ + sendMail: async () => { + throw new Error('SMTP: Postfach voll') + }, + }) + try { + await assert.doesNotReject(() => meldeFreigeschaltet(doc)) + } finally { + setMailTransportForTests(mails) + } + assert.equal(console.error.mock.calls.length, 1) + assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen/) + assert.match(geloggt(), /SMTP: Postfach voll/) + assert.equal(mails.sent.length, 0) +}) + +test('meldeFreigeschaltet: Kunde ohne E-Mail oder unbekannt => keine Mail, kein Wurf, kein Fehler im Log', async (t) => { + STUMM(t) + aw.seed('customers', { $id: 'c_leer', appwriteUserId: 'u_leer', email: '', name: 'Ohne Adresse', status: 'active' }) + const ohneMail = einrichtung('termine', { customerId: 'c_leer' }) + const unbekannt = einrichtung('termine', { customerId: 'c_gibtsnicht' }) + await meldeFreigeschaltet(ohneMail) + await meldeFreigeschaltet(unbekannt) + assert.equal(mails.sent.length, 0) + assert.equal(console.error.mock.calls.length, 0) +}) + +test('meldeFreigeschaltet: Kunde nicht lesbar oder Funktion nicht im Register => kein Wurf, geloggt, keine Mail', async (t) => { + STUMM(t) + const doc = einrichtung('termine', { einstellung: true }) + aw.failNext({ collection: 'customers', method: 'GET', status: 500, message: 'Nachbau: Kunden kaputt' }) + await assert.doesNotReject(() => meldeFreigeschaltet(doc)) + assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*Nachbau: Kunden kaputt/) + + const fremd = einrichtung('gibtsnicht') + await assert.doesNotReject(() => meldeFreigeschaltet(fremd)) + assert.match(geloggt(), /K16 an Kunde c1 fehlgeschlagen.*gibtsnicht nicht im Register/) + assert.equal(mails.sent.length, 0) +}) + +// -------------------------------------------------------------------------------------------- runActivation --- +test('runActivation sendet fuer ein aktiviertes Termine-Dokument genau ein K16 (ueber meldeFreigeschaltet), ein zweiter Lauf keins', async () => { + const doc = einrichtung('termine', { einstellung: true }) + const done = await sf.runActivation({ now: JETZT }) + assert.deepEqual(done.map((d) => d.$id), [doc.$id]) + assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active') + assert.equal(mails.sent.length, 1) + assert.equal(k16('Dein Terminplaner ist bereit').length, 1) + assert.deepEqual(await sf.runActivation({ now: JETZT }), []) + assert.equal(mails.sent.length, 1) +}) + +test('runActivation: ein fehlgeschlagener K16-Versand haelt weder die Aktivierung noch die uebrigen Mails auf', async (t) => { + STUMM(t) + const erstes = einrichtung('termine', { einstellung: true }) + const zweites = einrichtung('termine', { customerId: 'c2', einstellung: true }) + aw.seed('subscriptions', subscriptionDoc('sub_h2', { ...wartungDoc, customerId: 'c2', email: 'k2@example.invalid', stripeCustomerId: 'cus_c2' })) + let n = 0 + setMailTransportForTests({ + sendMail: async (mail) => { + n += 1 + if (n === 1) throw new Error('SMTP: erster Versand scheitert') + return mails.sendMail(mail) + }, + }) + let done + try { + done = await sf.runActivation({ now: JETZT }) + } finally { + setMailTransportForTests(mails) + } + assert.deepEqual(done.map((d) => d.$id).sort(), [erstes.$id, zweites.$id].sort()) + for (const doc of [erstes, zweites]) assert.equal(aw.docs('portalFeatures').get(doc.$id).state, 'active') + assert.equal(mails.sent.length, 1, 'die zweite Mail geht trotzdem raus') + assert.match(geloggt(), /K16 an Kunde c\d fehlgeschlagen.*erster Versand scheitert/) +}) diff --git a/test/termineKontext.test.js b/test/termineKontext.test.js index 011815c..b030530 100644 --- a/test/termineKontext.test.js +++ b/test/termineKontext.test.js @@ -31,7 +31,7 @@ const { leerFragebogen, normalisiereFragebogen, FRAGEBOGEN_MAX } = await import( const { activate } = await import('../server/services/siteFeatures.js') const K = await import('../server/services/termine/kontext.js') const M = await import('../server/services/termine/mails.js') -const { ladeEinstellung, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K +const { ladeEinstellung, ladeStand, speichereEinstellung, speichereFragebogen, kontextPerSiteKey, kontextPerFeature, kontextFuerPortal, websiteUrl, liveHost, resetKontextForTests } = K const { BETREFFE, werteFuer, anGast, anBetrieb } = M after(() => setMailTransportForTests(null)) @@ -266,6 +266,78 @@ test('ladeEinstellung: unlesbares JSON gilt als nicht gespeichert (mit Log); feh }) }) +// ---------------------------------------------------------------- ladeStand (TP2 D4: Stand fuer das Einrichtungs-Ticket) + +test('ladeStand: ohne termineConfig-Dokument null, mit Dokument Einstellung, Version, updatedAt und der Fragebogen als gespeicherte Angaben', async () => { + assert.equal(await ladeStand(FEATURE), null, 'noch nichts gespeichert') + const e = einstellung() + const fb = frage({ branche: 'salon', bestaetigt: ['branche'], einbau: { modus: 'knopf', knopfText: 'Termin buchen', stelle: 'Startseite' } }) + await speichereEinstellung(feature(), e, { version: 0, now: JETZT, fragebogen: fb }) + const stand = await ladeStand(FEATURE) + assert.deepEqual(Object.keys(stand).sort(), ['einstellung', 'fragebogen', 'updatedAt', 'version']) + assert.deepEqual(stand.einstellung, e) + assert.equal(stand.version, 1) + assert.equal(new Date(stand.updatedAt).getTime(), JETZT.getTime()) + assert.deepEqual(stand.fragebogen, JSON.parse(gespeichert().fragebogen)) + assert.equal(stand.fragebogen.einbau.knopfText, 'Termin buchen') + await speichereEinstellung(feature(), einstellung({ betrieb: betriebMit({ name: 'Salon Neu' }) }), { version: 1, now: SEKUNDEN(60) }) + const zweit = await ladeStand(FEATURE) + assert.equal(zweit.version, 2) + assert.equal(zweit.einstellung.betrieb.name, 'Salon Neu') + assert.equal(new Date(zweit.updatedAt).getTime(), SEKUNDEN(60).getTime()) + assert.deepEqual(zweit.fragebogen, stand.fragebogen, 'der Fragebogen bleibt, wenn nur die Einstellung gespeichert wird') + assert.equal(log.error.mock.callCount(), 0, 'lesbare Dokumente: keine Logzeile') +}) + +test('ladeStand: kaputtes fragebogen-JSON gibt fragebogen null bei gueltiger Einstellung (mit Log), kaputte Einstellung gibt einstellung null mit der Version', async () => { + const e = einstellung() + aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 2, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: '{kaputt' }) + const stand = await ladeStand(FEATURE) + assert.deepEqual(stand.einstellung, e) + assert.equal(stand.fragebogen, null) + assert.equal(stand.version, 2) + assert.equal(log.error.mock.callCount(), 1) + assert.match(String(log.error.mock.calls[0].arguments[0]), /Fragebogen f_p_projekt01_termine/) + + const fb = frage({ branche: 'salon' }) + await deleteDocument('termineConfig', FEATURE) + aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: '{kaputt', version: 3, updatedAt: JETZT.toISOString(), updatedBy: '', fragebogen: JSON.stringify(fb) }) + const kaputt = await ladeStand(FEATURE) + assert.equal(kaputt.einstellung, null) + assert.equal(kaputt.version, 3, 'die Version des Dokuments bleibt') + assert.deepEqual(kaputt.fragebogen, fb) + assert.equal(log.error.mock.callCount(), 2) +}) + +test('ladeStand: fehlendes oder leeres fragebogen-Attribut, ein Nicht-Objekt und fehlende Zeit ergeben null; Version ohne Zahl ist 0', async () => { + const e = einstellung() + const doc = { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e), version: 1, updatedAt: JETZT.toISOString(), updatedBy: '' } + for (const fragebogen of [undefined, null, '']) { + await deleteDocument('termineConfig', FEATURE).catch(() => {}) + aw.seed('termineConfig', { ...doc, ...(fragebogen === undefined ? {} : { fragebogen }) }) + assert.equal((await ladeStand(FEATURE)).fragebogen, null, String(fragebogen)) + } + assert.equal(log.error.mock.callCount(), 0, 'nichts gespeichert ist kein Fehler') + for (const roh of ['[1,2]', '"text"', '42', 'null']) { + await deleteDocument('termineConfig', FEATURE) + aw.seed('termineConfig', { ...doc, fragebogen: roh }) + assert.equal((await ladeStand(FEATURE)).fragebogen, null, roh) + } + await deleteDocument('termineConfig', FEATURE) + aw.seed('termineConfig', { $id: FEATURE, customerId: KUNDE, projectId: PROJEKT, einstellung: JSON.stringify(e) }) + const ohneZeit = await ladeStand(FEATURE) + assert.equal(ohneZeit.updatedAt, null) + assert.equal(ohneZeit.version, 0) + assert.deepEqual(ohneZeit.einstellung, e) +}) + +test('ladeStand: Eingabepruefung wie ladeEinstellung, fehlende Collection ist ein Fehler und kein "nichts"', async () => { + for (const id of [undefined, null, '', 7]) await assert.rejects(() => ladeStand(id), TypeError, String(id)) + await mitFehlenderCollection('termineConfig', async () => { + await assert.rejects(() => ladeStand(FEATURE), (err) => err.status === 404 && err.type === 'collection_not_found') + }) +}) + test('R40 speichereEinstellung mit Funktion: laeuft nach der Versionspruefung, bekommt die bisherige Einstellung (erst null), Ergebnis wird gespeichert', async () => { const aufrufe = [] const erste = einstellung({ betrieb: betriebMit({ name: 'Erste' }) }) diff --git a/test/termineRechtstexte.test.js b/test/termineRechtstexte.test.js index 88355bb..77b58c0 100644 --- a/test/termineRechtstexte.test.js +++ b/test/termineRechtstexte.test.js @@ -1,17 +1,22 @@ import { test } from 'node:test' import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' +import { existsSync, readFileSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { config } from '../server/config.js' import { RECHTSTEXTE_VERSION, + RECHTSTEXTE_ENTWURF, + WEBKLAR_KONTAKT, widerrufsbelehrung, + widerrufsbelehrungFunktion, formularHinweis, + datenschutzAbsatz, ZUSTIMMUNG_VORZEITIG, ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG, GESUNDHEIT_HINWEIS, } from '../server/services/termine/rechtstexte.js' -// ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen. // Die Belehrung wird gegen den Wortlaut der Anlagen 1 und 2 zu Art. 246a EGBGB geprueft (docs/rechtliches/quellen/, // abgerufen 2026-10-02): jeder Absatz des Musters muss unveraendert im Ergebnis stehen, nur die Luecken sind gefuellt. @@ -19,6 +24,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 @@ -45,8 +54,11 @@ function fristBaustein() { const belehrung = (betrieb = BETRIEB) => widerrufsbelehrung(betrieb) -test('Fassung der Rechtstexte', () => { - assert.equal(RECHTSTEXTE_VERSION, '2026-10-02') +test('version_gleich_register: RECHTSTEXTE_VERSION ist das Fassungsdatum des Registers und nicht mehr die Altfassung 2026-10-02', () => { + assert.equal(RECHTSTEXTE_VERSION, config.termine.avvVersion) + assert.notEqual(RECHTSTEXTE_VERSION, '2026-10-02') + assert.match(RECHTSTEXTE_VERSION, /^\d{4}-\d{2}-\d{2}$/) + assert.ok(RECHTSTEXTE_VERSION > '2026-10-02', 'die Fassung liegt nach der eingefrorenen Altfassung') }) // ---------------------------------------------------------------- Widerrufsbelehrung @@ -167,8 +179,8 @@ test('Zustimmungstexte wortgleich zu den Vorgaben (Sie-Form)', () => { + 'Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).', ) assert.equal(ZUSTIMMUNG_STORNO, 'Ich habe die Stornobedingungen gelesen.') - assert.equal(ZUSTIMMUNG_ERINNERUNG(24), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.') - assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.') + assert.equal(ZUSTIMMUNG_ERINNERUNG(24), 'Ich möchte 24 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.') + assert.equal(ZUSTIMMUNG_ERINNERUNG(2), 'Ich möchte 2 Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.') assert.equal(GESUNDHEIT_HINWEIS, 'Bitte keine Gesundheitsangaben.') }) @@ -177,6 +189,23 @@ test('Zustimmung zum vorzeitigen Beginn setzt den Betriebsnamen unverändert ein assert.ok(text.startsWith("Ich verlange ausdrücklich, dass Frisör $& $1 'Anna' mit der Leistung vor Ablauf")) }) +test('erinnerung_widerrufssatz: Haken-Text, Hinweise-Dokument wörtlich gleich; der Haken trägt den Widerrufssatz (Art. 7 Abs. 3 DSGVO)', () => { + const satz = (n) => `Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.` + for (const n of [2, 12, 24, 48]) assert.equal(ZUSTIMMUNG_ERINNERUNG(n), satz(n)) + const md = lies('docs/rechtliches/termine-hinweise.md').replace(/\r/g, '') + assert.ok(md.includes(satz('{{n}}')), 'Hinweise-Dokument: Haken Erinnerung weicht ab') + assert.ok(md.includes(ZUSTIMMUNG_ERINNERUNG('{{n}}'))) + assert.ok(!md.includes('Erinnerung per E-Mail erhalten. |'), 'der alte Haken ohne Widerrufssatz steht nicht mehr im Dokument') + assert.equal(ZUSTIMMUNG_ERINNERUNG(24).split('\n').length, 1, 'ein Satz im Haken, keine Umbrüche') +}) + +test('dokumente_nennen_fassung: die drei Gäste-Dokumente tragen die Fassung der Gästetexte', () => { + for (const rel of ['docs/rechtliches/termine-hinweise.md', 'docs/rechtliches/termine-datenschutz-absatz.md', 'docs/rechtliches/termine-widerrufsbelehrung-muster.md']) { + assert.ok(lies(rel).includes(`Fassung ${RECHTSTEXTE_VERSION}.`), `${rel} nennt nicht „Fassung ${RECHTSTEXTE_VERSION}.“`) + assert.ok(!lies(rel).includes('Fassung 2026-10-02.'), `${rel} trägt noch die Altfassung`) + } +}) + const EINSTELLUNG = { anrede: 'sie', aufbewahrungTage: 90, betrieb: BETRIEB } test('Formular-Hinweis (Sie): Zweck, Auftragsverarbeiter, Löschfristen, keine Cookies, Gesundheit, Verantwortlicher', () => { @@ -214,19 +243,295 @@ 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('D1 (f): die Rechtstexte gelten nicht mehr als Entwurf (RECHTSTEXTE_ENTWURF === false)', () => { + assert.equal(RECHTSTEXTE_ENTWURF, false) +}) + +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 …“), trägt sein Leerzeichen selbst in der Klammer (zwei Leerzeichen + * nach dem Doppelpunkt); die Ersetzung fügt nichts hinzu. + */ +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) => { + const eintrag = DS_BEDINGUNGEN.find(([muster]) => muster.test(bedingung)) + assert.ok(eintrag, `unbekannte Bedingung in der Quelle: ${bedingung}`) + return zustand[eintrag[1]] ? 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.')) +}) + +const ABO_ENDE_SATZ = 'Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.' + +test('absatz_abo_ende: Funktion und Markdown (Sie und Du) nennen den Abo-Ende-Satz mit „30 Tage nach dem Abschalten“; der alte Satz ist weg', () => { + const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '') + const fassungen = md.split(/^## Fassung /m).slice(1) + assert.equal(fassungen.length, 2) + for (const f of fassungen) { + assert.ok(normalisiere(f.replace(/^> ?/gm, '')).includes(ABO_ENDE_SATZ), 'Markdown: Abo-Ende-Satz fehlt') + assert.ok(f.includes('30 Tage nach dem Abschalten')) + assert.ok(!f.includes('löschen wir alle Termindaten 30 Tage'), 'Markdown: alter Satz steht noch') + } + for (const anrede of ['sie', 'du']) { + for (const zustand of [DS_ALLE_AN, DS_ALLE_AUS]) { + const text = datenschutzAbsatz(dsEinstellung(zustand), anrede) + assert.equal(text.split(ABO_ENDE_SATZ).length, 2, `${anrede}: Satz genau einmal`) + assert.ok(text.includes('30 Tage nach dem Abschalten')) + assert.ok(!text.includes('löschen wir alle Termindaten'), `${anrede}: alter Satz steht noch`) + } + } +}) + +test('haltezeit_aus_config: „der Bestätigungslink gilt N Minuten“ folgt config.termine.holdMinutes; die Markdown-Quelle nennt denselben Wert', () => { + const md = lies('docs/rechtliches/termine-datenschutz-absatz.md') + for (const anrede of ['sie', 'du']) { + assert.ok(datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede).includes(`(der Bestätigungslink gilt ${config.termine.holdMinutes} Minuten)`)) + } + assert.equal((md.match(new RegExp(`Bestätigungslink gilt ${config.termine.holdMinutes} Minuten`, 'g')) || []).length, 2, 'die Quelle nennt beide Fassungen mit dem Wert aus config') + const vorher = config.termine.holdMinutes + try { + config.termine.holdMinutes = 45 + for (const anrede of ['sie', 'du']) { + const text = datenschutzAbsatz(dsEinstellung(DS_ALLE_AN), anrede) + assert.ok(text.includes('(der Bestätigungslink gilt 45 Minuten)'), anrede) + assert.ok(!text.includes('30 Minuten'), `${anrede}: kein fester Wert mehr im Text`) + } + } finally { + config.termine.holdMinutes = vorher + } +}) + +test('quelle_satzteile_mit_leerzeichen: ein Satzteil der Quelle, der mit einem Wort beginnt, trägt sein Leerzeichen in der Klammer', () => { + const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '') + let mitLeerzeichen = 0 + for (const m of md.matchAll(/\[\[wenn ([^:]+): ([\s\S]*?)\]\]/g)) { + if (m[1] === '…') continue // die Erklärung der Schreibweise im Kopf der Quelle + const davor = md[m.index - 1] + if (/^[A-Za-zÄÖÜäöüß]/.test(m[2])) { + // Satzteil beginnt mit einem Wort: nur nach Leerzeichen erlaubt (Satzanfang), sonst klebte er am Vordertext („Platzund“) + assert.ok(/\s/.test(davor), `Satzteil „${m[2].slice(0, 30)}…“ hängt ohne Leerzeichen am Vordertext`) + } else if (m[2].startsWith(' ')) { + mitLeerzeichen += 1 + assert.ok(/^ [A-Za-zÄÖÜäöüß]/.test(m[2]) && !/\s/.test(davor), `Leerzeichen in der Klammer nur am Anschluss an ein Wort: „${m[2].slice(0, 30)}…“`) + } + } + assert.equal(mitLeerzeichen, 4, 'vier Satzteile mit „und“ tragen ihr Leerzeichen selbst (Sie und Du, je zwei)') +}) + +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 = [ 'docs/rechtliches/termine-widerrufsbelehrung-muster.md', 'docs/rechtliches/termine-datenschutz-absatz.md', 'docs/rechtliches/termine-hinweise.md', - 'docs/rechtliches/termine-datenschutz-6c-entwurf.md', + 'docs/rechtliches/termine-datenschutz-6c.md', ] -test('Jedes Rechtstext-Dokument beginnt mit dem Entwurfs-Kopf', () => { +// Das Wort ENTWURF als Kennzeichnung; ENTWURF_BANNER und RECHTSTEXTE_ENTWURF (Bezeichner im Code) zählen nicht +const ENTWURF_WORT = /ENTWURF(?![_A-Z])/ + +test('kein_entwurf_mehr: kein ENTWURF-Kopf in den vier Dokumenten, keiner in den Texten von rechtstexte.js und in der Einbau-Doku; die Konstante steht auf false', () => { for (const rel of DOKUMENTE) { - assert.equal(lies(rel).split('\n')[0].trim(), KOPF, rel) + const md = lies(rel) + assert.ok(!ENTWURF_WORT.test(md), `${rel}: ENTWURF steht noch im Dokument`) + assert.notEqual(md.split('\n')[0].trim(), KOPF, `${rel}: der Entwurfs-Kopf steht noch in der ersten Zeile`) + assert.ok(md.startsWith('# '), `${rel}: das Dokument beginnt mit der Überschrift`) } + assert.ok(!existsSync(new URL('../docs/rechtliches/termine-datenschutz-6c-entwurf.md', import.meta.url)), 'die 6c-Datei heißt nicht mehr -entwurf') + const quelle = lies('server/services/termine/rechtstexte.js') + assert.ok(!ENTWURF_WORT.test(quelle.replace(/RECHTSTEXTE_ENTWURF/g, '')), 'rechtstexte.js: ENTWURF in Kopf oder Kommentar (nur der Bezeichner RECHTSTEXTE_ENTWURF ist erlaubt)') + assert.ok(/export const RECHTSTEXTE_ENTWURF = false\b/.test(quelle)) + assert.equal(RECHTSTEXTE_ENTWURF, false) + const einbau = lies('docs/funktionen/termine-einbau.md') + assert.ok(!ENTWURF_WORT.test(einbau), 'termine-einbau.md: ENTWURF') + assert.ok(!/nur beim Pilotkunden einsetzen|Solange der Kopf dort steht/.test(einbau), 'termine-einbau.md Abschnitt 6 ohne die Pilot-Einschränkung') + // die Dokumente haben keine offene Rechtsprüfungs-Liste mehr (die Fragen stehen im Kanzlei-Paket); die 6c-Quelle (TP2 F12a) auch keine Einbau-Checkliste + for (const rel of DOKUMENTE) assert.ok(!/^## Für die Rechtsprüfung/m.test(lies(rel)), `${rel}: Liste „Für die Rechtsprüfung offen“ steht noch`) + const sechsC = lies('docs/rechtliches/termine-datenschutz-6c.md') + assert.ok(!/^## Einbau in TP2/m.test(sechsC) && !sechsC.includes('nicht eingebaut'), '6c-Quelle: Einbau-Checkliste und „nicht eingebaut“ sind weg') }) test('Hinweise-Dokument: Formular-Hinweis (Sie/Du) und alle Zustimmungstexte stehen wörtlich darin', () => { @@ -243,6 +548,19 @@ test('Hinweise-Dokument: Formular-Hinweis (Sie/Du) und alle Zustimmungstexte ste } }) +test('hinweise_ohne_entwurf: das Hinweise-Dokument nennt kein „Entwurf“ (auch nicht klein) und sagt, dass Vorlagen und Widget für die Abschnitte 4 bis 6 maßgeblich sind', () => { + const md = lies('docs/rechtliches/termine-hinweise.md').replace(/\r/g, '') + assert.ok(!/Entwurf/i.test(md), 'termine-hinweise.md: das Wort Entwurf steht noch im Dokument') + assert.ok(md.includes('## 5. Widerrufshinweis in der Bestätigungs-E-Mail')) + assert.ok(md.includes('## 6. Widerrufsfunktion (Texte)')) + const kopf = md.split('\n')[2] + assert.ok(kopf.includes('Die Abschnitte 4 bis 6 geben Texte des Widgets und der Mails wieder'), 'Zeile 3 sagt, was die Abschnitte 4 bis 6 sind') + assert.ok(kopf.includes('maßgeblich sind die Mailvorlagen und das Widget'), 'Zeile 3 nennt Vorlagen und Widget als maßgeblich') + // die genannten Quellen gibt es + assert.ok(md.includes('server/mail-templates/G02 Termin bestaetigt.html') && existsSync(new URL('../server/mail-templates/G02 Termin bestaetigt.html', import.meta.url))) + assert.ok(md.includes('server/services/termine/einstellungen.js') && existsSync(new URL('../server/services/termine/einstellungen.js', import.meta.url))) +}) + test('Datenschutz-Absatz: Löschfristen der Vorgaben, Sie- und Du-Fassung, Auftragsverarbeiter und Gesundheitshinweis', () => { const md = lies('docs/rechtliches/termine-datenschutz-absatz.md').replace(/\r/g, '') assert.ok(/Fassung Sie/i.test(md) && /Fassung Du/i.test(md)) @@ -260,16 +578,181 @@ 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)', () => { - const md = lies('docs/rechtliches/termine-datenschutz-6c-entwurf.md') - assert.ok(md.includes('6c. Online-Terminbuchung')) - assert.ok(md.includes('Auftragsverarbeiter')) - assert.ok(/nicht (in|auf) .*(einbauen|veröffentlich)|nicht eingebaut|nicht veröffentlicht/i.test(md)) - // TP2 baut 6c ein und entfernt diese Absicherung - const html = lies('public/datenschutz.html') - assert.ok(!/6c\./.test(html) && !html.includes('id="termine"'), '6c darf vor TP2 nicht in public/datenschutz.html stehen') +// ---------------------------------------------------------------- Widerrufsbelehrung der Funktionen (WEBklar als Unternehmer, Task F3) + +const KONTAKT_WEBKLAR = 'WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, Telefon: 0176 23726355, E-Mail: support@webklar.com' +const AN_ZEILE_WEBKLAR = 'An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:' +const WIDERRUFEN_URL = 'https://project.webklar.com/widerrufen.html' +const funktion = () => widerrufsbelehrungFunktion() + +test('Gäste-Belehrung bleibt byte-gleich (Hash der Ausgabe vor Task F3)', () => { + const text = widerrufsbelehrung(BETRIEB) + assert.equal(createHash('sha256').update(text).digest('hex'), 'a4b3549b7b4ba0bdeeca438bc823d07eede5e4aa35e21e335fb5304ff36a1ff6') + assert.equal(text.length, 2983) +}) + +test('WEBKLAR_KONTAKT: WEBklar GbR als Unternehmer', () => { + assert.deepEqual(WEBKLAR_KONTAKT, { + name: 'WEBklar GbR', + adresse: 'Am Schwimmbad 10, 67722 Winnweiler', + telefon: '0176 23726355', + email: 'support@webklar.com', + }) +}) + +test('belehrung_funktion_muster_woertlich: jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt', () => { + const text = funktion() + const muster = musterZeilen() + assert.equal(muster.length, 8) + assert.equal(fristBaustein(), 'des Vertragsabschlusses.') + for (const zeile of muster) { + const erwartet = zeile + .replace('ab dem Tag .', `ab dem Tag ${fristBaustein()}`) + .replace('uns ( )', `uns (${KONTAKT_WEBKLAR})`) + assert.ok(text.includes(erwartet), `Absatz fehlt oder ist verändert: ${erwartet}`) + } + // Fristbeginn und Kontakt stehen genau einmal + assert.equal(text.split('ab dem Tag des Vertragsabschlusses.').length - 1, 1) + assert.equal(text.split(KONTAKT_WEBKLAR).length - 1, 1) +}) + +test('belehrung_funktion_widerrufsfunktion: Satzrahmen der Anlage 1 wörtlich, die Lücke ist die Adresse der Widerrufsfunktion', () => { + const text = funktion() + const m = /„(Sie können Ihr Widerrufsrecht auch online unter) … \[[^\]]+\] (ausüben\.) (Wenn Sie diese Online-Funktion nutzen, [^“]+?)\s*“/.exec(ANLAGE_1) + assert.ok(m, 'Gestaltungshinweis zur Widerrufsfunktion fehlt oder hat sich geändert in anlage-1.txt') + const absatz = `${m[1]} ${WIDERRUFEN_URL} ${m[2]} ${m[3]}` + assert.ok(text.includes(absatz), 'Absatz zur Widerrufsfunktion fehlt oder weicht vom Satzrahmen der Anlage ab') + assert.equal(text.split(WIDERRUFEN_URL).length - 1, 1) + // kein Hinweis auf eine Schaltfläche der Gäste-Fassung + assert.ok(!text.includes('Termin verwalten') && !text.includes('Schaltfläche')) + // der Wertersatz für Dienstleistungen steht wie in der Gäste-Fassung + const w = /Haben Sie verlangt, dass die Dienstleistungen oder Lieferung von Wasser\/Gas\/Strom\/Fernwärme \[Unzutreffendes streichen\] (während[^“]+?)\s*“/.exec(ANLAGE_1) + assert.ok(w) + assert.ok(text.includes(`Haben Sie verlangt, dass die Dienstleistungen ${w[1]}`)) +}) + +test('belehrung_funktion_formular: Muster-Widerrufsformular der Anlage 2 wörtlich, „An“-Zeile mit WEBklar', () => { + const text = funktion() + const z = zeilen(ANLAGE_2) + const start = z.indexOf('Muster-Widerrufsformular') + assert.ok(start >= 0) + const inhalt = z.slice(start).filter((s) => s && s !== '–') + let anZeile = 0 + for (const zeile of inhalt) { + if (zeile.startsWith('An [hier ist')) { + anZeile += 1 + assert.ok(text.includes(`– ${AN_ZEILE_WEBKLAR}`), '„An“-Zeile mit WEBklar fehlt') + } else { + assert.ok(text.includes(zeile), `Formularzeile fehlt oder ist verändert: ${zeile}`) + } + } + assert.equal(anZeile, 1) + assert.equal(text.split(AN_ZEILE_WEBKLAR).length - 1, 1) +}) + +test('Belehrung der Funktionen: reiner Text ohne Platzhalter, deterministisch, Sie-Form, nichts zu Waren', () => { + const text = funktion() + assert.equal(typeof text, 'string') + assert.ok(text.startsWith('Widerrufsbelehrung\n\nWiderrufsrecht\n')) + assert.ok(text.includes('\n') && !text.includes('\r')) + assert.ok(!/\{\{|\}\}|\[|\]|…|\.\.\.|\(\s*\)/.test(text), 'offener Platzhalter') + assert.ok(!/undefined|null|NaN/.test(text)) + assert.ok(!/Rücksendung|zurückzusenden|Besitz genommen|Wir holen die Waren|Sie haben die Waren|elektronisch ausfüllen|Wir können die Rückzahlung verweigern/.test(text)) + assert.ok(!DU_WOERTER.test(text)) + assert.ok(!/&|<[a-z/]/i.test(text)) + assert.equal(funktion(), funktion()) + // Gäste-Fassung und Funktionen-Fassung unterscheiden sich nur in Kontakt, Widerrufsfunktion und „An“-Zeile + const gast = widerrufsbelehrung(WEBKLAR_KONTAKT) + assert.notEqual(gast, text) + const ohneUnterschied = (t) => t.replace(/uns \([^)]*\)/, 'uns (K)').replace(/Sie können Ihr Widerrufsrecht auch online unter .*? ausüben\./, 'WF').replace(/– An [^\n]*/, '– An A') + assert.equal(ohneUnterschied(gast), ohneUnterschied(text)) +}) + +/** Text eines HTML-Ausschnitts: Tags weg, Entitäten der Seite aufgelöst, Leerraum zu einem Leerzeichen. */ +const klartext = (html) => + html + .replace(/<[^>]+>/g, ' ') + .replace(/&/g, '&') + .replace(/ /g, ' ') + .replace(/\s+/g, ' ') + .trim() + +test('belehrung_gleich_abo: Belehrung der Funktionen = Belehrung in den Abo-Bedingungen (Drift-Wächter), Muster-Formular ebenso', () => { + const html = lies('public/abo-bedingungen.html') + const von = html.indexOf('

    Widerrufsrecht

    ') + const bis = html.indexOf('Ende der Widerrufsbelehrung') + assert.ok(von > 0 && bis > von, 'Aufbau der Abo-Bedingungen hat sich geändert') + // Die Kontaktklammer wird vorher in beiden durch einen Platzhalter ersetzt: die Gäste-Bausteine setzen „Telefon: …, E-Mail: …“ mit + // Doppelpunkt, die Abo-Bedingungen ohne + const klammer = (t) => t.replace(/uns \([^)]*\)/, 'uns (KONTAKT)') + const abo = klammer(klartext(html.slice(von, bis))) + const text = funktion() + const formularKopf = text.indexOf('\n\nMuster-Widerrufsformular\n\n') + assert.ok(formularKopf > 0) + const textBelehrung = text.slice(text.indexOf('Widerrufsrecht'), formularKopf) + assert.equal(klammer(klartext(textBelehrung)), abo) + + // Muster-Widerrufsformular: Kasten der Abo-Bedingungen gegen die Formularzeilen, „An“-Zeile vorher ersetzt + const formVon = html.indexOf('

    Muster-Widerrufsformular

    ') + const formBis = html.indexOf('

    ') + assert.ok(formVon > bis && formBis > formVon) + const an = (t) => t.replace(/– An .*?:(?= – Hiermit)/, '– An ANSCHRIFT:') + const aboForm = an(klartext(html.slice(formVon, formBis)).replace(/^Muster-Widerrufsformular /, '')) + const textForm = an(klartext(text.slice(formularKopf + 2).replace(/^Muster-Widerrufsformular\n+/, ''))) + assert.equal(textForm, aboForm) + assert.ok(aboForm.includes('– An ANSCHRIFT:') && textForm.includes('– An ANSCHRIFT:')) +}) + +// ---------------------------------------------------------------- Dokument widerrufsbelehrung-funktionen.md + +const DOKU = 'docs/rechtliches/widerrufsbelehrung-funktionen.md' + +/** Absätze eines Textes: durch Leerzeilen getrennt, jede Zeile eines Absatzes für sich (Formularzeilen), Überschriften-Zeichen weg. */ +const absaetze = (text) => + text + .replace(/\r/g, '') + .split(/\n{2,}/) + .flatMap((block) => block.split('\n')) + .map((z) => z.replace(/^#+\s+/, '').trim()) + .filter(Boolean) + +test('belehrung_dokument_gepinnt: Textblock der Datei = Ausgabe von widerrufsbelehrungFunktion(), Kopf mit Fassung, keine Platzhalter', () => { + const md = lies(DOKU).replace(/\r/g, '') + const zeilenMd = md.split('\n') + assert.equal(zeilenMd[0], '# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)') + assert.equal(zeilenMd[2], `**Fassung ${config.termine.avvVersion}**`) + assert.ok(!/\[|\]|…|\{\{|\}\}/.test(md), 'offener Platzhalter') + assert.ok(!/ENTWURF|Entwurf|Prüfpunkt|TODO|