diff --git a/docs/rechtliches/avv-terminplaner.md b/docs/rechtliches/avv-terminplaner.md index de730ae..fa7dbb0 100644 --- a/docs/rechtliches/avv-terminplaner.md +++ b/docs/rechtliches/avv-terminplaner.md @@ -28,7 +28,7 @@ zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am S ## 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); der Nachweis der Zustimmungen als Zeitpunkt und als Hashes (Prüfsummen) der gezeigten Texte; der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast. +(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. @@ -44,7 +44,7 @@ zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am S (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). +(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. @@ -62,7 +62,7 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e (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 Verlangen des Kunden sofort. +(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). @@ -78,7 +78,7 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e (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 nur den Zeitpunkt und die Hashes der Texte, die der Gast gesehen hat, nicht die 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. +(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). @@ -86,7 +86,7 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e (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. +(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. @@ -94,7 +94,7 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e (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). Auf Weisung des Kunden löscht WEBklar die Daten früher. +(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. @@ -136,9 +136,9 @@ WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis de | 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 dem Zeitpunkt und Hashes der gezeigten Texte, nicht aus den 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); Abfragen erfolgen nur je Funktion. 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 jeder Löschung wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht. | +| 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. | diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.html b/public/rechtliches/avv-terminplaner-2026-10-05.html index ec41242..57d8735 100644 --- a/public/rechtliches/avv-terminplaner-2026-10-05.html +++ b/public/rechtliches/avv-terminplaner-2026-10-05.html @@ -74,7 +74,7 @@

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); der Nachweis der Zustimmungen als Zeitpunkt und als Hashes (Prüfsummen) der gezeigten Texte; der Wunsch nach einer Erinnerung; der Zustellstatus der E-Mails an den Gast.

+

(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.

@@ -90,7 +90,7 @@

(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).

+

(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.

@@ -108,7 +108,7 @@

(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 Verlangen des Kunden sofort.

+

(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).

@@ -124,7 +124,7 @@

(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 nur den Zeitpunkt und die Hashes der Texte, die der Gast gesehen hat, nicht die 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.

+

(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).

@@ -132,7 +132,7 @@

(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.

+

(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.

@@ -140,7 +140,7 @@

(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). Auf Weisung des Kunden löscht WEBklar die Daten früher.

+

(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.

@@ -183,9 +183,9 @@ 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 dem Zeitpunkt und Hashes der gezeigten Texte, nicht aus den 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); Abfragen erfolgen nur je Funktion. 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 jeder Löschung wird der Zustand frisch gelesen; bei einem Lesefehler wird nichts gelöscht. + 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. diff --git a/public/rechtliches/avv-terminplaner-2026-10-05.pdf b/public/rechtliches/avv-terminplaner-2026-10-05.pdf index 1875c63..5b98a55 100644 Binary files a/public/rechtliches/avv-terminplaner-2026-10-05.pdf 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..4950ad3 --- /dev/null +++ b/public/rechtliches/avv-terminplaner-2026-10-05.pdf.sha256 @@ -0,0 +1 @@ +97d6afeb4e4491e7dfb8e139300b5653738b210e97c70a958f46fcfb58ec6479 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.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/scripts/build-legal-pdfs.sh b/scripts/build-legal-pdfs.sh index b90739c..55cb367 100755 --- a/scripts/build-legal-pdfs.sh +++ b/scripts/build-legal-pdfs.sh @@ -4,7 +4,9 @@ # 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` +# 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 @@ -25,7 +27,7 @@ ANALYSE_VERSION="${ANALYSE_VERSION:-2026-10-02}" termine_version() { local v="${TERMINE_VERSION:-}" if [ -z "$v" ]; then - v="$(node --max-old-space-size=256 --input-type=module -e "const { config } = await import('./server/config.js'); console.log(config.termine.avvVersion)")" + 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" @@ -40,7 +42,15 @@ 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; } - pdf "public/rechtliches/$slug-$TERMINE_VERSION.html" "public/rechtliches/$slug-$TERMINE_VERSION.pdf" + 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) diff --git a/server/services/legalDocs.js b/server/services/legalDocs.js index c60a8cb..460ccd5 100644 --- a/server/services/legalDocs.js +++ b/server/services/legalDocs.js @@ -89,11 +89,21 @@ function funktionSatz(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). + * 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, opts) { +export async function featureLegalAttachments(featureKey, { fassung, ...opts } = {}) { const satz = funktionSatz(featureKey) - return satz ? legalPdfAttachments(satz, aktuelleFassung(satz), opts) : [] + 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 } /** @@ -116,13 +126,13 @@ async function istLesbaresPdf(pfad) { /** * 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. + * Funktion und jeder Lesefehler: false (fail-closed), nie ein Wurf. opts.fassung prueft eine andere Fassung als die aktuelle. */ -export async function featureLegalReady(featureKey) { +export async function featureLegalReady(featureKey, { fassung } = {}) { try { const satz = funktionSatz(featureKey) if (!satz) return true - const v = aktuelleFassung(satz) + 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) diff --git a/server/services/siteFeatures.js b/server/services/siteFeatures.js index 89874a9..8e99257 100644 --- a/server/services/siteFeatures.js +++ b/server/services/siteFeatures.js @@ -1178,41 +1178,58 @@ async function meldeFallZurZahlung(doc, fall, session) { } /** K15 einer Funktion ohne Rechtsdokumente (News): keine Anhaenge, keine Bloecke anlagen und zustimmungen. */ -const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), anlagen: false, anhang: false, zustimmungen: '', fehlt: false }) +const OHNE_RECHTSUNTERLAGEN = Object.freeze({ attachments: Object.freeze([]), unterlagen: null, anhang: false, zustimmungen: '', fehlt: false }) /** - * Rechtsunterlagen der K15 einer Funktion mit Rechtsdokumenten: die PDFs der aktuellen Fassung (legalDocs.featureLegalAttachments, - * erst beim Aufruf geladen; kein Registerhaken) und der Wortlaut der Zustimmungen aus dem Nachweis zur bezahlten Sitzung. Gehoert der - * Nachweis zu einer anderen Fassung als der aktuellen (die Fassung wurde zwischen Klick und Zahlung angehoben), gehen weder PDFs noch - * Links raus: es waeren nicht die Unterlagen, denen der Kunde zugestimmt hat; das Team sendet sie von Hand (fehlt). Nicht lesbare PDFs: - * K15 ohne Anhang, fehlt true, Fehler im Log. Wirft nie. - * @returns {Promise<{ attachments: object[], anlagen: boolean, anhang: boolean, zustimmungen: string, fehlt: boolean }>} + * 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] Zustimmungsnachweis zu Sitzung ${session.id} fuer K15 nicht lesbar, der Block zustimmungen entfaellt:`, err.message) + 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) - let fassungNachweis = '' - try { - fassungNachweis = nachweis ? JSON.parse(nachweis.fassungen)?.zustimmung ?? '' : '' - } catch { - fassungNachweis = '' - } - if (fassungNachweis && fassungNachweis !== feature.consent.version) { - console.error(`[funktionen] K15 (Funktion ${doc.$id}): Nachweis gehoert zur Fassung ${fassungNachweis}, aktuell ist ${feature.consent.version} - ohne Unterlagen, das Team sendet sie von Hand`) - return { attachments: [], anlagen: false, anhang: false, zustimmungen, fehlt: true } + 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 { featureLegalAttachments } = await import('./legalDocs.js') - const attachments = await featureLegalAttachments(doc.featureKey) - return { attachments, anlagen: true, anhang: attachments.length > 0, zustimmungen, fehlt: attachments.length === 0 } + 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-Anhaenge fuer Funktion ${doc.$id} nicht lesbar, K15 geht ohne Anhang raus:`, err.message) - return { attachments: [], anlagen: true, anhang: false, zustimmungen, fehlt: true } + 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 } } } @@ -1237,29 +1254,30 @@ async function onFeaturePaid(session) { // 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 unterlagen = feature ? unterlagenAdressen(feature) : null + 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`) - const rechtlich = unterlagen ? await k15Rechtsunterlagen(doc, feature, session) : OHNE_RECHTSUNTERLAGEN + 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.anlagen ? unterlagen : null, + unterlagen: rechtlich.unterlagen, anhang: rechtlich.anhang, zustimmungenText: rechtlich.zustimmungen, }), - attachments: rechtlich.attachments, + attachments: [...rechtlich.attachments], }) } catch (err) { console.error('[funktionen] K15 fehlgeschlagen:', err.message) - if (unterlagen) anhangFehlt = true + if (mitRechtsdokumenten) anhangFehlt = true } - } else if (unterlagen) { + } 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). diff --git a/test/buildLegalHtml.test.js b/test/buildLegalHtml.test.js index daeb5fd..54b4d24 100644 --- a/test/buildLegalHtml.test.js +++ b/test/buildLegalHtml.test.js @@ -1,5 +1,6 @@ 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' @@ -256,6 +257,23 @@ test('pdf_dateien: je Dokument ein PDF unter dem Namen aus legalPdfFiles, größ } }) +test('pdf_pruefsumme: neben jedem PDF liegt .sha256 mit der SHA-256 der HTML-Seite, aus der es gedruckt wurde (HTML geändert ohne neues PDF fällt auf)', () => { + const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex') + for (const d of DOKUMENTE) { + const pdf = d.html.replace(/\.html$/, '.pdf') + const summe = lies(`${pdf}.sha256`) + assert.match(summe, /^[0-9a-f]{64}\n$/, `${pdf}.sha256: nur die Prüfsumme und ein Zeilenende`) + assert.equal(summe.trim(), sha256(readFileSync(join(ROOT, d.html))), `${d.html} wurde geändert, ohne das PDF neu zu drucken (scripts/build-legal-pdfs.sh termine)`) + } + // die Prüfung schlägt an: eine geänderte Seite ergibt eine andere Summe + const d = DOKUMENTE[1] + assert.notEqual(sha256(`${lies(d.html)} `), lies(`${d.html.replace(/\.html$/, '.pdf')}.sha256`).trim()) + // das Skript schreibt die Summe und druckt ein PDF mit gültiger Summe nicht neu + const sh = lies('scripts/build-legal-pdfs.sh') + assert.ok(sh.includes('sha256sum "$quelle"') && sh.includes('echo "$summe" > "$ziel.sha256"')) + assert.ok(sh.includes('(unveraendert)') && sh.includes('FORCE')) +}) + test('anhaenge_drei: legalPdfAttachments("termine", F) liefert lb, avv, widerruf als application/pdf', async () => { const anhaenge = await legalPdfAttachments('termine', V) assert.deepEqual(anhaenge.map((a) => a.filename), [ @@ -294,6 +312,11 @@ test('router_alias: /rechtliches/.html liefert die Bytes der Fassungsdatei assert.match(pdf.headers.get('content-type'), /application\/pdf/) const alt = await fetch(`${app.url}/rechtliches/${slug}-1999-01-01.html`) assert.equal(alt.status, 404, `${slug}-1999-01-01.html darf es nicht geben`) + // die Prüfsummen-Datei neben dem PDF wird einfach mit ausgeliefert, die Adressen der Seiten bleiben davon unberührt + const summe = await fetch(`${app.url}/rechtliches/${slug}-${V}.pdf.sha256`) + assert.equal(summe.status, 200) + assert.match(await summe.text(), /^[0-9a-f]{64}\n$/) + assert.equal((await fetch(`${app.url}/rechtliches/${slug}.pdf.sha256`)).status, 404) } // Fassung der Konstante ohne Datei: der Alias antwortet 404 const vorher = config.termine.avvVersion @@ -329,6 +352,7 @@ test('script_modus: build-legal-pdfs.sh kennt termine und datenschutz, lässt di } // Chrome höchstens 120 Sekunden je Aufruf; Aufbau und Aufrufe nacheinander assert.match(sh, /^ {2}timeout 120 "\$CHROME" /m) + assert.match(sh, /v="\$\(timeout 60 node --max-old-space-size=256 /, 'auch der Node-Aufruf für die Fassung hat Heap-Grenze und Timeout') assert.ok(!/ &$|^\s*wait\b|xargs -P/m.test(sh), 'keine parallelen Läufe') // bash versteht das Skript, und ein unbekannter Modus liefert die Usage mit allen Modi const syntax = spawnSync('bash', ['-n', 'scripts/build-legal-pdfs.sh'], { cwd: ROOT, encoding: 'utf8' }) diff --git a/test/funktionenRecht.test.js b/test/funktionenRecht.test.js index f4b4bd3..afdbee7 100644 --- a/test/funktionenRecht.test.js +++ b/test/funktionenRecht.test.js @@ -1,4 +1,4 @@ -import { readFileSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs' +import { readFileSync, writeFileSync, mkdtempSync, mkdirSync, copyFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' @@ -376,11 +376,153 @@ test('k15_anhang_nicht_lesbar: nicht lesbare PDFs => K15 ohne Anhang, T16 mit Bl } finally { log2.mock.restore() } - // Kunde ohne E-Mail-Adresse: keine K15, aber T16 sagt es dem Team + // Kunde ohne E-Mail-Adresse: keine K15, T16 sagt es dem Team, und das Log nennt den Grund (T16: "Der Grund steht im Log des Portals") aw.seed('customers', { $id: 'c9', name: 'Ohne Adresse', email: '' }) - const ohneAdresse = await bezahleTermine({ customerId: 'c9' }) - assert.equal(ohneAdresse.k15Anzahl, 0) - assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden')) + const log3 = stumm() + try { + const ohneAdresse = await bezahleTermine({ customerId: 'c9' }) + assert.equal(ohneAdresse.k15Anzahl, 0) + assert.ok(ohneAdresse.t16.text.includes('Unterlagen von Hand nachsenden')) + const zeilen = log3.mock.calls.map((c) => c.arguments.join(' ')) + assert.ok(zeilen.some((z) => z.includes(`K15 (Funktion ${ohneAdresse.doc.$id}) nicht gesendet: beim Kunden ist keine E-Mail-Adresse hinterlegt`)), zeilen.join('\n')) + assert.doesNotMatch(zeilen.join('\n'), /Ohne Adresse/, 'keine Personendaten im Log') + } finally { + log3.mock.restore() + } +}) + +/** Verzeichnis mit Kopien der ausgelieferten Dateien unter dem Namen einer anderen Fassung (HTML und PDF je Dokument). */ +function dateienDerFassung(fassung) { + const dir = mkdtempSync(join(tmpdir(), 'k15-fassung-')) + mkdirSync(join(dir, 'rechtliches')) + const echt = legalPdfFiles('termine', V) + for (const f of Object.values(echt)) { + const slug = f.path.split('/').at(-1).replace(`-${V}.pdf`, '') + copyFileSync(f.path, join(dir, 'rechtliches', `${slug}-${fassung}.pdf`)) + copyFileSync(f.path.replace(/\.pdf$/, '.html'), join(dir, 'rechtliches', `${slug}-${fassung}.html`)) + } + return dir +} + +test('k15_vereinbarte_fassung: wurde die Fassung zwischen Klick und Zahlung angehoben, gehen PDFs und Links der vereinbarten Fassung raus, wenn es sie gibt', async () => { + const alt = '2026-10-01' + const dir = dateienDerFassung(alt) + const log = stumm() + try { + setLegalDirForTests(dir) + const vereinbart = Object.fromEntries(Object.keys(consents.zustimmungsfassungen(T)).map((k) => [k, alt])) + const { k15, t16, doc } = await bezahleTermine({ fassungen: vereinbart }) + assert.deepEqual(k15.attachments.map((a) => a.filename), [`Leistungsbeschreibung-Terminplaner-${alt}.pdf`, `AVV-Terminplaner-${alt}.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-${alt}.pdf`]) + for (const a of k15.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 5).toString('latin1'), '%PDF-') + for (const slug of ['leistungsbeschreibung-terminplaner', 'avv-terminplaner', 'widerrufsbelehrung-funktionen']) { + assert.ok(k15.text.includes(`${BASE}/rechtliches/${slug}-${alt}.html`), `${slug}: Link der vereinbarten Fassung`) + assert.ok(!k15.text.includes(`${slug}-${V}.html`), `${slug}: kein Link der aktuellen Fassung`) + } + assert.ok(k15.text.includes(`Fassung ${alt}: Leistungsbeschreibung`)) + assert.match(k15.text, /Alle drei hängen als PDF an dieser E-Mail\./) + assert.doesNotMatch(t16.text, /Unterlagen von Hand nachsenden/) + assert.equal(doc.state, 'setup') + assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes(`zugestimmt, aktuell ist ${V}`)), 'das Log nennt den Fassungswechsel') + } finally { + setLegalDirForTests(null) + log.mock.restore() + rmSync(dir, { recursive: true, force: true }) + } + // gibt es die Dateien der vereinbarten Fassung nicht, geht K15 ohne PDFs und ohne Links raus (Dateien der aktuellen Fassung bleiben ungenutzt) + const log2 = stumm() + try { + const fehlend = await bezahleTermine({ fassungen: { ...consents.zustimmungsfassungen(T), zustimmung: '2026-09-30' } }) + assert.ok(!fehlend.k15.attachments || fehlend.k15.attachments.length === 0) + assert.doesNotMatch(fehlend.k15.text, /Deine Vertragsunterlagen|\/rechtliches\//) + assert.match(fehlend.k15.text, /Deine Zustimmungen/) + assert.ok(fehlend.t16.text.includes('Unterlagen von Hand nachsenden')) + } finally { + log2.mock.restore() + } +}) + +test('k15_nachweis_nicht_lesbar: ohne lesbaren Nachweis ist die vereinbarte Fassung unbekannt: keine PDFs, keine Links, T16 anhang_fehlt, Log', async () => { + // (a) Lesefehler beim Nachweis + zaehler += 1 + const projectId = `ptermine${zaehler}` + const sessionId = `cs_ptermine${zaehler}` + aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` }) + await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} }) + await consents.speichereZustimmung({ + customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, fassungen: consents.zustimmungsfassungen(T), + texte: consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() }), at: KLICK, + }) + const log = stumm() + try { + aw.failNext({ collection: 'featureConsents', method: 'GET', status: 500, message: 'GEHEIMER-ROHTEXT' }) + const vor = mails.sent.length + await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine')) + const neu = mails.sent.slice(vor) + const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung')) + const t16 = neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')) + assert.ok(k15, 'die Bestätigung geht trotzdem raus') + assert.ok(!k15.attachments || k15.attachments.length === 0, 'nie die aktuelle Fassung ungeprüft anhängen') + assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|Deine Zustimmungen|\/rechtliches\//) + assert.ok(t16.text.includes('Unterlagen von Hand nachsenden')) + assert.equal((await sf.getFeatureDoc('c1', projectId, 'termine')).state, 'setup') + assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('die vereinbarte Fassung ist unbekannt')), 'das Log nennt den Grund') + } finally { + log.mock.restore() + } +}) + +test('k15_nachweis_ohne_fassung: ein Nachweis ohne lesbare Fassung zählt wie ein unlesbarer', async () => { + zaehler += 1 + const projectId = `ptermine${zaehler}` + const sessionId = `cs_ptermine${zaehler}` + aw.seed('websiteProjects', { $id: projectId, customerId: 'c1', projectName: `Salon ${zaehler}` }) + await sf.markOrdered({ customerId: 'c1', projectId, key: 'termine', sessionId, consent: {} }) + aw.seed('featureConsents', { + $id: `fc_kaputt_${zaehler}`, customerId: 'c1', projectId, featureKey: 'termine', stripeSessionId: sessionId, acceptedAt: KLICK, + fassungen: 'kein json', texte: JSON.stringify(consents.nachweisTexte(T, { belehrung: widerrufsbelehrungFunktion() })), textHash: 'x', + }) + const log = stumm() + try { + const vor = mails.sent.length + await sf.handleFeatureCheckoutEvent(paidEvent(sessionId, projectId, 'termine')) + const neu = mails.sent.slice(vor) + const k15 = neu.find((m) => m.subject.startsWith('Deine Bestellung')) + assert.ok(!k15.attachments || k15.attachments.length === 0) + assert.doesNotMatch(k15.text, /Deine Vertragsunterlagen|\/rechtliches\//) + assert.match(k15.text, /Deine Zustimmungen/, 'der Wortlaut der Zustimmungen steht trotzdem in der Mail') + assert.ok(neu.find((m) => m.subject.startsWith('Neue Funktion bestellt')).text.includes('Unterlagen von Hand nachsenden')) + assert.ok(log.mock.calls.some((c) => String(c.arguments[0]).includes('Fassung im Zustimmungsnachweis nicht bestimmbar'))) + } finally { + log.mock.restore() + } +}) + +test('k15_frisches_array: der Mailer bekommt für attachments immer ein eigenes, nicht eingefrorenes Array', async () => { + const roh = [] + setMailTransportForTests({ + async sendMail(mail) { + roh.push(mail) + return mails.sendMail(mail) + }, + }) + try { + aw.seed('websiteProjects', { $id: 'pnews12', customerId: 'c1', projectName: 'Frisch' }) + await sf.markOrdered({ customerId: 'c1', projectId: 'pnews12', key: 'news', sessionId: 'cs_news12', consent: {} }) + await sf.handleFeatureCheckoutEvent(paidEvent('cs_news12', 'pnews12')) + await bezahleTermine() + } finally { + setMailTransportForTests(mails) + } + const k15 = roh.filter((m) => String(m.subject).startsWith('Deine Bestellung')) + assert.equal(k15.length, 2) + for (const mail of k15) { + assert.ok(Array.isArray(mail.attachments)) + assert.equal(Object.isFrozen(mail.attachments), false) + } + assert.notEqual(k15[0].attachments, k15[1].attachments) + k15[0].attachments.push('x') // ein Mailer darf das Array verändern, ohne das gemeinsame Muster zu treffen + const nochmal = await bezahleTermine() + assert.equal(nochmal.k15.attachments.length, 3) }) test('checkout_503_ohne_unterlagen: fehlen die PDFs der aktuellen Fassung, 503 unterlagen_fehlen vor jeder Stripe-Sitzung, ohne ordered-Dokument', async () => { diff --git a/test/legalDocs.test.js b/test/legalDocs.test.js index 4076901..573573e 100644 --- a/test/legalDocs.test.js +++ b/test/legalDocs.test.js @@ -11,7 +11,7 @@ import { useTestEnv } from './support/testEnv.js' 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, featureLegalAttachments, featureLegalReady, legalRouter, setLegalDirForTests } = await import( +const { legalPaths, legalPdfFiles, legalPdfAttachments, featureLegalAttachments, featureLegalReady, featureLegalPaths, legalRouter, setLegalDirForTests } = await import( '../server/services/legalDocs.js' ) const analyse = await import('../server/services/analyseLegal.js') @@ -200,6 +200,25 @@ test('featureLegalReady("termine"): je PDF stat und "%PDF-", nie den ganzen Inha assert.equal(await featureLegalReady('termine'), true, 'mit den ausgelieferten Dateien bereit') }) +// Fassung wählbar (Task F8, Fix-Runde 1): K15 schickt die Unterlagen der Fassung, der der Kunde zugestimmt hat +test('featureLegalAttachments / featureLegalReady / featureLegalPaths mit opts.fassung', async () => { + assert.deepEqual(featureLegalPaths('termine'), legalPaths('termine', V)) + assert.deepEqual(featureLegalPaths('termine', { fassung: '2026-10-01' }), legalPaths('termine', '2026-10-01')) + assert.equal(featureLegalPaths('news'), null) + assert.throws(() => featureLegalPaths('gibtsnicht'), /Funktion/) + assert.throws(() => featureLegalPaths('termine', { fassung: '2026-10-5' }), /Fassung/) + // ausdrücklich die aktuelle Fassung = ohne Angabe + assert.deepEqual((await featureLegalAttachments('termine', { fassung: V })).map((a) => a.filename), (await featureLegalAttachments('termine')).map((a) => a.filename)) + assert.equal(await featureLegalReady('termine', { fassung: V }), true) + // andere Fassung ohne Dateien: Wurf bei den Anhängen, false bei der Prüfung, news weiter [] + await assert.rejects(featureLegalAttachments('termine', { fassung: '1999-01-01' })) + assert.equal(await featureLegalReady('termine', { fassung: '1999-01-01' }), false) + assert.equal(await featureLegalReady('termine', { fassung: 'kaputt' }), false) + assert.deepEqual(await featureLegalAttachments('news', { fassung: '1999-01-01' }), []) + // opts.ohne wirkt weiter neben fassung + assert.deepEqual((await featureLegalAttachments('termine', { fassung: V, ohne: ['avv'] })).map((a) => a.filename.split('-')[0]), ['Leistungsbeschreibung', 'Widerrufsbelehrung']) +}) + test('config.termine.avvVersion: Datumsmuster, nach der Altfassung der Gästetexte, echter Tag', () => { assert.match(config.termine.avvVersion, /^\d{4}-\d{2}-\d{2}$/) assert.ok(config.termine.avvVersion > ALT, `${config.termine.avvVersion} liegt nicht nach ${ALT}`) diff --git a/test/termineVertragsunterlagen.test.js b/test/termineVertragsunterlagen.test.js index 11ca07a..41ff67d 100644 --- a/test/termineVertragsunterlagen.test.js +++ b/test/termineVertragsunterlagen.test.js @@ -32,6 +32,7 @@ const { setMailTransportForTests } = await import('../server/services/mailer.js' const { resetOutboxForTests, warteAufVersand } = await import('../server/services/termine/outbox.js') const { speichereEinstellung, resetKontextForTests } = await import('../server/services/termine/kontext.js') const { halte } = await import('../server/services/termine/ablauf.js') +const { loeschBezugsfeld } = await import('../server/services/termine/buchungen.js') after(() => setMailTransportForTests(null)) // Vertragsunterlagen des Terminplaners als Markdown: Leistungsbeschreibung (Task F4), AVV (Task F5). @@ -450,6 +451,14 @@ test('avv_gleich_datenschutz_absatz: Fristen und Orte stimmen mit dem Datenschut assert.ok(absatz.includes('nicht zustellbare E-Mails nach 48 Stunden')) assert.ok(absatz.includes('Rechenzentren Falkenstein und Nürnberg') && absatz.includes('Eine Übermittlung in Drittländer findet nicht statt.')) assert.ok(absatz.includes('Der Server speichert sie kurzzeitig in Protokollen und löscht sie nach 14 Tagen')) + // unbeantwortete Terminanfragen: derselbe Satz im Datenschutz-Absatz des Kunden und im AVV (Nr. 9 Abs. 2), und der Code tut es + assert.ok(absatz.includes('Terminanfragen, die wir bis zum Terminbeginn nicht beantworten, behandeln wir als abgelehnt.')) + const nr9abs2 = absatzTexte(abschnitt(AVV, 9))[1] + assert.ok(nr9abs2.startsWith('Abgesagte und abgelehnte Termine löscht WEBklar 30 Tage nach der Absage oder Ablehnung.')) + assert.ok(nr9abs2.includes('Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, behandelt der Terminplaner als abgelehnt; die 30 Tage laufen dann ab diesem Zeitpunkt.')) + assert.ok(JOBS.includes("angefragt mit Beginn in der Vergangenheit => abgelehnt vom System"), 'jobs.js lehnt verstrichene Anfragen ab') + assert.equal(loeschBezugsfeld('angefragt'), undefined, 'eine Anfrage wird nie unmittelbar gelöscht, erst als abgelehnte nach 30 Tagen') + assert.equal(loeschBezugsfeld('abgelehnt'), 'updatedAt', 'die 30 Tage laufen ab der letzten Änderung, also ab der Ablehnung durch das System') // der AVV nennt dieselben Zahlen const nr3 = avvNr(3) assert.ok(nr3.includes('In den Server-Protokollen stehen sie 14 Tage (Anlage 2 der Abo-Bedingungen).')) @@ -500,6 +509,12 @@ test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene assert.equal(buchungen.length, 1) const nachweis = JSON.parse(buchungen[0].nachweis) assert.ok(nachweis.fassung && nachweis.datenschutz, 'der Nachweis enthält Fassung und Hash des Datenschutz-Hinweises') + // genau die Bestandteile, die AVV Nr. 3 Abs. 1 und Nr. 8 Abs. 5 nennen: Fassung, Zeitpunkt, Knopftext, Anrede, Hashes (keine weiteren Klartexte) + const erlaubt = new Set(['fassung', 'am', 'knopf', 'anrede', 'datenschutz', 'storno', 'widerruf', 'vorzeitig', 'erinnerung']) + assert.deepEqual(Object.keys(nachweis).filter((k) => !erlaubt.has(k)), []) + for (const k of ['fassung', 'am', 'knopf', 'anrede']) assert.equal(typeof nachweis[k], 'string', k) + assert.deepEqual(Object.keys(nachweis.storno), ['version', 'hash', 'haken', 'am']) + assert.deepEqual(Object.keys(nachweis.erinnerung), ['stunden', 'hash', 'am']) const schluessel = (wert) => (wert && typeof wert === 'object' ? Object.entries(wert).flatMap(([k, v]) => [k, ...schluessel(v)]) : []) assert.deepEqual(schluessel(nachweis).filter((k) => nameIp.test(k)), []) // der Nachweis hält Hashes, keine Texte: jeder Hash ist 64 Hexzeichen @@ -516,6 +531,32 @@ test('avv_keine_ip_in_buchung: weder das Schema noch eine über halte gehaltene } }) +/** Jedes Attribut einer Buchung ist in AVV Nr. 3 Abs. 1 genannt (Wortlaut) oder eine technische Kennung/Eigenschaft des Dienstes. */ +const BUCHUNG_IM_AVV = { + gastName: 'Name und E-Mail-Adresse', gastEmail: 'Name und E-Mail-Adresse', gastTelefon: 'Telefonnummer', gastNachricht: 'eine Nachricht', gastZusatz: 'Zusatzfelder', + leistungName: 'die gebuchte Leistung', ressourceName: 'die Person oder der Platz', startsAt: 'der Termin', endsAt: 'der Termin', blockEndsAt: 'der Termin', + status: 'der Status der Buchung mit den Zeitpunkten', heldUntil: 'der Status der Buchung mit den Zeitpunkten', bestaetigtAt: 'der Status der Buchung mit den Zeitpunkten', + angenommenAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtAt: 'der Status der Buchung mit den Zeitpunkten', abgesagtVon: 'der Status der Buchung mit den Zeitpunkten', + widerrufenAt: 'der Status der Buchung mit den Zeitpunkten', createdAt: 'der Status der Buchung mit den Zeitpunkten', updatedAt: 'der Status der Buchung mit den Zeitpunkten', + absageGrund: 'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', nachweis: 'der Nachweis der Zustimmungen', + preisCent: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', zahlart: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', + zahlStatus: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', verbraucher: 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', + erinnerung: 'der Wunsch nach einer Erinnerung', erinnerungGesendetAt: 'der Wunsch nach einer Erinnerung', +} +const BUCHUNG_TECHNISCH = ['featureId', 'customerId', 'projectId', 'leistungId', 'ressourceId', 'preisArt', 'buchungsart', 'quelle', 'verifyHash', 'manageHash', 'stripeSessionId', 'stripePaymentIntent', 'icsSequenz'] + +test('avv_datenarten_decken_schema: jedes Attribut einer Buchung steht in Nr. 3 Abs. 1 oder ist eine technische Kennung', () => { + const nr3abs1 = norm(absatzTexte(abschnitt(AVV, 3))[0]) + const attribute = TERMINE_COLLECTIONS.termineBookings.attributes.map((a) => a.key) + const unbekannt = attribute.filter((k) => !Object.hasOwn(BUCHUNG_IM_AVV, k) && !BUCHUNG_TECHNISCH.includes(k)) + assert.deepEqual(unbekannt, [], 'neues Attribut der Buchung: in AVV Nr. 3 Abs. 1 aufnehmen oder als technisch einstufen') + for (const [k, wort] of Object.entries(BUCHUNG_IM_AVV)) { + assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`) + assert.ok(nr3abs1.includes(wort), `${k}: „${wort}“ fehlt in Nr. 3 Abs. 1`) + } + for (const k of BUCHUNG_TECHNISCH) assert.ok(attribute.includes(k), `${k} gibt es nicht mehr im Schema`) +}) + test('avv_verweise_auf_lb: jedes „Nr. N Abs. M der Leistungsbeschreibung“ und jedes „Nummer N“ im AVV zeigt auf Vorhandenes', () => { const text = norm(AVV_MD) @@ -600,7 +641,8 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten const nr3 = avvNr(3) for (const wort of [ 'Name und E-Mail-Adresse', 'Telefonnummer', 'Zusatzfelder', 'die gebuchte Leistung, die Person oder der Platz und der Termin', 'der Status der Buchung mit den Zeitpunkten', - 'Zeitpunkt und als Hashes (Prüfsummen) der gezeigten Texte', 'der Wunsch nach einer Erinnerung', 'der Zustellstatus der E-Mails an den Gast', + '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', + 'Absagegrund und Vermerke zu einer Absage oder einem Widerruf', 'auch mit dem Namen aus einer Widerrufserklärung', 'Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde', 'Namen oder Bezeichnungen der Personen oder Plätze', 'Arbeitszeiten und Abwesenheiten', 'geheimer Kalender-Link', ]) assert.ok(nr3.includes(wort), `Nr. 3 nennt ${wort}`) assert.ok(nr3.includes('Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht verarbeitet.')) @@ -611,12 +653,14 @@ test('avv_inhalt: Abschluss, Zwecke ohne Eigennutz, Datenarten, Weisungen, Daten const nr4 = avvNr(4) assert.ok(nr4.includes('Mit der Bestellung weist der Kunde WEBklar an')) assert.ok(nr4.includes('Sonstige Weisungen erteilt der Kunde in Textform')) + assert.ok(nr4.includes('Die Löschung auf Weisung nach Nummer 9 Abs. 6 setzt WEBklar immer um.')) const nr8 = avvNr(8) assert.ok(nr8.includes('Gastdaten löschen') && nr8.includes('CSV-Datei')) assert.ok(nr8.includes('möglichst innerhalb von 24 Stunden nach Kenntnis')) assert.ok(nr8.includes('bis drei Jahre nach dem Ende des Terminplaners')) - assert.ok(nr8.includes('nur den Zeitpunkt und die Hashes der Texte')) + assert.ok(nr8.includes('die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der übrigen Texte')) + assert.doesNotMatch(nr8, /nur den Zeitpunkt/) const nr11 = avvNr(11) assert.ok(nr11.includes('Art. 13 DSGVO') && nr11.includes('Datenschutz-Absatz') && nr11.includes('Beschäftigten')) @@ -673,6 +717,9 @@ const TOM_FUNDSTELLEN = { { datei: 'server/services/termine/schema.js', hat: /Eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments/ }, { datei: 'server/services/termine/schema.js', hat: /str\('featureId', 64\)/ }, { datei: 'server/services/termine/buchungen.js', hat: /Query\.equal\('featureId', featureId\)/ }, + // die Hintergrundläufe arbeiten über alle Funktionen (Löschlauf ohne Funktionsfilter, Aufräumen der Warteschlange nach Status) und löschen Dokument für Dokument + { datei: 'server/services/termine/buchungen.js', hat: /featureId === null \? \[\] : \[Query\.equal\('featureId', featureId\)\]/ }, + { datei: 'server/services/termine/outbox.js', hat: /Query\.equal\('status', \['gesendet', 'fehler', 'verworfen'\]\)/ }, ], 'Löschung': [ { datei: 'server/index.js', hat: /setInterval\(termineTagesJob, 24 \* 60 \* 60 \* 1000\)/ }, @@ -680,6 +727,9 @@ const TOM_FUNDSTELLEN = { { datei: 'server/services/termine/jobs.js', hat: /im Zweifel wird nichts geloescht/ }, { datei: 'server/services/termine/jobs.js', hat: /ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE \* TAG_MS/ }, { datei: 'server/services/termine/entwuerfe.js', hat: /export const ENTWURF_TAGE = 90/ }, + { datei: 'server/services/termine/entwuerfe.js', hat: /const funktion = await holeOderNull\(config\.collections\.portalFeatures, id\)/ }, + // die Warteschlange löscht die gelisteten Einträge ohne erneutes Lesen (nur Buchungen, abgeschaltete Funktionen und Entwürfe lesen frisch) + { datei: 'server/services/termine/outbox.js', hat: /await deleteDocument\(col\(\), eintrag\.\$id\)/ }, ], 'Protokolle': [ { datei: 'test/termineJobs.test.js', hat: /kein Gastname und keine Gastadresse im Log/ }, @@ -730,6 +780,12 @@ test('avv_tom_fundstellen: jede Zeile der Anlage hat ihre Fundstellen im Code, i assert.ok(zeilen['Datensicherung'].includes('die letzten sieben Tagessicherungen')) assert.ok(zeilen['Rechenzentrum'].includes('ISO/IEC 27001') && zeilen['Rechenzentrum'].includes('Falkenstein und Nürnberg')) assert.ok(zeilen['Löschung'].includes('30 Tage nach dem Abschalten')) + assert.ok(zeilen['Löschung'].includes('Vor der Löschung einer Buchung, einer abgeschalteten Funktion oder eines nicht bestellten Fragebogens wird der Zustand frisch gelesen')) + assert.doesNotMatch(zeilen['Löschung'], /Vor jeder Löschung/) + assert.ok(zeilen['Trennung'].includes('Das Kundenportal und die öffentliche Schnittstelle lesen immer nur die Daten der jeweiligen Funktion.')) + assert.ok(zeilen['Trennung'].includes('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.')) + assert.doesNotMatch(zeilen['Trennung'], /Abfragen erfolgen nur je Funktion/) + assert.ok(zeilen['Datenminimierung'].includes('der Fassung der Gästetexte, dem Zeitpunkt, dem Text des Buchungsknopfs, der Anrede und Hashes der übrigen gezeigten Texte')) assert.ok(zeilen['Speicher im Browser'].includes('keine Cookies')) assert.ok(zeilen['Zugriffskontrolle'].includes('konstanter Zeit')) assert.ok(zeilen['Organisation'].includes('Nummer 8 Abs. 3') && zeilen['Organisation'].includes('Nummer 8 Abs. 5'))