Compare commits

..
107 Commits
Author SHA1 Message Date
ClaudeandClaude Opus 5.5 348ddc927c Analyse-Tests: feste Zeiten vor NOW statt echter Uhr (Reihenfolge kippte ab 2026-10-05 10:00 UTC)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:55:54 +00:00
ClaudeandClaude Sonnet 5.5 9350fccd77 K02/K03: Hinweis auf das Ende von Analyse und Terminplaner nur für Kunden, die eine von beiden haben
Der Absatz "Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm."
stand bei jeder Kündigung eines Wartungs-Abos. Nach der Entscheidung vom 05.10.2026 steht er nur
noch, wenn der Kunde ein Funktionsdokument (portalFeatures, featureKey analyse oder termine) in
einem Zustand ausser off hat.

- abo.js: hatAboFunktionen(sub) liest über listCustomerFeatureDocs (wie die übrigen Funktions-
  Abfragen) die Dokumente des Kunden des Abos; ein Lesefehler zählt als ja (eine Logzeile, die Mail
  geht in jedem Fall raus). Der Block funktionen in K02 (Kündigung im Stripe-Kundenportal) und K03
  (Kündigungsseite, weiterhin nur beim Abo) ist jetzt isWartung(sub) && hatAboFunktionen(sub);
  beim Hosting-Abo wird gar nicht erst gelesen. Die Vorlagentexte bleiben unverändert.
- Tests: K02 und K03 ohne Dokument, mit aktiver Analyse, Terminplaner in setup/ordered/disabled,
  nur off (und ohne Zustand), News oder Dokument eines anderen Kunden, Lesefehler (Hinweis bleibt,
  Mail geht raus, eine Logzeile) und Hosting-Abo (kein Hinweis, kein Lesen).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 14:50:03 +00:00
ClaudeandClaude Sonnet 5.5 2c7cd26532 Termine-Outbox: notEqual mit genau einem Wert (Appwrite 1.8.1), nachrangige Mails kommen wieder dran
Auf der echten Appwrite 1.8.1 lehnt notEqual mit Liste die Abfrage ab (HTTP 400
general_query_invalid, "NotEqual queries require exactly one value"). Die Klasse normal der
Mail-Warteschlange schickte immer eine Liste, jeder Lauf warf dort, und die nachrangige Klasse
(G05, K20, K21) kam nie dran.

- appwriteAdmin: Query.notEqual nimmt genau einen Wert (eine Liste wirft einen TypeError, der
  keinerVon nennt); neu keinerVon(attribute, werte) = je Wert ein notEqual (Appwrite schneidet
  sie); dokumenteUrl() baut die Listen-URL ohne Netz, mit derselben Serialisierung wie die Anfrage.
- outbox: klassenFilter liefert fuer jede Klasse eine Liste von Abfragen (normal = sechs notEqual
  auf art), die Ausschlussliste ist keinerVon('featureId', ...). AUSSCHLUSS_MAX 90 -> 30 (der nginx
  antwortet in dieser Abfrageform ab 46 Funktions-IDs mit 414); der Kommentar "100 Werte je Abfrage"
  entfaellt.
- Nachbau (appwriteMock): notEqual mit Liste oder ohne Wert => 400 wie Appwrite; ein Dokument mit
  Feld null oder ohne Feld passt nicht zu notEqual (SQL-Semantik).
- Tests: Query-Helfer, keinerVon, dokumenteUrl, Nachbau (400, null), ein Lauf bedient alle drei
  Klassen, Ausschlussliste hoechstens 30 und URL unter 6000 Zeichen im schlimmsten Fall.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 14:47:29 +00:00
ClaudeandClaude Opus 5.5 af63b69ec1 Merge origin/main in feature/terminplaner-tp2 (Analyse-Cockpit, Subdomain-Sperre, Projekte)
Konflikte: funktionen.js folgt der Aufteilung aus main (fn-util.js, analyse.js); der Link
'Zu Verträge' der Analyse-Seite kommt jetzt auch in analyse.js aus vertraege-link.js.
Checkout und Team-Ansicht übernehmen assertOwnProject(..., { forNewOrder: true }) und
includeArchived aus main. Test-Pins der absoluten Importe nachgezogen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:11:58 +00:00
ClaudeandClaude Opus 5.5 be007af7d0 K22: Abo-Ende nur bei beendetem Wartungs-Abo (nicht das Datum eines Hosting-Abos)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 07:04:52 +00:00
ClaudeandClaude Sonnet 5.5 bea07e73b1 TP2 Abschlussprüfung: Rechtstexte (AVV, 6c, News-LB-Archiv, Livegang-Checkliste)
- AVV Vorbemerkung (2): K16 der Textform-Aktivierung trägt nur Leistungsbeschreibung und AVV als PDF
- AVV Nr. 9 Abs. 6, Anlage (Löschung), LB Nr. 8 Abs. 3 und Datenschutz 6c: das Einrichtungs-Ticket bleibt mit einer Kurzfassung der Einstellungen als Arbeitsnachweis (keine Daten von Gästen)
- AVV Nr. 8 Abs. 2: "Gastdaten löschen" löscht nur wartende E-Mails; fehlgeschlagene behalten Adresse und Inhalt bis Nr. 9 Abs. 4
- Datenschutz § 7: Frist für Bestellungen ohne Abo (drei Jahre nach Ablauf des Jahres der Bestellung)
- News-Leistungsbeschreibung vom 30.09.2026 unverändert archiviert (SHA-256-Pin), die geltende Datei verweist darauf
- Kanzlei-Paket: Zitate, Q4/Q9/Q23, Livegang-Checkliste (Abschnitt 7.6/7.7), K23-Hinweis
- build-legal-html: Rückwärtsschrägstrich in Navigationsadressen abgelehnt; TP1-Spezifikation E2 (ohne USt)
- HTML, PDFs und Prüfsummen neu erzeugt (Terminplaner-Unterlagen und Datenschutzerklärung)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:54:55 +00:00
ClaudeandClaude Sonnet 5.5 5f99f08a39 TP2 Abschlussprüfung: Fragebogen und Bestellseite (veralteter Tab, Abo-Hinweis, Zurück aus Stripe)
- PUT /fragebogen: ein Tab mit veralteter Branche bekommt 409 EINSTELLUNG_VERALTET (die Branche wechselt nur die Vorlage), bestaetigt wird mit
  dem gespeicherten Stand vereinigt; die ungeprüften Vorlagenpreise können nicht mehr als geprüft gelten
- GET /einstellungen liefert in off aboHinweis (aboMeldung, auch bei nicht lesbarem Abo); die Hinweiskarte zeigt ihn immer und hat „Neu laden“
- Bestellseite und Statuskarte laden bei pageshow (persisted) neu, wenn der Browser sie nach der Zahlungsseite aus dem bfcache holt
- renderBestellen zeichnet „Ändern“ und „Zum Schritt“ nur mit zumSchritt; nach einem 409 bekommt die Meldung den Fokus
- Zusammenfassung nennt „Kleinunternehmer: ja/nein“

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:35:42 +00:00
ClaudeandClaude Sonnet 5.5 2fc48df915 TP2 Abschlussprüfung: Löschung (IP/Browser auf portalFeatures, Erstattungsspur, K22-Datum)
- markOrdered speichert IP und Browser der Bestellung nicht mehr am Funktionsdokument (nur im Nachweis featureConsents, dort mit
  Löschfrist); Altwerte werden bei einer neuen Bestellung geleert
- Aufbewahrungslauf (retention): consentIp, consentUserAgent, consentText, consentAt werden an allen Funktionsdokumenten eines fälligen
  Kunden geleert, auch an abgeschalteten; Fehlschlag hält den Kunden wie die übrigen abhängigen Daten
- Einmal-Skript scripts/feature-consent-felder-leeren.mjs (Standard Probelauf, --apply leert nur, wo featureConsents die Angabe trägt)
- 30-Tage-Reset (jobs.js): auch eine bezahlte Einrichtung mit Grund abo_ende_vor_einrichtung und noch nicht gemeldetem T19 wird nicht
  zurückgesetzt, solange zahlungMeldung nicht gemeldet ist (die Spur der Erstattung bleibt)
- K22: enddatum ist das Abo-Ende (lage.endedAt), sonst der Tag des Abschaltens; loeschdatum bleibt 30 Tage nach dem Abschalten

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:26:33 +00:00
ClaudeandClaude Sonnet 5.5 b974b3a3ec TP2 Abschlussprüfung: Zahlung/Team (Wiederzustellung, K16-Warnung, Sperrschlüssel)
- adoptPaidSession übernimmt ein erneut zugestelltes checkout.session.completed nicht mehr, wenn das Dokument nach 30 Tagen zurückgesetzt wurde
  (off, purchaseStatus zurueckgesetzt, gleiche Sitzung): kein Setup, kein Ticket, keine K15/T16
- meldeFreigeschaltet liefert true/false; die Staff-Aktivierung in Textform vermerkt eine nicht gesendete K16 mit Unterlagen in adminNote und
  antwortet mit warnung: true
- disable() nutzt die Sperre feature-activate:<id> statt feature-disable:<id> (Freischaltung und Abschalten überholen sich nicht)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 06:18:34 +00:00
ClaudeandClaude Sonnet 5.5 4dc447ed33 Kanzlei-Paket: Prüfbefunde (genauere Aussagen, Zitate zu Q4/Q8/Q21, Fundstellen)
Genauere Aussagen: Widerrufsfrist je Bestellung mit spätestem Fristende für
"rechtzeitig", Textform beim Wiedereinschalten nur ohne Bestellung, Storno
und kein_widerruf in G02 getrennt beschrieben, keine Ablehnung nach dem
Klick außer aus den Gründen der Leistungsbeschreibung Nr. 4 Abs. 3, Preis
nur mit echten Wortlauten.

Zitate: Q4 mit dem früheren News-Haken und der früheren Leistungsbeschreibung
(Stand 3a0b008, per SHA-256 gepinnt und mit Git abgeglichen), Q8 mit
Leistungsbeschreibung Nr. 9 Abs. 2 und 3, Q19 mit Nr. 5 Abs. 2 und der Preiszeile
von K15, Q21 mit dem gespeicherten Nachweis und der Chat-Vorlage des
Ticketsystems. Begriffe (Betriebsart, Pilot, Kulanz) erklärt, interne
Entscheidungsnummern entfernt, Anlage C (gerenderte Mails K15 und K16) angekündigt.

Tests: Zitate unter 25 Zeichen zählen nicht mehr als geprüft (benannte Liste,
Belehrung und Knopftexte werden streng geprüft), schärfere Fundstellen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:53:24 +00:00
ClaudeandClaude Sonnet 5.5 7050b63a37 Kanzlei-Paket Terminplaner: Fragen Q1 bis Q26
Abschnitt 4 mit den 26 Fragen (Frage, Fundstelle im Text, unsere Annahme,
Alternative). Jede Kennung der früheren Listen "Für die Rechtsprüfung offen"
(H1 bis C8) steht in der Fundstelle genau einer Frage oder in Abschnitt 6
mit Begründung; die Hinweise aus den Reviews (Pilot/Kulanz, Rückkehr nur
nach Abo-Ende, Erstattung bei Ablehnung, Löschung auf Weisung, vereinbarte
Fassung in K15, G02 bei kostenlosen Buchungen) sind in den passenden Fragen.

Tests: Fragen lückenlos Q1 bis Q26 mit vier Teilen, jede Frage verwiesen,
Q4 nennt News, § 356 Abs. 3 und Altkunden, Abdeckung der entfernten Listen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:31:57 +00:00
ClaudeandClaude Sonnet 5.5 6765b14feb Kanzlei-Paket Terminplaner: Aufbau, Texte, Ablauf, Fundstellen
Sieben Abschnitte nach D5: Zweck und Umfang, Produktablauf, Texte in
Endfassung (wörtliche Zitate mit Marker auf die Fragen), Fundstellen im Code,
Nicht Teil der Prüfung (mit Zuordnung der früheren offenen Punkte) und
Checkliste für den Inhaber. Abschnitt 4 (Fragen Q1 bis Q26) folgt.

Der Test prüft Aufbau, jedes Zitat gegen Programm und Dokumente, die
Fundstellen, die Zahlen gegen den Code und die Konsistenz der Abo-Ende-Texte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:24:11 +00:00
ClaudeandClaude Sonnet 5.5 ec320853ce Gaestetexte/Datenschutz: Storno-Regel im Archiv, PDF-Pruefsumme, PDF-Kopfpruefung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:03:54 +00:00
ClaudeandClaude Sonnet 5.5 9a2a70b491 Retention: abhaengige Daten vor dem Abo loeschen, Fehlschlag haelt den Kunden fuer den naechsten Lauf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 05:03:54 +00:00
ClaudeandClaude Sonnet 5.5 bade4edb74 Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:44:44 +00:00
ClaudeandClaude Sonnet 5.5 5b5b4b90eb Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:38:00 +00:00
ClaudeandClaude Sonnet 5.5 a629b2519b Datenschutz: Abschnitt 6c Terminplaner, Kundenansicht und Nachweise bei Funktionsbestellung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:34:16 +00:00
ClaudeandClaude Sonnet 5.5 6ec8885bfd Terminplaner: Hinweistexte nach Recherche, Storno nur bei Preis
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:29:32 +00:00
ClaudeandClaude Sonnet 5.5 722c686690 Gästetexte: Hinweis-Dokument ohne Entwurf-Wort, Archiv-Schutz
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:21:17 +00:00
ClaudeandClaude Sonnet 5.5 495d5ad9ca Gästetexte des Terminplaners: Entwurf-Kennzeichnung entfernt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:08:46 +00:00
ClaudeandClaude Sonnet 5.5 c1266fcce7 Gästetexte des Terminplaners: Fassung 2026-10-05, Erinnerungs- und Abo-Ende-Satz, Archiv je Fassung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 04:01:49 +00:00
ClaudeandClaude Sonnet 5.5 1a91a331b4 Gaestetexte: Archiv-Skript und eingefrorene Fassung 2026-10-02
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:54:15 +00:00
ClaudeandClaude Sonnet 5.5 01bf05cc35 Widerruf: Terminplaner als eigene Auswahl, Frist und Mails je Funktion
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:50:26 +00:00
ClaudeandClaude Opus 5.5 34384c7a39 AVV Terminplaner: Kleinschreibung nach Semikolon (TOM Trennung)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 03:43:39 +00:00
ClaudeandClaude Sonnet 5.5 90098726b9 Rechtstexte und K15: Prüfbefunde F5–F8 (AVV genauer, PDF-Prüfsumme, Unterlagen der vereinbarten Fassung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:42:27 +00:00
ClaudeandClaude Sonnet 5.5 2c059cb3d6 Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:20:56 +00:00
ClaudeandClaude Sonnet 5.5 03bdeb4461 Bestelltexte des Terminplaners (vier Zustimmungen, Belehrung im Nachweis, Fassung)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:10:29 +00:00
ClaudeandClaude Sonnet 5.5 49acd13f84 Rechtstexte: HTML-Generator und PDFs des Terminplaners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 03:03:40 +00:00
ClaudeandClaude Sonnet 5.5 24cb549e78 Rechtstexte: AVV Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:57:05 +00:00
ClaudeandClaude Sonnet 5.5 6c474c46c3 Rechtstexte: Leistungsbeschreibung Terminplaner
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:39:28 +00:00
ClaudeandClaude Sonnet 5.5 6b4cbb3204 Rechtstexte: Widerrufsbelehrung der Funktionen aus dem Gesetzesmuster
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:35:04 +00:00
ClaudeandClaude Sonnet 5.5 3073fffcf5 News: Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz und § 19 UStG statt brutto
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:30:38 +00:00
ClaudeandClaude Sonnet 5.5 b20d0464c4 Rechtstexte: Dokument-Registry und Fassungskonstante des Terminplaners
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 02:24:42 +00:00
ClaudeandClaude Sonnet 5.5 938b7099dd Funktionen: Team-Route fuer die Terminplaner-Einbauanleitung
GET /api/team/funktionen/:customerId/termine/anleitung?projectId= liefert die volle
Einbauanleitung (ohne kurz) fuer das WOMS-Panel: stand, warnungen mit Drift-Warnung
gegen die Version im Ticket, abschnitte, text, state, ticketWoid, ticketVersion und
hindernis (nur in setup). Nur lesend, in jedem Zustand und Modus, ohne customerViewLog.
Die Teamansicht GET /:customerId traegt hindernis je Terminplaner in setup, mit einem
Abo-Lauf je Anfrage und ohne portalFeatures erneut zu lesen. Die Einbau-Doku nennt die
Anleitung je Kunde im WOMS-Panel und die Kurzfassung im Ticket.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:18:30 +00:00
ClaudeandClaude Sonnet 5.5 f830698dc3 Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)
Neue Team-Aktionen abnahme (Freischalten ohne Ticket-Schliessung, K16
einmal, unter feature-activate:<id> mit Haken freischaltHindernis) und
ticket (Ticket nachlegen: Zahlung bei Stripe pruefen und Webhook-Weg,
geloeschtes Ticket ersetzen, vorhandenes unberuehrt). Ticketanlage aus
markPaid in legeTicketAn herausgeloest.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 01:08:03 +00:00
ClaudeandClaude Sonnet 5.5 8fe14c2fe6 Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert
freischaltHindernis (Abo zuerst, dann Einstellung; Abo-Lesefehler = abo_unbekannt)
und meldeFreigeschaltet (K16, Fehler geloggt, mit attachments) in
termine/freischaltung.js. Der Standardhaken freischaltbar fuer termine
delegiert an freischaltHindernis, runActivation an meldeFreigeschaltet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:56:35 +00:00
ClaudeandClaude Sonnet 5.5 2db3eca357 Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:45:56 +00:00
ClaudeandClaude Sonnet 5.5 90692b646b Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:22:04 +00:00
ClaudeandClaude Sonnet 5.5 13bbadc729 Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen)
einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt, kunde,
entwurf }) setzt die ersten sechs Abschnitte der Team-Anleitung zusammen (modell, leistungen,
ressourcen, regeln, gastfelder, benachrichtigungen, feste Reihenfolge, nicht kopierbar) plus
stand { version, updatedAt } und den Volltext. Rein: kein I/O, config nur beim Aufruf
(Portal-Adresse, Haltezeit); gleiche Eingabe und gleiches now ergeben gleiche Ausgabe, die
Eingabe bleibt unveraendert.

Feldweissliste statt Objektkopie: nie feedToken, keine Betriebs-E-Mail, keine
Leistungsbeschreibung, keine unbekannten Zusatzfelder. Kundentexte stehen unmaskiert als Text,
einzeilige Werte werden auf eine Zeile gebracht (keine vorgetaeuschte Ueberschrift).
Abwesenheiten und Sonderoeffnungen nur ab heute (Berliner Kalendertag), Listen ueber 20
Eintraege enden mit "und N weitere". Hilfen wochenplanText und kuerzeListe sind exportiert.

Warnungen und Kurzfassung bleiben leer (kommen mit D3b und D4); ohne Einstellung entstehen
keine Abschnitte.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-05 00:07:38 +00:00
ClaudeandClaude Sonnet 5.5 ee454017b0 Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben
einbauSnippet und esc wandern aus routes/termine.js nach services/termine/einbauanleitung.js
(Parameter statt ctx/config). Neu: Modus knopf mit data-knopf (nur bei gesetztem Text, hoechstens
40 Zeichen, Zeilenumbrueche zu Leerzeichen, maskiert; unbekannter Modus gilt als inline), WCAG-
Helfer luminanz/kontrast/textFarbeAuf 1:1 aus dem Widget und cssVorschlag(farbe) mit Warnung,
wenn der Akzent gegen Weiss unter 3 : 1 liegt. VORGABE_FARBE wird exportiert. Die Kundenansicht
bleibt unveraendert (Modus inline). Farbparitaet Widget/Server ueber alle 4096 Farben im
17er-Raster getestet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:55:42 +00:00
ClaudeandClaude Sonnet 5.5 06ebcd1b0a Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein
datenschutzAbsatz(einstellung, anrede) liefert den Absatz "Online-Terminbuchung" fuer die
Datenschutzerklaerung der Betriebs-Website als reinen Text (Sie- und Du-Fassung, fuenf bedingte
Saetze: Telefon-Feld, Nachricht/Zusatzfelder, Erinnerung, Widerruf ueber zeigtWiderruf, Telefon des
Betriebs). RECHTSTEXTE_ENTWURF = true. Test pinnt den Wortlaut gegen die Markdown-Quelle in allen
32 Bedingungskombinationen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:47:58 +00:00
ClaudeandClaude Sonnet 5.5 1ac142b92f Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:34:38 +00:00
ClaudeandClaude Sonnet 5.5 87d267e51a Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe
Die Ansicht #termine folgt dem Zustand aus GET /api/termine/einstellungen:
off mit laufendem Abo zeigt den Fragebogen samt Bestellseite, ohne Abo den
Hinweis des Servers (termine.hinweis, R111) und bei kein_abo den Link zu
Vertraege aus einer gemeinsamen Quelle (vertraege-link.js); ordered und setup
zeigen die Statuskarte und die Reiter Leistungen, Team & Plaetze und
Einstellungen nur zum Lesen (ohne Reiter Termine); active und disabled
bleiben wie bisher. Rueckkehr von Stripe: #termine?bestellt=1&projekt=<id>
und #termine?projekt=<id> waehlen das Projekt, "Danke!" nur mit bestellt=1,
die Adresse wird einmal gelesen und bereinigt.

R110: GET /einstellungen liefert bestellung und offen auch in ordered (ohne
Abo-Punkt, abo bleibt null); scheitert "Zahlung fortsetzen", zeichnet die
Huelle Liste und Daten neu (bei 409 zustimmung_fehlt mit dem
Bestellformular, sonst nach dem neuen Zustand, die Meldung bleibt am Knopf,
solange die Bestellung offen ist).

Dazu: "null" als Text im lesbaren Reiter Leistungen entfernt, Export und
Status der Einstellungen erst ab der Freischaltung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 23:10:54 +00:00
ClaudeandClaude Sonnet 5.5 52456dac76 Terminplaner TP2 C10a: Nachbesserung 1 (Abo-Hinweis vom Server, R111)
GET /api/funktionen liefert bei termine.verfuegbar ein fuenftes Feld hinweis
(aboMeldung mit dem Register des Terminplaners, null bei bestellbar true).
Die Kachel zeigt diesen Text maskiert an; die Browser-Kopie der Abo-Texte und
der Gleichheitstest entfallen. Der Link "Zu Verträge" ist eine gemeinsame
Konstante fuer Analyse und Terminplaner und erscheint bei kein_abo.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:43:17 +00:00
ClaudeandClaude Sonnet 5.5 6ae0f67457 Terminplaner TP2: Kachel mit Bestellweg, Abo-Felder in der Funktionen-Liste
GET /api/funktionen liefert bei termine.verfuegbar: true zusaetzlich bestellbar
(true | false | null bei Lesefehler im Abo), grund (kein_abo | gekuendigt |
nicht_aktiv | null), preis und einrichtung aus dem Register; nicht verfuegbar
bleibt exakt { verfuegbar: false }. Die Kachel zeigt je Zustand Pille und Knopf:
Aus mit Abo "Terminplaner einrichten · 149 €" bzw. "Fragebogen fortsetzen",
Aus ohne Abo keinen Knopf, sondern den Hinweis zum Abo, Zahlung offen und In
Einrichtung "Status ansehen", Aktiv "Termine oeffnen", Abgeschaltet "Termine
ansehen". Zielprojekt: aktiv, ordered/setup, abgeschaltet, Entwurf, erstes.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:34:50 +00:00
ClaudeandClaude Sonnet 5.5 8d628ef906 Terminplaner TP2: Fragebogen-Schritte 4 bis 8 und Bestellseite
- public/termine-fragebogen.js: Schritt 4 Zeiten (je Person/Platz wochenplanFeld,
  Bundesland ohne Vorauswahl mit leerer erster Option, "An Feiertagen geschlossen" im
  PATCH, danach PUT mit bundeslandGewaehlt; darunter abwesenheiten und sonderoeffnungen
  mit eigenem Container und eigenem zeichne, damit ungespeicherte Zeiten bleiben),
  Schritt 5 Regeln (Sammelknopf Alle sofort buchbar / Alle nur als Anfrage mit
  buchungsartFuerAlle und buchungsartLage, regeln, storno mit Muster nach Absagefrist,
  rechtliches, zahlung), Schritt 6 Gastfelder, Schritt 7 mit gemeinsamem
  leseAbschnitte, Schritt 8 Website (betrieb nur Telefon und vier Links, Einbauwuensche
  Modus/Knopftext/Stelle/Gestaltung/Bemerkung im PUT, Hinweis auf Mehrwuensche nach
  Stunden); danach renderBestellen. Platzhalter-Schritt entfernt.
- termine.css: Klassen der Schritte 4 bis 8
- Tests: termineView (Importe, PATCH/PUT nur an erlaubten Stellen, Bundesland ohne
  Vorauswahl im Quelltext), termineFragebogenView (Verhalten mit Fake-DOM fuer die
  Schritte 4 bis 8, Schluesselmengen, Bestellseite nach Schritt 8)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 22:18:44 +00:00
ClaudeandClaude Sonnet 5.5 9bf5ec09f8 Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3
- public/termine-fragebogen.js: renderFragebogen(root, ctx) mit Fortschrittsleiste
  (ol, aria-current, Handy: Schritt n von 8), Start beim ersten offenen Schritt
  (alles bestaetigt: Bestellseite), freier Navigation, einem Formular je Schritt,
  Weiter = ein PATCH ueber neuerSpeicher, danach PUT /fragebogen; Fokus auf die
  Ueberschrift; Kundenansicht ohne PUT
- Schritt 1 Branche und Betrieb (Gesundheitsbereich: Erklaertext statt Weiter, keine
  Anfrage; Branche wechseln mit Rueckfrage), Schritt 2 Wer wird gebucht,
  Schritt 3 Leistungen mit Haken Preise geprueft; Schritt 7 als Beispiel eines
  Schritts mit Feldern, Schritte 4, 5, 6 und 8 folgen in C9b
- termine.css: Fortschrittsleiste und Handy-Ansicht
- Tests: termineView (Dateiliste, PUT nur im Fragebogen, nur ueber termine.js geladen),
  termineFragebogenView (Verhalten mit Fake-DOM)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:55:51 +00:00
ClaudeandClaude Sonnet 5.5 20583424a3 Funktionen: Bestellseite und Statuskarten
Terminplaner TP2 C8: public/funktion-bestellen.js mit sicherLink, bestellKoerper,
fortsetzenKoerper, renderBestellen (Zusammenfassung, offene Punkte, Preis,
vollstaendige Widerrufsbelehrung als scrollbarer Text, Unterlagen, Zustimmungs-Haken,
Knopf mit bestellung.consent.knopf) und statusKarte (ordered/setup, Danke).
request() in termine-dom.js behaelt den ganzen Body der Fehlerantwort (err.body),
damit 409 bestellung_offen und fassung_veraltet offen/consent zeigen koennen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:27:36 +00:00
ClaudeandClaude Sonnet 5.5 747be432c5 Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen
- termine-tab-einstellungen.js: die acht Abschnitte (betrieb, darstellung, regeln,
  gastFelder, erinnerung, storno, rechtliches, zahlung) sind Exporte; betrieb nimmt
  { nur, titel, hinweis } (nur die gewaehlten Felder, lese schreibt nur diese)
- termine-tab-team.js: wochenplanFeld(r, pfad) => { el, lese() => { plan } | { fehler } },
  der Editor einer Person benutzt es selbst
- termine-speichern.js: fehlt kommt aus der Antwort des Servers
- termine.js: baueCtx aus zeigeReiter herausgeloest (Verhalten gleich)
- test/support/fakeDom.js: Minimal-DOM und Fake-fetch fuer die Client-Tests

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 21:06:42 +00:00
ClaudeandClaude Sonnet 5.5 0090332c70 Terminplaner TP2 C7a: Nachbesserung 1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:53:48 +00:00
ClaudeandClaude Sonnet 5.5 edaefd4237 Terminplaner TP2: Fragebogen-Helfer im Client
Reine Helfer in public/termine-view.js: FRAGEBOGEN_SCHRITTE (Spiegel von
SCHRITTE und SCHRITT_TITEL), schrittStatus, ersterOffenerSchritt,
ressourcenAusNamen, buchungsartFuerAlle, buchungsartLage und
zusammenfassung (acht Gruppen, nur Text, Bundesland nur bei ausdruecklicher
Wahl, nie feedToken). Tests in test/termineFragebogenView.test.js, Spiegel-Test
in test/termineView.test.js.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:48:10 +00:00
ClaudeandClaude Sonnet 5.5 91b1a3bcd8 Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen
Neu: server/services/termine/entwuerfe.js. Ein Terminplaner im Zustand off ist ein
Entwurf (Fragebogen, Einstellung). loescheEntwuerfe(now) im Tagesjob (nach den
abgeschalteten Funktionen, nur im vollen Lauf): letzte Aenderung = juengerer Zeitpunkt
aus $updatedAt des Funktionsdokuments und termineConfig.updatedAt, aelter als 90 Tage
=> Einstellung weg, das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war
(sonst bleibt es wegen des siteKeys; ein von E8 zurueckgesetztes Dokument nie).
Vor jedem Loeschen unter feature-order, feature-admin und termine-cfg frisch lesen
(noch off, Frist, keine Buchung). Seiten zu 100 per cursorAfter, hoechstens 2000
geprueft und 100 geloescht je Lauf, der Rest folgt an Folgetagen; Einzelfehler zaehlen
in fehler. Tagesbericht: Segment "Entwuerfe n geloescht (m offen)".
loescheEntwuerfeVonKunde: Kundenloeschung entfernt alle off-Entwuerfe vollstaendig,
Fehler werden nur protokolliert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:33:33 +00:00
ClaudeandClaude Sonnet 5.5 29a38a9592 Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen
POST /api/termine/einstellungen/vorlage nimmt jetzt eine Branche (BRANCHEN), optionale
Betriebsfelder (nichtleere Werte ueberschreiben das Konto) und schreibt den Fragebogen mit
der Einstellung in einem Vorgang. Der Gesundheitsbereich ist ausgeschlossen (400
gesundheitsbereich, nichts wird angelegt). Eine bestehende Einstellung ersetzt nur
ersetzen:true mit passender version im Zustand off (neue IDs und Kalender-Token, Fragebogen
neu, Einbauwuensche bleiben). Die Route arbeitet im Entwurf (off, active, ordered/setup nur
in der Kundenansicht, disabled nie).

PUT /api/termine/fragebogen speichert nur den Fragebogen (Version der Einstellung bleibt),
ist in der Kundenansicht gesperrt (Waechter und Router), zaehlt gegen das Schreibbudget und
antwortet mit { fragebogen, offen }.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 20:12:00 +00:00
ClaudeandClaude Sonnet 5.5 1cd74226cb Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich
PATCH /api/termine/einstellungen schreibt den Entwurf nach eigenen Regeln
(withTermine({ schreiben, entwurf }) in routes/termine.js): off und active
ja, ordered und setup nur in der Kundenansicht (sonst 409 gesperrt), disabled
409 abgeschaltet wie bisher. In off zusaetzlich laeuft(customerId): ohne
Wartungs-Abo 409 kein_abo (Text aus aboMeldung), bei Lesefehler 503
abo_pruefung; eine Kuendigung oder offene Zahlung sperrt nur die Bestellung,
nicht das Speichern des Entwurfs. Das Funktionsdokument legt erst der Handler
an (ensureFeatureDoc), nach der Eingabepruefung. PATCH normalisiert mit
entwurf: state !== 'active' und antwortet mit fehlt. Tests: Zustandsmatrix,
Mandanten, Pflichtangaben, Abo, und die Gegenprobe an der oeffentlichen
Schnittstelle (Entwurf und Einbauwuensche nie oeffentlich).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:48:57 +00:00
ClaudeandClaude Sonnet 5.5 42a3befc5f Terminplaner TP2 C4a: Nachbesserung 1
Die Wahl des Live-Hosts steht jetzt nur noch in liveHost() (kontext.js);
websiteUrl() und der Betriebsvorschlag des Fragebogens (routes/termine.js)
nutzen dieselbe Funktion.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:34:37 +00:00
ClaudeandClaude Sonnet 5.5 250fc0c4df Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:29:12 +00:00
ClaudeandClaude Sonnet 5.5 31aa80affd Terminplaner TP2: Fragebogen im Kontext und Speicher
kontext.js: ctx.fragebogen (normalisiert; fehlt er oder ist er kaputt, der leere,
kaputt mit einer Logzeile und ohne Wurf), ladeEinstellung liefert zusaetzlich
fragebogen, neuer kontextFuerPortal(feature) fuer das Portal in jedem Zustand.
speichereFragebogen schreibt unter termine-cfg:<id> nur fragebogen, updatedAt und
updatedBy (ohne version; fehlt das Dokument: 409 keine_einstellung). Neue Option
fragebogen bei speichereEinstellung schreibt Einstellung und Fragebogen in
derselben Sperre. kontextPerSiteKey bleibt unveraendert (Entwurf nie oeffentlich),
Tests dazu fuer off, ordered, setup, disabled und nach activate. Der Test
ladeEntwurf in funktionenCheckout.test.js schaltet die neue Logzeile stumm.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:14:22 +00:00
ClaudeandClaude Sonnet 5.5 008ad4fb1e Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung
fragebogen.js ersetzt den Stub aus Block B: bestellpruefung (Kriterien (a) bis (h),
je Punkt ein Eintrag, nach Schrittfolge sortiert, Abo-Punkt nur bei mitAbo !== false),
SCHRITT_TITEL, gesundheitsWarnungen (Stichworte nur am Wortanfang, hoechstens 10
Meldungen) und bestellungAnsicht fuer die Bestellseite. einstellungen.js exportiert
sichtbareLeistungen und MELDUNG_PFLICHT. Testhelfer bestellbarerStand und entwurfDoc;
der Stub-Test entfaellt, die zwei B-Tests mit dem Stub laufen gegen das echte Gate.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 19:00:12 +00:00
ClaudeandClaude Sonnet 5.5 ab47fabcb2 Terminplaner TP2: Fragebogen-Attribut, Branchen, Schritte, Normalisierung
termineConfig bekommt das Attribut fragebogen (Text 20000, Inhalt bis
FRAGEBOGEN_MAX 16000). fragebogen.js: SCHRITTE, BRANCHEN (Vorlagen plus
Gesundheitsbereich), leerFragebogen und normalisiereFragebogen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:49:06 +00:00
ClaudeandClaude Sonnet 5.5 36b36bcc4d Terminplaner-Banner und Meldung nach Abschaltgrund mit Löschdatum
GET /api/termine/einstellungen liefert bei abgeschalteter Funktion
abgeschaltet { grund, seit, loeschungAm }. Die 409-Meldung bei
Schreibversuchen (Code abgeschaltet) und der Banner im Portal
unterscheiden Wartungs-Abo beendet, Abo vor der Einrichtung beendet und
die übrigen Gründe und nennen das Löschdatum, wenn es eines gibt.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:41:03 +00:00
ClaudeandClaude Sonnet 5.5 d66dd60a76 Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)
Nur Tests in test/termineAboAbgleich.test.js, kein Produktionscode: handleFeatureCheckoutEvent
und gleicheAboAb gegeneinander (Review Focus 1, nie Geld ohne Spur, nie doppelt).
- Zahlung nach dem Abschalten: genau ein T19 (zahlung_nach_ende, Sitzung), kein Ticket,
  kein K15/T16; Wiederholung und async_payment_succeeded stumm
- T19-Versand scheitert: Webhook wirft, Zahlung gebucht, Fall offen; erste Wiederholung
  mit funktionierendem Versand sendet genau ein T19
- Folgetag-Abgleich: K22 vor_einrichtung, Marker aboEndeMailAt, kein zweites T19
- Abgleich vor der Webhook-Wiederholung und parallel (Promise.all): insgesamt ein T19
- Grund team: T16 abgeschaltet, kein T19; Abo laeuft (ordered/setup): Ticket, K15, T16
- Rennen Zahlung vor Abgleich: Ticket, T16 und ein T19; Stripe-Retry stumm

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:29:10 +00:00
ClaudeandClaude Sonnet 5.5 c5009da570 Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:19:48 +00:00
ClaudeandClaude Sonnet 5.5 6bd6aed7d5 Terminplaner TP2 E8: Nachbesserung 1 (offener Zahlungsfall bleibt sichtbar, R108)
setzeFunktionZurueck setzt ein Funktionsdokument mit offenem Zahlungsfall
(zahlungMeldung nicht leer und nicht 'gemeldet') nicht auf off zurueck: Warnung
ohne Kennungen, +1 in lauf.fehler, kein Wurf. Das Dokument bleibt disabled mit
seinen Markern, der Abo-Abgleich holt T19 weiter nach, nach 'gemeldet' setzt der
naechste Lauf zurueck. Buchungen und Einstellung werden wie bisher geloescht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 18:08:54 +00:00
ClaudeandClaude Sonnet 5.5 30701ba277 Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off
Bei Grund abo_ende prüft die Löschung vor jedem unumkehrbaren Schritt frisch,
ob das Wartungs-Abo wieder läuft (Lesefehler: nichts löschen). Sind alle
Buchungen und die Einstellung weg, wird das Funktionsdokument unter
feature-admin und termine-cfg auf off zurückgesetzt (siteKey bleibt).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:57:41 +00:00
ClaudeandClaude Sonnet 5.5 91e7f7d8a3 Tagesjob: Abo-Abgleich vor der Löschung, auch bei TERMINE_ENABLED=off mit bezahlten Dokumenten
taeglicherJob ruft gleicheAboAb(now) nach den Löschfristen und vor der Löschung
abgeschalteter Funktionen auf (Schritt „Abo-Abgleich“, ein Fehler dort stoppt weder
Löschung noch Rest; die Fehler des Abgleichs zählen in fehler). Das Ergebnis trägt
aboAbgleich, tagesbericht hängt „Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein /
N Mails / N ausgenommen“ vor „N Fehler“ an.

fuehreAus nimmt trotzAus: Im Modus off läuft der Tagesjob nur, wenn es ein vom Kauf
freigeschaltetes Termine-Dokument gibt (eine Abfrage, limit 1; bei einem Lesefehler
Warnung und { aus: true }). Dann läuft ein eingeschränkter Lauf (eingeschraenkt: true,
gleiche Überlappungssperre): nur Abo-Abgleich und Löschung abgeschalteter Funktionen,
mit eigener Berichtszeile und den Feldern, die meldeTagesjob und die Statusroute lesen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:45:48 +00:00
ClaudeandClaude Sonnet 5.5 1c74c89feb Terminplaner: Erstattungsmeldung unabhaengig vom Abo nachholen (R107)
Der taegliche Abo-Abgleich holt T19 fuer jedes bezahlte, abgeschaltete Dokument mit
Grund abo_ende_vor_einrichtung und zahlungMeldung ungleich gemeldet nach, auch wenn das
Wartungs-Abo wieder laeuft oder nicht lesbar ist (der Nachlauf liest das Abo nie).
Reihenfolge wie bisher: T19, dann K22 (nur wenn aboEndeMailAt leer), dann Marker.
meldeAboEnde sendet bei gesetztem K22-Marker und offener Erstattung nur T19.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 17:31:54 +00:00
ClaudeandClaude Sonnet 5.5 13523ca5e2 Abo-Abgleich: Rückkehr innerhalb der Frist schaltet wieder ein (K23)
Schritt 3 von gleicheAboAb: Ein abgeschaltetes Dokument mit Grund abo_ende,
dessen Wartungs-Abo wieder läuft, wird unter feature-admin:<projectId> nach
frischem Lesen von Dokument und Abo und mit vorhandener Einstellung über
activate wieder eingeschaltet (Daten und Einstellungen bleiben). K23 geht
nur, wenn K22 ging (aboEndeMailAt vor dem activate gelesen); scheitert K23,
bleibt die Reaktivierung bestehen. Nicht automatisch: off nach der Löschung,
abo_ende_vor_einrichtung, team, kunde_geloescht, Altbestand ohne Grund.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:44:47 +00:00
ClaudeandClaude Sonnet 5.5 addb88d5fd Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22
Terminplaner TP2 E5. Schritt 1b in gleicheAboAb: Dokumente im Zustand ordered
oder setup werden bei fehlendem Wartungs-Abo unter feature-admin:<projectId> nach
frischem Lesen von Dokument und Abo mit Grund abo_ende_vor_einrichtung abgeschaltet.
Die Meldelogik liegt in meldeAboEnde (auch für den Sofortpfad E9): bei bezahlter
Einrichtung zuerst T19 über meldeErstattung (außerhalb jeder feature-admin-Sperre),
dann K22 mit Block vor_einrichtung, dann der Marker aboEndeMailAt; scheitert T19,
folgen weder K22 noch Marker und der nächste Tag wiederholt beides. Ein unbezahltes
ordered-Dokument wird nur abgeschaltet. Schritt 2 holt auch abo_ende_vor_einrichtung
nach. Die Stripe-Sitzung und das Ticket bleiben unangetastet.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:32:14 +00:00
ClaudeandClaude Sonnet 5.5 17f933dbc7 Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen
gleicheAboAb(now) liest alle nicht abgeschalteten Terminplaner-Funktionen einmal,
bewertet je Kunde die Abo-Lage (aboMemo) und schaltet aktive, bezahlte Funktionen
ohne laufendes Wartungs-Abo ab (disabledReason abo_ende). Geschrieben wird nur
unter feature-admin:<projectId> nach frischem Lesen von Dokument und Abo; bei
Lesefehlern bleibt alles aktiv. Pilot- und Kulanz-Dokumente zaehlen als
ausgenommen. meldeAboEnde sendet K22 und setzt aboEndeMailAt erst nach Erfolg.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:20:57 +00:00
ClaudeandClaude Sonnet 5.5 973135d2de Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03
Terminplaner TP2 E3. K22 "Terminplaner beendet" (Blöcke abo_ende, hosting,
vor_einrichtung, daten) und K23 "Terminplaner läuft wieder" im Layout von K19,
Du-Form, Texte als Entwurf für die Mail-Überarbeitung. K02 und K03 bekommen den
Block funktionen (in K03 innerhalb von abo); routes/abo.js setzt ihn nur beim
Wartungs-Abo und nie bei der Speicher-Kündigung.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:09:05 +00:00
ClaudeandClaude Sonnet 5.5 eae6eee03c Terminplaner TP2 E2: Nachbesserung 1 (Löschdatum nur für den Terminplaner)
Ruling R105: featureView setzt loeschungAm nur für featureKey 'termine' (der
30-Tage-Löschlauf löscht nur Terminplaner-Daten); disabledReason und disabledAt
bleiben für alle Funktionen. Test: News ohne Löschdatum, Zeitfenster-Prüfung
entfernt, Teamroute-Ansicht pinnt loeschungAm für den Terminplaner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 14:02:57 +00:00
ClaudeandClaude Sonnet 5.5 35c9800856 Abschaltgrund und Loeschfrist in der Funktionsansicht (featureView, frist.js)
featureView liefert zusaetzlich disabledReason, disabledAt und loeschungAm (Berliner
Tag, 30 Tage nach disabledAt); alle drei nur im Zustand disabled, sonst null.
Neu termine/frist.js mit LOESCHFRIST_TAGE, loeschzeit und loeschTag (nur zeit.js/util.js).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:58:06 +00:00
ClaudeandClaude Sonnet 5.5 144b053851 Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn
aboStatus.js (Kernversion aus B5 ausgebaut): ein privater Lader lage(customerId, { altbestand })
ueber getCurrentSubscription und listContractEvents, alle Praedikate bauen darauf. laeuft kennt
altbestand (Vorgabe true: productKey || 'wartung' plus Wechsel-Regel; false: die heutige Regel der
Analyse, keine Ereignisabfrage). Wechsel-Regel: sagt der Spiegel "Wartung laeuft" und ist das
juengste wechsel-Ereignis des Abos widerrufen, laeuft das Wartungs-Abo nicht (kein_abo beim
Bestell-Gate, Freischaltung bleibt in setup). Neu aboLage ({ laeuft, art, endedAt }) und
aboMemo().lage; der Memo merkt eine Ablehnung fuer den Lauf. analyse.js nutzt
laeuft(id, { altbestand: false }); Verhalten der Analyse unveraendert (+1 Pin-Test).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:50:51 +00:00
ClaudeandClaude Sonnet 5.5 cf9f9b30a0 Funktionen-Seite: Preis und Bestelltexte aus dem Register
GET /api/funktionen liefert features.news und features.termine (oeffentlicheAnsicht,
auch wenn der Terminplaner noch nicht freigegeben ist) und ustHinweis; setupEstimate und
consentText bleiben als News-Alias. Die News-Kachel und die Buchungsseite lesen Preis,
Knopftext und Zustimmungstext aus features.news und fallen ohne features auf die bisherigen
Werte zurueck. News sendet weiter consent: true an /api/funktionen/news/checkout.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:42:20 +00:00
ClaudeandClaude Sonnet 5.5 f57dad73ca Freischaltung ueber das Register mit Pruefung von Einstellung und Abo
activateClosedTickets oeffnet jetzt alle Funktionen mit ticketFreischaltung im Register
(ticketFreischaltungKeys) statt fest news und fragt unter der Sperre feature-activate:<id>
nach dem frischen Lesen den Haken freischaltbar(doc). Der Standard fuer termine verlangt eine
gespeicherte Einstellung und ein laufendes Wartungs-Abo. false oder Fehler (z. B. Abo nicht
lesbar) lassen das Dokument in setup: Log und genau ein adminNote-Eintrag "Freischaltung
wartet: <Grund>", keine Mail. News bleibt unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:35:57 +00:00
ClaudeandClaude Sonnet 5.5 f4754f573a Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)
onFeaturePaid wertet den Fall aus markPaid aus. Trifft die Zahlung eine Funktion, die
schon freigeschaltet, in Einrichtung oder abgeschaltet ist, geht nur eine Team-Meldung
raus (T16 Block bereits_aktiv, bei Abo-Ende vor der Einrichtung T19), kein Ticket und
keine K15. Beide Meldungen laufen genau einmal je Dokument: unter feature-meldung:<id>
frisch lesen, Marker zahlungMeldung gemeldet erst nach gesendeter Mail. Scheitert der
Versand, wirft onFeaturePaid (Webhook 500), Stripe wiederholt und der naechste Versuch
sendet genau einmal.

meldeErstattung ist die einzige Stelle fuer T19 (Zahlung nach dem Abo-Ende und spaeter
die Meldung des Abo-Abgleichs), wirft nie. Bei Funktionen mit Wartungs-Abo-Pflicht
zeigt T16 nach der Verbuchung den Block kein_abo, wenn das Abo nicht mehr laeuft; ein
Lesefehler laesst den Block aus und blockiert nichts. customerOf und projectName sind
exportiert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:25:26 +00:00
ClaudeandClaude Sonnet 5.5 468df49812 Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument
markPaid liefert jetzt { doc, created, fall }. Trifft eine Zahlung ein Dokument, das nicht
mehr auf die Bezahlung wartet (active, disabled, setup mit Ticket), wird sie einmalig
verbucht (purchaseStatus paid, unlockedByPurchase, Rechnung, Notiz) und der noch zu
meldende Fall im eigenen Feld zahlungMeldung vermerkt (bereits_aktiv | abgeschaltet |
abo_ende), in einem einzigen Schreibzugriff. Ein Folgeereignis bucht nichts doppelt und
liefert den Fall erneut, bis zahlungMeldung auf gemeldet steht. ordered und fortsetzbares
setup laufen wie bisher, off bleibt unveraendert (Uebernahme ueber adoptPaidSession).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:13:42 +00:00
ClaudeandClaude Sonnet 5.5 992d7358fa Mail T16 je Funktion, neue Vorlage T19 Erstattung faellig
T16 (Funktion bestellt) nennt die Funktion aus dem Register statt fest
"News-Pinnwand": neue Platzhalter funktion, zustand, anleitung_url und
gesundheit_text, neue Bloecke kein_abo, bereits_aktiv, anleitung (Knopf
"Im Ticketsystem oeffnen" zur Einbauanleitung) und gesundheit. teamNotice
und unmatchedNotice fuellen funktion aus featureConfig (ohne bekannten
Schluessel "Funktion"); die neuen Bloecke kein_abo, bereits_aktiv und
gesundheit bleiben aus, bis B10a und D4 sie fuellen. Die News-Mails
bleiben woertlich (HTML, Text und Betreff byte-gleich).

T19 (Erstattung faellig, Team-Hinweis) ist neu: volle Erstattung von
Hand in Stripe, Bloecke zahlung_nach_ende, ticket_offen und rechnung.
Gesendet wird sie spaeter ueber meldeErstattung (B10b).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 13:06:39 +00:00
ClaudeandClaude Sonnet 5.5 e200efb7e5 Mails K15/K16 je Funktion (News-Wortlaut eingefroren)
K15 (Bestellung eingegangen) und K16 (Funktion bereit) tragen den Wortlaut der
Funktion als Platzhalter (funktion, funktion_gen/akk/nom/pron/dein, preis_hinweis,
danach, bereit, knopf, portal_url) statt fest News-Pinnwand. siteFeatures baut die
Werte mit bestellMail/bereitMail aus dem Register-Eintrag (mails.*, setupEstimate,
returnHash) und dem Katalogpreis je feature.product. K15 geht ohne Anhaenge raus.
Die News-Mails bleiben Zeichen fuer Zeichen gleich (Test featureMails friert
Betreff und Text ein).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:56:45 +00:00
ClaudeandClaude Sonnet 5.5 9dd0244537 Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung
markPaid holt Betreff und Details des Einrichtungs-Tickets aus dem Register (ticketTexte), createFeatureTicket
bleibt die einzige Stelle, die das Ticket anlegt. Betreff auf 128, Details auf 7000 Zeichen gekuerzt (surrogatsicher,
Schnitt an der Zeilengrenze, einheitliche Schlusszeile). Wirft die Details-Funktion oder liefert sie keinen Text,
gelten die Notfall-Details (Kopfzeilen und Verweis ins WOMS). Nach einem 400 bei langen Details gibt es genau einen
zweiten Versuch mit den Notfall-Details und derselben woid; 5xx, Netzfehler und kurze Details werden ohne
Wiederholung weitergereicht. Stub termine/ticketDetails.js bis Task D4.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:48:02 +00:00
ClaudeandClaude Sonnet 5.5 7fcb268644 Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register
Rest der Route POST /api/funktionen/:key/checkout unter der Sperre feature-order:<Kunde>:<Projekt>:
Zustand aus dem Register (setup/active, disabled, ordered mit offener Sitzung, "Zahlung fortsetzen"),
Stripe-Sitzung mit der Zeit des Klicks, Zustimmungsnachweis (Serverfassungen und -texte) vor markOrdered
und vor der URL. Standard-Haken belehrung mit BELEHRUNG_PLATZHALTER fuer den Terminplaner.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:38:50 +00:00
ClaudeandClaude Sonnet 5.5 1f6ae0f7e0 Terminplaner TP2 B6: Nachbesserung 1
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:30:44 +00:00
ClaudeandClaude Sonnet 5.5 ded6bb2a77 Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation
POST /api/funktionen/:key/checkout ersetzt /news/checkout (der News-Pfad bleibt als
key = news erhalten). Stufen ohne Seiteneffekt: 1 Kundenansicht (403), 2 Schluessel
gegen das Register (404), 3 Zustimmung (400 zustimmung_fehlt, 409 fassung_veraltet mit
der Serverfassung; News weiter mit consent: true), 4 Projekt (400/403; bei fortsetzen
muss das Dokument ordered sein), 5 Schalter (409 nicht_verfuegbar), 5b Unterlagen
(503 unterlagen_fehlen), 6 Bestellpruefung (409 bestellung_offen mit offen), 7 Wartungs-Abo
(409 kein_abo | gekuendigt | nicht_aktiv, Lesefehler 500). fail() gibt err.body weiter.

featureOrder.js: pruefeZustimmung, istFortsetzen, aboMeldung (einzige Quelle der
Abo-Hinweistexte), ladeEntwurf, pruefeAbo, orderDeps. featureRegistry.js: featureSperre
und die Standard-Haken verfuegbar, bestellpruefung (mitAbo: false, ohne abo) und
unterlagenBereit des Terminplaners. Der Rest der Route (Sperre, Zustand, Sitzung,
Nachweis) bleibt bis Task B7 nur fuer News; andere Funktionen enden mit 503.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:25:09 +00:00
ClaudeandClaude Sonnet 5.5 d568a3798d Abo-Baustein (Kernversion) und Platzhalter fuer die Bestellpruefung
aboStatus.js: laeuft(customerId) (Wartungs-Abo, productKey || 'wartung', Status in
ACTIVE_STATUSES), bestellbar(customerId) mit den Gruenden kein_abo, gekuendigt,
nicht_aktiv in dieser Pruefreihenfolge und aboMemo() (je Aufruf und Kunde nur eine
Lesung). Fail-closed: ein Lesefehler wirft, nie false oder ok:false.

termine/fragebogen.js: Platzhalter bestellpruefung() mit der endgueltigen Signatur,
liefert []; Block C ersetzt ihn.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:12:09 +00:00
ClaudeandClaude Sonnet 5.5 786df1230e Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick
server/services/featureConsents.js speichert und liest den Nachweis zur Bestellung
einer Funktion (nur anfuegen, sieben Exporte): speichereZustimmung (Texte und Fassungen
als JSON, SHA-256 ueber die Texte, vollstaendig oder gar nicht), letzteZustimmung,
zustimmungFuerSitzung (leere Sitzung fragt nicht ab), leseZustimmung, texteHash,
zustimmungsfassungen und nachweisTexte (Haken, Belehrung, Knopf aus dem Register).
paidFeatureOrders liefert featureKey und vertragAt (Klick der bezahlten Sitzung, sonst
consentAt, sonst orderedAt; Lesefehler faellt mit Log auf orderedAt zurueck); der Widerruf
einer Funktion rechnet die Frist aus vertragAt. Eine spaetere, unbezahlte Zustimmung
verschiebt den Anker nicht.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 12:05:12 +00:00
ClaudeandClaude Sonnet 5.5 d8e4d79891 Schema: Zustimmungsnachweis featureConsents im Skript feature-setup
scripts/feature-setup.mjs legt neben den portalFeatures-Attributen die Collection
featureConsents (nur Server-Key, ohne Rechte) mit 11 Attributen und den Indizes
idx_session und idx_project_feature an; --check meldet je fehlende Collection,
Attribut und Index eine Zeile "- <id>[.<key>] FEHLT", schreibt nichts und liefert
Exit-Code 1 bei Luecken. config.collections.featureConsents (Env
APPWRITE_COLLECTION_FEATURE_CONSENTS) und schemaKeys('featureConsents') in
test/support/schema.js ziehen mit. setup-abo-collections.mjs bleibt unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:57:16 +00:00
ClaudeandClaude Sonnet 5.5 98fa03aad7 Stripe: Katalog Terminplaner-Einrichtung (149 €) und Checkout je Funktion aus dem Register
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:53:06 +00:00
ClaudeandClaude Sonnet 5.5 c584d220c8 Funktionen: Register config.features mit News- und Termine-Eintrag
config.features (news, termine) als reine Daten in config.js, config.ustHinweis,
config.funktionen als Alias mit Gettern auf features.news. Neu featureRegistry.js
(featureConfig, featureKeys, featureHaken mit Ueberschreib-Regel, oeffentlicheAnsicht);
FEATURE_KEYS, FEATURE_LABELS und DISABLED_MESSAGE in siteFeatures.js kommen aus dem
Register, Werte unveraendert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:48:06 +00:00
ClaudeandClaude Sonnet 5.5 e2f50407a0 Terminplaner: Minutenjob in Verfall und Mailversand getrennt geschuetzt
fuehreAus nimmt { teil } und sperrt je job:teil; minutenJob startet Verfall
und Mailversand (Warteschlange plus Betriebspruefung) gemeinsam mit
Promise.all. Haengt ein Teil, laeuft der andere im naechsten Takt weiter
(verfallUebersprungen bzw. mailUebersprungen); uebersprungen gilt nur, wenn
beide Teile uebersprungen werden. Die Ueberlappungswarnung nennt den Teil,
das Fehlerlog behaelt minutenJob.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:41:00 +00:00
ClaudeandClaude Sonnet 5.5 aaed273b62 Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup
disable() laeuft unter einer Sperre je Dokument und aendert ein schon
abgeschaltetes Dokument nicht mehr (disabledAt als Beginn der 30-Tage-Frist,
Grund und Notiz bleiben); nur Altbestand ohne disabledAt bekommt dieses Feld.
Neu: DISABLED_REASONS und der Parameter reason (Vorgabe team, unbekannt = 400),
disableCustomerFeatures reicht ihn durch, die Kundenloeschung nutzt
kunde_geloescht. activate() leert zusaetzlich disabledReason und aboEndeMailAt
und laesst zahlungMeldung unberuehrt.

scripts/feature-setup.mjs legt portalFeatures.disabledReason, aboEndeMailAt und
zahlungMeldung idempotent an (--check meldet Luecken mit Exit 1);
schemaKeys('portalFeatures') zieht die Schluessel mit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:33:17 +00:00
ClaudeandClaude Sonnet 5.5 39225594e5 Terminplaner: Status-Route fuer das Team
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:24:02 +00:00
ClaudeandClaude Sonnet 5.5 4601c5ef3b Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht
meldeTagesjob meldet am Ende des Tagesjobs ueber den Melder aus A10a2 die Vorfaelle
tagesjob (Fehler oder Abbruch), deckel (Loeschdeckel an 3 Tagesjobs in Folge) und
betriebe (ab 20 aktiven Betrieben, D8) und merkt letzterTagesjob (nur Zaehlungen).
taeglicherJob liefert geloescht und aktiveBetriebe, tagesbericht haengt
"n aktive Betriebe" an.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:17:07 +00:00
ClaudeandClaude Sonnet 5.5 689a21f9ac Terminplaner: Betriebspruefung mit Abkuehlzeit fuer die Team-Mail
pruefeBetrieb (neu: termine/betrieb.js) prueft am Ende jedes Minutenjobs
Mailbudget (ab 70 %), Warteschlange (aeltester faelliger Eintrag > 10 Min
ueberfaellig, neu outbox.warteschlangeAlter) und Anmeldepause
(pausiertBis). Der gemeinsame Melder meldeVorfall schreibt je Schluessel
hoechstens stuendlich ein Warnlog und schickt die Team-Mail T20 beim
Beginn und danach fruehestens nach 6 Stunden; Lesefehler und Mailfehler
stehen nur im Log. T20 steht jetzt auch im Test der Team-Fusszeile.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 11:01:16 +00:00
ClaudeandClaude Sonnet 5.5 3a3319ec14 Terminplaner: Team-Mail-Vorlage T20 Betriebsmeldung
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:26:08 +00:00
ClaudeandClaude Sonnet 5.5 43b3caddde Terminplaner: veraltete G01 verworfen, Index bookingId
Eine G01, die erst nach holdMinutes + 1 Minute faellig wird, ist mit verfallenem Link
nutzlos: sie wird nicht mehr gesendet, sondern verworfen (kein Transportaufruf, kein
Budgetplatz, Felder geleert, kein Zustellfehler, raeumeAuf loescht sie nach 48 Stunden).
Neuer Index idx_booking auf termineOutbox fuer zustellFehler und verwerfeWartende;
verworfeneG01() zaehlt seit Prozessstart.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:21:25 +00:00
ClaudeandClaude Sonnet 5.5 36b94c3035 Terminplaner: Mail-Warteschlange begrenzt und nach Vorrang geordnet
verarbeite liest nicht mehr alle wartenden Eintraege, sondern je Klasse
(vorrang, normal, nachrangig) eine begrenzte Liste (config.termine.mailListe,
Standard 100) mit select und orderAsc(naechsterVersuch). Bei Pause oder
leerem Gesamtbudget endet der Lauf ohne Abfrage, bei erreichter Reserve
entfaellt die nachrangige Klasse. Funktionen mit ausgeschoepftem Kontingent
kommen in eine Ausschlussliste (hoechstens 90 Werte); hoechstens vier weitere
Runden je Klasse, damit keine Funktion hinter einer vollen verhungert.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:13:26 +00:00
ClaudeandClaude Sonnet 5.5 4d96565240 Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 10:03:39 +00:00
ClaudeandClaude Sonnet 5.5 c35711caad Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:54:11 +00:00
ClaudeandClaude Sonnet 5.5 978722023f Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel
raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel }) liest
Zellen mit bis < jetzt - 1 Tag in Seiten zu 100 ueber idx_bis (aelteste
zuerst, ohne Cursor neu gelesen), gruppiert je Ressourcensperre mit Map und
loescht unter der Sperre nur, was dort noch dieselbe Buchung hat und noch alt
ist. Die Buchungen werden nicht gelesen. Der Lauf endet am Deckel (5000,
gedeckelt wird im Tagesjob gezaehlt und im Log gewarnt), bei einer leeren
Seite oder einer Seite ohne Erfolg; der erste Fehler wird am Ende geworfen.
ungueltigeZellen prueft zuerst das Zeitfenster (auch fuer belegeZellen).
verwaisteZellen, alleZellen und ALLE_ZELLEN_MAX entfallen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:44:47 +00:00
ClaudeandClaude Sonnet 5.5 d040a55f89 Terminplaner: CSV-Export seitenweise gestreamt
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:32:14 +00:00
ClaudeandClaude Sonnet 5.5 a1732613c9 Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen
loescheAbgeschaltete liest je abgeschalteter Funktion (disabledAt vor 30 Tagen) Seiten zu 100
(neu buchungen.ersteBuchungen: nur $id, limit, nie Gastfelder) und loescht sie ueber arbeiteSeiten
samt Zellen, bis die Seite leer ist. Die Funktion wird nach jedem Lesen einer Seite und alle 50
Buchungen frisch geprueft (nochAbgeschaltet); wird sie wieder eingeschaltet, endet die Loeschung
fuer sie sofort. Deckel config.termine.loeschDeckel je Funktion: am Deckel bleibt die Funktion
disabled mit altem disabledAt, eine Warnung ohne Kennungen steht im Log, der naechste Tag macht
weiter, die uebrigen Funktionen kommen im selben Lauf dran. Die Einstellung (termineConfig) wird
nur nach leerer Seite ohne Fehler und mit letzter Pruefung unter termine-cfg:<id> geloescht.
loescheAbgeschalteteFunktionen liefert { funktionen, buchungen, gedeckelt }; taeglicherJob traegt
gedeckelt.abgeschaltet getrennt vom Loeschzaehler ein (Tagesbericht: Loeschlaeufe am Deckel).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:24:33 +00:00
ClaudeandClaude Sonnet 5.5 9b4305e5de Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht
erledigt/nichterschienen werden jetzt je Funktion (portalFeatures termine, active|disabled)
nach deren aufbewahrungTage seitenweise geloescht; beide Status teilen sich den Deckel
loeschDeckel der Funktion, alle Schritte eines Laufs zusammen loeschDeckelGesamt (neu,
Standard 20000). Ein Sicherheitsnetz loescht erledigt/nichterschienen ohne Funktionsfilter
nach 365 Tagen (Buchungen ohne Funktionsdokument, unlesbare Einstellung). Der Tagesbericht
nennt "Loeschlaeufe am Deckel: n, Rest folgt an den nächsten Tagen" nur bei gedeckelt > 0.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 09:09:48 +00:00
ClaudeandClaude Sonnet 5.5 5c95dfc322 Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)
loeschKandidaten liest eine Seite (Standard 100, aelteste Bezugszeit zuerst, optional je Funktion)
ohne Gastfelder; die Frist steht in der Abfrage. arbeiteSeiten (Seite lesen, bearbeiten, neu lesen)
endet bei leerer Seite, am Deckel (config.termine.loeschDeckel, 2000 je Status und Lauf, Rest an den
Folgetagen, gezaehlt als gedeckelt) oder bei einer Seite ohne Fortschritt. verfallen, abgesagt und
abgelehnt laufen darueber, unter der Buchungssperre werden Status und Bezugszeit frisch geprueft.
taeglicherJob liefert gedeckelt getrennt vom Loeschzaehler. Neue Indizes idx_status_ends und
idx_status_updated auf termineBookings (scripts/termine-setup.mjs legt sie an).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:55:32 +00:00
ClaudeandClaude Sonnet 5.5 2dd9c0dec7 Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:38:21 +00:00
ClaudeandClaude Sonnet 5.5 8c1055e8a0 Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-04 08:34:29 +00:00
ClaudeandClaude Opus 5.5 a72380cc8b Terminplaner TP2 Verkauf: Umsetzungsplan (Bloecke A, B, E, C, D, F und Livegang-Checkliste G)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 20:04:49 +00:00
ClaudeandClaude Opus 5.5 6ed0e3bcaf Terminplaner TP2 Verkauf: Design mit den Entscheidungen D1–D8 des Inhabers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-03 18:17:11 +00:00
170 changed files with 44690 additions and 1214 deletions

No files matched your search

Binary file not shown.
@@ -0,0 +1 @@
2fa927a159954657293882cf9ef03d8a076a528126fca079b2a174811cecfca4
+3 -3
View File
@@ -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
@@ -115,7 +115,7 @@ Bricht das Netz mitten in der Strecke ab, zeigt das Widget „Gerade nicht errei
2. Person wählen („Egal“ zuerst). Nur wenn mehrere Personen infrage kommen und der Betrieb die Wahl erlaubt; bei Plätzen entfällt der Schritt.
3. Tag (Monatsraster, nur Tage mit freien Zeiten sind wählbar) und Uhrzeit. Alle Daten und Uhrzeiten kommen vom Server in Berliner Zeit. Das Widget rechnet nie mit dem Datum oder der Zeitzone des Browsers.
4. Angaben: Name, E-Mail, Telefon, Nachricht und Zusatzfelder nach den Einstellungen des Betriebs.
5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
5. Zusammenfassung mit Beschreibung der Leistung, Stornobedingungen (nur bei einer Leistung mit Preis; daneben der Link „AGB des Betriebs“, wenn im Portal eine https-Adresse hinterlegt ist), Widerrufsbelehrung (aufklappbar, bei Verbraucher-Betrieben), Datenschutz-Hinweis, den Haken (Storno nur bei einer Leistung mit Preis, vorzeitiger Beginn, Erinnerung) und dem Knopf („Zahlungspflichtig buchen“, „Termin buchen“ oder „Termin anfragen“, kommt vom Server).
6. „Fast geschafft“: Der Gast bestätigt den Termin über den Link in der E-Mail. Die Reservierung gilt 30 Minuten.
Im Fuß stehen „Vertrag widerrufen“ als zweiter Knopf (kein bloßer Textlink; für Verbraucher bei jeder Leistung, ob sofort buchbar oder als Anfrage, weil auch ein angenommener Anfrage-Vertrag widerrufen werden kann), „Datenschutz“ (Link auf die Datenschutzerklärung des Betriebs) und klein „Terminbuchung über WEBklar“. Im Modus `knopf` steht der Fuß nur im geöffneten Dialog. Damit die Widerrufsfunktion (§ 356a BGB) trotzdem dauerhaft auffindbar ist, lädt das Widget dort schon beim Laden der Seite die Konfiguration (ein einziger Abruf, sonst nichts) und setzt, wenn der Betrieb ein Widerrufsrecht hat, den Knopf „Vertrag widerrufen“ als zweiten Knopf neben den Buchungsknopf. Er öffnet den Dialog gleich beim Widerrufsformular. Anrede (Sie oder Du) und Betriebsfarbe kommen aus den Einstellungen. Die rechtlich vorgeschriebenen Texte (Haken, Belehrung) stehen immer in der Sie-Form.
@@ -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
+23
View File
@@ -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-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.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-<Fassung>.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 (Textänderungen: 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.“ Außerdem ändert sich die Regel zum Haken „Stornobedingungen“, nicht sein Text: Pflicht ist er nur noch bei einer Leistung mit Preis, bei kostenlosen Leistungen fragt der Terminplaner nicht danach (die Fassung 2026-10-02 verlangte ihn bei jeder Leistung, sobald der Betrieb Stornobedingungen hat). Buchungen, die vor dem Livegang der neuen Fassung entstanden sind, behalten im Nachweis die Fassung 2026-10-02.
@@ -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).
@@ -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 bei einer Leistung mit Preis, wenn der Betrieb Stornobedingungen hat; bei kostenlosen Leistungen fragt der Terminplaner nicht danach; 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).
+145
View File
@@ -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 anschließend eine E-Mail mit diesen beiden Texten als PDF.
(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. Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). 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. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen (zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Bis zur Löschung 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; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6). 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. |
@@ -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. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).
(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.
@@ -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 `<h2 id="termine">` 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
<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>
<!-- Entwurf – vor Verwendung anwaltlich prüfen lassen (Rollenverteilung Auftragsverarbeiter, wie 6b) -->
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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).</p>
```
## 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.
@@ -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
<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen. 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).</p>
```
+8 -19
View File
@@ -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.
>
+6 -15
View File
@@ -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.
@@ -31,8 +29,8 @@ Wortlaut immer in der Sie-Form, auch bei Betrieben, die ihre Gäste duzen: Es si
| Haken | Wann | Pflicht | Wortlaut |
|---|---|---|---|
| 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. |
| Stornobedingungen | der Betrieb hat Stornobedingungen hinterlegt und die gebuchte Leistung hat einen Preis (bei kostenlosen Leistungen fragt der Terminplaner nicht danach) | 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. 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.
+966
View File
@@ -0,0 +1,966 @@
# Kanzlei-Paket Terminplaner (WEBklar)
Dieses Paket gehört zum Verkaufsweg des Terminplaners der WEBklar GbR (Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein). Es zeigt einer Kanzlei, was verkauft wird, wie der Ablauf ist, welche Texte Kunden und Gäste in Endfassung sehen, wo diese Texte im Programm stehen und was wir wissen möchten.
Fassungen: Leistungsbeschreibung, Auftragsverarbeitungsvertrag (AVV), Widerrufsbelehrung und Bestelltexte tragen die Fassung **2026-10-05** (Konstante `config.termine.avvVersion`). Die Gästetexte (Hinweise, Haken, Belehrung und Datenschutz-Absatz für die Gäste unserer Kunden) tragen die Fassung **2026-10-05** (Konstante `RECHTSTEXTE_VERSION`). Beide Konstanten haben heute dasselbe Datum, sind aber getrennt und können sich später unterscheiden.
## 1. Zweck und Umfang
**Zweck.** Der Inhaber lässt die Texte des Terminplaners durch eine Kanzlei prüfen, bevor der erste fremde Betrieb den Terminplaner nutzt. Bis dahin ist der Terminplaner nicht für fremde Betriebe freigeschaltet. Das Paket soll ohne Rückfragen zeigen, was die Kanzlei prüfen soll und von welchen Annahmen wir ausgehen.
**Kernpaket.** Der Inhaber hat festgelegt, dass die Kanzlei dieses Kernpaket prüft und alles Übrige WEBklar selbst prüft (Hausrunde). Die Kanzlei prüft:
- die Widerrufsbelehrung und das Muster-Widerrufsformular der Bestellung der Einrichtung für 149 € (Abschnitt 3.2),
- den Zustimmungssatz zum vorzeitigen Beginn mit Erlöschen des Widerrufsrechts und Wertersatz (Abschnitt 3.1, Haken 3),
- den Auftragsverarbeitungsvertrag Terminplaner (Anlage A, Auszüge in Abschnitt 3.5),
- die Haftungs- und Ende-mit-dem-Abo-Klauseln der Leistungsbeschreibung (Anlage B, Auszüge in Abschnitt 3.4),
- die Gästetexte mit Gesetzlichkeitsfiktion: Widerrufsbelehrung für Gäste und die Hinweise und Haken im Widget (Abschnitte 3.6 bis 3.10).
**Hausrunde.** Die Datenschutzerklärung von WEBklar (Abschnitt 6c und die Ergänzungen in den Abschnitten 6 und 7), die Hinweise im Kundenportal und die Mailtexte prüft WEBklar selbst. Zu einzelnen Punkten daraus stellen wir trotzdem Fragen (Q15 bis Q19 und Q24); was wir nicht fragen, steht in Abschnitt 6.
**Begriffe.** *Betriebsart* (im Programm „Modus“) des Terminplaners: `off` (abgeschaltet), `pilot` (nur Kunden auf einer Liste von Pilotadressen) oder `on` (alle Kunden mit Wartungs-Abo). *Pilot* ist ein Kunde auf dieser Liste; derzeit sind das nur Konten des Inhabers. `on` setzt der Inhaber erst nach Ihrem Befund und seiner Freigabe. *Kulanz* heißt, dass das Team einem Kunden den Terminplaner ohne Bestellung und ohne Zahlung freischaltet (Abschnitt 2.1). *Fassung* ist ein Stand der Texte mit Datum.
**Keine Rechtsberatung durch WEBklar.** Die Texte sind Mustertexte für die Standardkonfiguration und keine Rechtsberatung (Leistungsbeschreibung Nr. 11 Abs. 1). Die rechtlichen Aussagen in diesem Paket (vor allem „unsere Annahme“ bei den Fragen) sind ein Arbeitsstand von WEBklar und kein Rechtsurteil.
**Fassungen und Änderungen.** Eine ausgelieferte Fassung wird nie überschrieben. Ändern wir nach dem Befund einen Text, bekommt er ein neues Fassungsdatum, neue Dateien (HTML und PDF) und ein neues Archiv der Gästetexte; die Zitate in diesem Paket werden dann angepasst. Ein Test prüft jedes Zitat wörtlich gegen den Quelltext, damit Paket und Programm nicht auseinanderlaufen.
**Aufbau.** Abschnitt 2 beschreibt den Produktablauf, Abschnitt 3 enthält die Texte, Abschnitt 4 unsere Fragen Q1 bis Q26, Abschnitt 5 die Fundstellen im Programm, Abschnitt 6 was nicht Teil der Prüfung ist, Abschnitt 7 die Checkliste für den Inhaber. Hinter jedem Zitat in Abschnitt 3 steht „⚖ Q…“ mit den Fragen, die den Text betreffen. Anlagen: **Anlage A** ist der AVV Terminplaner, **Anlage B** die Leistungsbeschreibung Terminplaner, beide in voller Länge als PDF (Dateien in Abschnitt 3.11). **Anlage C** sind die gerenderten Mails K15 (Bestellbestätigung mit den Zustimmungen) und K16 (Terminplaner bereit); sie folgt nach der Mail-Überarbeitung des Inhabers, weil ihr Wortlaut bis dahin noch nicht endgültig ist.
## 2. Produktablauf in Kurzform
Der Terminplaner ist ein Buchungs-Widget für die Website eines Betriebs (zum Beispiel Friseur, Werkstatt oder Studio) mit einer Verwaltung im Kundenportal von WEBklar. Der Betrieb ist Kunde von WEBklar („Kunde“); seine Besucher, die einen Termin buchen, sind „Gäste“. Verkauft wird die Einrichtung für 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG. Für den Betrieb berechnet WEBklar neben dem Wartungs-Abo nichts; der Terminplaner läuft, solange das Wartungs-Abo läuft. Bezahlt wird beim Terminplaner nur vor Ort, es gibt keine Online-Zahlung der Gäste.
### 2.1 Bestellung und Einrichtung
| Schritt | Was geschieht | Frage |
|---|---|---|
| 1. Abo-Prüfung | Nur Kunden mit laufendem Wartungs-Abo können bestellen; ein Hosting-Abo genügt nicht. Bestellbar ist der Terminplaner nur, solange das Wartungs-Abo aktiv ist und keine Kündigung vorgemerkt ist. | ⚖ Q25 |
| 2. Fragebogen-Entwurf | Der Kunde beantwortet im Kundenportal einen Fragebogen in acht Schritten. Jeder Schritt wird sofort als Entwurf der späteren Einstellungen gespeichert; ein Entwurf ohne Bestellung wird 90 Tage nach der letzten Änderung gelöscht. Die Branchenfrage kennt den Gesundheitsbereich; wählt der Kunde ihn, ist die Bestellung gesperrt. Zusätzlich warnt eine Stichwortprüfung über Betriebsname, Leistungen und Felder das Team, das die Einrichtung ablehnen kann. | ⚖ Q11, Q24 |
| 3. Bestellung mit vier Haken | „Prüfen und bestellen“: Zusammenfassung, offene Punkte (der Server prüft, ob alles Nötige vorliegt, darunter die Adressen von Impressum und Datenschutzerklärung der Website), volle Widerrufsbelehrung mit Muster-Widerrufsformular, vier Haken und der Knopf „Zahlungspflichtig bestellen · 149 €“ (Abschnitte 3.1 und 3.2). Alle Besteller werden wie Verbraucher behandelt. | ⚖ Q1, Q3, Q19, Q20 |
| 4. Nachweis | Der Server legt zuerst die Zahlungssitzung bei Stripe an und speichert dann den Nachweis: die gezeigten Texte, ihre Fassungen, einen Hash, den Zeitpunkt des Klicks, IP-Adresse und Browser-Kennzeichen, Kundenkonto und angemeldete Person. Erst danach erhält der Browser die Adresse der Zahlungsseite. Der Zeitpunkt des Klicks ist der Anker der Widerrufsfrist. | ⚖ Q2, Q23 |
| 5. Zahlung | Stripe Checkout (Karte oder SEPA-Lastschrift); Stripe erstellt die Rechnung. | ⚖ Q19 |
| 6. Ticket und Bestätigung | Nach dem Zahlungseingang legt das Portal ein Einrichtungs-Ticket mit der Einbauanleitung an. Der Kunde erhält die Bestellbestätigung K15 mit den gezeigten Zustimmungen und, als PDF-Anhang auf einem dauerhaften Datenträger, Leistungsbeschreibung, AVV und Widerrufsbelehrung mit Muster-Widerrufsformular in der Fassung, der er zugestimmt hat. Das Team erhält die Meldung T16. | ⚖ Q3, Q20 |
| 7. Einbau und Freischaltung | WEBklar baut das Widget ein, der Kunde nimmt es ab, WEBklar schaltet es frei; der Kunde erhält die Mail K16 („bereit“). | |
| 8. Betrieb | Gäste buchen im Widget (Abschnitt 2.2), der Kunde verwaltet seine Termine im Kundenportal. | |
**Freischaltung ohne Bestellung (Pilot, Kulanz).** Das Team kann einen Terminplaner ohne Zahlung aktivieren. Sobald die Betriebsart `on` gilt (alle Kunden mit Wartungs-Abo), verlangt das Portal dafür einen Nachweis der Zustimmung des Kunden in Textform (Datum, Weg Chat oder E-Mail, Name des Mitarbeiters), außer für Pilotadressen; danach erhält der Kunde die Mail K16 mit Leistungsbeschreibung und AVV als PDF. Solche Dokumente gelten nicht als durch Kauf freigeschaltet; der tägliche Abo-Abgleich nimmt sie aus (⚖ Q7, Q21).
### 2.2 Gast: Buchung, Widerruf, Daten
1. Der Gast wählt Leistung, Termin und gegebenenfalls Person oder Platz und gibt Name und E-Mail-Adresse an (je nach Einstellung des Kunden auch Telefon, Nachricht, Zusatzfelder). Er sieht den Datenschutz-Hinweis (Abschnitt 3.8), die Haken (Abschnitt 3.7) und, bei Verbrauchern, die Widerrufsbelehrung zum Aufklappen (Abschnitt 3.6). ⚖ Q12, Q13, Q17
2. Die Buchung wird 30 Minuten gehalten. In dieser Zeit bestätigt der Gast per Link in einer E-Mail; erst die Bestätigung macht die Buchung verbindlich, bei einer Terminanfrage nimmt der Kunde später an. Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist. ⚖ Q14
3. Die Bestätigungs-E-Mail (G02) enthält bei Verbrauchern Widerrufshinweis, Widerrufsbelehrung und Muster-Widerrufsformular; bei einer Freizeit-Dienstleistung mit festem Termin, die der Kunde so einstuft, steht stattdessen der Hinweis, dass kein Widerrufsrecht besteht (Abschnitt 3.9). ⚖ Q15, Q16
4. Widerrufen kann der Gast über den Knopf „Vertrag widerrufen“ im Terminbereich der Website oder über den Link „Termin verwalten“ in der Bestätigungs-E-Mail; er erhält eine Eingangsbestätigung mit Datum und Uhrzeit (G08). Die Widerrufsfrist beträgt 14 Tage.
5. Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes; die IP-Adresse dient dem Missbrauchsschutz nur im Arbeitsspeicher des Servers. Die Buchung speichert als Nachweis die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der gezeigten Texte; WEBklar archiviert jede Fassung der Gästetexte. ⚖ Q26
6. Löschfristen: bestätigte und erledigte Termine 90 Tage nach dem Terminende (einstellbar 14 bis 365 Tage), abgesagte oder abgelehnte Termine 30 Tage nach der Absage, nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist; in der Versandwarteschlange für E-Mails werden Empfängeradresse und Inhalt nach dem Versand geleert und der Eintrag nach 7 Tagen gelöscht; Sicherungen halten gelöschte Daten bis zu sieben Tage. ⚖ Q9
### 2.3 Ende des Abos
1. Ein täglicher Abgleich prüft das Wartungs-Abo. Endet es (auch durch Wechsel ins Hosting-Abo oder durch Widerruf des Abschlusses oder eines Wechsels), schaltet WEBklar den Terminplaner spätestens am folgenden Tag ab und informiert den Kunden mit der Mail K22. Ist etwas nicht lesbar, schaltet der Abgleich nichts ab und versucht es am nächsten Tag erneut. ⚖ Q7
2. Nach dem Abschalten nimmt der Terminplaner keine neuen Buchungen mehr an. Die Termine bleiben im Kundenportal sichtbar und als CSV-Datei exportierbar, Gäste können über ihren Link absagen, bereits angeforderte Erinnerungen laufen weiter, der Kunde informiert seine Gäste.
3. 30 Tage nach dem Abschalten löscht WEBklar alle Termindaten und die Einstellungen; das Einrichtungs-Ticket des Teams bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen (keine Daten von Gästen). Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein (Rückkehr, Mail K23). Nach der Löschung sind ein neuer Fragebogen und die Einrichtung für 149 € noch einmal nötig. ⚖ Q9
4. Endet das Wartungs-Abo, bevor der Terminplaner freigeschaltet ist, erstattet WEBklar die 149 € vollständig. Der Abgleich schaltet den Terminplaner mit dem Grund „Abo endete vor Einrichtung“ ab und meldet dem Team die Erstattung (T19); die Erstattung löst der Inhaber in Stripe aus, es gibt keine automatische Rückzahlung. In diesem Fall gibt es keine automatische Rückkehr. ⚖ Q6
### 2.4 Widerruf der Bestellung durch den Kunden
Der Kunde widerruft auf der Seite „Vertrag widerrufen“ mit der Auswahl „Terminplaner (Einrichtung)“. Jede Bestellung hat ihre eigene Frist von 14 Tagen ab dem Zeitpunkt des Klicks auf „Zahlungspflichtig bestellen“ (bei mehreren Website-Projekten also mehrere Fristen). Die Widerrufsseite ordnet den Widerruf keinem Projekt zu: Sie bewertet den Eingang gegen das späteste Fristende aller bezahlten Terminplaner-Bestellungen des Kunden; das Team erhält in der Meldung T09 jede Bestellung mit Projekt, Zustand und eigenem Fristende und entscheidet. Der Widerruf erzeugt die Mail K04 an den Kunden und die Meldung T09 an das Team; die Abwicklung (Erstattung in Stripe abzüglich Wertersatz für einen schon begonnenen Einbau, Abschalten des Terminplaners) erledigt das Team von Hand. ⚖ Q2, Q5, Q7
### 2.5 Zahlen
| Wert | Bedeutung | Quelle im Code |
|---|---|---|
| 149 € | Einrichtung je Website-Projekt, einmalig, ohne Umsatzsteuer gemäß § 19 UStG | `priceLabel` des Terminplaners, `amountCents` 14900 |
| 14 Tage | Widerrufsfrist | `WITHDRAWAL_DAYS` |
| 30 Minuten | Haltezeit einer Buchung, Gültigkeit des Bestätigungslinks | `holdMinutes` |
| 48 Stunden | Löschung nicht bestätigter Buchungen nach Ablauf der Bestätigungsfrist | `VERFALLEN_BEHALTEN_MS` |
| 90 Tage | Aufbewahrung bestätigter Termine nach dem Terminende (Vorgabe) und Aufbewahrung von Entwürfen ohne Bestellung | `AUFBEWAHRUNG_VORGABE`, `ENTWURF_TAGE` |
| 7 Tage | Eintrag in der Versandwarteschlange nach dem Versand; Sicherungen des Servers | `GESENDET_BEHALTEN_MS` |
| 30 Tage | Löschung nach dem Abschalten; Löschung abgesagter oder abgelehnter Termine | `LOESCHFRIST_TAGE`, `ABGESAGT_BEHALTEN_MS` |
## 3. Texte in Endfassung
Alle Zitate stehen wörtlich so im Programm oder in den Dokumenten (Abschnitt 5 nennt die Fundstellen). Platzhalter in doppelten geschweiften Klammern stehen für Angaben des jeweiligen Betriebs. In den Zitaten steht vor dem Sternchen in Klammern (\*) und vor spitzen Klammern ein Rückstrich, damit die Darstellung sie nicht als Hervorhebung oder Markup deutet; gemeint sind die Zeichen selbst.
### 3.1 Bestellung des Terminplaners: vier Haken und Knopf
Auf der Bestellseite stehen vor dem Knopf die volle Widerrufsbelehrung mit Muster-Widerrufsformular (Abschnitt 3.2) und diese vier Haken, keiner ist vorangekreuzt. Die Links auf Leistungsbeschreibung, AVV und Widerrufsbelehrung in der Fassung stehen daneben, nicht im Text der Haken. Gespeichert wird genau der gezeigte Text mit Fassung und Zeitpunkt.
**Haken 1 (`unterlagen`): Zustimmung zu Leistungsbeschreibung und AVV**
> Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung 2026-10-05 zu.
⚖ Q1, Q10
**Haken 2 (`gesundheit`): kein Gesundheitsbereich**
> Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).
⚖ Q11
**Haken 3 (`beginn`): vorzeitiger Beginn, Erlöschen, Wertersatz**
> 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).
⚖ Q3, Q5
**Haken 4 (`information`): Pflichtinformationen**
> 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 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), voraussichtliche Dauer meist innerhalb von 1–2 Wochen, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.
⚖ Q7, Q19, Q20
**Knopf**
> Zahlungspflichtig bestellen · 149 €
⚖ Q2, Q19
### 3.2 Widerrufsbelehrung und Muster-Widerrufsformular der Funktionen
WEBklar ist hier der Unternehmer. Die Belehrung steht auf der Bestellseite, im PDF-Anhang der Bestellbestätigung K15 und in der Datei `docs/rechtliches/widerrufsbelehrung-funktionen.md` (Fassung 2026-10-05).
**Widerrufsbelehrung und Muster-Widerrufsformular (Bestellung der Einrichtung)**
> 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.
⚖ Q1, Q2, Q12
Das Dokument enthält vor der Belehrung einen Einleitungssatz und nach dem Muster-Widerrufsformular „Hinweise zum Widerruf“, die nicht zur gesetzlichen Belehrung gehören:
**Einleitung des Dokuments**
> 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.
⚖ Q1
**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.
⚖ Q5, Q7
### 3.3 Zustimmung bei der Bestellung der News-Pinnwand (Altbestand)
Die News-Pinnwand (30 € einmalig) wird seit 2026-09-30 über denselben Bestellweg verkauft, bisher mit einem Haken und ohne Widerrufsbelehrung. Zum Vergleich der Text, den neue Bestellungen jetzt sehen, und der Haken, der bis zur Korrektur galt (Programmstand 3a0b008 vor der Korrektur). Die Leistungsbeschreibung der News-Pinnwand aus dieser Zeit steht in Frage Q4.
**Neuer Haken der News-Pinnwand**
> 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).
⚖ Q4, Q3
**Alte Fassung (Stand 3a0b008): früherer Haken der News-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).
⚖ Q4
### 3.4 Leistungsbeschreibung Terminplaner (Anlage B), Auszüge
Die Datei ist `docs/rechtliches/leistungsbeschreibung-terminplaner.md`; sie ist als Anlage B vollständig beigefügt. Zitiert sind die Nummern, auf die sich unsere Fragen beziehen; weitere Nummern behandeln Geltung, Leistung, Voraussetzungen und Verfügbarkeit.
**Nr. 4 Abs. 3: Gesundheitsbereich**
> (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.
⚖ Q11
**Nr. 4 Abs. 9: Datenschutz-Absatz auf der Website des Kunden**
> (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.
⚖ Q18
**Nr. 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).
⚖ Q19, Q20
**Nr. 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.
⚖ Q8
**Nr. 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. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).
>
> (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.
⚖ Q6, Q7, Q9
**Nr. 9 Abs. 4: Einstellung ohne Grund**
> (4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
⚖ Q8
**Nr. 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.
⚖ Q3, Q5, Q7
**Nr. 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.
⚖ Q15, Q18, Q22
### 3.5 Auftragsverarbeitungsvertrag Terminplaner (Anlage A), Auszüge
Die Datei ist `docs/rechtliches/avv-terminplaner.md`; sie ist als Anlage A vollständig beigefügt (mit der Anlage der technischen und organisatorischen Maßnahmen).
**Vorbemerkung Abs. 1 und 2: Abschluss**
> (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 anschließend eine E-Mail mit diesen beiden Texten als PDF.
⚖ Q10, Q21
**Nr. 3 Abs. 1: Daten der Gäste**
> (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.
⚖ Q10
**Nr. 4 Abs. 2: Weisungen**
> (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.
⚖ Q10
**Nr. 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.
⚖ Q10
**Nr. 8 Abs. 5: Nachweis und Archiv der Gästetexte**
> (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.
⚖ Q26
**Nr. 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. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen (zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Bis zur Löschung 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.
⚖ Q7, Q9, Q10
### 3.6 Gäste: Widerrufsbelehrung und Muster-Widerrufsformular
Der Terminplaner erzeugt diese Belehrung mit den Angaben des Betriebs (alle vier Angaben sind Pflicht) und zeigt sie Verbrauchern beim Buchen und in der Bestätigungs-E-Mail. Grundlage ist das Muster der Anlage 1 und das Muster-Widerrufsformular der Anlage 2 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB (Dateien `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt`, abgerufen 2026-10-02). Gestaltungshinweise für einen Dienstleistungsvertrag sind angewendet: Fristbeginn „des Vertragsabschlusses“, Wertersatz für Dienstleistungen; die Widerrufsfunktion nennt keine Internetadresse, sondern einen anderen geeigneten Hinweis. Ausgelassen sind die Textbausteine zu Waren und der Absatz zum elektronischen Webformular. Die Anrede ist immer „Sie“.
**Widerrufsbelehrung und Muster-Widerrufsformular für Gäste (Platzhalter für die Betriebsangaben)**
> 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.
⚖ Q12, Q13
### 3.7 Gäste: Haken und Hinweis zu Gesundheitsangaben
Die Haken stehen immer in der Sie-Form. Keiner ist vorangekreuzt.
**Haken „Vorzeitiger Beginn“** (Pflicht, nur für Verbraucher bei Leistungen mit Preis, wenn der Termin vor Ablauf der Widerrufsfrist liegt; bei Anfragen mit Preis immer)
> 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).
⚖ Q13, Q3
**Haken „Stornobedingungen“** (Pflicht, wenn der Betrieb Stornobedingungen hinterlegt hat und die Leistung einen Preis hat)
> Ich habe die Stornobedingungen gelesen.
⚖ Q16
**Haken „Erinnerung“** (freiwillig; nur wenn der Betrieb Erinnerungen anbietet; `{{n}}` ist 2, 12, 24 oder 48)
> Ich möchte {{n}} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.
⚖ Q17
**Hinweis über jedem Freitextfeld**
> Bitte keine Gesundheitsangaben.
⚖ Q11
### 3.8 Gäste: Datenschutz-Hinweis am Buchungsformular
Der Hinweis steht in der Zusammenfassung vor dem Absenden, zusammen mit einem Link auf die Datenschutzerklärung des Betriebs; es gibt keinen Haken dafür. Sie- oder Du-Fassung nach der Anrede des Betriebs; die Löschfristen sind die des Löschlaufs.
**Fassung Sie**
> 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: {{Firma}}, {{Anschrift}}.
⚖ Q17, Q9
**Fassung Du**
> 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: {{Firma}}, {{Anschrift}}.
⚖ Q17, Q9
### 3.9 Gäste: Hinweise in der Bestätigungs-E-Mail (G02)
Die Bestätigungs-E-Mail (Vorlage `server/mail-templates/G02 Termin bestaetigt.html`) enthält je nach Buchung einen der beiden folgenden Hinweise (hier die Sie-Fassung); danach folgt bei Widerrufsrecht die Belehrung aus Abschnitt 3.6. Die Du-Fassung ist inhaltlich gleich.
**Widerrufsrecht besteht (Verbraucher)**
> Widerrufsrecht. Wenn Sie Verbraucher sind, können Sie diesen Vertrag innerhalb von 14 Tagen ab Vertragsschluss ohne Angabe von Gründen widerrufen. Bis zum Beginn des Termins geht das über den Knopf „Vertrag widerrufen“; danach über das Formular „Vertrag widerrufen“ auf der Website von {{betrieb_name}} oder per E-Mail an {{betrieb_email}}. Die ausführliche Widerrufsbelehrung und das Muster-Widerrufsformular finden Sie weiter unten in dieser E-Mail.
⚖ Q2, Q12
**Kein Widerrufsrecht (Freizeit-Dienstleistung mit festem Termin)**
> Für diese Buchung besteht kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9 BGB). Absagen richten sich nach den Stornobedingungen.
⚖ Q15, Q16
### 3.10 Gäste: Knopftexte
Die Knopftexte hängen davon ab, ob die Buchung verbindlich oder eine Anfrage ist und ob die Leistung einen Preis hat. Im Widget und auf der Bestätigungsseite heißen die Knöpfe:
**Verbindliche Buchung mit Preis: Widget, dann Bestätigungsseite**
> Zahlungspflichtig buchen
>
> Zahlungspflichtig bestätigen
⚖ Q14
**Verbindliche Buchung ohne Preis: Widget, dann Bestätigungsseite**
> Termin buchen
>
> Termin bestätigen
⚖ Q14
**Anfrage: Widget, dann Bestätigungsseite**
> Termin anfragen
>
> Anfrage absenden
⚖ Q14
**Widerrufsfunktion: Knopf im Terminbereich, dann Bestätigung des Formulars**
> Vertrag widerrufen
>
> Widerruf bestätigen
⚖ Q12, Q14
### 3.11 Anlagen A und B
- **Anlage A:** Auftragsverarbeitungsvertrag Terminplaner, Fassung 2026-10-05, als PDF `public/rechtliches/avv-terminplaner-2026-10-05.pdf` (Quelle `docs/rechtliches/avv-terminplaner.md`).
- **Anlage B:** Leistungsbeschreibung Terminplaner, Fassung 2026-10-05, als PDF `public/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.pdf` (Quelle `docs/rechtliches/leistungsbeschreibung-terminplaner.md`).
- Die Widerrufsbelehrung mit Muster-Widerrufsformular der Funktionen steht in Abschnitt 3.2 vollständig; ihr PDF ist `public/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.pdf`.
- **Anlage C** (gerenderte Mails K15 und K16) wird nach der Mail-Überarbeitung des Inhabers nachgereicht (Abschnitt 7.4).
## 4. Fragen an die Kanzlei
Jede Frage hat vier Teile. **Frage:** was wir wissen möchten. **Fundstelle im Text:** wo der Text steht (Abschnitt 3, Anlage A oder B, Programm) und, als Kennung H1 bis C8, welche Punkte der früheren Listen offener Rechtsfragen die Frage mit abdeckt (Abschnitt 6.3 ordnet alle Kennungen zu). **Unsere Annahme:** der Stand, mit dem wir arbeiten; bitte bestätigen oder korrigieren. **Alternative:** was wir stattdessen täten. Die Zählung von § 356 BGB haben wir am 2026-10-05 auf gesetze-im-internet.de abgeglichen; für die übrigen Normen bitten wir um Gegenprüfung (Q3). Einordnungen wie „Annahme“ sind keine Rechtsurteile.
### Q1: Alle Besteller wie Verbraucher
**Frage.** Der Inhaber hat entschieden, alle Besteller der Einrichtung (149 €) wie Verbraucher zu behandeln: Alle sehen die volle Widerrufsbelehrung mit Muster-Widerrufsformular und alle setzen die Haken zum vorzeitigen Beginn. Die Besteller sind meist Unternehmer (Betriebe mit eigener Website); ob jemand als Verbraucher (§ 13 BGB) oder Unternehmer (§ 14 BGB) bestellt, kann WEBklar nicht verlässlich prüfen (Existenzgründer, Nebenerwerb). Genügt der Satz am Anfang des Dokuments „Das Widerrufsrecht gilt nur für Verbraucher. Wir zeigen die Belehrung und das Muster-Widerrufsformular trotzdem allen, die eine Funktion bestellen.“, damit Unternehmern kein vertragliches Widerrufsrecht entsteht und die Haken für sie nichts Falsches sagen?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 1 bis 4), Abschnitt 3.2 (Einleitung des Dokuments und Belehrung), Leistungsbeschreibung Nr. 10 Abs. 1 („Verbraucher können die Bestellung der Einrichtung nach der Widerrufsbelehrung widerrufen“).
**Unsere Annahme.** Ja. Verbraucher haben das gesetzliche Widerrufsrecht, die Haken wirken bei ihnen; Unternehmern räumt die Einleitung kein Widerrufsrecht ein, die Haken schaden ihnen nicht. Die Abwicklung eines Widerrufs ist Handarbeit des Teams (Meldung T09); ob ein Unternehmer erstattet bekommt, entscheidet das Team im Einzelfall.
**Alternative.** Vor den Haken fragen, ob als Verbraucher oder als Unternehmer bestellt wird (Haken 3 und Belehrung nur für Verbraucher), oder den Terminplaner nur an Unternehmer verkaufen (Bestätigung nach § 14 BGB) und die Belehrung als freiwillige Zusage geben.
### Q2: Beginn der Widerrufsfrist
**Frage.** Wann beginnt die Widerrufsfrist der Bestellung? Wir rechnen ab dem Zeitpunkt des Klicks auf „Zahlungspflichtig bestellen · 149 €“ (gespeichert im Nachweis); das Fristende wird nach § 193 BGB auf einen Werktag verschoben. Die Belehrung steht vor dem Klick auf der Bestellseite, auf einem dauerhaften Datenträger (PDF-Anhang der Bestellbestätigung K15) erhält der Kunde sie erst nach dem Zahlungseingang. Beginnt die Frist mit dem Klick (Annahme durch WEBklar), erst mit dem Zahlungseingang (bei SEPA-Lastschrift Tage später) oder erst mit dem Zugang von K15 (§ 356 Abs. 3 BGB)? Gilt dasselbe für die Gäste (Vertragsschluss bei verbindlicher Buchung mit der Bestätigung per Link, bei einer Anfrage mit der Annahme durch den Betrieb)?
**Fundstelle im Text.** Abschnitt 3.1 (Knopf), Abschnitt 3.2 („ab dem Tag des Vertragsabschlusses“), Abschnitt 3.9 (G02), Abschnitt 2.4; Programm: `withdrawFeature` rechnet mit dem Zeitpunkt des Klicks aus dem Nachweis (ersatzweise mit dem Bestellzeitpunkt), `withdrawalDeadline` mit 14 Tagen.
**Unsere Annahme.** Der Klick ist der Vertragsschluss: Mit ihm löst der Kunde die Zahlung aus; danach gibt es keine gesonderte Annahmeerklärung von WEBklar. WEBklar kann die Einrichtung nur aus den Gründen von Leistungsbeschreibung Nr. 4 Abs. 3 (Gesundheitsbereich, Q11) ablehnen oder abschalten. Die Belehrung ist vor dem Klick lesbar; K15 bestätigt sie mit den PDFs auf einem dauerhaften Datenträger.
**Alternative.** Die Frist ab Zahlungseingang oder ab Zugang von K15 rechnen und die Belehrung entsprechend formulieren („ab dem Tag, an dem Sie diese Bestellbestätigung erhalten“).
### Q3: Zustimmungssatz: Erlöschen, Wertersatz, Kenntnis, dauerhafter Datenträger
**Frage.** Genügt Haken 3 („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 … schulde ich einen angemessenen Betrag … (§ 357a Abs. 2 BGB).“)? Bitte prüfen: (a) die Zählung der Norm für das Erlöschen bei Dienstleistungen (§ 356 Abs. 5 Nr. 2 BGB) und für den Wertersatz (§ 357a Abs. 2 BGB); (b) ob das ausdrückliche Verlangen und die Bestätigung der Kenntnis im selben Haken stehen dürfen; (c) ob der Wertersatz-Hinweis im Haken ausreicht oder auch in die Belehrung gehört; (d) ob K15 mit den Texten der Zustimmung, dem Zeitpunkt und den PDFs die nötige Bestätigung auf einem dauerhaften Datenträger ist (die gerenderte Mail folgt als Anlage C nach der Mail-Überarbeitung des Inhabers).
**Fundstelle im Text.** Abschnitt 3.1 (Haken 3), Abschnitt 3.2 („Hinweise zum Widerruf“), Abschnitt 3.3 (Haken der News), Leistungsbeschreibung Nr. 10 Abs. 2; Programm: `termineConsent` (Register), `onFeaturePaid` (K15, Block „Zustimmungen“ und Anhänge).
**Unsere Annahme.** Der Haken erfüllt beides: Verlangen, Kenntnis vom Erlöschen und Hinweis auf den Wertersatz in einem Satz mit Zeitpunkt im Nachweis; K15 gibt den Satz zusammen mit der Belehrung auf einem dauerhaften Datenträger wieder. Die Zählung entspricht der Fassung auf gesetze-im-internet.de vom 2026-10-05.
**Alternative.** Zwei Haken (Verlangen und Kenntnis getrennt); die Bestätigung als eigener, ausdrücklich so benannter Absatz in K15.
### Q4: News-Altkunden
**Frage.** Seit 2026-09-30 verkauft WEBklar die News-Pinnwand (30 € einmalig) über denselben Bestellweg wie jetzt den Terminplaner. Bis zur Korrektur sah die Bestellung so aus („3a0b008“ ist die Kennung des Programmstands vor der Korrektur). Alte Fassung (Stand 3a0b008), der Haken:
> 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).
Alte Fassung (Stand 3a0b008), Leistungsbeschreibung der News-Pinnwand, Abschnitt 6 „Widerrufsrecht“ (nur diese zwei Absätze, keine gesetzliche Musterbelehrung, kein Muster-Widerrufsformular):
> 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.
Alte Fassung (Stand 3a0b008), Abschnitt 2 derselben Leistungsbeschreibung zum 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.
Die Bestellbestätigung K15 enthielt nach unserem Stand weder eine Belehrung noch Anhänge. Der Haken zitierte also § 356 Abs. 4 BGB und enthielt keinen Hinweis auf den Wertersatz (§ 357a Abs. 2 BGB), den die Leistungsbeschreibung nur allgemein nannte; der Preis war „inklusive Umsatzsteuer“ angegeben (richtig ist § 19 UStG, Q19). Lief bei diesen News-Altkunden, soweit sie Verbraucher sind, die Widerrufsfrist nach § 356 Abs. 3 BGB nicht an? Wenn ja: Sollen wir die Belehrung per Mail nachholen (Form, Inhalt, Zeitpunkt, Hinweis zum Wertersatz), oder genügt es, nur künftig die neuen Texte zu verwenden? Was gilt für bereits eingebaute und freigeschaltete News-Pinnwände (Erlöschen des Widerrufsrechts, Wertersatz)? Die Zahl der betroffenen Fälle liefert der Inhaber vor der Übergabe: [Anzahl der Bestellungen seit 2026-09-30 mit dem alten Text].
**Fundstelle im Text.** Abschnitt 3.3 (neuer und früherer Haken), Abschnitt 3.2 (Belehrung der Funktionen, die die News künftig ebenfalls nutzt); Programm: `config.features.news` (neuer Haken; bestehende Bestellungen behalten den damals gespeicherten Text und die Fassung). Die alten Texte stammen aus dem Programmstand 3a0b008 (`server/config.js`, `public/vertragsunterlagen/funktionen-leistungsbeschreibung.html`). Die Leistungsbeschreibung der News-Pinnwand vom 30.09.2026 liegt unverändert unter `public/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html` (unveränderte Kopie des Standes 3a0b008, durch eine Prüfsumme gesichert), weil Bestellungen seit dem 30.09.2026 auf ihr beruhen; die geltende Datei (Stand 05.10.2026) verweist am Anfang auf sie.
**Unsere Annahme.** Nach unserer Lesart (kein Rechtsurteil) kann die Frist bei fehlender ordnungsgemäßer Belehrung nach § 356 Abs. 3 BGB nicht angelaufen sein. Der Inhaber entscheidet nach Ihrem Befund: Belehrung nur nachholen, wenn die Frist danach nicht lief; sonst gelten die neuen Texte nur für künftige Bestellungen.
**Alternative.** Allen News-Altkunden vorsorglich die Belehrung samt Muster-Formular per Mail schicken (mit dem Hinweis, dass die Frist damit beginnt), unabhängig vom Befund; oder, wenn die Kanzlei keinen Handlungsbedarf sieht, nichts tun.
### Q5: Wertersatz: Anteil am Gesamtpreis oder Stundenaufwand
**Frage.** Die Muster-Belehrung verlangt beim Widerruf einen angemessenen Betrag, der dem Anteil der bis zur Unterrichtung erbrachten Dienstleistungen im Vergleich zum Gesamtumfang entspricht (§ 357a Abs. 2 BGB). Unsere „Hinweise zum Widerruf“ sagen: „Der Betrag bemisst sich als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand für die Einrichtung.“ Intern war ursprünglich eine Berechnung „nach Stundenaufwand“ vorgesehen. Ist diese Berechnung (Anteil an 149 € nach dem erfassten Aufwand im Verhältnis zum Gesamtaufwand der Einrichtung dieses Projekts) zulässig, oder muss der Gesamtumfang vorab festgelegt werden, damit der Anteil bestimmbar ist?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 3), Abschnitt 3.2 (Muster-Belehrung, Absatz „Haben Sie verlangt …“, und „Hinweise zum Widerruf“, dritter Punkt), Leistungsbeschreibung Nr. 10 Abs. 2; Mails K04 und T09; Programm `withdrawFeature`.
**Unsere Annahme.** Anteilig am Preis (nicht Stundensatz mal Zeit). Das Maß der erbrachten Leistung ist der im Arbeitsblatt des Einrichtungs-Tickets erfasste Aufwand, der Gesamtumfang der Aufwand der Einrichtung dieses Projekts nach dem Ticket. Eine feste Gesamtstundenzahl steht nicht im Vertrag.
**Alternative.** Den Gesamtumfang in der Leistungsbeschreibung festlegen (zum Beispiel feste Schritte oder eine Stundenzahl) und den Wertersatz nach erledigten Schritten bemessen; oder bei Widerruf nach Einbaubeginn pauschal einen festen Anteil ansetzen.
### Q6: Volle Erstattung, wenn das Abo vor der Freischaltung endet
**Frage.** Leistungsbeschreibung Nr. 8 Abs. 5 lautet: „Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.“ Ist die Klausel wirksam und klar (§ 307 BGB), auch wenn WEBklar den Einbau schon begonnen oder fertiggestellt hat und auch wenn der Kunde das Abo selbst beendet? Außerdem: Nr. 8 Abs. 4 sagt, dass WEBklar den Terminplaner bei einem neuen Wartungs-Abo innerhalb der 30 Tage wieder einschaltet. Das Programm tut das nur für den Abschaltgrund „Abo beendet“ (`abo_ende`, nach Freischaltung), nie für `abo_ende_vor_einrichtung`: Nach der Erstattung bleibt der Terminplaner aus; ob und wie er wieder eingerichtet wird, entscheidet das Team im Einzelfall. Muss der Text das sagen?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 4 bis 6), Abschnitt 2.3 Punkt 4; Programm: `gleicheAboAb` (Grund `abo_ende_vor_einrichtung`, Meldung T19 an das Team, keine automatische Rückkehr), Mail K22 Block „vor_einrichtung“.
**Unsere Annahme.** Die Klausel begünstigt den Kunden und ist unproblematisch. Die Rückkehr gilt nur für den Fall nach der Freischaltung; für den Erstattungsfall ist sie nicht gewollt, weil die Erstattung voll geschuldet bleibt.
**Alternative.** Die Erstattung auf den Fall beschränken, dass WEBklar noch nicht mit dem Einbau begonnen hat (sonst Wertersatz); Nr. 8 Abs. 4 um den Satz ergänzen, dass die Rückkehr nicht gilt, wenn der Einrichtungspreis erstattet wurde.
### Q7: Ende mit dem Abo, auch bei Wechsel und Widerruf; Ausnahme für Pilot und Kulanz
**Frage.** (a) Leistungsbeschreibung Nr. 8 Abs. 1 lässt den Terminplaner mit dem Wartungs-Abo enden, auch beim Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo. Ist diese Kopplung zulässig, obwohl der Kunde die Einrichtung einmalig bezahlt hat und sie ein eigener Vertrag ist? Wie verhält sich der Widerruf des Abos zum Einrichtungsvertrag, insbesondere wenn der Terminplaner schon freigeschaltet ist und deshalb nichts erstattet wird? (b) Ausnahme: Terminplaner, die das Team ohne Bestellung freigeschaltet hat (Pilot, Kulanz; im Programm `unlockedByPurchase` nicht wahr), nimmt der Abgleich aus und zählt sie im Tagesbericht als „ausgenommen“; sie laufen weiter, bis WEBklar sie abschaltet. Genügt der Satz „WEBklar schaltet ihn spätestens am folgenden Tag ab“ trotzdem, oder braucht es eine Klausel, die diese Kulanzfälle nennt? (c) Der Abgleich schaltet bei Lesefehlern nichts ab und versucht es am nächsten Tag; ein Abo im Status „Zahlungsverzug“ gilt als laufend. Ist „spätestens am folgenden Tag“ dann zu streng gesagt?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 1 und 6, Nr. 10), Abschnitt 3.1 (Haken 4: „der Terminplaner endet mit dem Wartungs-Abo“), Abschnitt 2.3, Abschnitt 2.1 (Freischaltung ohne Bestellung); Programm: `gleicheAboAb`, `endeTermineFuerKunde`.
**Unsere Annahme.** Die Kopplung ist zulässig, weil der Terminplaner nur mit einem Wartungs-Abo betrieben wird und der Kunde das in Haken 4 liest. Kulanzfälle sind keine Verträge über die Einrichtung; ihre Ausnahme braucht keine Klausel. „Spätestens am folgenden Tag“ beschreibt den Normalfall.
**Alternative.** Nr. 8 Abs. 1 auf „in der Regel am folgenden Tag“ abschwächen; eine Kulanzklausel („Terminplaner, die WEBklar ohne Bestellung freigeschaltet hat, schaltet WEBklar nach eigenem Ermessen ab“) aufnehmen; beim Widerruf des Abos nach Freischaltung eine anteilige Erstattung prüfen.
### Q8: Haftung für verpasste Termine; Erstattung bei Einstellung
**Frage.** (a) Leistungsbeschreibung Nr. 7 Abs. 3 (Abschnitt 3.4) beschränkt die Haftung für entgangenen Gewinn und Folgeschäden aus verpassten Terminen auf den Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen (unbeschränkt bei Vorsatz, grober Fahrlässigkeit, Verletzung von Leben, Körper oder Gesundheit; bei leicht fahrlässiger Verletzung wesentlicher Pflichten begrenzt auf den vertragstypischen, vorhersehbaren Schaden; im Übrigen ausgeschlossen). Ist die Klausel gegenüber Verbrauchern mit § 309 Nr. 7 BGB vereinbar, und ist die Verweisung hinreichend transparent? (b) Braucht es gegenüber Unternehmern einen Höchstbetrag (etwa den Jahresbetrag des Wartungs-Abos)? (c) Nr. 9 Abs. 4 schließt eine Einstellung ohne Grund aus. Wann WEBklar den Terminplaner beenden oder aussetzen darf, regeln Nr. 9 Abs. 2 und 3:
> (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.
Zur Erstattung des Einrichtungspreises, wenn WEBklar den Terminplaner danach beendet, schweigt der Text. Braucht es eine (anteilige) Erstattung?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 7 und Nr. 9 Abs. 4), Leistungsbeschreibung Nr. 9 Abs. 2 und 3 (oben zitiert), Abo-Bedingungen § 13 (`public/abo-bedingungen.html`).
**Unsere Annahme.** Die Verweisung auf § 13 genügt; ein Höchstbetrag ist nicht nötig, weil § 13 Abs. 2 die Haftung schon auf den vorhersehbaren Schaden begrenzt. Bei Einstellung aus den Gründen von Nr. 9 Abs. 2 erstattet WEBklar nichts, weil der Terminplaner bis dahin lief; die Rechte aus § 327r BGB bleiben.
**Alternative.** Einen Höchstbetrag gegenüber Unternehmern festlegen; bei Einstellung durch WEBklar den Einrichtungspreis anteilig für die Restlaufzeit erstatten.
### Q9: 30 Tage nach dem Abschalten: Anker, Rückkehr, Sicherungen
**Frage.** Der Terminplaner endet mit dem Wartungs-Abo; WEBklar schaltet ihn spätestens am folgenden Tag ab und löscht alle Termindaten und Einstellungen 30 Tage nach dem Abschalten. Bis dahin kann der Kunde die Termine im Kundenportal ansehen und als CSV-Datei exportieren; beginnt ein neues Wartungs-Abo, schaltet WEBklar ohne Kosten mit den vorhandenen Daten wieder ein (Rückkehr, nur beim Abschaltgrund „Abo beendet“, `abo_ende`). Fragen: (a) Ist der Anker (Zeitpunkt des Abschaltens, `disabledAt`, nicht der Tag des Abo-Endes) und die Frist von 30 Tagen mit Art. 28 Abs. 3 lit. g DSGVO vereinbar? (b) Darf die Rückkehr ohne neue Zustimmung zur Fassung automatisch erfolgen? (c) Gelöschte Daten bleiben in der täglichen Vollsicherung bis zu sieben Tage (Rückspielen holt der Löschlauf nach): genügt das? (d) Der tägliche Löschlauf löscht frühestens nach Ablauf der jeweiligen Frist, nicht sekundengenau; „48 Stunden“ und „30 Tage“ sind frühestmögliche Zeitpunkte (H4, A3). (e) Terminanfragen, die der Kunde bis zum angefragten Terminbeginn nicht beantwortet, gelten ab dann als abgelehnt, und die 30 Tage laufen ab diesem Zeitpunkt (AVV Nr. 9 Abs. 2). (f) Der Satz im Datenschutz-Absatz der Kunden-Website („Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“) und der Satz in Abschnitt 6c stimmen mit dem Programm überein, seit der tägliche Abgleich das Abschalten übernimmt. (g) Das Einrichtungs-Ticket, in dem das Team die Einrichtung als Arbeitsnachweis festhält, bleibt nach der Löschung mit einer Kurzfassung der Einstellungen bestehen (Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze, Wünsche des Kunden zum Einbau; keine Daten von Gästen). Leistungsbeschreibung Nr. 8 Abs. 3 und AVV Nr. 9 Abs. 6 sagen das; die Datenschutzerklärung (Abschnitt 6) nennt die Arbeitsnachweise und die Aufbewahrung bis zum Ablauf der Verjährungsfrist. Ist das neben der Löschpflicht des Art. 28 Abs. 3 lit. g DSGVO zulässig, auch wenn der Kunde die Löschung aller Daten anweist (AVV Nr. 9 Abs. 6)?
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 8 Abs. 3 und 4), Abschnitt 3.5 (AVV Nr. 9, besonders Abs. 2, 6 und 8), Abschnitt 2.3; frühere Punkte H4, A3, C4 (Sicherungen), C7 (Satz in 6c); Mail K22 und Datenschutzerklärung 6c (Hausrunde); Programm: `LOESCHFRIST_TAGE`, `loescheAbgeschaltete`, `gleicheAboAb`; Einrichtungs-Ticket: `termineTicketDetails`.
**Unsere Annahme.** Ja. 30 Tage ab dem Abschalten sind angemessen, weil der Kunde in dieser Zeit Termine ansehen und exportieren kann und eine Rückkehr kostenlos ist. Die Rückkehr stellt den früheren Stand wieder her; die zugestimmte Fassung gilt weiter, eine neue Fassung braucht ihre Zustimmung nach AVV Nr. 12. Der Sicherungsüberhang von sieben Tagen folgt aus der täglichen Vollsicherung bei Hetzner (letzte sieben Tagessicherungen) und steht offen im Vertrag.
**Alternative.** Das Modell der Website-Analyse: Löschung am 61. Tag nach dem Ende, Kopie der Daten auf Verlangen bis zum 46. Tag (AVV Website-Analyse Nr. 9 Abs. 2); oder 30 Tage ab dem Tag des Abo-Endes statt ab dem Abschalten.
### Q10: AVV Terminplaner: Aufbau, Hetzner, Drittland, Daten, Weisungen
**Frage.** Bitte den AVV (Anlage A) prüfen; besonders: (a) der Abschluss durch Haken bei der Bestellung (Textform; Art. 28 Abs. 9 DSGVO) mit gespeicherter Fassung, Zeitpunkt, Konto und Person, und durch Zustimmung in Textform bei Freischaltung durch das Team (Q21); (b) die Wahl eines eigenen Vertrags (Geschwister des AVV der Website-Analyse, Vorrang vor Anlage 1 der Abo-Bedingungen) statt eines Rahmen-AVV oder eines Zusatzes, damit Kunden der Website-Analyse nicht neu zustimmen müssen; (c) Nr. 7: Hetzner Online GmbH, Rechenzentrum Falkenstein (Server und Sicherung) und Nürnberg (SMTP-Dienst des Webhostings) und der Satz „Eine Übermittlung in Drittländer findet nicht statt“, auch wenn ein Kunde den geheimen Kalender-Link in einem Kalenderdienst eines Anbieters wie Google oder Apple abonniert (das Abonnement richtet der Kunde ein, WEBklar übermittelt dorthin nichts) (A2); (d) Nr. 7 Abs. 4: Stripe ist kein Unterauftragsverarbeiter, weil keine Daten von Gästen oder Beschäftigten an Stripe gehen; (e) Aufruf des Widgets: IP-Adresse und Browser-Kennzeichen erhält der Server technisch bei jedem Abruf, Server-Protokolle 14 Tage (Art. 6 Abs. 1 lit. f DSGVO) (A1); im Knopfmodus lädt das Widget die Konfiguration schon beim Laden der Seite (A6, C8), vom selben Server wie das Skript; (f) Beschäftigtendaten: Namen, Zeiten und der geheime Kalender-Link; der Kunde informiert seine Beschäftigten (C3); (g) Nr. 3 Abs. 1: Absagegrund und Vermerke (Freitext, auch mit dem Namen aus einer Widerrufserklärung) sowie Preis, Zahlart, Zahlungsstatus und die Angabe, ob als Verbraucher gebucht wurde; Nr. 2 Abs. 3: Zählungen ohne Personenbezug, die WEBklar für den sicheren Betrieb braucht, sind keine eigenen Zwecke; (h) Nr. 4 Abs. 2 und Nr. 9 Abs. 6: Löschung auf Weisung in Textform „unverzüglich“, ohne feste Frist.
**Fundstelle im Text.** Abschnitt 3.5 (Vorbemerkung Abs. 1 und 2, Nr. 3 Abs. 1, Nr. 4 Abs. 2, Nr. 7, Nr. 9), Anlage A vollständig mit der Anlage der technischen und organisatorischen Maßnahmen; frühere Punkte A1, A2, A6, C3, C8; Programm: `baueNachweis`, `datenschutzAbsatz`.
**Unsere Annahme.** Der AVV ist vollständig und passt zum Programm (Datenarten, Löschfristen, Unterauftragsverarbeiter sind durch Tests an den Code gebunden). Eine Drittlandübermittlung durch WEBklar findet nicht statt; das Kalender-Abonnement liegt in der Verantwortung des Kunden. „Unverzüglich“ genügt für die Löschung auf Weisung.
**Alternative.** Ein Rahmen-AVV mit einer Anlage je Funktion (Abschnitt 6.2); im AVV einen Hinweis auf das Kalender-Abonnement bei Drittanbietern aufnehmen; für die Löschung auf Weisung eine feste Frist nennen (zum Beispiel 14 Tage).
### Q11: Gesundheits-Ausschluss
**Frage.** Der Terminplaner ist nicht für den Gesundheitsbereich bestimmt (Art. 9 DSGVO). Schutzmaßnahmen: die Branchenfrage im Fragebogen (der Gesundheitsbereich ist wählbar, die Bestellung dann aber gesperrt), Haken 2 mit Aufzählung (Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis), eine weiche Stichwortprüfung über Betriebsname, Leistungen und Felder, die das Team warnt, das Recht in Leistungsbeschreibung Nr. 4 Abs. 3, die Einrichtung abzulehnen oder den Terminplaner dort abzuschalten, der Hinweis „Bitte keine Gesundheitsangaben“ über jedem Freitextfeld und im Datenschutz-Hinweis (A5), die Pflicht des Kunden in AVV Nr. 11 Abs. 4. Reicht das, damit WEBklar als Auftragsverarbeiter keine besonderen Kategorien verarbeitet? Und: Leistungsbeschreibung Nr. 4 Abs. 3 sagt nichts zur Erstattung, wenn WEBklar die Einrichtung wegen des Gesundheitsbereichs ablehnt oder abschaltet; muss der Text das regeln?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 2), Abschnitt 3.4 (Nr. 4 Abs. 3), Abschnitt 3.7 (Hinweis), Anlage A Nr. 3 Abs. 3 und Nr. 11 Abs. 4; früherer Punkt A5; Programm: `BRANCHEN`, `gesundheitsWarnungen`, `bestellpruefung`.
**Unsere Annahme.** Die Kombination aus Frage, Haken, Warnung und Ablehnungsrecht genügt; die Aufzählung im Haken ist beispielhaft. Lehnt WEBklar die Einrichtung ab, erstattet WEBklar den Preis aus Kulanz (nach Widerruf oder Kulanz, kein Satz im Text).
**Alternative.** Eine ausdrückliche Erstattungsklausel für die Ablehnung; die Aufzählung im Haken durch „Heilkunde und Gesundheitsversorgung“ ersetzen; eine zweite Bestätigung im Fragebogen.
### Q12: Gäste-Belehrung: Abweichungen vom Muster und Gesetzlichkeitsfiktion
**Frage.** Die Belehrung für Gäste (Abschnitt 3.6) beruht auf dem Muster der Anlage 1 zu Art. 246a § 1 Abs. 2 Satz 2 EGBGB, angepasst für einen Dienstleistungsvertrag. Genügt sie für die Gesetzlichkeitsfiktion, obwohl sie abweicht? (a) Die Klammer zu den Lieferkosten im Absatz „Folgen des Widerrufs“ bleibt stehen, weil der Gestaltungshinweis keine Streichung bei Dienstleistungen vorsieht (B1); dasselbe gilt für die Belehrung der Funktionen (Abschnitt 3.2). (b) Die Widerrufsfunktion nennt statt einer Internetadresse einen „anderen geeigneten Hinweis“: die Schaltfläche „Vertrag widerrufen“ im Terminbereich und den Link „Termin verwalten“ in der Bestätigungs-E-Mail; die Adresse der Seite fehlt (B5). (c) „… dass die Dienstleistungen … beginnen soll“ steht im Singular des Gesetzestexts (B2). (d) Das Widget zeigt die Belehrung aufklappbar in der Zusammenfassung vor dem Knopf, die Bestätigungs-E-Mail enthält sie als Text (H1). (e) Die Fiktion gilt nur, wenn das Muster inhaltlich und gestalterisch dem Gesetz entspricht; Zeilenumbrüche, Überschriften und die Aufzählung im Formular sind unsere Darstellung (B6).
**Fundstelle im Text.** Abschnitt 3.6, Abschnitt 3.2 (Lieferkosten-Klammer und Widerrufsfunktion mit Adresse der Widerrufsseite bei den Funktionen), Abschnitt 3.9, Abschnitt 3.10 („Vertrag widerrufen“); frühere Punkte H1, B1, B2, B5, B6; Programm: `widerrufsbelehrung`, Muster in `docs/rechtliches/quellen/`.
**Unsere Annahme.** Ja: Die Abweichungen sind die Ausfüllung der Lücken, die das Muster vorsieht, oder der Gesetzestext selbst; die aufklappbare Darstellung ist zulässig, weil sie vor dem Absenden vollständig erreichbar ist.
**Alternative.** Die Klammer zu den Lieferkosten streichen; die Widerrufsfunktion zusätzlich mit der Adresse der Seite des Betriebs nennen; die Belehrung ohne Aufklappen offen anzeigen; „beginnen sollen“ im Plural.
### Q13: Gäste: Haken zum vorzeitigen Beginn und Wertersatz
**Frage.** Der Haken „Vorzeitiger Beginn“ (Abschnitt 3.7) wird nur Verbrauchern bei Leistungen mit Preis gezeigt, wenn der Termin vor Ablauf der Widerrufsfrist beginnt; bei Anfragen mit Preis immer, weil der Tag der Annahme nicht feststeht. Die Frist rechnen wir mit 14 Tagen ab dem Tag nach dem Vertragsschluss im Berliner Kalender (§§ 187, 188, 193 BGB), gerechnet ab Buchungszeitpunkt plus Haltezeit. (a) Genügt der Haken (Verlangen, Kenntnis, Wertersatz) in dieser Form? (b) Der Absatz zum Wertersatz steht immer in der Belehrung, auch wenn der Gast keinen vorzeitigen Beginn verlangt hat, weil das Muster ihn für Dienstleistungen vorsieht (B4). Ist das unschädlich? (c) Liegt der Termin nach Ablauf der Frist, zeigen wir keinen Haken: stimmt das?
**Fundstelle im Text.** Abschnitt 3.7 (Haken „Vorzeitiger Beginn“), Abschnitt 3.6 (Absatz „Haben Sie verlangt …“); früherer Punkt B4; Programm: `ZUSTIMMUNG_VORZEITIG`, `brauchtZustimmungVorzeitig`.
**Unsere Annahme.** Ja zu (a) bis (c): Der Haken wirkt nur, wo ein Widerrufsrecht noch besteht und die Leistung vor Fristende beginnt; der Muster-Absatz zum Wertersatz bleibt unverändert, um die Fiktion nicht zu gefährden.
**Alternative.** Den Haken bei jeder Buchung mit Preis zeigen; den Wertersatz-Absatz nur bei gesetztem Haken in die Belehrung aufnehmen (Abweichung vom Muster).
### Q14: Anfrage mit Festpreis, Bestätigungslink und Seitenknopf als Bestellung
**Frage.** Wann ist die Buchung eines Gastes eine Bestellung mit Zahlungspflicht im Sinne von § 312j Abs. 3 BGB? Unser Ablauf: Bei einer verbindlichen Buchung mit Preis trägt der Knopf im Widget die Aufschrift „Zahlungspflichtig buchen“; der Gast bestätigt dann per Link in einer E-Mail, und die Bestätigungsseite hat den Knopf „Zahlungspflichtig bestätigen“; erst damit kommt der Vertrag zustande. Bezahlt wird vor Ort. Bei einer Terminanfrage, auch mit Festpreis, heißen die Knöpfe „Termin anfragen“ und „Anfrage absenden“, der Vertrag kommt erst mit der Annahme durch den Betrieb zustande. Ohne Preis lauten die Knöpfe „Termin buchen“ und „Termin bestätigen“. Genügt das? Muss der Knopf einer Anfrage mit Festpreis ebenfalls „zahlungspflichtig“ heißen? Ist der Seitenknopf auf der Bestätigungsseite die maßgebliche Bestellung?
**Fundstelle im Text.** Abschnitt 3.10 (Knopftexte), Abschnitt 2.2 Punkt 2; Programm: `knopfText`.
**Unsere Annahme.** Ja: Die zahlungspflichtige Handlung ist die Bestätigung auf der Bestätigungsseite; bei einer Anfrage entsteht die Zahlungspflicht erst mit der Annahme, die der Betrieb erklärt und nicht der Gast.
**Alternative.** Auch den Knopf einer Anfrage mit Festpreis „Zahlungspflichtig anfragen“ nennen; bei einem Preis den Preis am Knopf zeigen.
### Q15: Einstufung als Freizeitleistung und Hinweis im Portal
**Frage.** Im Kundenportal stuft der Betrieb selbst ein, ob seine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB); kreuzt er das an, zeigt der Terminplaner keine Widerrufsbelehrung, kein Formular und keine Widerrufsfunktion, und die Bestätigung nennt den Satz aus Abschnitt 3.9. Der Hinweis dazu im Portal lautet:
> 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. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.
Ist diese Einschätzung vertretbar, vor allem für Fotoshootings, Coaching, Beratung, Tattoo und Massage? Genügt „Das ist keine Rechtsberatung“, oder haftet WEBklar, wenn ein Betrieb zu Unrecht ankreuzt und Gäste noch bis zu 12 Monate und 14 Tage widerrufen können? Ein zweiter Hinweis im Portal (`WIDERRUF_HINWEIS`) erklärt dem Betrieb, dass Privatpersonen eine Buchung 14 Tage nach Vertragsschluss widerrufen können (bei Anfragen zählt die Annahme), dass das Widerrufsrecht seiner Stornoregel vorgeht, dass bei vorzeitigem Beginn Wertersatz geschuldet sein kann (§ 357a Abs. 2 BGB) und dass der Terminplaner Belehrung und Knopf „Vertrag widerrufen“ automatisch zeigt.
**Fundstelle im Text.** Abschnitt 3.9 (Satz „kein Widerrufsrecht“), Leistungsbeschreibung Nr. 4 Abs. 8 und Nr. 11 Abs. 1 (die Einstufung trägt der Kunde); Programm: `FREIZEIT_HINWEIS`, `WIDERRUF_HINWEIS`, `zeigtWiderruf`.
**Unsere Annahme.** Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung fallen in der Regel nicht unter die Ausnahme; bei Massage und Wellness ist die Einordnung unklar, im Zweifel besteht ein Widerrufsrecht. Der Hinweis stellt die Einstufung klar dem Betrieb zu.
**Alternative.** Die Einstufung nicht dem Betrieb überlassen: Der Terminplaner zeigt die Belehrung immer, die Freizeit-Ausnahme gibt es nicht; oder die Beispiele im Hinweis streichen.
### Q16: Storno-Muster (50 Prozent, 24 Stunden) und Wegfall bei kostenlosen Leistungen
**Frage.** Die Vorlagen schlagen dem Betrieb Stornobedingungen vor, zum Beispiel für Salon, Kosmetik und Beratung:
> Sie können Ihren Termin bis 24 Stunden vorher kostenlos absagen oder verschieben (Link in der Bestätigungsmail). Danach oder bei Nichterscheinen berechnen wir für den freigehaltenen Termin 50 % des Preises. Dem Kunden bleibt der Nachweis vorbehalten, dass uns kein oder ein geringerer Aufwand entstanden ist. Ihr gesetzliches Widerrufsrecht bleibt unberührt.
(Bei Tattoo und Fotografie 48 Stunden.) Im Portal steht neben dem Textfeld der Hinweis:
> Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.
(a) Hält das Muster einer Kontrolle nach § 307 BGB (und § 309 Nr. 5 BGB gegenüber Verbrauchern) stand, soweit es als Vorschlag für den Betrieb dient? (b) Hat keine Leistung einer Vorlage einen Preis (Vorlagen „handwerk“ und „sonstiges“), gibt es keinen Vorschlag. Bietet ein Betrieb einzelne kostenlose Leistungen neben Leistungen mit Preis an, bleibt sein Stornotext stehen, aber der Pflicht-Haken „Ich habe die Stornobedingungen gelesen.“ entfällt bei den kostenlosen Leistungen. Genügt das? (c) Die Bestätigungs-E-Mail G02 zeigt den Stornotext immer, wenn der Betrieb einen hinterlegt hat; ein Betrieb kann auch dann einen Text eingeben, wenn er kostenlose Leistungen anbietet, und dann steht er auch in der Bestätigung kostenloser Buchungen. Der Satz „Absagen richten sich nach den Stornobedingungen“ im Block „kein_widerruf“ (Abschnitt 3.9) steht dagegen bei jeder Buchung, die der Betrieb als Freizeit-Dienstleistung einstuft, also auch bei kostenlosen Buchungen und auch ohne hinterlegten Stornotext. Die Einbauanleitung für Mitarbeiter nennt den Stornotext unabhängig vom Preis. Ist das bei kostenlosen Leistungen irreführend? Wir wollen für G02 einen neutralen Satz festlegen (Checkliste, Abschnitt 7.4).
**Fundstelle im Text.** Abschnitt 3.7 (Haken „Stornobedingungen“), Abschnitt 3.9 (G02); Programm: `stornoText`, `STORNO_PROZENT`, `pruefeEingabe`, `STORNO_MUSTER_HINWEIS`.
**Unsere Annahme.** Der Vorschlag ist ein Muster mit Nachweisvorbehalt und dem Hinweis auf das Widerrufsrecht; der Betrieb prüft und passt ihn an. Bei kostenlosen Leistungen gibt es nichts zu stornieren, der Haken ist dort nicht nötig. Der Satz in G02 wird für Buchungen ohne Preis neutral gefasst.
**Alternative.** Keinen Stornovorschlag mitliefern; oder 50 Prozent durch eine feste Gebühr oder einen Staffelsatz ersetzen.
### Q17: Datenschutz- und Impressum-Link; Widerrufssatz am Erinnerungshaken
**Frage.** (a) Der Datenschutz-Hinweis am Buchungsformular verweist auf die Datenschutzerklärung des Betriebs; das Widget ließe den Link sonst still weg. Deshalb verlangt das Bestell-Gate vor der Bestellung die Adressen von Impressum und Datenschutzerklärung (H3). Ist die Pflicht ausreichend, oder müssen Links zu Datenschutzerklärung und Impressum direkt am Haken stehen? (b) Der Erinnerungshaken endet mit „Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO), ohne Widerrufsweg und ohne Link am Haken; die Einzelheiten stehen im Datenschutz-Absatz (H2). Genügt das?
**Fundstelle im Text.** Abschnitt 3.7 (Haken „Erinnerung“), Abschnitt 3.8 (Datenschutz-Hinweis); frühere Punkte H2, H3; Programm: `ZUSTIMMUNG_ERINNERUNG`, `formularHinweis`, `bestellpruefung`.
**Unsere Annahme.** Ja: Der Satz am Haken informiert vor der Einwilligung über das Recht zum Widerruf, die Einzelheiten und der Weg stehen im Hinweis und im Datenschutz-Absatz.
**Alternative.** Den Widerrufsweg („über den Link in der E-Mail oder per Nachricht an {{E-Mail}}“) in den Haken schreiben; Links zu Datenschutzerklärung und Impressum neben den Haken setzen.
### Q18: Datenschutz-Absatz auf der Kundenseite: Einfügen durch WEBklar im Preis
**Frage.** Leistungsbeschreibung Nr. 4 Abs. 9: Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einbau den Datenschutz-Absatz für die Online-Terminbuchung ein, das gehört zur Einrichtung und wird nicht auf das Stundenkontingent angerechnet; sonst erhält der Kunde den Absatz als Textbaustein. Der Kunde bleibt für seine Datenschutzerklärung verantwortlich; fällt WEBklar ein offensichtlicher Widerspruch auf, weist WEBklar darauf hin, eine Prüfpflicht entsteht nicht. (a) Ist das Einfügen eines Mustertextes durch WEBklar eine Rechtsdienstleistung (§ 2 RDG), oder bleibt es Technik und Mustertext (Nr. 11 Abs. 1)? (b) Reicht die Abgrenzung der Verantwortung? (c) Dieselbe Regel steht in der Leistungsbeschreibung der Website-Analyse (Nr. 4 Abs. 4); wir wollen sie für beide gleich halten.
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 4 Abs. 9, Nr. 11 Abs. 1); `docs/rechtliches/leistungsbeschreibung-website-analyse.md` Nr. 4 Abs. 4; Programm: `datenschutzAbsatz`.
**Unsere Annahme.** Das Einfügen eines unveränderten Mustertextes mit Hinweis ist keine Rechtsdienstleistung im Einzelfall; die Verantwortung bleibt beim Kunden.
**Alternative.** WEBklar fügt nichts ein und liefert nur den Textbaustein; oder WEBklar fügt ein und der Kunde bestätigt den Text ausdrücklich.
### Q19: Preisangabe nach § 19 UStG; Preis am Knopf
**Frage.** (a) Die Einrichtung steht überall sinngemäß als „149 € einmalig, ohne Umsatzsteuer gemäß § 19 UStG“, im Wortlaut so: im Haken 4 als „Gesamtpreis 149 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG)“ (Abschnitt 3.1), in Leistungsbeschreibung Nr. 5 Abs. 2:
> (2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG.
und in der Bestellbestätigung K15 als Zusatz zur Preiszeile:
> einmalig, ohne Umsatzsteuer gemäß § 19 UStG; der Terminplaner läuft, solange Dein Wartungs-Abo läuft
Früher stand bei den News „inklusive Umsatzsteuer“ (Q4); das haben wir korrigiert. Ist die Angabe als Endpreis richtig, solange WEBklar Kleinunternehmer bleibt, und was ist bei Überschreiten der Grenze zu tun? (b) Der Knopf trägt den Preis: „Zahlungspflichtig bestellen · 149 €“ (Abschnitt 3.1). Ist der Zusatz mit § 312j Abs. 3 BGB vereinbar und nötig?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 4 und Knopf), Abschnitt 3.4 (Nr. 5); Programm: `ustHinweis`, `TERMINE_PREIS`, `termineConsent`.
**Unsere Annahme.** WEBklar bleibt Kleinunternehmer (der Inhaber bestätigt das, Abschnitt 7.1); der Preis am Knopf schadet nicht, weil die gesetzlichen Worte „zahlungspflichtig bestellen“ unverändert davor stehen.
**Alternative.** Knopf ohne Preis und den Preis direkt darüber zeigen; bei Überschreiten der Kleinunternehmergrenze Preise und Texte neu fassen (neues Fassungsdatum).
### Q20: Pflichtinformationen (Art. 246a § 1 EGBGB) als Haken und in K15
**Frage.** Haken 4 bestätigt, dass der Besteller die Pflichtinformationen gelesen hat: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis, voraussichtliche Dauer, Ende mit dem Wartungs-Abo, und dass er die Widerrufsbelehrung gelesen hat. Genügen Haken 4 zusammen mit der Belehrung, den versionierten Links auf Leistungsbeschreibung und AVV und der Bestellbestätigung K15 (Zustimmungstexte und PDF-Anhänge) den Informationspflichten nach Art. 246a § 1 Abs. 1 EGBGB? Wird zwischen Klick und Zahlung eine neue Fassung veröffentlicht, schickt K15 die Unterlagen der Fassung, der der Kunde zugestimmt hat (der vereinbarten Fassung), nicht die aktuelle; fehlen sie, geht K15 ohne Anhänge und das Team sendet sie von Hand. Ist das ausreichend? Die gerenderte Mail K15 folgt als Anlage C nach der Mail-Überarbeitung des Inhabers.
**Fundstelle im Text.** Abschnitt 3.1 (Haken 4), Abschnitt 3.4 (Nr. 5), Abschnitt 2.1 Schritt 6; Programm: `k15Rechtsunterlagen`, `onFeaturePaid`, `legalPdfAttachments`.
**Unsere Annahme.** Ja: Die Informationen stehen vor der Bestellung im Text der Seite und in den Unterlagen; der Haken macht die Kenntnisnahme nachweisbar; K15 gibt sie auf einem dauerhaften Datenträger wieder.
**Alternative.** Die Pflichtinformationen als eigenen Textblock auf der Bestellseite und in K15 im Wortlaut wiederholen, statt sie im Haken zu bündeln.
### Q21: Freischaltung durch das Team mit Zustimmung in Textform; K16 mit PDFs
**Frage.** Ohne Bestellung (Kulanz, Pilot) schaltet das Team einen Terminplaner in der Betriebsart `on` (alle Kunden mit Wartungs-Abo, Abschnitt 1) nur frei, wenn der Kunde vorher in Textform (Chat im Kundenportal oder E-Mail) zugestimmt hat; Pilotadressen sind davon ausgenommen. Das Portal speichert als Nachweis (ohne Stripe-Sitzung, IP und Browser; Konto ist der Mitarbeiter) diesen Satz, mit Fassung, Datum und Weg des Falls:
> Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung \<Fassung>) am \<TT>.\<MM>.\<JJJJ> per Chat im Kundenportal in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.
Bei der Zustimmung per E-Mail steht „per E-Mail“ statt „per Chat im Kundenportal“. Für den Chat liefert das Ticketsystem dem Mitarbeiter diese Textvorlage zum Kopieren (die Adressen sind die versionierten Seiten der beiden Unterlagen):
> Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung \<Fassung>: \<Adresse der Leistungsbeschreibung> · \<Adresse des AVV>. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.
Danach geht K16 mit Leistungsbeschreibung und AVV als PDF an den Kunden. Genügt das? (a) Zustimmung durch eine Chat-Antwort für den AVV (Art. 28 Abs. 9 DSGVO) und die Leistungsbeschreibung; (b) der Nachweis ist der Eintrag des Mitarbeiters, die Chat-Nachricht bleibt im Portal; (c) K16 nennt die Anhänge nicht im Text und ist die Bestätigung auf einem dauerhaften Datenträger (die gerenderte Mail folgt als Anlage C nach der Mail-Überarbeitung des Inhabers); (d) für diesen Weg gibt es keine Widerrufsbelehrung (kein Kauf, kein Preis); (e) auch das Wiedereinschalten eines abgeschalteten Terminplaners, den das Team ohne Bestellung freigeschaltet hatte, verlangt dieselbe Textform (bei einem gekauften oder schon aktiven Terminplaner entfällt sie).
**Fundstelle im Text.** Abschnitt 3.5 (Vorbemerkung Abs. 2), Abschnitt 2.1 (Freischaltung ohne Bestellung); Programm: `termineTextformNoetig`, `textformFuerAktivierung`, `adminActivate`, `textformNachweisText`, `meldeFreigeschaltet`; Zusammenhang mit Q7.
**Unsere Annahme.** Ja: Textform genügt für die Zustimmung zu Leistungsbeschreibung und AVV, der Nachweis des Mitarbeiters mit dem Chatverlauf im Portal ist hinreichend, und K16 liefert den dauerhaften Datenträger.
**Alternative.** Die Zustimmung durch den Kunden selbst im Portal (Haken) einholen, auch für Kulanzfälle; K16 im Text auf die Anhänge hinweisen lassen.
### Q22: BFSG-Satz ohne Konformitätszusage
**Frage.** Leistungsbeschreibung Nr. 11 Abs. 2 sagt: 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. Ist die Formulierung ohne Konformitätszusage zulässig und für den Kunden klar genug? Nach unserem Stand gab es für das Widget bisher keinen Lauf mit einem automatischen Prüfwerkzeug oder einem Screenreader.
**Fundstelle im Text.** Abschnitt 3.4 (Nr. 11 Abs. 2).
**Unsere Annahme.** Ja. Der Satz sagt nur, was gestaltet wurde, und weist die Prüfung des BFSG dem Kunden zu.
**Alternative.** Den Satz streichen; oder eine Prüfung des Widgets beauftragen und die Aussage danach anpassen.
### Q23: Nachweis der Bestellung mit IP-Adresse und Browser; Aufbewahrung drei Jahre
**Frage.** Beim Klick auf „Zahlungspflichtig bestellen“ speichert das Portal als Nachweis die gezeigten Texte und Fassungen, den Zeitpunkt, die Stripe-Sitzung, IP-Adresse und Browserkennung, das Konto und die angemeldete Person. Die Datenschutzerklärung sagt dazu:
> 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. Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.
Ist die Speicherung der IP-Adresse und der Browserkennung für den Nachweis des Vertragsschlusses und der Zustimmungen erforderlich (Art. 6 Abs. 1 lit. f DSGVO) und die Aufbewahrung bis drei Jahre nach Ablauf des Jahres, in dem das letzte Abo endet, angemessen? Genügt die Information in der Datenschutzerklärung? Für Bestellungen ohne Abo (nur News) nennt die Erklärung eine eigene Frist: drei Jahre nach Ablauf des Jahres der Bestellung. Das Programm löscht diese Nachweise heute noch nicht; die Löschung wird vor Ablauf der ersten Frist (31.12.2029) nachgerüstet. Ist diese Frist angemessen?
**Fundstelle im Text.** Abschnitt 2.1 Schritt 4, Datenschutzerklärung § 7 (Hausrunde); Programm: `speichereZustimmung`, `zustimmungFuerSitzung`, `findDueDeletions` (Löschung der Nachweise gemeinsam mit den Abo-Daten des Kunden; für Kunden ohne Abo noch offen, Abschnitt 7.7).
**Unsere Annahme.** Ja: Der Nachweis dient dem Widerrufsrecht und der Beweisführung; die Frist entspricht der regelmäßigen Verjährung samt Ablauf des Jahres.
**Alternative.** IP-Adresse und Browserkennung weglassen (nur Zeit, Texte, Konto); oder eine kürzere Frist, etwa bis zum Ablauf der Widerrufsfrist und der Gewährleistung.
### Q24: Rollen in Abschnitt 6c; Entwurfsaufbewahrung 90 Tage
**Frage.** Die Datenschutzerklärung von WEBklar (Abschnitt 6c) beschreibt WEBklar als Auftragsverarbeiter für die Daten der Gäste und als Verantwortlichen für die Betriebsdaten im Kundenportal, den Entwurf des Fragebogens und die Bestellung (C1). (a) Stimmt die Rollenverteilung? (b) Der Satz „mit der Buchung speichern wir die IP-Adresse nicht“ ist durch einen Test belegt (Buchung ohne IP und Browser-Kennzeichen); für den Missbrauchsschutz merkt sich der Server Zeitpunkte je IP-Adresse nur im Arbeitsspeicher (C2). Genügt die Darstellung? (c) Anfragen von Gästen leitet WEBklar an den Kunden weiter (A4). (d) Entwürfe aus dem Fragebogen ohne Bestellung löscht WEBklar 90 Tage nach der letzten Änderung: Ist diese Frist zweckgebunden und angemessen?
**Fundstelle im Text.** Abschnitt 2.1 Schritt 2, Anlage A Nr. 8 und Nr. 9 Abs. 7; Datenschutzerklärung 6c (`docs/rechtliches/termine-datenschutz-6c.md`, Hausrunde); frühere Punkte C1, C2, A4; Programm: `ENTWURF_TAGE`.
**Unsere Annahme.** Die Rollen sind richtig verteilt (wie beim Abschnitt 6b der Website-Analyse); 90 Tage Entwurf sind angemessen, weil Kunden Fragebögen über Wochen ausfüllen.
**Alternative.** Entwürfe kürzer aufbewahren (30 Tage) oder nur auf ausdrückliches Speichern des Kunden.
### Q25: Mindestlaufzeit des Wartungs-Abos und Terminplaner
**Frage.** Das Wartungs-Abo hat eine Mindestlaufzeit von zwölf Monaten (§ 8 der Abo-Bedingungen) und ist danach zum Monatsende kündbar. Der Terminplaner hat keine eigene Laufzeit und kein Sonderkündigungsrecht; er endet mit dem Abo. Bestellbar ist er nur bei aktivem Abo ohne vorgemerkte Kündigung (Abschnitt 2.1 Schritt 1). (a) Ist es unbedenklich, dass der Kunde 149 € einmalig für etwas zahlt, das mit dem Abo enden kann, zum Beispiel wenn die Mindestlaufzeit bald endet? (b) Reicht Haken 4 („der Terminplaner endet mit dem Wartungs-Abo“) für die Transparenz, oder braucht die Bestellseite einen Hinweis auf das Ende der laufenden Mindestlaufzeit?
**Fundstelle im Text.** Abschnitt 3.1 (Haken 4), Abschnitt 3.4 (Nr. 8 Abs. 1 und 5), Abo-Bedingungen § 8 (`public/abo-bedingungen.html`); Programm: `bestellbar`, `laeuft`.
**Unsere Annahme.** Ja: Ohne vorgemerkte Kündigung läuft das Abo weiter; wird es später gekündigt, greift die Erstattung nur vor der Freischaltung, danach hat der Kunde den Terminplaner genutzt.
**Alternative.** Auf der Bestellseite das Ende der Mindestlaufzeit anzeigen; bei Beendigung in den ersten Monaten nach der Freischaltung anteilig erstatten.
### Q26: Nachweis der Gäste: Hash und Archiv der Textfassung
**Frage.** Die Buchung eines Gastes speichert als Nachweis die Fassung der Gästetexte, den Zeitpunkt, den Text des Buchungsknopfs, die Anrede und Hashes der gezeigten Texte, nicht diese Texte selbst. WEBklar archiviert jede Fassung der Gästetexte (Hinweis am Formular, Texte der Haken, Datenschutz-Absatz, Belehrung für Gäste) mit Platzhaltern für die Betriebsangaben bis drei Jahre nach dem Ende des Terminplaners. Die Betriebsangaben (Name, Anschrift, Stornotext, Stunden der Erinnerung, Aufbewahrung) ändern sich später; der Stornotext hat eine eigene Version. Genügt der Hash zusammen mit dem Archiv der Textfassung, um die Zustimmung eines Gastes nachzuweisen? Der Nachweis beruht auf dem Textstand des Servers zum Zeitpunkt der Buchung, nicht auf dem, was das Widget angezeigt hat; wird die Fassung während der Haltezeit von 30 Minuten gewechselt, können beide abweichen (Änderungen sollen außerhalb der Stoßzeiten eingespielt werden).
**Fundstelle im Text.** Abschnitt 2.2 Punkt 5, Abschnitt 3.5 (AVV Nr. 8 Abs. 5); Archiv `docs/rechtliches/archiv/termine-gaestetexte-2026-10-05.md`; Programm: `baueNachweis`, `baueArchiv`.
**Unsere Annahme.** Ja: Mit der Fassung, dem Archiv und den Betriebsangaben des Zeitpunkts lässt sich der gezeigte Wortlaut rekonstruieren; das genügt für den Nachweis, ohne Volltexte je Buchung zu speichern.
**Alternative.** Den vollständigen Text je Buchung speichern (mehr Daten, mehr Speicher); oder den Text je Betrieb und Fassung einmal als Schnappschuss ablegen.
## 5. Fundstellen im Code
Pfade gelten im Repository des Kundenportals (`Webklar-Kundenbereich`); Bezeichner sind Funktionen oder Konstanten und stehen in der genannten Datei. Zeilennummern nennen wir nicht, weil sie sich ändern.
| Thema | Datei | Funktion/Konstante |
|---|---|---|
| Fassung der Vertragsunterlagen und Bestelltexte | `server/config.js` | `avvVersion` |
| Bestell-Haken und Knopf des Terminplaners (Register) | `server/config.js` | `termineConsent`, `TERMINE_PREIS` |
| Preisangabe und Umsatzsteuerhinweis | `server/config.js` | `ustHinweis` |
| Preis im Produktkatalog (14 900 Cent) | `server/config.js` | `termineSetup`, `amountCents` |
| Widerrufsbelehrung der Funktionen (WEBklar als Unternehmer) | `server/services/termine/rechtstexte.js` | `widerrufsbelehrungFunktion`, `WEBKLAR_KONTAKT` |
| Gästetexte: Belehrung, Haken, Hinweis, Datenschutz-Absatz | `server/services/termine/rechtstexte.js` | `widerrufsbelehrung`, `ZUSTIMMUNG_VORZEITIG`, `ZUSTIMMUNG_STORNO`, `ZUSTIMMUNG_ERINNERUNG`, `GESUNDHEIT_HINWEIS`, `formularHinweis`, `datenschutzAbsatz` |
| Fassung der Gästetexte | `server/services/termine/rechtstexte.js` | `RECHTSTEXTE_VERSION` |
| Knopftexte der Gäste, Widerrufsfunktion | `server/services/termine/einstellungen.js` | `knopfText`, `zeigtWiderruf` |
| Wann der Haken zum vorzeitigen Beginn gilt | `server/services/termine/einstellungen.js` | `brauchtZustimmungVorzeitig` |
| Eingabeprüfung der Buchung, Storno-Haken nur bei Preis, Nachweis mit Hashes | `server/services/termine/ablauf.js` | `pruefeEingabe`, `baueNachweis` |
| Storno-Vorschlag der Vorlagen (50 %, 24 Stunden) | `server/services/termine/vorlagen.js` | `stornoText`, `STORNO_PROZENT` |
| Zustimmungsnachweis der Bestellung | `server/services/featureConsents.js` | `speichereZustimmung`, `zustimmungFuerSitzung` |
| Bestellung: Reihenfolge Zahlungssitzung, Nachweis, Weiterleitung | `server/routes/funktionen.js` | `/:key/checkout`, `speichereNachweis`, `createFeatureCheckout` |
| Bestellbestätigung K15 mit PDF-Anhängen der vereinbarten Fassung | `server/services/siteFeatures.js` | `onFeaturePaid`, `k15Rechtsunterlagen` |
| Adressen und Dateien der Rechtsunterlagen | `server/services/legalDocs.js` | `legalPaths`, `legalPdfAttachments`, `featureLegalAttachments` |
| Abo-Prüfung (laufend, bestellbar) | `server/services/aboStatus.js` | `laeuft`, `bestellbar` |
| Täglicher Abo-Abgleich, Mail K22/K23, Erstattung T19 | `server/services/termine/aboAbgleich.js` | `gleicheAboAb`, `meldeAboEnde` |
| Abschalten, Wiedereinschalten, Abschaltgründe | `server/services/siteFeatures.js` | `disable`, `activate`, `DISABLED_REASONS` |
| Löschfrist nach dem Abschalten (30 Tage) | `server/services/termine/frist.js` | `LOESCHFRIST_TAGE` |
| Löschlauf, Fristen der Buchungen | `server/services/termine/jobs.js` | `loescheAbgeschaltete`, `VERFALLEN_BEHALTEN_MS`, `ABGESAGT_BEHALTEN_MS`, `AUFBEWAHRUNG_VORGABE` |
| Aufbewahrung der Entwürfe (90 Tage) | `server/services/termine/entwuerfe.js` | `ENTWURF_TAGE` |
| Versandwarteschlange für E-Mails | `server/services/termine/outbox.js` | `GESENDET_BEHALTEN_MS`, `FEHLER_BEHALTEN_MS` |
| Aufbewahrung der Zustimmungsnachweise der Bestellung | `server/services/retention.js` | `findDueDeletions`, `applyDeletions` |
| Gesundheits-Ausschluss: Branchen, Stichwortwarnung, Bestell-Gate | `server/services/termine/fragebogen.js` | `BRANCHEN`, `gesundheitsWarnungen`, `bestellpruefung` |
| Freischaltung ohne Bestellung: Textform nötig? | `server/services/termine/schalter.js` | `termineTextformNoetig` |
| Freischaltung ohne Bestellung: Prüfung von Datum und Weg, PDFs | `server/routes/admin/funktionenAdmin.js` | `textformFuerAktivierung`, `unterlagenAlsAnhang` |
| Freischaltung ohne Bestellung: Nachweis und Aktivierung | `server/services/siteFeatures.js` | `adminActivate`, `textformNachweisText` |
| Widerruf der Bestellung: Fristende je Bestellung, spätestes Fristende entscheidet über „rechtzeitig“ | `server/routes/abo.js` | `withdrawFeature` |
| Widerrufsfrist (14 Tage), Fristende | `server/services/deadlines.js` | `WITHDRAWAL_DAYS`, `withdrawalDeadline` |
| Widerruf durch Gäste (Widerrufsfunktion) | `server/routes/publicTermine.js` | `/:siteKey/widerruf`, `/termin/widerruf`, `widerrufeGast`, `widerrufeOhneLink` |
| Bestätigungs-E-Mail an Gäste, Hinweis kein Widerrufsrecht | `server/mail-templates/G02 Termin bestaetigt.html` | `kein_widerruf`, `widerrufsbelehrung` |
| Versand der Bestätigungs-E-Mail | `server/services/termine/mails.js` | `gastG02` |
| Eingangsbestätigung des Widerrufs | `server/mail-templates/G08 Eingang Widerruf.html` | `{{eingang}}`, `{{nummer}}`, `nach_frist` |
| Mail „Terminplaner beendet“ (30 Tage, Rückkehr) | `server/mail-templates/K22 Terminplaner beendet.html` | `loeschdatum`, `Rückkehr` |
| Mail „Terminplaner läuft wieder“ | `server/mail-templates/K23 Terminplaner laeuft wieder.html` | `{{kunde_name}}`, `innerhalb der Löschfrist` |
| Hinweise im Kundenportal (Widerruf, Freizeit, Storno-Muster) | `public/termine-tab-einstellungen.js` | `WIDERRUF_HINWEIS`, `FREIZEIT_HINWEIS`, `STORNO_MUSTER_HINWEIS` |
| Archiv der Gästetexte je Fassung | `scripts/termine-texte-archiv.mjs` | `baueArchiv` |
| HTML-Seiten der Vertragsunterlagen | `scripts/build-legal-html.mjs` | `termineDokumente` |
| PDFs der Vertragsunterlagen | `scripts/build-legal-pdfs.sh` | `termine_version`, `TERMINE_VERSION` |
| Muster der Widerrufsbelehrung (Anlage 1) | `docs/rechtliches/quellen/anlage-1.txt` | `Folgen des Widerrufs` |
| Muster-Widerrufsformular (Anlage 2) | `docs/rechtliches/quellen/anlage-2.txt` | `Muster-Widerrufsformular` |
| Datenschutzerklärung von WEBklar: Abschnitt 6c, § 6, § 7 | `public/datenschutz.html` | `id="termine"` |
## 6. Nicht Teil der Kanzleiprüfung
Dieser Abschnitt nennt, was wir der Kanzlei nicht zur Prüfung vorlegen, und warum.
### 6.1 Hausrunde
Diese Texte prüft WEBklar selbst (wie bei der Website-Analyse), weil sie keine der Kernfragen aus Abschnitt 1 betreffen:
- die Datenschutzerklärung von WEBklar mit Abschnitt 6c „Online-Terminbuchung auf Websites unserer Kunden“ und den Ergänzungen in § 6 (Kundenansicht) und § 7 (Nachweise der Funktionsbestellung); Quelle `docs/rechtliches/termine-datenschutz-6c.md` und `public/datenschutz.html`. Nur die Rollenverteilung fragen wir (Q24),
- die Hinweise im Kundenportal (Widerrufs-, Freizeit- und Storno-Hinweise); zu zwei Punkten fragen wir (Q15, Q16),
- die Mailtexte (K15, K16, K22, K23, K04, T09, G02, G08); sie gehen durch die Mail-Überarbeitung des Inhabers. Die offene Formulierung in G02 steht in Q16 und in Abschnitt 7,
- die Einbauanleitung und das Einrichtungs-Ticket für Mitarbeiter.
### 6.2 Später
Diese Themen gehören nicht zu diesem Verkaufsweg und stehen für spätere Pakete bereit:
- Abo-Bedingungen in der Fassung 6 bzw. ein Rahmen-AVV mit einer Anlage je Funktion,
- Themen der Online-Zahlung der Gäste (ein Satz zu Stripe in Abschnitt 6c, Zahlungsdienste, Vorauszahlung in den AGB, Preisangabenverordnung),
- Pflichtangaben auf Geschäftsbriefen (§ 37a HGB),
- Rechtsmissbrauch bei spätem Widerruf,
- Minderjährige,
- Mitgliedschaften,
- wiederholtes Versenden der Mails G08 und K21 nach Fristende,
- Widerruf nur vor Terminbeginn im Verwaltungslink.
### 6.3 Frühere Listen „Für die Rechtsprüfung offen“
Beim Fertigstellen der Gästetexte und der Datenschutzerklärung haben wir Listen offener Rechtsfragen aus den Dokumenten entfernt. Jeder Punkt dieser Listen hat eine Kennung: H1 bis H4 (Hinweise-Dokument), A1 bis A6 (Datenschutz-Absatz für die Website der Betriebe), B1 bis B6 (Muster der Widerrufsbelehrung für Gäste), C1 bis C8 (Entwurf von Abschnitt 6c der Datenschutzerklärung). Jeder Punkt wird entweder bei einer Frage in Abschnitt 4 behandelt (die Kennung steht dort in der Fundstelle) oder ist hier mit Grund aufgeführt:
| Kennung | Punkt | Wo behandelt |
|---|---|---|
| H1 | Belehrung vor dem Vertragsschluss: aufklappbare Darstellung im Widget | Q12 |
| H2 | Widerruf der Einwilligung am Erinnerungshaken | Q17 |
| H3 | Link zur Datenschutzerklärung neben dem Hinweis | Q17 |
| H4 | Bestätigungsfrist: 48 Stunden sind der früheste Zeitpunkt der Löschung | Q9 |
| A1 | Server-Protokolle: 14 Tage und Rechtsgrundlage | Q10 |
| A2 | Drittländer: gilt nur, solange Server und Mailversand bei Hetzner in Deutschland bleiben | Q10 |
| A3 | Löschlauf: Fristen sind die frühesten Zeitpunkte | Q9 |
| A4 | Kontakt der Gäste: Anfragen gehen an den Betrieb, WEBklar leitet weiter | Q24 |
| A5 | Gesundheitsbereich: Hinweis „Bitte keine Gesundheitsangaben“ | Q11 |
| A6 | Knopfmodus: Konfiguration schon beim Laden der Seite | Q10 |
| B1 | Lieferkosten-Klammer in der Belehrung | Q12 |
| B2 | „beginnen soll“ im Singular | Q12 |
| B3 | Anschrift und Telefon des Betriebs sind Pflicht | hier: technisch erledigt. `widerrufsbelehrung()` wirft einen Fehler, wenn eine der vier Angaben fehlt, und die Einstellungsprüfung verlangt beide Angaben. Keine Rechtsfrage. |
| B4 | Wertersatz-Absatz steht immer in der Belehrung | Q13 |
| B5 | Hinweis statt Internetadresse bei der Widerrufsfunktion | Q12 |
| B6 | Gesetzlichkeitsfiktion des Musters | Q12 |
| C1 | Rolle: Auftragsverarbeiter für die Gäste, Verantwortlicher für die Betriebsdaten | Q24 |
| C2 | IP-Adresse im Arbeitsspeicher: Satz „mit der Buchung speichern wir die IP-Adresse nicht“ | Q24 |
| C3 | Kalender-Abo-Link: Information der Beschäftigten | Q10 |
| C4 | Sicherungen: gelöschte Daten bis zu sieben Tage | Q9 |
| C5 | Zahlung der Gäste (spätere Online-Zahlung): Satz zu Stripe in Abschnitt 6c | hier: gehört zur späteren Online-Zahlung der Gäste (Abschnitt 6.2); der Terminplaner kennt heute nur die Zahlung vor Ort. |
| C6 | Geschäftsbriefe: Bestätigungen mit Preis sind keine Rechnungen | hier: Abschnitt 6.2 (Pflichtangaben auf Geschäftsbriefen); die Aufbewahrung nach Abschnitt 9 der Datenschutzerklärung betrifft den Terminplaner nicht. |
| C7 | Ende des Wartungs-Abos: Satz in Abschnitt 6c | Q9 |
| C8 | Knopfmodus: Formulierung zum Laden der Konfiguration in Abschnitt 6c | Q10 |
## 7. Checkliste für den Inhaber
Diese Punkte erledigt der Inhaber; kein Punkt ist Aufgabe der Kanzlei. Die Reihenfolge entspricht dem Ablauf bis zum ersten fremden Betrieb.
### 7.1 Vor der Beauftragung der Kanzlei
- [ ] Die PDFs der Anlagen A und B und der Widerrufsbelehrung einmal am Bildschirm ansehen (Kopfzeile „WEBklar · …“, „Seite x von y“, Tabelle der Anlage des AVV); wir konnten sie nur über ihre HTML-Seiten prüfen.
- [ ] Die Zahl der News-Bestellungen seit 2026-09-30 ermitteln, die mit dem alten Zustimmungstext und ohne Widerrufsbelehrung abgeschlossen wurden, und der Kanzlei nennen (Q4).
- [ ] Bestätigen, dass WEBklar Kleinunternehmer nach § 19 UStG bleibt (Q19).
### 7.2 Hetzner (nur im Konto prüfbar)
- [ ] Auftragsverarbeitungsvertrag mit Hetzner für die Cloud **und** für das Webhosting abgeschlossen (die Buchungs-E-Mails laufen über den SMTP-Dienst des Webhostings).
- [ ] Anlage 1 des Vertrags nennt „Terminbucher“, „Beschäftigte“ und „Buchungsdaten“ als Betroffene und Datenarten.
- [ ] Die Kontoadresse bei Hetzner stimmt mit dem Impressum überein.
- [ ] Die Datensicherung (Backups) ist eingeschaltet; AVV Nr. 9 Abs. 8 und die Abo-Bedingungen versprechen sieben Tagessicherungen.
### 7.3 Uploads in WOMS „Rechtliches“
- [ ] Das Setup-Skript von WOMS mit API-Key ausführen: Es legt die drei Karten „Leistungsbeschreibung Terminplaner“, „AVV Terminplaner (Auftragsverarbeitung)“ und „Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)“ an.
- [ ] Für diese drei Karten die Version der Fassung hochladen (PDF oder HTML aus `public/rechtliches/`; Markdown ist im Bucket nicht erlaubt).
- [ ] Die neue Datenschutzerklärung (Stand 05.10.2026, `docs/WEBklar-Datenschutzerklaerung.pdf`) als neue Version der Karte „Datenschutzerklärung“ hochladen.
- [ ] „Gültig ab“ in `docs/rechtliches/archiv/README.md` eintragen (Fassung der Gästetexte vom 2026-10-05 steht auf „offen bis Livegang“; bei der Fassung vom 2026-10-02 prüfen, ob „gültig ab 2026-10-03 (Pilot)“ stimmt).
### 7.4 Mailtexte
- [ ] Die Platzhalter „ENTWURF (Mailrunde): …“ in `mails.danach` und `mails.bereit` des Terminplaners (Register in `server/config.js`) durch den endgültigen Wortlaut ersetzen. Sie stehen heute in K15 und K16 an Kunden.
- [ ] Die Kopfkommentare von K22 und K23 („Texte sind Entwurf für die Mail-Überarbeitung“) in der Mail-Überarbeitung abarbeiten.
- [ ] K23 (Terminplaner läuft wieder) sagt „Deine Termine und Einstellungen sind unverändert da“. Für die Termine stimmt das nicht immer, weil der Löschlauf auch bei abgeschaltetem Terminplaner abgesagte und erledigte Termine nach ihren Fristen löscht. Den Satz in der Mail-Überarbeitung abschwächen.
- [ ] Nach der Mail-Überarbeitung die gerenderten Mails K15 (mit den Zustimmungen) und K16 als **Anlage C** beilegen und der Kanzlei nachreichen; ihre Angemessenheit als Bestätigung auf einem dauerhaften Datenträger fragen Q3, Q20 und Q21.
- [ ] G02 zeigt die Stornobedingungen und den Satz „Absagen richten sich nach den Stornobedingungen“ (Block `kein_widerruf`) auch bei kostenlosen Buchungen. Einen neutralen Satz festlegen, der für Buchungen ohne Preis passt (Q16).
### 7.5 Beauftragung und Freigabe
- [ ] Die Kanzlei mit der Prüfung des Kernpakets und den Fragen Q1 bis Q26 beauftragen (Anlagen A und B beilegen; Umfang und Frist vereinbaren).
- [ ] Den Befund einarbeiten: Texte ändern, neues Fassungsdatum setzen, HTML und PDFs neu erzeugen, Archiv der Gästetexte und Prüfsummen nachziehen. Dieses Paket (`docs/rechtliches/termine-kanzlei-paket.md`) wird dabei nachgezogen; sein Test meldet jedes Zitat, das nicht mehr wörtlich im Programm oder in den Dokumenten steht.
- [ ] Über die News-Altfälle entscheiden (Q4): Belehrung per Mail nachholen oder nur künftig die neuen Texte.
- [ ] Die Fassung vor dem ersten fremden Betrieb ausdrücklich freigeben; erst danach die Adresse des ersten fremden Betriebs in die Pilotliste aufnehmen.
- [ ] Beim Livegang die Reihenfolge in Abschnitt 7.6 einhalten; den Datenschutz-Abschnitt der Test-Website des Terminplaners auf den Endtext bringen.
### 7.6 Livegang: Reihenfolge
Diese Schritte gelten für den Tag, an dem die Betriebsart `on` gesetzt wird. Die Reihenfolge ist wichtig, vor allem: beide Schemata vor dem Code-Deploy und `on` als letzter Schritt.
- [ ] Vor dem Code-Deploy das Schema des Terminplaners anlegen, erst mit `--check`: `node scripts/termine-setup.mjs` ergänzt in `termineConfig` die Spalte `fragebogen`, in `termineBookings` die Indizes `idx_status_ends` und `idx_status_updated` und in `termineOutbox` den Index `idx_booking`. Ohne sie scheitern Schreibzugriffe und Abfragen des neuen Programms (der Fragebogen, der tägliche Löschlauf und das Löschen von Gastdaten).
- [ ] Vor dem Code-Deploy das Schema der Funktionen anlegen, erst mit `--check`: `node scripts/feature-setup.mjs` (neue Felder von `portalFeatures` und die Collection `featureConsents` mit ihren Indizes). Der Zeitpunkt ist der Code-Deploy, nicht erst der erste Löschlauf der Aufbewahrungsfristen: Schon die erste Bestellung des neuen Programms schreibt die neuen Felder, und ohne Schema antwortet Appwrite mit „Unknown attribute“.
- [ ] Hat sich `public/datenschutz.html` geändert, das PDF der Datenschutzerklärung mit `scripts/build-legal-pdfs.sh datenschutz` neu drucken, nicht mit `all` (das druckt auch die PDFs der Abo-Bedingungen und des Leitfadens neu); die Prüfsumme `docs/WEBklar-Datenschutzerklaerung.pdf.sha256` gehört dazu.
- [ ] Den Code zu einer ruhigen Zeit deployen. Der Nachweis einer Buchung speichert die Fassung der Gästetexte, die der Server beim Speichern hat; bucht ein Gast genau während des Deploys, kann sie von der abweichen, die er auf dem Bildschirm sah.
- [ ] Nach dem Deploy die Uploads in WOMS „Rechtliches“ erledigen (Abschnitt 7.3).
- [ ] Nach dem Deploy und nur mit Freigabe des Inhabers: `scripts/feature-consent-felder-leeren.mjs` zuerst als Probelauf ausführen (ohne Argument oder mit `--dry-run`; schreibt nichts), die Liste prüfen und erst dann mit `--apply` ausführen. Das Skript leert IP-Adresse und Browserkennung der Bestellung an den Funktionsdokumenten (`portalFeatures`), soweit der Nachweis (`featureConsents`) sie trägt; neue Bestellungen speichern sie dort nicht mehr.
- [ ] Als allerletzten Schritt `TERMINE_ENABLED=on` setzen, nachdem alle Punkte oben und die Freigabe der Fassung (Abschnitt 7.5) erledigt sind.
### 7.7 Nach dem Livegang
- [ ] Die Löschfrist für die Nachweise von Kunden ohne Abo (nur News) umsetzen, bevor die erste Frist abläuft (31.12.2029). Die Datenschutzerklärung (Abschnitt 7) sagt für Bestellungen ohne Abo „spätestens drei Jahre nach Ablauf des Jahres der Bestellung“; der Löschlauf (`findDueDeletions`) erfasst heute nur Kunden mit einem Abo, ein Kunde ohne Abo ist nie fällig. Umsetzung: Kunden ohne Abo-Dokument, aber mit Nachweisen in `featureConsents`, mit dem Ankerjahr der letzten Bestellung in den Plan aufnehmen und nur die Nachweise und die Bestellangaben am Funktionsdokument löschen, nicht die Beiträge.
@@ -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.
@@ -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.
@@ -0,0 +1,909 @@
# Terminplaner TP2 Verkauf – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Kunden mit laufendem Wartungs-Abo bestellen den Terminplaner selbst (Fragebogen → Zustimmungen → 149 € über Stripe → Ticket mit Einbauanleitung → Einbau → Freischaltung); endet das Abo, endet der Terminplaner (Löschung nach 30 Tagen); dazu Rechtstexte + Kanzlei-Paket; am Ende `TERMINE_ENABLED=on`.
**Architecture:** Der News-Bestellweg wird zu einem Register `config.features` verallgemeinert (Checkout, Mails, Ticket, Freischaltung je Funktion). Der Fragebogen schreibt in dasselbe `termineConfig`-Dokument wie TP1 (Entwurf = spätere Live-Einstellung) plus ein neues Attribut `fragebogen`. Ein gemeinsamer Abo-Baustein `aboStatus.js` dient Analyse und Terminplaner; ein täglicher Abgleich koppelt den Terminplaner an das Abo. Einbauanleitung als reine Funktion, im Ticket (kurz) und im WOMS (voll).
**Tech Stack:** wie TP1 (Node 20 Produktion / 24 Host, Express 4, Appwrite 1.8.1 REST, nodemailer 9, Stripe über `stripeBilling.js`, Playwright aus `/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs`), WOMS React/Vite.
**Spec:** `docs/superpowers/specs/2026-10-03-terminplaner-tp2-verkauf-design.md` (Abschnitte 1–11, Entscheidungen D1–D8). TP1-Spec `docs/superpowers/specs/2026-10-02-terminplaner-design.md`, TP1-Ledger `/root/reports/terminplaner-tp1/progress.md` (Rulings R1–R55), Erkundung `/root/reports/terminplaner-tp2/erkundung-2026-10-03.json`.
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2` (von `origin/main` 3a0b008; Spec-Commit 6ed0e3b; eigenes `node_modules`). WOMS `/home/webklar/apps/ticket.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2` von `origin/test` 78bc99a (V1).
## Global Constraints
- Nichts pushen, nichts in Produktion schreiben, keine Live-Stripe-Aufrufe; das macht der Inhaber bzw. der Livegang mit Freigabe. Nie `git add -A`; Dateien explizit stagen. WOMS: jeder gepushte Branch wird ausgeliefert.
- Node-20-tauglich (keine APIs ab Node 21). `config.*` bei jedem Aufruf lesen (Tests ändern Werte zur Laufzeit).
- Tests: Portal `npm test` (`node --test test/*.test.js`), Mocks aus `test/support/` (`startAppwriteMock`, `startFakeStripe`, `mailSink`, `useTestEnv`, `startTestApp`, `sessionCookie`); nie echte Dienste. `test/previewDevRouter.test.js` ist ein bekannter, unabhängiger Wackel-Test (einzeln wiederholen). WOMS `npm test` + einmal `nice npx vite build`. Playwright nicht in `npm test`, schwere Läufe nacheinander, vorher `free -m`, mit `nice`.
- Schema nur über idempotente Skripte mit `--check`: `scripts/termine-setup.mjs` (Termine-Collections) und das neue `scripts/feature-setup.mjs` (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`, neue Collection `featureConsents`); `scripts/setup-abo-collections.mjs` bleibt unverändert; `test/support/schema.js` zieht mit.
- News bleibt im Verhalten unverändert (bestehende Tests grün), **außer** den Rechtstext-Korrekturen in Block F. Analyse ändert ihr Verhalten nicht (nur gemeinsamer Abo-Baustein).
- Funktionsschlüssel `news`, `termine` im Register; `analyse` bleibt eigener Weg. Preise in Cent; Terminplaner-Einrichtung `14900`, Stripe-Katalogschlüssel `termineSetup`, Lookup-Key `webklar_termine_einrichtung_14900`. Preisanzeige „149 €“ mit Hinweis „ohne Umsatzsteuer gemäß § 19 UStG“ (gilt nach Block F auch für News).
- Zustände `portalFeatures.state`: `off` | `ordered` | `setup` | `active` | `disabled`. `disabledReason` ∈ `team` | `abo_ende` | `abo_ende_vor_einrichtung` | `kunde_geloescht`. `unlockedByPurchase === true` nur bei bezahlter Bestellung; Staff-/Pilot-Dokumente sind vom Abo-Abgleich ausgenommen.
- Mails neu: **K22** „Terminplaner beendet“ (Blöcke `abo_ende`, `hosting`, `vor_einrichtung`, `daten`), **K23** „Terminplaner läuft wieder“, **T19** „Erstattung fällig: Terminplaner-Einrichtung“ (Vorlage aus B9b; einziger Sender `meldeErstattung`, B10b), **T20** „Terminplaner Betriebsmeldung“ (A10a1). K15/K16/T16 mit `{{funktion}}`; T16 neue Blöcke `kein_abo`, `bereits_aktiv`, `anleitung`, `gesundheit` (`{{gesundheit_text}}`) und `anhang_fehlt` (F8); K02/K03 Block `funktionen`; K04/T09 mit `{{produkt}}` und `{{preis}}`. K/T in Du-Form; Layout nach den bestehenden Vorlagen; `test/mailTemplates.test.js`: Liste endet `…ids('K',23), …ids('T',19), 'T20'`, Kopfkommentar ergänzen.
- Kundentexte Du-Form, Gästetexte Sie/Du nach `anrede`, Rechtstexte für Verbraucher Sie-Form. Text nur über `textContent`/`esc()`; `href` nur geprüfte `https:`-URLs mit `rel="noopener noreferrer"`. Keine Gesundheitsangaben.
- Kundenansicht: `POST /api/funktionen/:key/checkout` und `PUT /api/termine/fragebogen` sind für `req.session.viewAs` gesperrt (403); die Wildcard `POST /^\/api\/termine\/.+$/` wird durch eine explizite Liste ersetzt (Block A).
- Protokolle ohne Gast-PII; Tagesbericht nur mit Zählungen.
- Ein Portal-Prozess (Sperren, Grenzen, Mailbudget im Speicher) – festhalten, nicht ändern.
## Review Focus
1. Zahlung trifft ein, nachdem sich der Zustand geändert hat (Abo beendet, Staff hat aktiviert, Sitzung abgelaufen, Dokument `disabled`): nie Geld ohne Spur – immer Ticket **oder** T16/T19 an das Team, nie doppelt. → Block B (onFeaturePaid-Fälle), Block E
2. Abo-Abgleich irrt: Lesefehler, `past_due`, Kündigung vorgemerkt, Pilot-/Staff-Dokument, Abo kommt innerhalb von 30 Tagen zurück ⇒ nie Abschalten bei Fehler, Pilot nie, Rückkehr schaltet wieder ein, Löschung prüft vorher frisch. → Block E
3. Entwurf ohne aktive Funktion: nie öffentlich sichtbar, nur der eigene Kunde schreibt, ab `ordered` nur lesbar (außer Staff in Kundenansicht), Kundenansicht bestätigt keine Fragebogen-Schritte. → Block C
4. Löschjobs mit vielen Betrieben: seitenweise, nie früher als die Frist, nie still für immer übersprungen (Überlauf läuft an Folgetagen weiter und wird gezählt). → Block A
5. Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt sind gespeichert, **bevor** der Kunde zu Stripe geht (die Stripe-Sitzung wird zuerst angelegt, weil der Nachweis ihre ID trägt; die URL geht erst nach gespeichertem Nachweis an den Browser); Widerrufs-Anker = Zeit des Klicks. → Block B, F
## Schnittstellen über Blockgrenzen (verbindlich für alle Tasks)
- **Register** `config.features` (Block B, `server/config.js`): `{ news: {...}, termine: {...} }`, je Eintrag: `key`, `product` (Katalogschlüssel `newsSetup` | `termineSetup`), `label: { name, kurz, keine }`, `priceLabel` (z. B. „149 €“), `setupEstimate`, `consent: { version, knopf, items: [{ id, text, haken }] }`, `leistungsbeschreibung`, `avv`, `widerruf` (Fassungen + URLs), `returnHash` (`'news'` | `'termine'`), `ticket: { topic(project) ⇒ string, details(ctx) ⇒ Promise<string> }` mit `ctx = { feature, doc, project, customer }`, `mails: { funktion, knopf }`, `requiresWartungsAbo: boolean`, `ticketFreischaltung: boolean`, `disabledMessage`, `orderedConflictMessage`; dazu `config.ustHinweis`. Helfer in `server/services/featureRegistry.js`: `featureConfig(key) ⇒ Eintrag | null`, `featureHaken(key)` mit den Haken `verfuegbar`, `bestellpruefung`, `unterlagenBereit`, `belehrung`, `freischaltbar`, und `anleitungsLink`.
- **Checkout-Body** (Block B): `POST /api/funktionen/:key/checkout` mit `{ projectId, zustimmung: { version, haken } }` oder `{ projectId, fortsetzen: true }` (offene Sitzung fortsetzen).
- **Abo-Baustein** (`server/services/aboStatus.js`; Kernversion in B5 mit endgültiger Signatur, ausgebaut in E1): `laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>` (Wartungs-Abo, `productKey || 'wartung'`, Status ∈ `ACTIVE_STATUSES`; wirft bei Lesefehler; B5 kennt nur `laeuft(customerId)`, E1 ergänzt `altbestand` und die Wechsel-Regel), `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>` (Gründe in dieser Prüfreihenfolge), `aboMemo() ⇒ { laeuft, bestellbar }` (je Lauf zwischengespeichert; E1 ergänzt `lage`) und **erst ab E1** `aboLage(customerId) ⇒ Promise<{ laeuft, art, endedAt }>` (gelesener Spiegel inkl. Wechsel-Regel; in B5 gibt es sie nicht). **`aboMeldung` gehört nicht in diese Datei:** `aboMeldung(feature, grund) ⇒ string` (Argumentreihenfolge: Register-Eintrag, dann Grund ∈ `kein_abo` | `gekuendigt` | `nicht_aktiv` | `unbekannt`) liegt in `server/services/featureOrder.js` (B6) und ist die einzige Quelle der Abo-Hinweistexte; C2, C4a, C4b, C10a und E importieren sie von dort. Andere Blöcke nutzen nur diese Funktionen.
- **siteFeatures**: `disable(docId, { by, adminNote, reason })` idempotent (ein bereits `disabled` Dokument behält `disabledAt`), setzt `disabledReason` (A11); `activate()` leert `disabledAt`, `disabledReason`, `aboEndeMailAt`, nicht `zahlungMeldung` (A11); `markPaid(...) ⇒ { doc, created, fall }` und Marker `zahlungMeldung` (B10a); `meldeErstattung(doc, { customer, sitzung, nachAboEnde })` ist der **einzige** T19-Sender (B10b); `activateClosedTickets({ now, customerId })` über alle Register-Schlüssel mit `ticketFreischaltung: true` und Haken `freischaltbar(doc) ⇒ Promise<boolean>` aus dem Register (Termine: Einstellung vorhanden **und** `laeuft`) (B11).
- **Zustimmungsnachweis** (Block B, `server/services/featureConsents.js`, Collection `featureConsents`): `speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc>` (Hash über die Texte), `letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null>`, `zustimmungFuerSitzung(sessionId)`, `nachweisTexte(feature, { belehrung })`; Widerrufs-Anker `vertragAt` über `paidFeatureOrders`.
- **Fragebogen** (Block C, `server/services/termine/fragebogen.js`; Stub in B5): `bestellungAnsicht(...)` (async), `BRANCHEN` (Liste `{ key, name, vorlage | null, gesundheit: boolean }`), `SCHRITTE` (Schlüssel der 8 Schritte), `normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler }`, `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` (leer = bestellbar; der Abo-Punkt (h) nur bei `mitAbo !== false`: der Checkout (B6, Standard-Haken) ruft mit `mitAbo: false` und ohne `abo`, weil dort die Abo-Stufe (7) die Codes `kein_abo` | `gekuendigt` | `nicht_aktiv` liefert; nur `GET /api/termine/einstellungen` (C4a) übergibt `abo` und zeigt (h) als offenen Punkt), `gesundheitsWarnungen(einstellung) ⇒ string[]`. Attribut `termineConfig.fragebogen` (JSON-String, ≤ 16 000 Zeichen). Route `PUT /api/termine/fragebogen { projectId, fragebogen }`.
- **Einbauanleitung** (Block D, `server/services/termine/einbauanleitung.js`): `einbauanleitung({ feature /* portalFeatures-Dokument */, project, einstellung, fragebogen, version, now }) ⇒ { stand: { version, updatedAt }, warnungen: string[], abschnitte: Array<{ id, titel, text, kopierbar }>, text, kurz }` (rein, nie `feedToken`); `einbauSnippet({ siteKey, apiBase, telefon, modus, knopfText })` (aus `routes/termine.js` hierher verschoben). Team-Route `GET /api/team/funktionen/:customerId/termine/anleitung?projectId=`.
- **Rechtstexte** (Blöcke D/F, `server/services/termine/rechtstexte.js`): `datenschutzAbsatz(einstellung, anrede) ⇒ string` (Quelle für Anleitung und Doku).
- **Status-Route** (Block A): `GET /api/team/termine/status ⇒ { budget, queue: { wartet, aeltesteMin }, pause, letzterTagesjob, aktiveBetriebe }`.
## Grundlagen der Blöcke (aus den Block-Einleitungen, verbindlich für alle Tasks)
Jeder Task bekommt Kopf und eigenen Text, nicht die Einleitung seines Blocks. Was ein Task aus einer Block-Einleitung braucht, steht deshalb hier.
- **Zeilenangaben:** Alle `Z. n` und `:n` im Plan gelten für Portal `3a0b008` (WOMS `78bc99a`) und verschieben sich durch frühere Tasks an denselben Dateien (besonders `test/termineJobs.test.js`, die A3a, A3b, A4, A6, A10a2, A10a3, A12, E7, E8 und C6 ändern; ebenso `server/services/termine/jobs.js`, `outbox.js`, `server/config.js`, `server/services/siteFeatures.js`, `server/routes/termine.js`): **per Test- oder Funktionsname suchen**, nicht nach Zeilennummer.
- **Neue Werte in `config.termine`** (Standardwerte, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit; angelegt in A3a, A3b, A6, A7 und A9a): `mailsProFunktion: 40`, `mailsReserve: 0.25`, `mailListe: 100`, `loeschDeckel: 2000`, `loeschDeckelGesamt: 20000`, `zellenDeckel: 5000` (vorhanden: `mailsProStunde: 200`, `holdMinutes: 30`).
- **Testkonventionen für Register-, Checkout-, Abo- und Zustimmungstests** (Block B; gelten ebenso für die Tests in C, D, E und F, die diese Teile berühren): `startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), termineConfig: schemaKeys('termineConfig') } })` (`featureConsents` ab B3), `useTestEnv` vor den dynamischen Importen, `config.termine.mode = 'pilot'` und `config.termine.pilotEmails = ['*']` für Termine-Fälle, Mails über `setMailTransportForTests(mailSink())`. Der Nachbau prüft Schlüssel nur beim Anlegen/Ändern, nicht in `seed`: Felder, die erst Block A/E ins Schema bringen (`disabledReason`), dürfen Tests schon seeden (`schemaKeys('portalFeatures')` enthält seit A11 `disabledReason`, `aboEndeMailAt`, `zahlungMeldung`). **Pins auf Werte, die Block F ändert** (News-`consent.version`/-Text, `mails.preisHinweis`, Termine-Items und -Fassung, `leistungsbeschreibung`/`avv`) lesen `config.features.*` statt Literale; nur der Einfrier-Test `featureMails` (B9a, B9b) und die dort genannten Wortlaute sind Literale, die Block F2 gezielt anpasst.
- **Falle `seedFixtures`:** `seedFixtures(aw)` legt für `c1` nur ein **Hosting**-Abo `sub_h` an, `c2` hat ebenfalls nur Hosting, `c3` gar kein Abo. Jeder Test, der für `c1` ein **Wartungs**-Abo braucht (Termine-Bestellung, Abo-Baustein, Freischaltung, Abgleich), überschreibt es mit `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))` (Hilfen in `test/support/fixtures.js`); sonst scheitert er an `kein_abo`.
- **Im Code geprüft (Block B):** Das Schema läuft über `scripts/feature-setup.mjs` (A11 legt es nach dem Muster `scripts/analyse-setup.mjs` an, B3 ergänzt `featureConsents`); `scripts/setup-abo-collections.mjs` kennt **kein** `--check`, ist ein Top-Level-Skript ohne Exporte und bleibt unangetastet. `scripts/stripe-products.mjs` exportiert nur `parseArgs` (Task B2 exportiert `KEYS` und `catalogEntry`). `aboStatus.js`, `featureRegistry.js`, `featureConsents.js` und `termine/fragebogen.js` gibt es noch nicht. `markPaid` übergibt weder `topic` noch `details` (`siteFeatures.js:261-266`), `createFeatureTicket` kürzt nichts (`ticketAdmin.js:85`). `teamNotice` und `unmatchedNotice` kennen kein `funktion` (T16-Betreff „…: News-Pinnwand“ ist in `test/funktionenRoutes.test.js` Z. 150, 339, 371 gepinnt und bleibt für News so). `seedFixtures(aw)` legt für `c1` ein **Hosting**-Abo `sub_h` an; Termine-Tests brauchen `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))` (überschreibt es), `c2` hat nur Hosting, `c3` gar kein Abo.
- **Festlegungen des Blocks E** (gelten für E4–E10):
- Schreiben nur unter der Sperre `feature-admin:<projectId>` (wie `routes/admin/funktionenAdmin.js:87`) und nach **frischem** Lesen von Dokument **und** Abo (`aboLage` direkt, nie der Memo). Der Memo (`aboMemo()`) dient nur der Vorauswahl je Kunde im Listenlauf.
- Lesefehler (Appwrite, Abo, Ereignisse) ⇒ Kunde/Dokument überspringen, `fehler += 1`, Log ohne Namen, Adressen und IDs von Gästen; **nie** abschalten, wenn etwas nicht lesbar war.
- Marker erst nach Erfolg: `aboEndeMailAt` (K22) und `zahlungMeldung === 'gemeldet'` (T19 über `meldeErstattung`, B10b; `purchaseStatus` bleibt `paid`). Schlägt der Versand fehl, wiederholt ihn der nächste Lauf; `disabledAt` ändert sich dabei nie (sonst wüchse die Löschfrist).
- Abgleich nur für `disabledReason`-Werte aus dem Kopf (`team | abo_ende | abo_ende_vor_einrichtung | kunde_geloescht`); ein `disabled`-Dokument ohne Grund (Altbestand) wird wie `team` behandelt und nie angefasst. Die Spec nennt in Abschnitt 6 zusätzlich `frist`; maßgeblich ist die Liste aus dem Plankopf.
- **Fassungsdatum `<F>`** (gilt für F1 bis F15b und überall, wo `<F>` oder `<V>` steht): `<F>` ist der **Berliner Kalendertag des F1-Commits** (`JJJJ-MM-TT`, muss nach `2026-10-02` liegen, der eingefrorenen Altfassung der Gästetexte in F10a). F1 schreibt ihn als Wert von `config.termine.avvVersion`; alle späteren Tasks lesen die Konstante und tragen den Tag nie als Literal in Code oder Tests (Ausnahme: das Literal `RECHTSTEXTE_VERSION` in `termine/rechtstexte.js`, das F10b1 auf denselben Tag setzt; `<V>` = `<F>`, ein Test hält beide gleich). Drei Stände: `config.termine.avvVersion` (Leistungsbeschreibung, AVV, Belehrung, Bestelltexte, News-`consent.version`), `RECHTSTEXTE_VERSION` (Gästetexte) und „Stand“ der Datenschutzerklärung (Tag der Änderung). Eine **ausgelieferte** Fassung wird nie überschrieben: sobald die Texte an die Kanzlei gegangen oder live sind (Block G1/G7), heißt jede Textänderung neues Datum und neue Dateien; davor darf der Controller `<F>` mit einem eigenen Commit „Fassungsdatum anheben“ auf den Tag der Übergabe setzen. Beim Anheben entstehen neu: die F6-Dateien `*-<F>.html/.pdf` (alte, nie ausgelieferte Dateien werden gelöscht), das Archiv `termine-gaestetexte-<V>.md` (F10b1) samt Hash-Pin, die Fassungs- und Hash-Pins in den Tests von F2, F7, F10b1, F13a und F15a, `RECHTSTEXTE_VERSION` (F10b1) und News-`consent.version` (F2); die Datenschutzerklärung nur bei Textänderung.
---
## Vorab (Controller, kein Subagent)
- [ ] **V1 WOMS-Branch:** im Worktree `/home/webklar/apps/ticket.webklar.com/dev-termine` `git fetch origin && git checkout -b feature/terminplaner-tp2 origin/test`; `npm test` grün als Basis.
- [ ] **V2 Basis Portal:** im Portal-Worktree `npm ci` falls `node_modules` fehlt, `npm test` als Basis (Erkundung: 2235 Portal grün, WOMS 294); dazu der Node-20-Lauf der reinen Termine-Tests (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js`).
- [ ] **V3 Ledger:** SDD-Workspace für diesen Plan anlegen; die Rulings R1–R55 aus TP1 gelten weiter, sofern dieser Plan nichts anderes sagt. Abweichungen aus der Planprüfung festhalten: der Entwurfszugriff (C4b) und der `kein_abo`-Hinweis (B10b) nutzen `laeuft`; der Tagesbericht zählt `ausgenommen`e Dokumente; der Abo-Abgleich hat keinen eigenen Beobachtungsschalter (er beobachtet über den Datenstand); der Checkout wendet den Abo-Punkt (h) des Bestell-Gates nicht an (`mitAbo: false`, B6/C2), die Abo-Stufe (7) in B6 liefert die Abo-Codes.
Reihenfolge der Blöcke: **A → B → E → C → D → F**, danach **Block G** (Livegang, am Ende von Block F). Was TP2 bewusst nicht enthält, steht in Spec Abschnitt 1 und 8 („SOLLTE“/„Kann warten“).
---
## Block A: Technik-Voraussetzungen aus TP1
Schuld aus TP1 (Spec Abschnitt 8, MUSS #1–#6; die Verallgemeinerung von `activateClosedTickets` baut Block B, Task B11) plus die billigen SOLLTE-Punkte am selben Code (Frühausstieg in `verarbeite`, Index `termineOutbox(bookingId)`, Minutenjob teilen). Löschjobs, Zellen-Sweep und CSV-Export lesen seitenweise (`limit` ≤ 100, Export 500), haben einen Deckel je Lauf und laufen an Folgetagen weiter; die Mail-Warteschlange bekommt Kontingent, Vorrang, Alarm und eine Status-Route. Neue Werte in `config.termine` (`server/config.js` Z. 378–394, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit): `mailsProFunktion: 40`, `mailsReserve: 0.25`, `mailListe: 100`, `loeschDeckel: 2000`, `loeschDeckelGesamt: 20000`, `zellenDeckel: 5000`.
Reihenfolge: A2 vor A3a–A6 und A9a; A3a vor A3b, A4 und A6; A7 vor A9a; A9a vor A9b; A3a, A3b, A4, A6, A7 und A9b vor A10a1; A10a1 vor A10a2 vor A10a3; A10a3 vor A10b und A12; A11 steht für sich (vor Block B). Schema-Änderungen (Indizes, `portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`) laufen **vor** dem Code-Deploy über die idempotenten Skripte `scripts/termine-setup.mjs` und das neue `scripts/feature-setup.mjs` (beide mit `--check`; Livegang Schritt 3): `activate()` schreibt die neuen Felder, ohne Schema käme `Unknown attribute`. `scripts/setup-abo-collections.mjs` bleibt unverändert (es hat kein `--check` und ist ein Top-Level-Skript ohne Exporte); `feature-setup.mjs` folgt dem Muster von `scripts/analyse-setup.mjs` (exportierte Attribut-Listen, `main({ check, client, log })`, Test mit Mock-Client).
**Bewusst nicht in TP2** (Spec Abschnitt 8 „SOLLTE“/„Kann warten“, kein Task; im Ledger als offen führen): Transport `pool: true` und Begrenzung der Parallelität der `einreihen`-Sendungen (`mailer.js:25`); Mailbudget nach einem Neustart aus der Outbox rekonstruieren; `/tage` und `/zeiten` außerhalb des Horizonts vor `imZeitraum` kurzschließen (`publicTermine.js`); Hetzner-Überlimit-Antworten als Pause statt als `fehler` einstufen (`outbox.js`); `mailSicher`-Fehlermarker, damit das Portal „nicht zugestellt“ zeigt; Tagesjob zu fester Nachtzeit (heute 180 s nach jedem Start); `TERMINE_TOKEN_SECRET`; `engines >=20`; axe-/Screenreader-Lauf von Widget und Gästeseite (deshalb in der Leistungsbeschreibung keine Konformitätszusage, F4). Lock-Wartezeiten, `halte` ohne `termine-cfg`-Lock, Fetch-Timeouts und der Widget-Monolith bleiben liegen.
### Task A1: Kundenansicht-Wildcard durch eine explizite Liste ersetzen
**Files:** Modify `server/middleware/kundenansichtGuard.js` (Z. 18–21), `test/kundenansichtGuard.test.js` (Z. 106–127 und neue Tests am Ende).
**Consumes:** –. **Produces:** `KUNDENANSICHT_ALLOWED` mit genau sechs Termine-Einträgen, jeweils mit `\/?$`: POST `/api/termine/einstellungen/vorlage`, POST `/api/termine/ressourcen/[^/]+/feed`, POST `/api/termine/buchungen`, POST `/api/termine/buchungen/[^/]+/aktion`, PATCH `/api/termine/einstellungen`, DELETE `/api/termine/buchungen/[^/]+`. Alles andere unter `/api/termine` (auch Block C `PUT /fragebogen`) bleibt in der Kundenansicht gesperrt, ohne dass es einen Eintrag braucht; Block B legt den Checkout unter `/api/funktionen` (dort nie erlaubt).
- [ ] Tests: (1) „Erlaubnisliste“ (Z. 106): `KUNDENANSICHT_ALLOWED.length === 8 + 6`; die bisherigen `ok`-Fälle bleiben erlaubt; neu **nicht** erlaubt: `POST /api/termine/zukunft`, `POST|PUT /api/termine/fragebogen`, `POST /api/termine/export.csv`, `POST /api/termine/buchungen/abc` (bisher durch die Wildcard erlaubt), `POST …/buchungen/abc/aktion/x`, `POST …/einstellungen/vorlage/x`, `POST …/ressourcen/abc/feed/x`, `POST /api/funktionen/termine/checkout`. (2) Neu „unbekanntes POST unter /api/termine ⇒ 403“: über HTTP mit `kv()` ⇒ 403 `view_blocked` für `POST /api/termine/zukunft`, `PUT /api/termine/fragebogen`, `POST /api/termine/buchungen/abc/aktion/x` und `POST /API/Termine/Zukunft` (der Wächter antwortet vor dem Router, auch wenn es die Route nicht gibt); dieselben Pfade mit `customerCookie('c1')` ⇒ nicht 403 (404, der Wächter fasst normale Kunden nicht an). (3) Neu „Router-Abgleich“: `writeRoutes([['/api/termine', termineRouter]])` gefiltert mit `allowed()` ergibt exakt die sechs Routen (`:id` ⇒ `x`); eine künftige Schreib-Route unter `/api/termine` fällt dadurch im Test „jede Schreib-Route ausserhalb der Erlaubnisliste ⇒ 403“ auf, nicht erst in Produktion. (4) Der Test „Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert“ (`termineRoutes.test.js` Z. 1028: Vorlage, Feed, Buchung, Aktion, PATCH, DELETE) bleibt grün.
- [ ] FAIL → implementieren (die vier POST-Muster einzeln ausschreiben, kein `.+`) → PASS → `npm test` → Commit „Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste“
### Task A2: Abfrage-Bausteine und Nachbau-Erweiterung
**Files:** Modify `server/services/appwriteAdmin.js` (`Query` Z. 77–94, neu `countDocuments`), `test/support/appwriteMock.js` (`matches` Z. 124, `supportedQueries` Z. 145, `list` Z. 147–169, Rückgabeobjekt Z. 388); Test `test/appwriteBulk.test.js` (dort stehen die Query- und Nachbau-Tests, ab Z. 37).
**Consumes:** –. **Produces:** `Query.select(attrs[])` ⇒ `{ method: 'select', values: attrs }`; `Query.notEqual(attr, wert | wert[])` ⇒ `{ method: 'notEqual', attribute, values: […] }` (Liste = „keiner von“); `countDocuments(collectionId, queries) ⇒ Promise<number | null>` (ein Aufruf mit `limit(1)`, liefert `total` der Antwort, `null`, wenn Appwrite keines liefert). Nachbau: `select` liefert nur die genannten Felder plus alle `$…`-Felder; `notEqual` (kein Wert gleich), `total` = Treffer vor `limit`/`cursorAfter` (heute: Länge nach `limit`), `aw.listCalls(collection?)` ⇒ `[{ collection, queries: [{ method, attribute, values }] }]` (nur Listen-GETs, geparst) und `aw.resetListCalls()`. `Query.offset` wird bewusst nicht gebaut (Löschseiten lesen ohne Cursor neu), ebenso `Query.isNull` (Block E filtert `aboEndeMailAt` im Code; sein Verhalten bei Datetime-Attributen auf Appwrite 1.8.1 ist ungemessen). Dass `notEqual` mit Liste und `total` auf der echten Appwrite 1.8.1 dasselbe bedeuten wie im Nachbau, ist ebenfalls ungemessen: Messprüfung in Block G (Livegang 5).
- [ ] Tests (`appwriteBulk.test.js`): `Query`-Helfer liefern die Formen oben; Nachbau: `select(['a'])` ⇒ Dokumente nur mit `a` und `$id`; `notEqual('f', ['x','y'])` schließt beide aus, `notEqual` mit einem Wert; `total` bei `limit(2)` über 5 Treffer ⇒ 5 und `countDocuments` ⇒ 5; `listCalls('termineClaims')` zeichnet Listenabfragen auf (keine POST/GET-by-ID), `resetListCalls` leert; „Nachbau ohne Optionen verhält sich wie bisher“ (Z. 196) bleibt grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu“
### Task A3a: Aufbewahrungs-Löschung seitenweise – Grundlagen und Schritte `verfallen`/`abgesagt`/`abgelehnt` (Review Focus 4)
**Files:** Modify `server/services/termine/buchungen.js` (`loeschKandidaten` Z. 183–192), `server/services/termine/jobs.js` (`loeschfristen` Z. 288–315, nur die Schritte `verfallen`, `abgesagt`, `abgelehnt`; neu `arbeiteSeiten`; `wendeLoeschfristenAn` Z. 400, `taeglicherJob` Z. 420), `server/config.js` (`loeschDeckel`, Standard 2000), `server/services/termine/schema.js` (termineBookings: `index('idx_status_ends', 'status', 'endsAt')` und `index('idx_status_updated', 'status', 'updatedAt')`; die Spec nennt nur das erste, `abgesagt`/`abgelehnt` laufen über `updatedAt` und brauchen das zweite); Tests `test/termineJobs.test.js` (Z. 672–751 anpassen, neue), `test/termineZellen.test.js` (Z. 933–958), `test/termineSetup.test.js` (Z. 143–155).
**Consumes:** A2 (`Query.select`, `aw.listCalls`). **Produces:** `loeschKandidaten(status, vor, { featureId = null, limit = 100 } = {}) ⇒ Promise<doc[]>` (höchstens `limit`, älteste Bezugszeit zuerst, `Query.select` ohne Gastfelder: `$id, featureId, status, endsAt, updatedAt, heldUntil`; TypeError für nicht löschbare Status wie bisher); `arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel }) ⇒ { geloescht, gedeckelt, vollstaendig }` (für A3b, A4); `wendeLoeschfristenAn(now) ⇒ { verfallen, abgesagt, abgelehnt, erledigt, nichterschienen, gedeckelt }` (`gedeckelt` = Zahl der Schritte, die am Deckel endeten; die Schritte `erledigt`/`nichterschienen` laufen bis A3b unverändert über den alten Weg); `taeglicherJob` liefert zusätzlich `gedeckelt: { loeschfristen, abgeschaltet: 0, zellen: 0 }` (A4/A6 füllen die anderen) und trennt `gedeckelt` vom Löschzähler ab, damit die Summe im `tagesbericht` stimmt.
**Ansatz:** Die Frist steht in der Datenbankabfrage (`lessThan(Bezug, jetzt − Frist)`) und wird nie im Code nachgeholt. Seiten ohne Cursor: Seite lesen, jede Buchung wie bisher unter ihrer Sperre frisch prüfen und samt Zellen löschen (`loescheMitZellen`), Liste neu lesen (Gelöschtes fehlt). `arbeiteSeiten` endet bei leerer Seite, beim Deckel (zählt `lauf.gedeckelt`, Log-Warnung ohne IDs) oder bei einer Seite ohne einen Erfolg (Fehler stehen im Log; bereits versuchte IDs merkt eine Menge, kein Endlosdurchlauf). Durchgang (1): global `verfallen` (48 h), `abgesagt`, `abgelehnt` (30 Tage), Deckel `loeschDeckel` je Status. Die Prüfung am Kandidaten (`endsAt` < Frist) bleibt als zweiter Boden.
- [ ] Tests: (1) `loeschKandidaten` (termineZellen): 150 alte `erledigt` ⇒ Standard 100, `{ limit: 10 }` ⇒ 10, aufsteigend nach `endsAt`, `{ featureId }` filtert, Treffer ohne `gastName`/`gastEmail`, Bezugszeit je Status und TypeError für `bestaetigt`/`gehalten` wie bisher. (2) Seitenlauf: 250 `verfallen`/`abgesagt`/`abgelehnt` gemischt, davon 40 eine Millisekunde zu jung ⇒ ein Lauf löscht genau die 210 fälligen samt Zellen und keine junge; alle Listenabfragen an `termineBookings` haben `limit ≤ 100` und ein `select` ohne Gastfelder (`aw.listCalls`), kein `$id`-Seitenlauf mit 500; der Standard `loeschDeckel` (2000, je Status) liegt über den 210 fälligen, ohne dass der Test ihn setzt. (3) Deckel: `loeschDeckel = 7`, 20 fällige `verfallen` ⇒ Lauf 1 löscht 7 und meldet `gedeckelt: 1` (Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mit `gedeckelt: 0`; nach jedem Lauf stimmen Rest und Zellen, an keinem wird eine junge Buchung gelöscht. (4) Fehler stoppt nicht für immer: alle DELETE auf `termineBookings` scheitern (fetch-Wrapper) ⇒ der Lauf endet nach höchstens einer Seite je Schritt (Anfragenzahl begrenzt), Fehler gezählt, die anderen Schritte laufen; nächster Lauf ohne Fehler löscht alles. (5) Angepasste TP1-Tests: Z. 672 erwartet `gedeckelt: 0`; der Race-Test (Z. 742) bleibt. (6) `termineSetup`: Index-Liste von `termineBookings` um `idx_status_ends` (status, endsAt) und `idx_status_updated` (status, updatedAt) ergänzen, `main` legt beide an.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)“
### Task A3b: Aufbewahrungs-Löschung seitenweise – je Funktion, Sicherheitsnetz, Gesamtdeckel, Bericht (Review Focus 4)
**Files:** Modify `server/services/termine/jobs.js` (`loeschfristen` Z. 288–315, die Schritte `erledigt`/`nichterschienen`; `wendeLoeschfristenAn` Z. 400, `taeglicherJob` Z. 420, `tagesbericht` Z. 437), `server/config.js` (`loeschDeckelGesamt`, Standard 20000; `loeschDeckel` Standard 2000 kommt aus A3a); Tests `test/termineJobs.test.js` (Z. 672–751 anpassen, neue).
**Consumes:** A3a (`loeschKandidaten`, `arbeiteSeiten`, `loeschDeckel`, Indizes). **Produces:** `wendeLoeschfristenAn` und `taeglicherJob` behalten die Form aus A3a; `tagesbericht` bekommt das Segment „Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen“ (nur bei `gedeckelt` > 0, direkt nach `Mails nicht zugestellt`).
**Ansatz:** Durchgang (2): je Funktion (`portalFeatures` mit `featureKey termine`, `state` active|disabled, `listAllDocuments` mit `Query.select(['$id'])`) `erledigt`/`nichterschienen` nach `aufbewahrungTage` der Funktion (`aufbewahrungTage()` wie bisher, `null` ⇒ Funktion auslassen), gemeinsamer Deckel `loeschDeckel` je Funktion und `loeschDeckelGesamt` je Lauf. Durchgang (3), Sicherheitsnetz: `erledigt`/`nichterschienen` mit `endsAt < jetzt − 365 Tage` ohne Funktionsfilter (die größte erlaubte Aufbewahrung; fängt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spätestens nach 365 Tagen). Die Prüfung am Kandidaten (`endsAt` < Frist) bleibt als zweiter Boden.
- [ ] Tests: (1) „viele Betriebe“: 60 Funktionen (`active`, `aufbewahrungTage` 14/90/365 gemischt), je 6 Buchungen (3 fällig, 3 eine Millisekunde zu jung, Status gemischt) ⇒ ein Lauf löscht genau die 180 fälligen samt Zellen und keine junge (die Standards `loeschDeckel` 2000 je Funktion und `loeschDeckelGesamt` 20000 je Lauf liegen darüber, der Test setzt sie nicht); alle Listenabfragen an `termineBookings` haben `limit ≤ 100` (`aw.listCalls`), kein `$id`-Seitenlauf mit 500. (2) Deckel je Funktion: `loeschDeckel = 7`, eine Funktion mit 20 fälligen ⇒ Lauf 1 löscht 7 und meldet `gedeckelt: 1` (Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mit `gedeckelt: 0`; A 50 fällige, B 2 fällige, `loeschDeckel = 5` ⇒ A 5, B 2 im ersten Lauf; `loeschDeckelGesamt = 6` ⇒ insgesamt 6, Rest im nächsten Lauf; an keinem Lauf wird eine junge Buchung gelöscht. (3) Sicherheitsnetz: Buchung ohne Funktionsdokument, 366 Tage alt ⇒ gelöscht, 200 Tage alt ⇒ bleibt (dokumentierte Grenze, Waisen gibt es in Produktion nicht); Funktion mit unlesbarer Einstellung (`failNext` termineConfig GET): 100 Tage bleibt (nie früher), 366 Tage weg. (4) Angepasste TP1-Tests: Z. 692 legt für F2 und F4 `portalFeatures`-Dokumente an (F2 ohne Einstellung ⇒ 90, F4 kaputte Einstellung ⇒ 90). (5) `tagesbericht` unverändert ohne Deckel, mit `gedeckelt` > 0 zusätzlich das Segment oben (vollständige Zeile siehe E7).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht“
### Task A4: Löschung abgeschalteter Funktionen seitenweise (Review Focus 4)
**Files:** Modify `server/services/termine/jobs.js` (`loescheAbgeschaltete` Z. 347–391, `nochAbgeschaltet` Z. 325, `loescheAbgeschalteteFunktionen` Z. 410), `server/services/termine/buchungen.js` (neu `ersteBuchungen`); Tests `test/termineJobs.test.js` (Z. 753–870).
**Consumes:** A3a (`arbeiteSeiten`, `loeschDeckel`, `gedeckelt` im Tagesjob). **Produces:** `ersteBuchungen(featureId, limit = 100) ⇒ doc[]` (`Query.equal('featureId')`, `limit`, `select(['$id'])`); `loescheAbgeschalteteFunktionen(now) ⇒ { funktionen, buchungen, gedeckelt }`. Block E hängt die frische Abo-Prüfung an `nochAbgeschaltet` und setzt das Funktionsdokument nach dem Löschen der Einstellung auf `off`.
**Ansatz:** Je abgeschaltete Funktion (Frist `disabledAt < jetzt − 30 Tage` bleibt in der Funktionsliste) Seiten zu 100 lesen und löschen, bis die Seite leer ist; vor jeder Seite nach dem Lesen und alle 50 Buchungen `nochAbgeschaltet` (frisch, wie bisher); Deckel `loeschDeckel` je Funktion. Die Einstellung (`termineConfig`) wird nur nach leerer Seite ohne Fehler und mit letzter Prüfung unter `termine-cfg:<id>` gelöscht; sonst bleibt die Funktion `disabled` mit altem `disabledAt` und der nächste Tag macht weiter (`gedeckelt` + Log-Warnung).
- [ ] Tests: (1) Funktion seit 31 Tagen abgeschaltet mit 230 Buchungen (mit Zellen), `loeschDeckel = 100`: Lauf 1 ⇒ `{ funktionen: 1, buchungen: 100, gedeckelt: 1 }`, `termineConfig` und `disabledAt` unverändert; Lauf 2 ⇒ weitere 100; Lauf 3 ⇒ 30 und Einstellung weg, `gedeckelt: 0`; alle Listenabfragen `limit ≤ 100`. (2) Deckel gilt je Funktion: A 150 Buchungen, B 10 ⇒ B im selben Lauf komplett samt Einstellung, A noch nicht. (3) Schaltet das Personal die Funktion nach der ersten Seite wieder ein (`mitEinwurf` auf die zweite Listenabfrage) ⇒ keine weitere Löschung, Einstellung bleibt, Log „inzwischen wieder aktiv“, kein Fehler. (4) Scheitert jedes Löschen (Wrapper) ⇒ Ende nach einer Seite, Einstellung bleibt, Anfragenzahl begrenzt, kein Wurf. (5) Die bestehenden Tests Z. 753–870 bleiben bis auf `gedeckelt: 0` in den `deepEqual`-Pins (`{ funktionen, buchungen, gedeckelt }`; Block E pinnt dieselbe Form) grün (Wiedereinschalten nach 50 Buchungen ⇒ genau 50 gelöscht; Neulesen scheitert ⇒ nichts gelöscht, ein Fehler im Log).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen“
### Task A5: CSV-Export seitenweise streamen (Review Focus 4)
**Files:** Modify `server/routes/termine.js` (`GET /export.csv` Z. 450–474, Import Z. 11), `server/services/termine/buchungen.js` (neu `exportSeite`); Tests `test/termineRoutes.test.js` (Z. 927–990).
**Consumes:** A2 (`aw.listCalls`). **Produces:** `exportSeite(featureId, { nach = null, limit = 500 } = {}) ⇒ doc[]` (`Query.equal('featureId')`, `orderAsc('startsAt')`, `limit`, `cursorAfter(nach)` ⇒ `idx_feature_start`).
**Ansatz:** Die erste Seite wird gelesen, **bevor** Header gesendet werden (Fehler ⇒ normales JSON-500). Dann BOM, Kopfzeile und je Seite die Zeilen per `res.write` (Sperren, `quelle sperre`, je Seite herausfiltern; Cursor = letzte Zeile der Rohseite). Ein Fehler nach dem ersten Byte ⇒ `res.destroy(err)`: ein abgebrochener Download statt einer still gekürzten Datei. Wird das Cursor-Dokument zwischen zwei Seiten vom Tagesjob gelöscht, bricht der Export ab und wird neu geladen (selten, dokumentieren). `imZeitraum` bleibt für die übrigen Aufrufer.
- [ ] Tests: (1) 1 200 Buchungen + 40 Sperren + fremde Funktion ⇒ 1 200 Datenzeilen nach Beginn sortiert, Zeilen byte-gleich zum bisherigen Format; genau drei Listenabfragen an `termineBookings` (500/500/200) mit `limit 500` und ohne `$id`-Sortierung. (2) 20 100 Buchungen (über dem alten Deckel von 20 000 in `listAllDocuments`) ⇒ 20 100 Zeilen, kein `list_limit_exceeded`. (3) `failNext` GET 500 bei der ersten Seite ⇒ 500 JSON ohne CSV-Anfang; bei der zweiten (`after: 1`) ⇒ der Antwortkörper bricht ab (`arrayBuffer()` wirft), kein vollständig aussehendes Ende; im Log kein Gastname/keine Adresse. (4) Die Tests Z. 934 (BOM, Kopf, Sortierung, Sperren, fremde Funktionen) und Z. 957 (Hochkomma-Schutz) bleiben unverändert grün; `Content-Disposition`, `no-store`, `nosniff` wie bisher.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: CSV-Export seitenweise gestreamt“
### Task A6: Zellen-Sweep seitenweise über `idx_bis` (Review Focus 4)
**Files:** Modify `server/services/termine/zellen.js` (`ALLE_ZELLEN_MAX` Z. 46, `ungueltigeZellen` Z. 92–99, `verwaisteAus`/`alleZellen`/`verwaisteZellen`/`raeumeVerwaisteZellen` Z. 215–270), `server/config.js` (`zellenDeckel`, Standard 5000), `server/services/termine/jobs.js` (`taeglicherJob`); Tests `test/termineZellen.test.js` (Z. 600–745), `test/termineJobs.test.js` (Tagesjob-Test Z. 867).
**Consumes:** A2 (`Query.select`, `listCalls`), A3a (`gedeckelt` im Tagesjob). **Produces:** `raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel } = {}) ⇒ Promise<{ geloescht, gedeckelt }>`; `verwaisteZellen`, `alleZellen`, `ALLE_ZELLEN_MAX` entfallen (nur in Tests benutzt); `ungueltigeZellen(zellen, nowMs)` prüft zuerst das Zeitfenster: eine Zelle mit `bis < jetzt − 1 Tag` ist ohne Buchungsabfrage ungültig, nur die übrigen werden gegen ihre Buchung geprüft (`raeumeBlockierer` in `belegeZellen` nutzt dieselbe Funktion).
**Ansatz:** Seiten über `idx_bis`: `lessThan('bis', jetzt − 1 Tag)`, `orderAsc('bis')`, `limit(100)`, `select(['$id','featureId','ressourceId','bookingId','bis'])`, ohne Cursor neu lesen. Gruppierung je Ressourcensperre mit `Map.get`/`push` (statt `[...alt, zelle]`, Z. 247, quadratisch); je Gruppe unter der Sperre erneut lesen (`leseNachIds`) und nur löschen, was dort noch dieselbe `bookingId` hat und alt ist; erster Fehler wird am Ende geworfen, eine Seite ohne Erfolg beendet den Lauf. **Bewusste Änderung:** Zellen mit künftigem `bis`, deren Buchung fehlt oder nichts mehr belegt (nur möglich, wenn `gibFreiSicher` scheiterte), findet der Sweep nicht mehr täglich; sie stören nicht (`belegeZellen` räumt Blockierer bei Bedarf, `raeumeBlockierer`) und fallen spätestens einen Tag nach `bis` in den Sweep.
- [ ] Tests: (1) Zellen mit `bis` genau `jetzt − 1 Tag` bleiben, eine Millisekunde älter ⇒ weg; der Sweep fragt `termineBookings` nicht ab (kein GET). (2) 260 alte Zellen auf zwei Ressourcen (Test Z. 687 angepasst; der Standard `zellenDeckel` 5000 liegt darüber) ⇒ `{ geloescht: 260, gedeckelt: false }`, alle Listenabfragen an `termineClaims` mit `limit ≤ 100`. (3) Deckel: 600 alte Zellen, `zellenDeckel = 250` ⇒ Lauf 1 `{ 250, true }`, Lauf 2 `{ 250, true }`, Lauf 3 `{ 100, false }`; `taeglicherJob` meldet `gedeckelt.zellen` und `zellen` als Zahl. (4) 3 000 alte Zellen einer einzigen Ressource (ein Sperrschlüssel, Deckel 5 000) ⇒ alle gelöscht (Regression des quadratischen Aufbaus). (5) Race (Tests Z. 697/711 angepasst auf alte Zellen): wird eine gelistete Zelle vor der Sperre durch eine andere `bookingId` und künftiges `bis` ersetzt (`mitEinwurf`), bleibt sie. (6) Waise mit künftigem `bis` (Buchung `abgesagt`) bleibt im Sweep stehen, blockiert aber keine Neubuchung (`belegeZellen` räumt sie, bestehender Test Z. 361/406 grün). (7) Ein DELETE-Fehler stoppt die übrigen Blöcke nicht und wird am Ende geworfen (Z. 736); danach kein Endlosdurchlauf. (8) **Angepasste/entfallende TP1-Tests (einzeln):** die beiden `verwaisteZellen`-Tests Z. 600 (Buchung fehlt, nicht mehr belegend, Zelle länger als einen Tag vorbei) und Z. 636 (liest alle Zellen, prüft in Blöcken) werden **gelöscht**, weil die Funktion entfällt; ihre Fälle gehen in (1), (2) und (6) auf. Der Test Z. 670 („löscht genau die verwaisten Zellen und zählt sie“) liest `.geloescht` statt der Zahl (Z. 681–683: `equal(…, 4)` und `equal(…, 0)`, dazu müssen die Zellen `bis` älter als einen Tag haben; die künftigen `c_ok`/`c_abgesagt`/`c_fehlt` bleiben bewusst liegen, vgl. (6)); Z. 687 (260 Zellen) laut (2); Z. 697 und Z. 711 laut (5); Z. 736 laut (7), dort `equal(…, 1)` (Z. 742) ebenfalls auf `.geloescht`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel“
### Task A7: Mail-Fairness 1 – Kontingent je Funktion, Vorrang-Reserve, Haltungsgrenze
**Files:** Modify `server/services/termine/outbox.js` (Budgetzustand Z. 50–79, `sende` Z. 161–204), `server/config.js` (`mailsProFunktion`, Standard 40; `mailsReserve`, Standard 0.25), `server/services/termine/limits.js` (`HALTUNGEN_JE_SITE` Z. 44: 60 → 25); Tests `test/termineOutbox.test.js` (Z. 247–297, 466–493), `test/termineLimits.test.js` (Z. 139–155, 176–193), `test/publicTermine.test.js` (Z. 614–628).
**Consumes:** –. **Produces:** `MAIL_VORRANG = ['G01','G02','G08']`, `MAIL_NACHRANGIG = ['G05','K20','K21']`, `mailKlasse(art) ⇒ 'vorrang' | 'normal' | 'nachrangig'`; `pruefePlatz(nowMs, featureId, art) ⇒ null | 'pause' | 'budget' | 'reserve' | 'kontingent'`; `mailBudget(now) ⇒ { stunde: { benutzt, max }, nachrangig: { benutzt, max }, jeFunktionMax, spitzen: [{ featureId, benutzt }] (höchstens 3) }` (für A10a2 und A10b; nur Zahlen und Funktions-IDs, keine Adressen).
**Ansatz:** Das Stundenfenster hält Einträge `{ t, f, n }` (Zeit, Funktion, nachrangig) statt Zahlen; `gibPlatzFrei` entfernt per Objekt. Regeln in dieser Reihenfolge: Pause; Gesamtzahl im Fenster ≥ `mailsProStunde` ⇒ `budget`; nachrangige im Fenster ≥ `max(1, floor(mailsProStunde × (1 − mailsReserve)))` ⇒ `reserve` (so bleibt ein Viertel der Stunde Vorrang- und Normal-Mails vorbehalten); Mails der Funktion im Fenster ≥ `mailsProFunktion` ⇒ `kontingent`. `sende` liest den Eintrag (kennt dann `featureId` und `art`), belegt den Platz erst danach und lässt einen gebremsten Eintrag unverändert `wartet` (kein Versuch, `versuche` bleibt); der Sofortversand aus `einreihen` unterliegt denselben Regeln. Mit 25 Haltungen je Stunde und siteKey löst eine Funktion höchstens 25 G01 aus, unter dem Kontingent 40.
- [ ] Tests (`config.termine.mailsProFunktion`, `mailsReserve` je Test setzen, `beforeEach` stellt 200/40/0.25 zurück): (1) Kontingent: `mailsProFunktion = 3`, Funktion A reiht 5 G02 ein ⇒ 3 gesendet, 2 `wartet` (`versuche 0`, Inhalt erhalten); Funktion B reiht 2 ein ⇒ beide gehen raus; `verarbeite(+61 min)` ⇒ A's 2 gehen raus. (2) Reserve: `mailsProStunde = 8` (nachrangig höchstens 6): 10 G05 ⇒ 6 gesendet, danach ein G01 ⇒ geht raus (Plätze 7 und 8 gehören dem Vorrang), das siebte G05 bleibt `wartet`, obwohl noch ein Platz frei ist; `mailKlasse` für alle zehn Arten (G03/G04/G06/G07 ⇒ `normal`). (3) Gleitendes Fenster je Funktion: Plätze laufen 60 Minuten nach dem Versand ab (59 Min nein, 60 Min ja); 4xx-Fehlversuche und Anmeldepause belegen weder Gesamt- noch Funktionsplatz (Tests Z. 281/386 grün). (4) Gleichzeitig: `Promise.all` über 6 Einträge einer Funktion mit Kontingent 3 ⇒ genau 3 Transportaufrufe. (5) `mailBudget`: nach 5 Sendungen aus zwei Funktionen stimmen `benutzt`, `max`, `spitzen` (sortiert, höchstens 3), kein `@` im Ergebnis. (6) TP1-Pins: Z. 466 (`Stundenbudget gilt auch bei gleichzeitigen Versuchen`) nimmt `art: 'G02'`; Z. 247–263 bleibt außer der `faellig`-Zeile (A9a). (7) Limits: `darfHaltung` 25 je siteKey und Stunde (Test Z. 139: Titel, 65 Aufrufe ⇒ 25 gezählte; Test Z. 176: Schleife `i < 60` ⇒ 25), `publicTermine.test.js` Z. 614: Titel und Schleifen 60/59 ⇒ 25/24, die 25. Haltung geht noch, die 26. ⇒ 429.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze“
### Task A8: Empfänger-Kappe über Betriebe (G01, Freitext in G04/G06)
**Files:** Modify `server/services/termine/limits.js` (`darfG01` Z. 196, neu `darfEmpfaenger`), `server/services/termine/mails.js` (`anGast` Z. 472–494); `server/routes/publicTermine.js` Z. 535 bleibt unverändert (ruft `darfG01`); Tests `test/termineLimits.test.js` (Z. 162–174 neu geschrieben, Z. 176–193), `test/publicTermine.test.js` (Z. 630–647), `test/termineAblaufBetrieb.test.js` (G04 Z. 281, G06 Z. 323).
**Consumes:** –. **Produces:** `darfG01(featureId, adresse, now)` zählt zusätzlich über alle Funktionen (`zaehleAlle`, alles oder nichts): `empfaenger-h:<adresse>` 3 je Stunde und `empfaenger-d:<adresse>` 10 je Tag, neben dem bisherigen `g01:<funktion>:<adresse>` 5 je Tag; `darfEmpfaenger(adresse, now) ⇒ boolean` (dieselben zwei Zähler, ohne Funktionsregel). Die Adresse ist die normalisierte (klein, getrimmt).
**Ansatz:** Das Gast-Formular bleibt ein stiller Erfolg wie heute (keine Buchung, keine Mail, gleiche Antwort), weil `darfG01` vor `halte` prüft (kein verwaister Halte-Eintrag). Für G04 (`extra.nachricht`) und G06 vom Betrieb (`extra.grund`, `extra.nachricht`) mit Freitext prüft `anGast` `darfEmpfaenger(an)`: ist die Kappe erreicht, geht die Mail **ohne den Freitext** raus (der Hinweis auf Ablehnung/Absage bleibt, der Missbrauchswert fällt weg), Log-Warnung ohne Adresse; ohne Freitext zählt nichts, G05/K20/K21 sind nicht betroffen.
- [ ] Tests: (1) `darfG01` (Test Z. 162 wird neu geschrieben): dieselbe Adresse bei `f1` ⇒ `[true, true, true, false, false, false, false]`; `f2` mit derselben Adresse ⇒ `false` (Kappe über Betriebe, bisher `true`), andere Adresse ⇒ `true`; nach 61 Minuten wieder drei; Tageskappe: 3 + 3 + 3 + 1 über vier Stunden verteilt ja, die 11. nein; alles oder nichts (ein abgelehnter Aufruf erhöht keinen Zähler, auch nicht den der Funktion); `darfG08` zählt weiter getrennt. Test Z. 176 (Speicher): drei statt fünf Durchläufe, der vierte `false`. (2) `darfEmpfaenger` teilt die Zähler mit `darfG01` (zwei `darfEmpfaenger` + zwei `darfG01` ⇒ der vierte Aufruf `false`). (3) `publicTermine` Test Z. 630 wird „je Adresse 3 je Stunde“: sechs Anfragen derselben Adresse ⇒ alle 201 mit gleicher Antwort, drei Mails (statt 5), drei Buchungen, die 4. bis 6. ohne Haltung und ohne Mail; andere Adresse und „Fehler der Zeit kommen vor der Mailgrenze“ (409) wie bisher. (4) `anGast`: G04 mit Nachricht an eine Adresse über der Kappe ⇒ Mail geht raus und enthält den Text nicht, darunter mit Text; G06 `von: 'betrieb'` mit `grund` ebenso, G06 `von: 'gast'` zählt nicht; das Log enthält keine Adresse.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)“
### Task A9a: Mail-Fairness 2a – Frühausstieg, begrenzte und nach Klasse geordnete Warteschlange
**Files:** Modify `server/services/termine/outbox.js` (`verarbeite` Z. 260–275, `sende` Z. 161–204: nur die Platzprüfung), `server/config.js` (`mailListe`, Standard 100); Tests `test/termineOutbox.test.js` (Z. 218–263, neue).
**Consumes:** A2 (`select`, `notEqual`, `listCalls`), A7 (`pruefePlatz`, `mailKlasse`, `MAIL_*`). **Produces:** `verarbeite(now) ⇒ { faellig, gesendet }` (Form unverändert; `faellig` = gelesene Einträge).
**Ansatz:** (1) Frühausstieg ohne Abfrage bei Pause oder leerem Gesamtbudget. (2) Je Klasse `vorrang` ⇒ `normal` ⇒ `nachrangig` (entfällt bei `reserve`) eine Abfrage: `status wartet`, `naechsterVersuch ≤ jetzt`, `art` in/nicht in der Klasse (`equal`/`notEqual`), `featureId notEqual blockierte` (nur wenn nicht leer, höchstens 90 Werte), `orderAsc('naechsterVersuch')`, `limit(mailListe)`, `select(['$id','featureId','art','naechsterVersuch'])` (keine Bodies). (3) Vor jedem Versand `pruefePlatz`: `budget`/`pause` ⇒ Ende; `kontingent` ⇒ Funktion in `blockiert`, Eintrag überspringen; ist die Seite voll und wurde etwas gesendet oder blockiert, höchstens 4 weitere Runden mit der neuen Ausschlussliste (keine Funktion verhungert hinter einer vollen).
- [ ] Tests: (1) Frühausstieg: Budget voll (`mailsProStunde = 2`, zwei gesendet) ⇒ `verarbeite` liefert `{ faellig: 0, gesendet: 0 }` ohne eine Listenabfrage an `termineOutbox` (`listCalls`); ebenso während der Anmeldepause; der TP1-Test Z. 247–263 erwartet in der Zeile `verarbeite(+59 min)` jetzt `{ faellig: 0, gesendet: 0 }`, `+61 min` weiter `{ faellig: 1, gesendet: 1 }`. (2) Begrenzt: 250 fällige G02 über 3 Funktionen ⇒ jede Abfrage `limit ≤ 100`, ohne `$id`-Seitenlauf, mit `select` (kein `html` im Abfragetext); gesendet wird höchstens bis zum Kontingent. (3) Reihenfolge: ein älteres G05, ein G03 und ein jüngeres G01 fällig, Budget 1 ⇒ G01; Budget 2 ⇒ G01 und G03; G05 zuletzt; innerhalb einer Klasse nach `naechsterVersuch` (TP1-Test Z. 218 erwartet „fälligste zuerst“ statt `createdAt`). (4) Kein Verhungern: Funktion A mit 150 fälligen G02 (Kontingent 40) vor einem einzelnen G02 von Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B, höchstens 5 Abfragen je Klasse. (5) `reserve` erreicht ⇒ keine nachrangige Abfrage mehr.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Mail-Warteschlange begrenzt und nach Vorrang geordnet“
### Task A9b: Mail-Fairness 2b – veraltete G01 verwerfen, Index `bookingId`
**Files:** Modify `server/services/termine/outbox.js` (`sende` Z. 161–204: G01-Prüfung, `raeumeAuf` Z. 304), `server/services/termine/schema.js` (termineOutbox: `index('idx_booking', 'bookingId')`; `zustellFehler` und `verwerfeWartende` fragen nach `bookingId`); Tests `test/termineOutbox.test.js` (neue), `test/termineSetup.test.js` (Z. 157).
**Consumes:** A9a. **Produces:** neuer Status `verworfen` (9 Zeichen, passt in `status`, kein Schema-Eingriff); `sendeEintrag` kann zusätzlich `'verworfen'` liefern; `verworfeneG01() ⇒ number` (Zähler seit Prozessstart, für A10b).
**Ansatz:** Veraltetes G01: in `sende` nach dem Lesen und nach dem Check auf `unverbucht`, vor dem Budget: `art 'G01'`, `bookingId !== '-'` (G01 `zu_viele` hat keinen Link), `nowMs − createdAt > (config.termine.holdMinutes + 1) · 60 s` ⇒ Status `verworfen`, `an/html/text/ics` geleert, `fehler 'Verworfen: Bestätigungslink abgelaufen'`, `naechsterVersuch = jetzt + 48 h`; `raeumeAuf` löscht `gesendet`, `fehler` und `verworfen`; `zustellFehler` zählt `verworfen` nicht (der Betrieb sieht keinen Zustellfehler für einen verfallenen Halte-Eintrag).
- [ ] Tests: (1) Veraltetes G01: `createdAt` = jetzt − 31 min − 1 s ⇒ `verworfen` ohne Transportaufruf, Felder geleert, `zustellFehler([id])` leer, `raeumeAuf(+48 h)` löscht ihn; genau 31 min ⇒ wird gesendet; G01 mit `bookingId '-'` (`zu_viele`) und ein gleich altes G02 werden gesendet; kein Budgetplatz belegt; `holdMinutes` wird bei jedem Aufruf gelesen. (2) Ein bereits gesendeter, nicht verbuchter Eintrag (`unverbucht`) wird nie verworfen und nie doppelt gesendet. (3) Schema: `indexes('termineOutbox')` = `[['idx_status_next','status','naechsterVersuch'], ['idx_booking','bookingId']]`, `main` legt ihn an.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: veraltete G01 verworfen, Index bookingId“
### Task A10a1: Sichtbarkeit 1a – Team-Mail-Vorlage T20
**Files:** Create `server/mail-templates/T20 Terminplaner Betriebsmeldung.html` (Layout von `T14 Loeschfristen.html`, Betreff `[Terminplaner] {{vorfall}}`, Platzhalter `vorfall`, `details`, `zeit`, ohne Blöcke); Modify `test/mailTemplates.test.js` (Z. 8 Kopfkommentar, Z. 11 Liste `[...ids('G', 8), ...ids('K', 21), ...ids('T', 18), 'T20']`, Z. 28 Präfix-Regel um `Terminplaner`). T19 „Erstattung fällig“ gehört **Block B (B9b)**; dort wird die Liste zu `[...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']`, in E3 zu `ids('K', 23)`; `'T20'` bleibt stehen.
**Consumes:** –. **Produces:** die Vorlage T20 (Versand ab A10a2 über `sendAdminNotice`).
- [ ] Tests (`mailTemplates.test.js`): T20 rendert ohne Reste mit und ohne Blöcke, WEBklar-Logo vorhanden, Kopfkommentar passt zum `<title>`, Betreff beginnt mit `[Terminplaner]`, die Platzhalter `vorfall`, `details`, `zeit` sind deklariert und verwendet; Liste und Präfix-Regel wie oben.
- [ ] FAIL → Vorlage schreiben → PASS → `npm test` → Commit „Terminplaner: Team-Mail-Vorlage T20 Betriebsmeldung“
### Task A10a2: Sichtbarkeit 1b – Betriebsprüfung mit Abkühlzeit (`pruefeBetrieb`)
**Files:** Create `server/services/termine/betrieb.js`, `test/termineBetrieb.test.js`; Modify `server/services/termine/outbox.js` (neu `warteschlangeAlter`), `server/services/termine/jobs.js` (Betriebsprüfung am Ende von `minutenJob`).
**Consumes:** A2 (`countDocuments`), A7 (`mailBudget`), A10a1 (T20), `pausiertBis()` (bisher ungenutzt). **Produces:** `pruefeBetrieb(now) ⇒ Promise<string[]>` (aktive Vorfälle, wirft nie); der gemeinsame Melder `meldeVorfall(schluessel, text, now)` (Warnlog stündlich, Team-Mail mit Abkühlzeit je Schlüssel; A10a3 meldet über ihn die Vorfälle `tagesjob`, `deckel`, `betriebe`); `resetBetriebForTests()`.
**Ansatz:** Vorfälle dieses Tasks: `budget` (≥ 70 % von `mailsProStunde` im Fenster), `queue` (ältester fälliger `wartet`-Eintrag > 10 Min überfällig; `warteschlangeAlter` = `aeltesteMin` aus `status wartet`, `naechsterVersuch ≤ jetzt`, `orderAsc`, `limit(1)`), `pause` (`pausiertBis() > now`). Je Vorfall: Warnlog höchstens stündlich (`[termine-betrieb] …`, nur Zahlen), Team-Mail T20 über `sendAdminNotice` beim Beginn und danach frühestens nach 6 Stunden (Abkühlzeit je Schlüssel, auch wenn der Vorfall zwischendurch endete). Lesefehler ⇒ Werte `null` und ein Log, nie ein 500; Modus `off` ⇒ `minutenJob` ruft nichts auf. Fällt der SMTP aus, scheitert auch die Team-Mail; das Log bleibt der Rückfall.
- [ ] Tests (`termineBetrieb.test.js`, feste Uhr, `mailSink`, `config.chatNotify.emails` gesetzt): (1) Budget: 139 von 200 ⇒ kein Vorfall, 140 ⇒ Vorfall; Warnlog mit „Mailbudget 140/200“ genau einmal, 30 Min später kein zweites, 61 Min später wieder; T20 genau einmal, Betreff beginnt mit `[Terminplaner]`, weder Adresse noch Name im Text. (2) Queue: Eintrag 10 Min überfällig ⇒ kein Vorfall, 11 Min ⇒ Vorfall; ein Eintrag mit `naechsterVersuch` in der Zukunft (Wiederholung) zählt nicht. (3) Pause: EAUTH ⇒ Vorfall `pause` mit Endzeit im Log; endet nach Ablauf; zweite Pause innerhalb 6 Stunden ⇒ Log ja, Mail nein, nach 6 Stunden Mail ja. (4) `pruefeBetrieb` wirft nie: Appwrite 500 ⇒ ein Log, keine Mail-Schleife; Team-Mail scheitert ⇒ Log, Vorfall bleibt gemerkt. (5) `minutenJob` im Modus `off` ruft `pruefeBetrieb` nicht auf (0 Anfragen).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Betriebspruefung mit Abkuehlzeit fuer die Team-Mail“
### Task A10a3: Sichtbarkeit 1c – Tagesjob-Meldung, Tagesjob-Felder und Bericht
**Files:** Modify `server/services/termine/betrieb.js` (neu `meldeTagesjob`), `server/services/termine/jobs.js` (`meldeTagesjob` am Ende von `taeglicherJob` mit `aktiveBetriebe` und `geloescht` im Ergebnis, `tagesbericht`), `test/termineBetrieb.test.js`, `test/termineJobs.test.js` (Z. 867, gepinnte `tagesbericht`-Zeile).
**Consumes:** A10a2 (`betrieb.js`, `meldeVorfall`), A2 (`countDocuments`), A3a/A3b/A4/A6 (`gedeckelt`). **Produces:** `meldeTagesjob(ergebnis, now)` (liest `ergebnis.fehler`, `ergebnis.gedeckelt` = `{ loeschfristen, abgeschaltet, zellen }`, `ergebnis.geloescht` = Summe aller Löschzähler und `ergebnis.aktiveBetriebe` = Zahl oder `null`; merkt `letzterTagesjob = { at, fehler, gedeckelt, geloescht }` (nur Zählungen); prüft Fehler, Deckel, Betriebe; wirft nie); `taeglicherJob` liefert dafür zusätzlich `geloescht` und `aktiveBetriebe` (Zahl); der eingeschränkte Lauf aus E7 liefert dieselben Felder.
**Ansatz:** Vorfälle über den Melder aus A10a2: `tagesjob` (`fehler > 0` oder Abbruch), `deckel` (Löschdeckel an 3 Tagesläufen in Folge), `betriebe` (≥ 20 aktive Betriebe, D8); `aktiveBetriebe` = `countDocuments` der aktiven Termine-Dokumente.
- [ ] Tests (`termineBetrieb.test.js` und `termineJobs.test.js`, feste Uhr, `mailSink`, `config.chatNotify.emails` gesetzt): (1) `meldeTagesjob({ fehler: 2, … })` ⇒ Vorfall `tagesjob`; Deckel an 2 Tagen ⇒ nein, am 3. ⇒ `deckel`; `aktiveBetriebe` 19 ⇒ nein, 20 ⇒ `betriebe`; `letzterTagesjob` trägt `{ at, fehler, gedeckelt, geloescht }` als Zahlen; `meldeTagesjob` wirft nie (Appwrite 500, Team-Mail scheitert). (2) Die exakt gepinnte `tagesbericht`-Zeile (`termineJobs.test.js` Z. 867) bekommt am Ende „, n aktive Betriebe“; das Ergebnis von `taeglicherJob` trägt `aktiveBetriebe` (Zahl) und `geloescht`, die Zeile bleibt frei von IDs, Adressen und Namen.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht“
### Task A10b: Sichtbarkeit 2 – Status-Route
**Files:** Create `server/routes/team/termine.js`, `test/termineStatus.test.js`; Modify `server/services/termine/betrieb.js` (neu `statusDaten`), `server/routes/team/index.js` (`router.use('/termine', termineTeamRoutes)` hinter `requireStaffJwt`).
**Consumes:** A10a2 (`betrieb.js`, `warteschlangeAlter`), A10a3 (`letzterTagesjob`, `aktiveBetriebe`), A7 (`mailBudget`), A9b (`verworfeneG01`), `pausiertBis()`. **Produces:** `statusDaten(now) ⇒ Promise<{ budget, queue: { wartet, aeltesteMin }, pause: { aktiv, bis }, letzterTagesjob, aktiveBetriebe }>` (`budget` = `mailBudget(now)` plus `verworfeneG01`; `letzterTagesjob` = `null` oder `{ at, fehler, gedeckelt, geloescht }`, nur Zählungen, nach einem Neustart `null` bis zum Tagesjob; `aktiveBetriebe` = `countDocuments` der aktiven Termine-Dokumente); `GET /api/team/termine/status` liefert genau das, `Cache-Control: no-store`. Lesefehler ⇒ Werte `null` und ein Log, nie ein 500.
- [ ] Tests (`termineStatus.test.js`, Team-Eingang wie `teamAdminRoutes.test.js`): ohne JWT 401, `jwt-kunde` 403, `jwt-staff` 200; Form wie oben; `letzterTagesjob` vorher `null`, nach `taeglicherJob` gefüllt; `aktiveBetriebe` 2 bei zwei aktiven und einem abgeschalteten Dokument; `failNext` auf `termineOutbox` ⇒ 200 mit `queue` aus `null`-Werten; rekursiv keine Adresse und kein Gastname im Body; ein Kunden-Cookie erreicht die Route nicht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Status-Route fuer das Team“
### Task A11: `disable()` idempotent, `disabledReason`, Schema-Skript `feature-setup.mjs`
**Files:** Modify `server/services/siteFeatures.js` (`activate` Z. 293–302, `disable` Z. 304–313, `disableCustomerFeatures` Z. 321–337), `server/services/customerAdmin.js` (Z. 289: `reason: 'kunde_geloescht'`), `test/support/schema.js` (`schemaKeys('portalFeatures')` hängt die Schlüssel aus `PORTAL_FEATURE_ATTRIBUTES` an, Import aus dem neuen Skript); Create `scripts/feature-setup.mjs`, `test/featureSetup.test.js`; Tests `test/siteFeatures.test.js` (Z. 216–265), `test/funktionenTermine.test.js` (Z. 74, 172–195). `scripts/setup-abo-collections.mjs`, `retention.js` und die WOMS-Route bleiben unverändert (Vorgabe-Grund `team`).
**Consumes:** –. **Produces (verbindlich laut Kopf):** `DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']`; `disable(docId, { by, adminNote, reason = 'team' })` unter `withLocks(['feature-disable:' + docId])`: Dokument lesen (404 wie bisher), steht es schon auf `disabled`, bleibt **alles** unverändert (`disabledAt`, `disabledReason`, `adminNote`, kein Schreibzugriff) und das Dokument wird zurückgegeben; **einzige Ausnahme Altbestand:** fehlt einem `disabled`-Dokument `disabledAt`, wird nur dieses Feld auf jetzt gesetzt (sonst fiele es nie in die 30-Tage-Löschung), Grund und Notiz bleiben; sonst `state disabled`, `disabledAt` jetzt, `disabledReason`, Notiz wie bisher; ein unbekannter Grund ⇒ 400 ohne Schreibzugriff. Die Analyse-Nacharbeit (`stopAnalyseCollecting`) läuft für `analyse`-Dokumente unverändert bei jedem Aufruf. `activate()` setzt zusätzlich `disabledReason: null` und `aboEndeMailAt: null` (`zahlungMeldung` fasst `activate()` bewusst **nicht** an: ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen; nur E8 setzt es beim Zurücksetzen auf `''`). `disableCustomerFeatures(customerId, key, { by, adminNote, reason })` reicht den Grund durch. `portalFeatures.consentText` bleibt unberührt. **Schema:** `scripts/feature-setup.mjs` (Muster `scripts/analyse-setup.mjs`, importiert dessen `createAppwriteClient`; Hauptlauf nur bei direktem Aufruf) exportiert `PORTAL_FEATURE_ATTRIBUTES = [{ type: 'string', key: 'disabledReason', size: 32 }, { type: 'datetime', key: 'aboEndeMailAt' }, { type: 'string', key: 'zahlungMeldung', size: 24 }]` (alle optional; `zahlungMeldung` ist der Marker aus B10a/B10b: `''` | `'bereits_aktiv'` | `'abgeschaltet'` | `'abo_ende'` | `'gemeldet'`) und `main({ check = false, client, log = console.log } = {})`: legt Fehlendes idempotent an, `--check` liest nur und meldet je Lücke `- portalFeatures.<key> FEHLT` (Exit 1 bei Lücken). Block B (B3) ergänzt `featureConsents` im selben Skript.
- [ ] Tests: (1) `disable` ohne Grund ⇒ `disabledReason 'team'`; mit `reason: 'abo_ende'` gespeichert; mit `'frist'` ⇒ 400 und nichts geschrieben. (2) Idempotenz: zweites `disable` (anderer Grund, andere Notiz) lässt `disabledAt`, `disabledReason`, `adminNote` und `$updatedAt` unverändert und liefert das Dokument; `Promise.all([disable, disable])` ⇒ ein Schreibzugriff, gleiches `disabledAt`; Altbestand `disabled` ohne `disabledAt` ⇒ `disabledAt` wird jetzt gesetzt, Grund und Notiz bleiben (zweiter Aufruf schreibt nichts mehr). (3) Die 30-Tage-Löschung wird nicht verschoben: Dokument mit `disabledAt` vor 31 Tagen, erneutes `disable` (Doppelklick im WOMS, Kundenlöschung) ⇒ `loescheAbgeschalteteFunktionen` löscht weiterhin. (4) `activate` leert `disabledAt`, `disabledReason` und `aboEndeMailAt` (Dokument vorher mit allen dreien gefüllt) und lässt `zahlungMeldung` unverändert. (5) `disableCustomerFeatures` mit `reason` ⇒ alle Dokumente tragen ihn, ein schon abgeschaltetes behält Grund und `disabledAt`; Kundenlöschung (Test Z. 172) ⇒ `kunde_geloescht`, `schonAus` behält `team`. (6) Analyse: `disable` einer `analyse`-Funktion ruft `analyseDisabled` wie vorher (auch beim zweiten Aufruf), kein Verhaltensunterschied. (7) `featureSetup.test.js` (Mock-Client wie `analyseSetup.test.js`): `PORTAL_FEATURE_ATTRIBUTES` mit Typ und Größe (`disabledReason` ≥ 24, längster Wert `abo_ende_vor_einrichtung`; `aboEndeMailAt` datetime; alle optional); `main` legt nur Fehlendes an und überspringt Vorhandenes; `main({ check: true })` legt nichts an und meldet; `schemaKeys('portalFeatures')` enthält die drei Schlüssel (der Analyse-Aufbau `[...schemaKeys('portalFeatures'), ...ANALYSE_KEYS]` bleibt fehlerfrei).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup“
### Task A12: `minutenJob` in Verfall und Mailversand getrennt schützen
**Files:** Modify `server/services/termine/jobs.js` (`fuehreAus` Z. 91–108, `minutenJob` Z. 151–157); Tests `test/termineJobs.test.js` (Z. 300–361, 963–1005). `server/index.js` bleibt unverändert (der Pin-Test Z. 1007 prüft `minutenJob` in `index.js`).
**Consumes:** A10a2 (die Betriebsprüfung wandert in den Mail-Teil). **Produces:** `fuehreAus(job, now, arbeit, { teil = null } = {})` führt die Überlappungssperre je `job:teil` (Block E7 erweitert dasselbe Optionsobjekt um `trotzAus`; die Signatur ist `{ teil = null, trotzAus = null } = {}`); `minutenJob(now)` startet beide Teile mit `Promise.all` und liefert wie bisher `{ verfallen, faellig, gesendet, fehler }` (Summe der Fehler), `{ aus: true }` im Modus `off`, `{ uebersprungen: true }`, wenn **beide** Teile übersprungen werden; ist nur der Verfall übersprungen, kommt zusätzlich `verfallUebersprungen: true` (analog `mailUebersprungen`). Die Warnung bei Überlappung nennt den Teil (`minutenJob (Verfall)`), das Fehlerlog behält `minutenJob` als Jobnamen.
- [ ] Tests: (1) Hängt der Verfall (erste Appwrite-Anfrage blockiert), versendet der nächste Takt trotzdem die Warteschlange (`gesendet: 1`, `verfallUebersprungen: true`), die Überlappung steht einmal im Log; (2) hängt der Mailversand, verfällt im nächsten Takt eine abgelaufene Haltung trotzdem; (3) hängen beide ⇒ `{ uebersprungen: true }` wie im TP1-Test Z. 963 (Pin wird um die Teilnamen im Log ergänzt); (4) scheitern beide Teile (Z. 991) ⇒ `fehler: 2`, die Sperren sind danach frei; (5) Fehler im Verfall stoppt den Mailversand nicht (Z. 347 grün); (6) Modus `off` ⇒ `{ aus: true }` ohne Abfrage (Z. 277 grün); (7) der Pin `server/index.js: die drei Jobs …` (Z. 1007) bleibt unverändert grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Minutenjob in Verfall und Mailversand getrennt geschuetzt“
**Abschlussprüfung Block A (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Schema-Check vor jedem Deploy: `node scripts/termine-setup.mjs --check` und `node scripts/feature-setup.mjs --check` (Livegang Schritt 3).
---
## Block B: Register, Checkout, Zustimmungsnachweis
Block B macht aus dem festverdrahteten News-Bestellweg ein Register `config.features` (Checkout, Mails, Ticket, Freischaltung je Funktion), legt den Stripe-Preis `termineSetup` an, baut `POST /api/funktionen/:key/checkout` mit der Prüfreihenfolge aus Spec 4.3 samt unveränderlichem Zustimmungsnachweis (`featureConsents`) und fängt jede Zahlung ab, die in einem anderen Zustand eintrifft (Review Focus #1 und #5). Reihenfolge im Gesamtplan A → B → E → C → D → F: Was Block C (`bestellpruefung`), E (`aboStatus`) und D (Ticket-Kurzfassung) liefern, steht hier als **Stub mit endgültiger Signatur** (Task B5, B8), den die genannten Tasks ersetzen. Alle Zeilenangaben beziehen sich auf Portal `3a0b008` plus Block A.
**Im Code geprüft, Testkonventionen für B und die Falle `seedFixtures`:** stehen im Kopf, Abschnitt „Grundlagen der Blöcke“ (jeder Task bekommt nur Kopf und eigenen Text).
Der Register-Eintrag (Tabelle der verbindlichen Form) steht in Task B1.
---
### Task B1: Register `config.features` und `featureRegistry.js`
**Files:** Modify `server/config.js` (neuer Block `features` vor `export const config`, `funktionen` Z. 193–197 wird Alias mit Gettern auf `features.news`; neu `config.ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG'`, von Register und `mails.preisHinweis` genutzt), `server/services/siteFeatures.js` (`FEATURE_LABELS` Z. 11–14 und `DISABLED_MESSAGE` Z. 17–18 werden aus dem Register abgeleitet, Exporte und Werte bleiben). Create `server/services/featureRegistry.js`, `test/featureRegistry.test.js`.
**Consumes:** – (Block A fasst diese Stellen nicht an).
**Produces:** `config.features` (Tabelle unten in diesem Task); `featureConfig(key) ⇒ Eintrag | null` (nur eigene Schlüssel des Registers, `Object.hasOwn`; `'analyse'`, `'__proto__'`, `'constructor'`, `undefined` ⇒ `null`); `featureKeys() ⇒ ['news','termine']`; `featureHaken(key) ⇒ { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }` (B1 liefert die leere Hülle samt Überschreib-Regel, B6, B7 und B11 füllen die Standards; Standard-Haken laden Dienste per dynamischem `import()`, weil `featureRegistry.js` von `siteFeatures.js` importiert wird); `oeffentlicheAnsicht(key) ⇒ null | { key, name, kurz, priceLabel, setupEstimate, consent: { version, knopf, items: [{ id, text, haken }] }, leistungsbeschreibung, avv, widerruf, returnHash, requiresWartungsAbo }` (JSON-sicher, ohne Funktionen und ohne `ticket`/`mails`).
**Register-Eintrag (verbindliche Form; der Plan-Kopf-Kern bleibt, alles Weitere ist additiv):**
| Feld | `news` (heutige Werte, wörtlich) | `termine` |
|---|---|---|
| `key`, `product` | `'news'`, `'newsSetup'` | `'termine'`, `'termineSetup'` |
| `label` | `{ name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' }` | `{ name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' }` |
| `priceLabel`, `setupEstimate` | `'30 €'`, `'meist innerhalb von 1–2 Wochen'` | `'149 €'`, gleicher Text (Frage Q1) |
| `consent` | `{ version: '2026-09-30', knopf: 'Zahlungspflichtig bestellen · 30 €', items: [{ id: 'beginn', haken: true, text: <heutiger Satz aus config.js:195-196, wörtlich inkl. „§ 356 Abs. 4 BGB“> }] }` | `{ version: 'ENTWURF-2026-10-03', knopf: 'Zahlungspflichtig bestellen · 149 €', items }` mit vier Haken `unterlagen`, `gesundheit`, `beginn`, `information` (alle `haken: true`, ohne `url`; die IDs sind die endgültigen aus F7, Block F ersetzt nur Texte und Fassung); Texte nur Platzhalter „ENTWURF (Block F): …“ nach Spec 4.3 (1)–(4). Die Widerrufsbelehrung ist kein Haken-Item, sondern wird als eigener Nachweiseintrag `belehrung` gespeichert (B4/B7/F7) |
| `leistungsbeschreibung`, `avv`, `widerruf` | `{ version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }`, `null`, `null` | `leistungsbeschreibung` und `avv` je `{ version: 'ENTWURF', url: '' }`, `widerruf` `null` (Block F1/F7 setzt `config.termine.avvVersion`-Fassung und versionierte Adressen) |
| `returnHash`, `returnProjekt` | `'news'`, `false` | `'termine'`, `true` (Rücksprung `#termine?bestellt=1&projekt=<id>`) |
| `ticket` | `topic(project)` = `` `News-Pinnwand einbauen – ${projectName}`.trim() ``; `details(ctx)` = die 6 Zeilen aus `ticketAdmin.js:95-102` | `topic` = `` `Terminplaner einbauen – ${projectName}` ``; `details(ctx)` ruft lazy `termine/ticketDetails.js` (Stub aus B8, Block D4 ersetzt den Inhalt) |
| `mails` | `funktion: 'News-Pinnwand'`; `formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' }`; `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'`; `anleitung: false` | `funktion: 'Terminplaner'`; `formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' }`; `knopf: 'Termine öffnen'`; `danach`, `bereit` als Platzhaltertexte für die Mail-Überarbeitung des Inhabers (Mailrunde des Inhabers, nicht Block F; vom `todo`-Test unten ausgenommen); `preisHinweis` = `` `einmalig, ${config.ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft` ``; `anleitung: true` (die K15-Anhänge hängt F8 direkt in `onFeaturePaid` an, es gibt keinen Registerhaken dafür) |
| Schalter | `requiresWartungsAbo: false`, `ticketFreischaltung: true` | `requiresWartungsAbo: true`, `ticketFreischaltung: true` |
| Meldungen | `disabledMessage` = heutiger `DISABLED_MESSAGE`; `orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.'` | `disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.'`; `orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.'`; `unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.'` (= `MELDUNG.nichtVerfuegbar`, `routes/termine.js:57`) |
| Haken (optional) | – | **nicht in `config.js`**, sondern als Standard-Haken in `featureRegistry.js` (`featureHaken(key)`): `verfuegbar(email)` = `termineFuerKunde` (B6), `bestellpruefung(ctx)` = `ladeEntwurf` + Block-C-Funktion (B6), `unterlagenBereit()` (B6, Standard `true`; F8 hängt `featureLegalReady` ein), `belehrung()` (B7, Standard: der Platzhaltertext `BELEHRUNG_PLATZHALTER` = „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“, exportiert aus `featureRegistry.js`; F7 liefert `widerrufsbelehrungFunktion()`; `news` hat keinen `belehrung`-Haken), `freischaltbar(doc)` (B11); wer `config.features.<key>.<haken>` setzt (Tests), überschreibt den Standard |
- [ ] Tests (`test/featureRegistry.test.js`, Testaufbau laut Kopf): „News-Eintrag entspricht den bisherigen Festwerten“ (`product 'newsSetup'`, `priceLabel '30 €'`, `setupEstimate`, Satz wörtlich, `returnHash 'news'`, `requiresWartungsAbo false`, `ticketFreischaltung true`, beide Meldungen wortgleich zu den heutigen, `topic({ projectName: 'Bäckerei' }) === 'News-Pinnwand einbauen – Bäckerei'`, `details` == die 6 Zeilen des heutigen Defaults mit `siteKey` und Domain `liveDomain || previewUrl || subdomain`); „Alias bleibt“ (`config.funktionen.setupEstimate`/`consentText` == News-Werte, Getter folgen einer Änderung an `config.features.news` zur Laufzeit); „`FEATURE_LABELS` unverändert“ (`{ news: { name, keine }, termine: { name, keine } }` exakt) und `FEATURE_KEYS` == `featureKeys()`; „Schlüsselschutz“ (`featureConfig` für `analyse`, `__proto__`, `toString`, `undefined`, `''` ⇒ `null`); „Haken überschreibbar“ (`config.features.termine.bestellpruefung = fn` gewinnt gegen den Standard, danach zurücksetzen); „Ansicht ohne Funktionen“ (`JSON.parse(JSON.stringify(ansicht))` gleich, kein `ticket`, kein `mails`); „Termine: Preis und Struktur“ (`product 'termineSetup'`, `priceLabel '149 €'`, `requiresWartungsAbo true`, `returnProjekt true`, Item-IDs `['unterlagen','gesundheit','beginn','information']` mit `haken` `[true,true,true,true]`, `config.ustHinweis` wörtlich, `mails.preisHinweis` enthält `config.ustHinweis`); **`{ todo: 'Block F ersetzt die Platzhaltertexte' }`-Test** „Bestelltexte ohne ENTWURF“ (Version, Items, `leistungsbeschreibung`, `avv`, `widerruf` frei von `ENTWURF`, ab B7 auch der Text von `await featureHaken('termine').belehrung()`; **ausgenommen** `mails.danach`/`mails.bereit`, die der Inhaber in seiner Mailrunde schreibt; schlägt bis Block F1/F7 fehl, ohne die Suite zu brechen).
- [ ] FAIL → implementieren (Einträge als reine Daten in `config.js`; `ticket.*` des News-Eintrags ohne Importe, `ticket.details` des Termine-Eintrags per dynamischem `import()` innerhalb der Funktion, weil `config.js` keine Dienste statisch importieren darf; das Ziel `termine/ticketDetails.js` entsteht erst in B8, aufgerufen wird es vorher nirgends) → PASS → `npm test` → Commit „Funktionen: Register config.features mit News- und Termine-Eintrag“
### Task B2: Stripe – Katalog `termineSetup` und generisches `createFeatureCheckout`
**Files:** Modify `server/config.js` (`termineSetup` neben `newsSetup` Z. 100–107, im `config` neben `newsSetup` Z. 190), `server/services/stripeBilling.js` (`PRODUCT_METADATA` Z. 54 + `termineSetup: 'termine-einrichtung'`, `catalogFor` Z. 57–62, `createFeatureCheckout` Z. 647–682), `scripts/stripe-products.mjs` (`KEYS` Z. 20 + `'termineSetup'`, `KEYS` und `catalogEntry` Z. 30–34 mit `export`), `test/stripeBilling.test.js` (neu neben `news_einrichtung_einmalpreis`, Z. 246–260). Create `test/featureCheckout.test.js`, `test/stripeProductsScript.test.js`.
**Consumes:** B1 `featureConfig`.
**Produces:** `config.termineSetup = { key: 'termineSetup', name: 'Terminplaner – Einrichtung', amountCents: 14900, lookupKey: 'webklar_termine_einrichtung_14900', oneTime: true, priceNickname: 'Terminplaner-Einrichtung 149 €' }`; `createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) ⇒ { id, url }` (unbekannter `key` ⇒ Fehler `status 400`, **vor** jedem Stripe-Aufruf): Preis `ensurePrice(feature.product)`, `invoice_data: { description: <Katalogname>, footer, metadata: { featureKey: key, projectId } }`, `metadata` unverändert (`customerId, projectId, featureKey, consentAt`), `success_url` = `${publicBaseUrl}/dashboard.html#${returnHash}?bestellt=1` (+ `&projekt=<encodeURIComponent(projectId)>` bei `returnProjekt`), `cancel_url` = `…#${returnHash}` (+ `?projekt=<id>`); SEPA-Rückfall und 24-h-Ablauf unverändert. Skript: `KEYS` und `catalogEntry(key)` exportiert (Import startet `main()` nicht, `import.meta.url`-Wächter besteht).
- [ ] Tests: `stripeBilling.test.js` „termine_einrichtung_einmalpreis“ nach Vorbild News: ein `POST /v1/prices` mit `unit_amount '14900'`, `lookup_key 'webklar_termine_einrichtung_14900'`, ohne `recurring`, Produkt-Metadaten `webklar = 'termine-einrichtung'`, zweiter Aufruf legt nichts an. `featureCheckout.test.js` (Stripe-Nachbau, direkter Aufruf): News: Preis 3000, `success_url`/`cancel_url` **identisch zu heute** (`…#news?bestellt=1`, `…#news`), `invoice_creation[invoice_data][description] = 'News-Pinnwand – Einrichtung'`, Footer, `metadata[featureKey]='news'`, `expires_at` < jetzt + 24 h, `payment_method_types` Karte + SEPA, SEPA-Rückfall nur Karte; Termine: Preis 14900, `invoice_data[description] = 'Terminplaner – Einrichtung'`, `invoice_data[metadata][featureKey]='termine'` und `[projectId]`, `success_url …#termine?bestellt=1&projekt=p%2F1` (Sonderzeichen kodiert), `cancel_url …#termine?projekt=p%2F1`; unbekannter Schlüssel (`'analyse'`) ⇒ `rejects` mit `status 400` und `st.calls` unverändert. `stripeProductsScript.test.js`: `KEYS` deepEqual `['wartung','hosting','mailStorage','newsSetup','termineSetup']`, `catalogEntry('termineSetup') === config.termineSetup`, Import ohne Stripe-Aufruf und ohne `process.exit` (kein Live-Aufruf, `--anlegen` macht der Inhaber im Livegang-Schritt 4).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Stripe: Katalog Terminplaner-Einrichtung (149 €) und Checkout je Funktion aus dem Register“
### Task B3: Schema `featureConsents` in `scripts/feature-setup.mjs`
**Files:** Modify `scripts/feature-setup.mjs` (aus A11: neu `FEATURE_CONSENT_ATTRIBUTES`, `FEATURE_CONSENT_INDEXES`, `CONSENT_COLLECTION_NAME`; `main` legt die Collection samt Attributen und Indizes an bzw. meldet sie mit `--check`), `server/config.js` (`collections.featureConsents`, Env `APPWRITE_COLLECTION_FEATURE_CONSENTS`), `test/support/schema.js` (`schemaKeys('featureConsents')` liefert die Schlüssel aus `FEATURE_CONSENT_ATTRIBUTES`), `test/featureSetup.test.js` (aus A11, erweitert). `scripts/setup-abo-collections.mjs` bleibt unverändert (Top-Level-Skript ohne Exporte und ohne `--check`; ein nachträglich eingebauter Prüfmodus gefährdete das Live-Schema-Skript).
**Consumes:** A11 (`feature-setup.mjs`; `createAppwriteClient` aus `analyse-setup.mjs`). **Produces:** Collection `featureConsents` (nur Server-Key, wie `analyseConsents`; Anlegen über `ensureCollection` ohne Rechte): `customerId` 36, `projectId` 36, `featureKey` 16, `stripeSessionId` 128, `acceptedAt` datetime, `fassungen` 1000 (JSON), `texte` 20000 (JSON, Appwrite macht daraus eine TEXT-Spalte), `textHash` 64, `ip` 64, `userAgent` 300, `konto` 255; Indizes `idx_session` [`stripeSessionId`], `idx_project_feature` [`projectId`, `featureKey`, `acceptedAt`]. `node scripts/feature-setup.mjs --check` meldet je fehlende Collection/Attribut/Index eine Zeile `- <id>[.<key>] FEHLT`, schreibt nichts, Exit-Code 1 bei Lücken.
- [ ] Tests (`featureSetup.test.js`, Mock-Client wie `analyseSetup.test.js`): „Schema featureConsents“ (die 11 Schlüssel, Größen: `texte` ≥ 20000, `stripeSessionId` ≥ 128, `acceptedAt` datetime, alle optional; `schemaKeys('featureConsents')` == dieselben Schlüssel; beide Indizes mit den Attributen oben); `main` legt bei fehlender Collection genau `featureConsents` mit 11 Attributen und 2 Indizes an (und für `portalFeatures` nur Fehlendes, A11); `main` überspringt Vorhandenes (keine Anlege-Aufrufe); `main({ check: true })` schreibt nie (keine `createAttribute`/`ensureCollection`/`createIndex`-Aufrufe), meldet `featureConsents FEHLT` und liefert die Lücken im Ergebnis. Der Lauf gegen die echte Appwrite gehört in den Livegang-Schritt 3 (Block G), weil der Nachbau die Attribut-Wartezeiten nicht nachstellt.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Schema: Zustimmungsnachweis featureConsents im Skript feature-setup“
### Task B4: Zustimmungsnachweis `featureConsents.js` und Widerrufs-Anker
**Files:** Create `server/services/featureConsents.js`, `test/featureConsents.test.js`. Modify `server/services/siteFeatures.js` (`paidFeatureOrders` Z. 190–206), `server/routes/abo.js` (`withdrawFeature` Z. 1091: `o.vertragAt || o.orderedAt`), `test/publicForms.test.js` (neuer Test neben „Widerruf Funktion…“ Z. 515).
**Consumes:** B3 Schema, B1 `featureConfig`.
**Produces:** `speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc>` (Plan-Kopf-Signatur; `fassungen` Objekt Name ⇒ Fassung, `texte` Array `{ id, text, haken }` genau in Anzeigereihenfolge; speichert beides als JSON-Text, `textHash` = SHA-256 (hex) über `JSON.stringify(texte)`, `acceptedAt = at`); `letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null>` (neueste nach `acceptedAt`); `zustimmungFuerSitzung(stripeSessionId) ⇒ Promise<doc | null>`; `leseZustimmung(doc) ⇒ { …doc, fassungen: object, texte: array }`; `texteHash(texte) ⇒ string`; `zustimmungsfassungen(feature) ⇒ { zustimmung, leistungsbeschreibung, avv?, widerruf? }` (jeweils die `version` des Registerfelds, falls vorhanden) und `nachweisTexte(feature, { belehrung = null } = {}) ⇒ [{ id, text, haken }]` (alle `consent.items` in Anzeigereihenfolge, dann – falls übergeben – `{ id: 'belehrung', text: belehrung, haken: false }` (der **gezeigte** Belehrungstext samt Muster-Formular, Review Focus 5; die Route holt ihn über `featureHaken(key).belehrung`), zuletzt `{ id: 'knopf', text: consent.knopf, haken: false }`). `speichereZustimmung` erlaubt leeres `stripeSessionId`, `ip` und `userAgent` sowie fehlendes `haken` (Nachweis der Staff-Aktivierung aus F13a); `zustimmungFuerSitzung('')` (leer/`undefined`) liefert `null` **ohne Abfrage** (sonst träfe es die F13-Zeilen über `idx_session`). `paidFeatureOrders` liefert zusätzlich `featureKey` und `vertragAt` (= `acceptedAt` der Zustimmung **zur bezahlten Sitzung**, sonst `doc.consentAt`, sonst `orderedAt`; Lesefehler ⇒ Rückfall auf `orderedAt` mit Log, die Widerrufsprüfung darf an der Nachweis-Collection nie scheitern). Das Modul exportiert bewusst nur diese Lese-/Anfügefunktionen (keine Änderung, kein Löschen).
- [ ] Tests (`featureConsents.test.js`): „speichert und liest“ (Rundlauf, `fassungen`/`texte` als Objekt/Array zurück, `textHash` == unabhängig berechneter SHA-256); „append-only“ (`Object.keys(import)` genau die sieben Exporte `speichereZustimmung`, `letzteZustimmung`, `zustimmungFuerSitzung`, `leseZustimmung`, `texteHash`, `zustimmungsfassungen`, `nachweisTexte`; kein `update`/`delete`/`remove`); „neueste Zustimmung“ (zwei Einträge, `at` verschieden, `letzteZustimmung` liefert den späteren, anderes Projekt/andere Funktion stört nicht); „`zustimmungFuerSitzung`“; „vollständig oder gar nicht“ (`texte` > 20 000 Zeichen ⇒ wirft 400, nichts gekürzt; fehlendes/ungültiges `at` ⇒ wirft; leere `texte` ⇒ wirft); „IP/UA/Konto gekürzt auf 64/300/255“; „Nachweistexte aus dem Register“ (`nachweisTexte(featureConfig('termine'))` hat fünf Einträge (vier Haken + `knopf`), letzter `knopf`; mit `{ belehrung: 'Text' }` sechs, `belehrung` direkt vor `knopf`, `haken false`); „Staff-Nachweis“ (`stripeSessionId: ''`, `ip: ''`, `userAgent: ''`, Text ohne `haken` ⇒ gespeichert; `zustimmungFuerSitzung('')` ⇒ `null` und **keine** Listenabfrage an `featureConsents`, `zustimmungFuerSitzung(undefined)` ebenso). `publicForms.test.js`: „Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung“: Dokument `orderedAt` = vor 1 Tag (durch Neubestellung überschrieben), Zustimmung zur bezahlten Sitzung vor 13 Tagen ⇒ T09 nennt das Fristende von `withdrawalDeadline(vor 13 Tagen)` und `RECHTZEITIG`, mit Zustimmung vor 20 Tagen stattdessen `NACH Fristende` (je ein Testfall); eine **spätere, unbezahlte** Zustimmung desselben Kunden/Projekts (andere Sitzung) verschiebt den Anker nicht (Block F9 stützt sich auf `vertragAt`/`featureKey` aus diesem Task, nicht auf `letzteZustimmung`); Nachweis-Collection kaputt (`failNext` auf `featureConsents`) ⇒ Rückfall auf `orderedAt`, Antwort 200.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick“
### Task B5: Schnittstellen-Stubs für Block E und C (`aboStatus`, `fragebogen`)
**Files:** Create `server/services/aboStatus.js`, `server/services/termine/fragebogen.js` (Stub), `test/aboStatus.test.js`, `test/termineFragebogenStub.test.js`.
**Consumes:** `getCurrentSubscription`, `ACTIVE_STATUSES` aus `stripeBilling.js` (Z. 24, 178).
**Produces (Plan-Kopf-Signaturen; Block E1 erweitert die Datei `aboStatus.js` (Modify, die Marke `KERNVERSION` entfällt) und zieht `hasWartungsAbo` aus `analyse.js:43-46` hinein; Block C ersetzt den Stub):**
- `aboStatus.js` (Kopfkommentar `// KERNVERSION (Block B) – Block E baut aus`; **fail-closed**, kein Stub im Sinn von „durchlassen“): `laeuft(customerId) ⇒ Promise<boolean>` (`productKey || 'wartung'` == `'wartung'` und Status ∈ `ACTIVE_STATUSES`; wirft bei Lesefehler), `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>` (**Reihenfolge der Gründe, einmal für alle Blöcke:** nicht `laeuft` ⇒ `kein_abo`; `cancelAtPeriodEnd || cancelAt` ⇒ `gekuendigt`; Status ≠ `active` ⇒ `nicht_aktiv`), `aboMemo() ⇒ { laeuft, bestellbar }` (Zwischenspeicher je Aufruf von `aboMemo()` und `customerId`).
- `termine/fragebogen.js` (Stub, Kopfkommentar `// STUB (Block B) – ersetzt Task C…`): `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` gibt `[]` zurück (Signatur wie im Kopf, endgültig).
- [ ] Tests (`aboStatus.test.js`; Testaufbau laut Kopf: `seedFixtures` gibt `c1` nur ein Hosting-Abo, für Wartungsfälle überschreibt der Test es mit `aw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))`): Wartungs-Abo `active` ⇒ `laeuft` true, `bestellbar { ok: true }`; Hosting-Abo ⇒ false / `kein_abo`; Dokument **ohne** `productKey` zählt als Wartung (wie `abo.js:68`); `canceled` ⇒ false / `kein_abo`; `past_due` und `trialing` ⇒ `laeuft` true, `bestellbar { ok: false, grund: 'nicht_aktiv' }`; `cancelAtPeriodEnd` und `cancelAt` ⇒ `gekuendigt`, auch bei `trialing` und `past_due` (Kündigung vor Status); kein Abo (`c3`) ⇒ `kein_abo`; **Lesefehler** (`aw.failNext({ collection: 'subscriptions', status: 500 })`) ⇒ beide Funktionen **werfen** (nie `false`/`ok:false`); Memo: zweiter Aufruf desselben Kunden aus demselben `aboMemo()` kommt aus dem Speicher (ein danach eingeplantes `failNext` trifft ihn nicht, ein frischer `laeuft('c1')` schon), anderer `aboMemo()` liest neu. `termineFragebogenStub.test.js`: Stub liefert `[]` und trägt die Marke (Block C löscht diese Datei mit dem Stub).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Baustein (Kernversion) und Platzhalter fuer die Bestellpruefung“
### Task B6: Checkout-Route, Teil 1 – Vorprüfungen in Spec-Reihenfolge
**Files:** Modify `server/routes/funktionen.js` (`POST /news/checkout` Z. 137–181 wird `POST /:key/checkout`; `fail` Z. 35–40 gibt zusätzlich `err.body` weiter), `server/services/featureRegistry.js` (`featureSperre`). Create `server/services/featureOrder.js`, `test/funktionenCheckout.test.js`; Modify `test/kundenansichtGuard.test.js` (Z. 118: `['POST','/api/funktionen/termine/checkout']` in die Negativliste).
**Consumes:** B1 `featureConfig`/`oeffentlicheAnsicht`/`featureHaken`, B5 `aboMemo`, `bestellpruefung`, `ladeEinstellung` (`termine/kontext.js:75`), `termineFuerKunde` (`schalter.js:22`), `assertOwnProject`.
**Produces:** Vertrag der Route `POST /api/funktionen/:key/checkout`, Body `{ projectId, zustimmung: { version, haken: [id, …] } }` (News zusätzlich wie heute `{ projectId, consent: true }`, gilt nur für Funktionen mit genau einem Haken-Item) **oder** `{ projectId, fortsetzen: true }` („Zahlung fortsetzen“ aus der Statuskarte: die Zustimmungsprüfung (3) entfällt **nur**, wenn das Dokument nach (4) `ordered` ist, sonst 400 `zustimmung_fehlt`; Antwort und Nachweisregel in B7; enthält der Body beides, gilt `zustimmung`); `featureOrder.js`: `pruefeZustimmung(feature, eingabe) ⇒ { akzeptiert: [id] }` (wirft 400 `zustimmung_fehlt`, 409 `fassung_veraltet` mit `err.body.consent = oeffentlicheAnsicht(key).consent`), `aboMeldung(feature, grund) ⇒ string` (exportiert aus `featureOrder.js`; **die** Quelle der Abo-Hinweistexte, Du-Form, Akkusativ aus `feature.mails.formen.akk`; in den Wortlauten steht „den Terminplaner“ für `formen.akk`: `kein_abo` „Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.“, `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.“; Block C und E definieren keine eigenen Wortlaute), `ladeEntwurf(featureId) ⇒ { einstellung, fragebogen }` (`ladeEinstellung` + tolerantes Parsen von `termineConfig.fragebogen`, fehlt/kaputt ⇒ `null`), `orderDeps = { aboMemo }` (Test-Einhängepunkt), `featureSperre(key, email) ⇒ string | null` (in `featureRegistry.js`; `featureHaken(key).verfuegbar`, bei `false` die `unavailableMessage`; Standard-Haken `verfuegbar`, `bestellpruefung` und `unterlagenBereit` (Standard `true`) werden hier gefüllt). **Prüfreihenfolge, jede Stufe ohne Seiteneffekt:** (1) `req.session.viewAs` ⇒ 403 „In der Kundenansicht kann nicht bestellt werden.“; (2) `featureConfig(key)` fehlt ⇒ 404 „Unbekannte Funktion“; (3) Zustimmungen vollständig und Fassung == Serverfassung (400/409 wie oben); bei `fortsetzen: true` ohne `zustimmung` übersprungen; (4) `projectId` Text ⇒ sonst 400 „Bitte wähle ein Projekt.“; `assertOwnProject` ⇒ sonst 403; bei `fortsetzen: true` danach: `getFeatureDoc` nicht `ordered` ⇒ 400 `zustimmung_fehlt`; (5) `featureSperre` ⇒ 409 `nicht_verfuegbar`; (5b) `featureHaken(key).unterlagenBereit()` falsch ⇒ 503 `unterlagen_fehlen` „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“ (Standard immer bereit; F8 hängt `featureLegalReady` ein, vor jeder Stripe-Sitzung); (6) `featureHaken(key).bestellpruefung({ customerId, projectId, feature })` (der Kontext enthält **kein** `abo`) ⇒ nicht leer ⇒ 409 `bestellung_offen` mit `body.offen` (Standard für `termine`: `getFeatureDoc` → `ladeEntwurf(doc.$id)`, ohne Dokument `einstellung`/`fragebogen` = `null`, dann `bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })` aus `termine/fragebogen.js`, **ohne** `abo`); (7) `requiresWartungsAbo`: `aboMemo().bestellbar(customerId)` ⇒ nicht ok ⇒ 409 mit `code` und `body.grund` = `kein_abo` | `gekuendigt` | `nicht_aktiv` (ohne Präfix, derselbe Code wie in Block C) und Text aus `aboMeldung`; Lesefehler ⇒ 500 mit generischem Text (fail-closed). **Regel für den Abo-Punkt (h) des Gates (verbindlich für B6 und C2):** der Checkout wendet (h) nicht an; der Standard-Haken ruft mit `mitAbo: false` und ohne `abo`, und auch der überschreibbare Haken `config.features.termine.bestellpruefung` bekommt kein `abo`. Die Abo-Stufe (7) ist damit die **einzige** Quelle der Checkout-Codes `kein_abo` | `gekuendigt` | `nicht_aktiv` und bleibt erreichbar: ein Kunde ohne Abo mit vollständigem Fragebogen bekommt 409 `kein_abo`, nie `bestellung_offen` mit Schritt `bestellen`; mit unvollständigem Fragebogen kommt zuerst `bestellung_offen` (ohne Eintrag `bestellen`, die Abo-Stufe wird nicht gelesen). Nur die Vorschau (`GET /api/termine/einstellungen`, C4a) übergibt `abo` und zeigt (h) als offenen Punkt.
- [ ] Tests (`funktionenCheckout.test.js`, jede Ablehnung prüft zusätzlich: `st.checkoutSessions.size === 0`, kein `featureConsents`-Dokument, kein `portalFeatures`-Dokument): „Kundenansicht ⇒ 403“ (Cookie `kundenansichtCookie()`, Testanwendung ohne Wächter, gültiger Body); „Wächter sperrt zusätzlich“ (`kundenansichtGuard.test.js`: die vorhandene Schleife `writeRoutes(MOUNTS)` trifft `/api/funktionen/x/checkout` automatisch ⇒ 403 `view_blocked`; Negativliste um den Termine-Pfad ergänzt); „unbekannter Schlüssel“ (`analyse`, `__proto__`, `nope` ⇒ 404); „Zustimmung“: fehlt/leer ⇒ 400 `zustimmung_fehlt`; ein Pflicht-Haken fehlt ⇒ 400; unbekannte Haken-ID ⇒ 400; falsche Fassung ⇒ 409 `fassung_veraltet` mit `consent.version` == Serverfassung und allen Item-Texten; `consent: true` bei Termine (vier Haken) ⇒ 400, bei News ⇒ weiter; „Projekt“: ohne `projectId` 400, fremd (`p5` von `c2`) 403; „Schalter“: `mode 'off'` ⇒ 409 „Der Terminplaner ist noch nicht verfügbar.“, `pilot` mit fremder Adresse ⇒ 409, News unabhängig vom Modus; **„Reihenfolge“**: `config.features.termine.bestellpruefung` auf `async () => [{ schritt: 'zeiten', text: 'Zeiten bestätigen' }]` und `orderDeps.aboMemo` auf einen Zähler ⇒ 409 `bestellung_offen` mit `offen[0].schritt === 'zeiten'` und Zähler **0**; danach Pruefung `[]` ⇒ Zähler 1; der Haken erhält ein Objekt ohne `abo`-Eigenschaft (`'abo' in arg === false`); „Abo-Gründe“ (Testaufbau laut Kopf, `seedFixtures`-Falle): `c3` (kein Abo) und `c2` (Hosting) ⇒ `kein_abo`, Kündigung vorgemerkt ⇒ `gekuendigt`, `past_due` ohne Kündigung ⇒ `nicht_aktiv`, `past_due` mit Kündigung ⇒ `gekuendigt`, Lesefehler ⇒ 500 ohne Rohtext; `nicht_aktiv`-Text == der volle Wortlaut oben (endet auf „bestellen.“); mit leerer Prüfung (`[]`, Stub oder echtes Gate) und Kunde ohne Abo ⇒ 409 `kein_abo`, nicht `bestellung_offen`; „Unterlagen“: `config.features.termine.unterlagenBereit = async () => false` ⇒ 503 `unterlagen_fehlen`, Zähler 0, Reihenfolge (5) → (5b) → (6) im Reihenfolge-Test; „fortsetzen“: `{ projectId, fortsetzen: true }` bei `off` oder ohne Dokument ⇒ 400 `zustimmung_fehlt`, kein Dokument entstanden; bei fremdem Projekt ⇒ 403; „News braucht kein Abo“ (`c3` bestellt News weiter nach Teil 2).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation“
### Task B7: Checkout-Route, Teil 2 – Sperre, Zustandsprüfung, Nachweis vor der Weiterleitung
**Files:** Modify `server/routes/funktionen.js` (Rest der Route, `openOrderSession` Z. 121–135 mit Meldung aus dem Register), `server/services/siteFeatures.js` (`markOrdered` Z. 208–226: Meldungen aus dem Register, `consentText` aus den Haken-Texten), `server/services/featureRegistry.js` (Standard-Haken `belehrung`, `BELEHRUNG_PLATZHALTER`), `test/funktionenRoutes.test.js` (Schema `featureConsents`, neue News-Fälle), `test/funktionenCheckout.test.js`.
**Consumes:** B2 `createFeatureCheckout`, B4 `speichereZustimmung`/`zustimmungFuerSitzung`/`zustimmungsfassungen`/`nachweisTexte`, B6.
**Produces:** Ablauf unter `withLocks(['feature-order:<customer>:<project>'])` (Eingangszeit `klickAt = new Date().toISOString()` **vor** der Sperre): (a) `getFeatureDoc`: `setup`/`active` ⇒ 409 `feature.orderedConflictMessage`, `disabled` ⇒ 409 `feature.disabledMessage`; (b) `ordered` ⇒ `openOrderSession` (offene Sitzung wiederverwenden, `expired` ⇒ neu, `complete` oder Stripe nicht erreichbar < 24 h ⇒ 409); bei Body `fortsetzen: true` (ohne `zustimmung`, „Zahlung fortsetzen“) wird eine offene Sitzung **nur** zurückgegeben, wenn `zustimmungFuerSitzung(session.id)` einen Nachweis kennt (dann weder zweite Stripe-Sitzung noch zweiter Nachweis); fehlt der Nachweis oder bräuchte es eine neue Sitzung (`expired`), antwortet die Route 409 `zustimmung_fehlt` ohne URL, und die Bestellseite zeigt das Formular, in dem der Kunde erneut zustimmt; (c) neue Sitzung: `createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })`; (d) `zustimmungFuerSitzung(session.id)` leer ⇒ `speichereZustimmung({ …, stripeSessionId: session.id, fassungen: zustimmungsfassungen(feature), texte: nachweisTexte(feature, { belehrung: (await featureHaken(key).belehrung?.()) ?? null }), ip: req.ip, userAgent: req.get('user-agent'), konto: 'Name <E-Mail>' (max. 255), at: klickAt })` — **auch** bei Wiederverwendung einer offenen Sitzung, falls für sie noch kein Nachweis liegt; (e) erst danach `markOrdered({ customerId, projectId, key, sessionId, consent: { text, at: klickAt, ip, userAgent } })`; (f) Antwort `{ url }`. Gespeichert werden **die Serverfassungen und -texte**, nie Texte aus dem Client. Scheitert (d), gibt es **keine** URL und kein `ordered` (die offene, nicht erreichbare Stripe-Sitzung läuft nach 24 h ab; kein Stripe-Storno nötig). **Belehrungs-Haken:** B7 füllt den Standard-Haken `featureHaken('termine').belehrung()` in `featureRegistry.js` mit dem Platzhalter `BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.'` (exportiert; F7 ersetzt ihn durch `widerrufsbelehrungFunktion()`). Damit hat der Nachweis ab B7 immer einen Eintrag `belehrung` (`haken false`, direkt vor `knopf`) und die Bestellseite (C2, C8, C11) zeigt vor dem Knopf einen Belehrungstext; `news` hat keinen `belehrung`-Haken (kein solcher Eintrag, Verhalten unverändert).
- [ ] Tests, **Review Focus #5** (`funktionenCheckout.test.js`, Termine): „Nachweis steht vor der Weiterleitung“ – gültiger Aufruf ⇒ 200 `{ url }`; genau **ein** `featureConsents`-Dokument mit `stripeSessionId` == der Stripe-Sitzung, `texte` (geparst) deepEqual `nachweisTexte(featureConfig('termine'), { belehrung: (await featureHaken('termine').belehrung?.()) ?? null })` in Reihenfolge, `fassungen` == `zustimmungsfassungen(…)`, `textHash` == SHA-256 über die gespeicherten Texte, `ip`/`userAgent`/`konto` gesetzt; **ein Zeitpunkt**: `acceptedAt` == `metadata.consentAt` der Stripe-Sitzung == `portalFeatures.consentAt`, zwischen den vor/nach dem Aufruf genommenen Zeiten; „ohne Nachweis keine URL“ – `aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })` ⇒ 500, Antwort ohne `url`, Dokument nicht `ordered` (fehlt oder `off`), `orderedAt` leer; „Belehrung im Nachweis“ – `texte` endet auf `[…, { id: 'belehrung', text: BELEHRUNG_PLATZHALTER, haken: false }, { id: 'knopf', … }]`, der News-Nachweis hat keinen `belehrung`-Eintrag; „Client-Texte zählen nicht“ – Body mit zusätzlichem `texte: ['manipuliert']` ⇒ gespeichert werden die Registertexte; „Fassungswechsel“ – `config.features.termine.consent.version` zur Laufzeit ändern ⇒ alter Body 409, neuer Body 200 mit neuer Fassung im Nachweis; „Wiederverwendung“ – zweiter Aufruf bei offener Sitzung ⇒ gleiche URL, keine zweite Stripe-Sitzung, **kein** zweites Nachweis-Dokument; Nachweis der Sitzung per Test gelöscht ⇒ zweiter Aufruf legt einen neuen an, bevor die URL zurückkommt; „Zahlung fortsetzen“ (`{ projectId, fortsetzen: true }`, Review Focus 5) – Dokument `ordered` mit Nachweis zur offenen Sitzung ⇒ 200 mit **derselben** URL, keine zweite Stripe-Sitzung, kein zweites `featureConsents`-Dokument; Nachweis per Test gelöscht ⇒ 409 `zustimmung_fehlt`, keine URL, danach derselbe Aufruf mit `zustimmung` ⇒ 200, Nachweis wieder da (ein Dokument); abgelaufene Sitzung mit `fortsetzen` ⇒ 409 `zustimmung_fehlt`, mit `zustimmung` ⇒ neue Sitzung; „abgelaufene Sitzung“ ⇒ neue Sitzung und neuer Nachweis (zwei Dokumente, zwei Sitzungs-IDs); „parallele Klicks“ (`Promise.all` ×2) ⇒ eine Sitzung, ein Nachweis, beide 200 mit gleicher URL; Zustände: `setup`/`active` ⇒ 409 `orderedConflictMessage`, `disabled` ⇒ 409 `disabledMessage`, `complete` ⇒ 409, nirgends neue Sitzung; Datenbankfehler beim `markOrdered` (`failNext` PATCH `portalFeatures`) ⇒ 500 mit „Bestellung konnte gerade nicht gestartet werden“, ohne Rohtext. News (`funktionenRoutes.test.js`, Schema um `featureConsents` ergänzt, alle bisherigen Tests unverändert grün): zusätzlich „News schreibt auch einen Nachweis“ (`texte` == die `consent.items` von `config.features.news` + Knopf, `fassungen { zustimmung: config.features.news.consent.version, leistungsbeschreibung: config.features.news.leistungsbeschreibung.version }` – gegen `config` statt Literale, damit Block F2 die Fassung ohne Testbruch anhebt –, `portalFeatures.consentText` weiter == `config.funktionen.consentText`, Widerrufs-Anker über `paidFeatureOrders().vertragAt`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register“
### Task B8: Ticket aus dem Register (Deckel, Notfall-Details) – alleinige Besitzerin von `createFeatureTicket`
**Files:** Modify `server/services/siteFeatures.js` (`markPaid` Z. 243–270), `server/services/ticketAdmin.js` (`createFeatureTicket` Z. 75–109), `server/services/featureRegistry.js` (`ticketTexte`, `minimaleTicketDetails`, `anleitungsLink`, `kuerze`, Konstanten `TICKET_TOPIC_MAX = 128`, `TICKET_DETAILS_MAX = 7000`, `LANGE_DETAILS = 1000`). Create `server/services/termine/ticketDetails.js` (Stub), `test/featureTicket.test.js`. Der bestehende Test `webhook_retry_setzt_ticketlose_einrichtung_fort` (`test/funktionenRoutes.test.js:321-345`) bleibt unverändert grün. Block D ändert `createFeatureTicket` **nicht** mehr (D5 entfällt).
**Consumes:** B1.
**Produces:** `ticketTexte(ctx) ⇒ Promise<{ topic, details, fallbackDetails }>` mit `ctx = { feature, doc, project, customer }` (`feature` = Register-Eintrag, `doc` = `portalFeatures`-Dokument, `project` = `websiteProjects`-Dokument, `customer` best effort oder `null`; `now` setzt Block D4 selbst im Rumpf): `topic` auf `TICKET_TOPIC_MAX` gekürzt, `details` auf `TICKET_DETAILS_MAX` (Schnitt an der letzten Zeilengrenze, eine einheitliche Schlusszeile „… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen“); wirft `feature.ticket.details` oder liefert es Leeres/Nicht-Text, gilt `fallbackDetails`. `kuerze(text, max, { anZeile = false, endzeile = '' })` kürzt **surrogatsicher** (nie ein halbes Zeichenpaar). `minimaleTicketDetails(ctx)` = Kopfzeilen (Funktion, Projekt, Domain = `liveDomain || previewUrl || subdomain`, Kunde, `siteKey`) + `Verweis: ${anleitungsLink(doc.customerId, doc.projectId)}`. `anleitungsLink(customerId, projectId)` = `${config.womsBaseUrl}/customers/<id>?tab=funktionen&anleitung=<encodeURIComponent(projectId)>` (der Reiter `funktionen` existiert in `W/src/lib/customerTabs.js`; das WOMS-Panel öffnet sich mit `anleitung=` automatisch, D10; B9b (T16 `anleitung_url`) und D3 nutzen dieselbe Funktion). `createFeatureTicket({ …, topic, details, fallbackDetails })`: `topic` und `details` bleiben **optional** mit den heutigen News-Festwerten als Rückfall (direkte Aufrufer und die Analyse bleiben wirkungsgleich; `markPaid` übergibt immer die Registertexte), kürzt beides selbst; **Wiederholung nur**, wenn `createDocument` mit `status 400` scheitert **und** `fallbackDetails` übergeben wurde **und** `details.length > LANGE_DETAILS` (dann **einmal** mit `fallbackDetails`, gleiche `woid`); kurze Details (News, Standardtext) und jeder andere Fehler (5xx, Netz: unklar, ob das Ticket entstand, sonst Doppel-Tickets) werden ohne Wiederholung weitergereicht; scheitert auch der Fallback, wird der **erste** Fehler geworfen (`markPaid` bleibt dann `setup` ohne `ticketId`, Stripe wiederholt – unverändert). `markPaid` lädt den Kunden (best effort) und übergibt Register-`topic`/`details`/`fallbackDetails`. `termine/ticketDetails.js`: `termineTicketDetails(ctx) ⇒ Promise<string>` = `minimaleTicketDetails` + Zeile „Einrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.“ (Kopfkommentar `// STUB (Block B) – ersetzt Task D4`; **Block D4 ersetzt den Inhalt** durch `einbauanleitung(…).kurz`, Signatur und `ctx` bleiben).
- [ ] Tests (`featureTicket.test.js`; die bisherigen News-Tests in `siteFeatures.test.js`/`funktionenRoutes.test.js` bleiben unverändert grün): „News-Ticket byte-gleich“ (`topic === 'News-Pinnwand einbauen – Bäckerei'`, `details` == die 6 Zeilen des heutigen Defaults, einschließlich `Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)`); „Termine-Ticket aus dem Register“ (`topic` beginnt `Terminplaner einbauen – `, `details` enthält `siteKey` und `…?tab=funktionen&anleitung=<projectId>`); „Deckel“ (Projektname 255 Zeichen ⇒ `topic.length <= 128`; 100 × „😀“ als Projektname ⇒ gültige Zeichenkette ohne halbes Surrogat; `details`-Funktion liefert 20 000 Zeichen ⇒ gespeichert ≤ 7000, Schnitt an einer Zeilengrenze, endet auf der Schlusszeile; genau 7000 Zeichen unverändert); „Notfall“: `details`-Funktion wirft ⇒ Ticket entsteht mit `minimaleTicketDetails` (Geld ohne Spur ausgeschlossen); „Wiederholung nur bei langen Details“: Details mit 5000 Zeichen und `aw.failNext({ collection: 'workorders', method: 'POST', status: 400 })` einmal ⇒ zweiter Versuch mit `fallbackDetails` (Kopf + Link, ohne Snippet), genau ein Dokument in `workorders`, gleiche `woid`; zweimal 400 ⇒ `markPaid` wirft den **ersten** Fehler, Dokument `setup` ohne `ticketId`, eine spätere Wiederholung legt genau ein Ticket an; dieselbe Lage mit `status: 503` ⇒ wirft ohne Wiederholung, `workorders` unverändert (kein Doppel-Ticket); **kurze** Details (News/Standard) + einmal 400 ⇒ wirft, keine Wiederholung (das ist der bestehende Test `webhook_retry_setzt_ticketlose_einrichtung_fort`: Webhook 500, danach 200); „Termine über `markPaid`“ (lange Kurzfassung + 400 ⇒ Ticket mit Minimal-Details, `created: true`, K15/T16 gehen raus); „Analyse unberührt“ (`createFeatureTicket` mit eigenem `topic`/`details` ohne `fallbackDetails` ⇒ ein `createDocument`-Aufruf, Text unverändert, Deckel nur bei Überlänge); „Defaults“ (ohne `topic`/`details` ⇒ der heutige News-Text).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung“
### Task B9a: Mails K15 und K16 je Funktion (News-Wortlaut zuerst eingefroren)
**Files:** Modify `server/mail-templates/K15 Bestellung Funktion.html`, `K16 Funktion bereit.html`; Modify `server/services/siteFeatures.js` (K15-Versand Z. 530–546 in `onFeaturePaid`, `runActivation` Z. 578–593, `portalUrl` Z. 406); Modify `test/mailTemplates.test.js` (Platzhalter-/Blocklisten-Tests für K15 und K16). Create `test/featureMails.test.js` (K15/K16-Teil; B9b ergänzt T16 und T19).
**Consumes:** B1 (`mails.*`, `formen`, `featureConfig`).
**Produces:** Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): **K15** `kunde_name, projekt, funktion, funktion_gen, funktion_akk, funktion_nom, funktion_pron, preis, preis_hinweis, zeit, danach, portal_url, rechnung_url`, Block `rechnung`; Betreff „Deine Bestellung: {{funktion}}“; die News-Texte werden 1:1 aus den Platzhaltern erzeugt (z. B. „Wir richten {{funktion_akk}} jetzt für Dein Projekt ein“, „Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei … {{danach}}“, Preiszeile „<strong>{{preis}}</strong> ({{preis_hinweis}})“, Knopf „Zum Kundenportal“ ⇒ `{{portal_url}}`); der Spec-Block „kein Abo“ entfällt zugunsten des Registerwerts `preisHinweis`. **K16** `kunde_name, projekt, funktion, funktion_dein, funktion_nom, bereit, knopf, portal_url`; Betreff „{{funktion_dein}} ist bereit“. Aufrufstellen: K15/K16 füllen alle Werte aus `featureConfig(doc.featureKey)`, `eur(config.newsSetup.amountCents)` wird `eur(catalog.amountCents)` je `feature.product`, `portal_url` = `${publicBaseUrl}/dashboard.html#${returnHash}`. **Anhänge:** K15 geht in B9a **ohne** Anhänge raus; die PDF-Anhänge der Termine-Bestellung hängt F8 direkt in `onFeaturePaid` an (`featureLegalAttachments(doc.featureKey)`, lazy importiert; es gibt keinen Registerhaken `mails.anhaenge`).
- [ ] **Zuerst Charakterisierung** (vor dem Umbau der Vorlagen): `renderMail` mit den heutigen News-Werten für K15 (mit/ohne `rechnung`) und K16 ausführen, `subject` und `text` als Literale in `test/featureMails.test.js` einfrieren („News-Mails bleiben wörtlich“, u. a. „Wir richten die News-Pinnwand jetzt für Dein Projekt ein.“, „(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)“, „Danach kannst Du Beiträge im Portal schreiben.“, „Deine News-Pinnwand ist bereit“, Knopf „Beitrag schreiben“, Link `…/dashboard.html#news`). Danach Vorlagen umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-Mails“ (K15/K16 mit Termine-Werten enthalten weder „News“ noch „Beitr“; Betreff „Deine Bestellung: Terminplaner“, „Dein Terminplaner ist bereit“; Preis „149,00 €“; K15-Link `…#termine`; Knopf „Termine öffnen“); „Platzhalter-/Blocklisten exakt“ (`loadTemplate('K15')` und `loadTemplate('K16')`: `.placeholders`/`.blocks` deepEqual den Listen oben, die Schleifen in `mailTemplates.test.js` decken Rendern mit/ohne Blöcke ab); „K15 ohne Anhänge“ (Termine-Bestellung in B9a ⇒ K15 trägt keinen Anhang, der Versand ruft keinen Registerhaken).
- [ ] FAIL → implementieren → PASS → `npm test` (alle vorhandenen Mail-Tests in `funktionenRoutes.test.js` Z. 150, 166, 199, 238, 339, 371 grün) → Commit „Mails K15/K16 je Funktion (News-Wortlaut eingefroren)“
### Task B9b: Mail T16 je Funktion und neue Vorlage T19 Erstattung fällig
**Files:** Modify `server/mail-templates/T16 Funktion bestellt.html`; Create `server/mail-templates/T19 Erstattung faellig.html` (Layout von T16, Footer wie T16); Modify `server/services/siteFeatures.js` (`teamNotice` Z. 456, `unmatchedNotice` Z. 472), `test/mailTemplates.test.js` (Kopfkommentar Z. 8–9: „K15, K16, T16 Funktionen“ um „T19 Erstattung fällig“ ergänzen; `EXPECTED` Z. 11: `[...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20']` (A10a1 hat `'T20'` angehängt, nicht überschreiben; E3 hebt nur die K-Zahl); `TEAM_FOOTER` Z. 489 + `'T19'`; Blockliste/Platzhalter-Tests für T16 und T19), `test/featureMails.test.js` (aus B9a: T16/T19-Teil dazu).
**Consumes:** B1 (`mails.*`, `featureConfig`), B8 (`anleitungsLink`), B9a (`featureMails.test.js`).
**Produces:** Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): **T16** `kunde_name, email, projekt, funktion, woid, sitekey, sitzung, zustand, anleitung_url, gesundheit_text`; Blöcke `zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit`; Betreff „Neue Funktion bestellt: {{funktion}}“; Text `kein_abo`: Zahlung da und Ticket angelegt, aber kein laufendes Wartungs-Abo, Freischaltung bleibt gesperrt, endet das Abo vor Fertigstellung ⇒ volle Erstattung (T19); Text `bereits_aktiv`: „Zahlung eingegangen, aber die Funktion war schon {{zustand}}. Es wurde kein Ticket angelegt und nichts eingerichtet. Bitte prüfen: Einbau nachholen oder erstatten. Sitzung: {{sitzung}}“; `anleitung` = Knopf „Im Ticketsystem öffnen“ ⇒ `{{anleitung_url}}` (= `anleitungsLink(doc.customerId, doc.projectId)` aus B8, also `…?tab=funktionen&anleitung=<projektId>`, nur bei `mails.anleitung`); `gesundheit` = Warnzeile „Gesundheitsbereich prüfen: {{gesundheit_text}}“ (Spec 3.5; B deklariert Platzhalter und Block, sie bleiben bis D4 aus; **Block D4 füllt sie** in `teamNotice` aus `gesundheitsWarnungen`). Der T16-Betreff „…: News-Pinnwand“ ist in `test/funktionenRoutes.test.js` Z. 150, 339, 371 gepinnt und bleibt für News so. **T19** (Betreff „[Abo] Erstattung fällig: {{funktion}}-Einrichtung“, passt zur Betreff-Regel für T-Vorlagen in `mailTemplates.test.js` Z. 28): `kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url`, Blöcke `zahlung_nach_ende, ticket_offen, rechnung`; **B9b ist die einzige Besitzerin von T19** (Block E legt sie nicht noch einmal an; E5 und E10 senden sie über `meldeErstattung`, B10b). Inhalt: der Kunde hat gezahlt (Betrag, Sitzung, Rechnung), das Wartungs-Abo lief nicht mehr bzw. endete vor der Freischaltung ⇒ volle Erstattung von Hand in Stripe (keine automatische Rückzahlung); Ticket mit Vermerk schließen (die Freischaltung beim Schließen schaltet dann nicht frei, weil das Dokument `disabled` ist); `zahlung_nach_ende`: „Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt“; `ticket_offen`: das Ticket `{{woid}}` ist noch offen. Aufrufstellen: T16 füllt alle Werte aus `featureConfig(doc.featureKey)` (`unmatchedNotice` ohne bekannten Schlüssel: `funktion = 'Funktion'`, Rest leer); `eur(...)` und `portal_url` wie in B9a. Exportiert wird `meldeErstattung` erst in B10b.
- [ ] **Zuerst Charakterisierung** (vor dem Umbau der Vorlage): `renderMail` mit den heutigen News-Werten für T16 (Normal, `zahlung_fehlgeschlagen`, `ungeklaert`) ausführen, `subject` und `text` als Literale in `test/featureMails.test.js` einfrieren („News-Mails bleiben wörtlich“: Betreff „Neue Funktion bestellt: News-Pinnwand“ und die Texte der drei Fälle). Danach die Vorlage umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-T16“ (enthält weder „News“ noch „Beitr“; Betreff „Neue Funktion bestellt: Terminplaner“); „Platzhalter-/Blocklisten exakt“ (`loadTemplate('T16')` und `loadTemplate('T19')`: `.placeholders`/`.blocks` deepEqual den Listen oben, `mailTemplates.test.js`-Schleifen decken Rendern mit/ohne Blöcke ab); „T19 rendert beide Fälle“ (nur `zahlung_nach_ende`, nur `ticket_offen`, mit Rechnungsknopf; `problems` leer; Footer „Ticketsystem“); „T16 gesundheit“ (Block aus ⇒ kein Warntext, Block an mit `gesundheit_text` ⇒ der Text steht in der Mail; `anleitung_url` trägt `&anleitung=<projektId>`).
- [ ] FAIL → implementieren → PASS → `npm test` (alle vorhandenen Mail-Tests in `funktionenRoutes.test.js` Z. 150, 166, 199, 238, 339, 371 grün) → Commit „Mail T16 je Funktion, neue Vorlage T19 Erstattung faellig“
### Task B10a: Zahlungseingang in jedem Zustand 1 – `markPaid` meldet den Fall, Meldemarker (Review Focus #1)
**Files:** Modify `server/services/siteFeatures.js` (`markPaid` Z. 243–270); Tests `test/siteFeatures.test.js` (neue `fall`-Tests).
**Consumes:** B1, B8 (Ticket), A11 (`portalFeatures.zahlungMeldung` im Schema; `activate()` lässt es unberührt, nur E8 setzt es beim Zurücksetzen zurück).
**Produces:** `markPaid(…) ⇒ { doc, created, fall }` mit `fall ∈ null | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende'`. Unter der vorhandenen Sperre `feature-paid:<sessionId>` gilt für ein per Sitzung gefundenes Dokument: `ordered` oder fortsetzbares `setup` ⇒ wie bisher (`created: true`, `fall: null`); `off` ⇒ unverändert `created: false`, `fall: null` (die Übernahme `adoptPaidSession` bleibt zuständig); **jeder andere Zustand** (`active`, `disabled`, `setup` mit Ticket): hat das Dokument `purchaseStatus !== 'paid'`, wird die Zahlung **einmalig verbucht** (`purchaseStatus: 'paid'`, `unlockedByPurchase: true`, `stripeInvoiceId`, `invoiceUrl`, `adminNote` neuester Eintrag `<ISO> System: Zahlung eingegangen (Sitzung …), nichts eingerichtet`, 300 Zeichen) und im **eigenen Feld `zahlungMeldung`** der noch zu meldende Fall vermerkt (`active` und `setup` mit Ticket ⇒ `'bereits_aktiv'`; `disabled` mit `disabledReason === 'abo_ende_vor_einrichtung'` ⇒ `'abo_ende'`; sonstiges `disabled` ⇒ `'abgeschaltet'`). `fall` ist der Wert von `zahlungMeldung`, solange er einer dieser drei ist; nach der erfolgreichen Meldung steht dort `'gemeldet'` (B10b) und `fall` ist `null`. Eine **Wiederholung** des Ereignisses (Stripe-Retry, `completed` + `async_payment_succeeded` bei SEPA) bucht deshalb nichts doppelt (`purchaseStatus` bleibt `'paid'`), liefert den Fall aber erneut, solange die Meldung noch nicht raus ist. Eine normal bezahlte Bestellung (Übergang aus `ordered`) hat `zahlungMeldung ''`: ihr Folgeereignis liefert `fall: null`, auch wenn das Dokument inzwischen `active` ist. `purchaseStatus` wird nie mit Meldezuständen überladen (andere Stellen, z. B. die Widerrufsprüfung, lesen es).
- [ ] Tests (`siteFeatures.test.js`, `markPaid` direkt): `fall` je Zustand (`active` ⇒ `bereits_aktiv`, `disabled`/`team` ⇒ `abgeschaltet`, `disabled`/`abo_ende_vor_einrichtung` ⇒ `abo_ende`, `setup` mit Ticket ⇒ `bereits_aktiv`), jeweils `created: false`, `purchaseStatus 'paid'`, `unlockedByPurchase true`, Rechnung gespeichert, `zahlungMeldung` == Fall; zweiter Aufruf ⇒ nichts doppelt verbucht (`adminNote` unverändert), `fall` bleibt gesetzt, bis `zahlungMeldung` auf `'gemeldet'` steht, danach `null`; normal bezahltes Dokument (`ordered` ⇒ `setup` ⇒ `active`), Folgeereignis ⇒ `fall: null` und `zahlungMeldung ''`; `off` ⇒ `created: false`, `fall: null`, Dokument unverändert; zwei parallele Aufrufe ⇒ einmal verbucht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument“
### Task B10b: Zahlungseingang in jedem Zustand 2 – Meldungen T16/T19, `meldeErstattung` (Review Focus #1)
**Files:** Modify `server/services/siteFeatures.js` (`onFeaturePaid` Z. 515–552, `teamNotice`, neu `meldeErstattung`, `meldeZahlungFall`, `zahlungsNotiz`; `customerOf` und `projectName` werden exportiert). Create `test/featurePaid.test.js`.
**Consumes:** B1, B5 `laeuft`, B8 (Ticket), B9a (K15), B9b (T16-Vorlage, T19), B10a.
**Produces:** `onFeaturePaid`: `created` ⇒ K15 + T16 wie bisher; zusätzlich `feature.requiresWartungsAbo` und `await laeuft(customerId)` **nach** der Verbuchung (Lesefehler ⇒ Log, kein Block, nie 500) falsch ⇒ T16-Block `kein_abo`. `fall` `'bereits_aktiv'`/`'abgeschaltet'` ⇒ `meldeZahlungFall(doc, fall, { customer })` = **nur** T16-Block `bereits_aktiv` (`zustand` „freigeschaltet“ / „in Einrichtung“ bzw. „abgeschaltet“, `woid` leer), `console.error` mit Sitzungs-ID, keine K15. `fall` `'abo_ende'` ⇒ **nur** `meldeErstattung(doc, { customer, nachAboEnde: true })`, kein Ticket, keine K15. `meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) ⇒ Promise<{ sent: boolean, schonGemeldet?: boolean }>` sendet T19 (`betrag` = `priceLabel`, `woid`, `rechnung_url`, Blöcke `zahlung_nach_ende`, wenn `nachAboEnde` gesetzt ist oder `doc.zahlungMeldung === 'abo_ende'` (Zahlung nach dem Abo-Ende, so auch wenn der Abgleich vor dem Webhook-Versand zuerst meldet), `ticket_offen`, wenn `doc.ticketId` gesetzt ist (kein Ticket und keine späte Zahlung ⇒ keiner der beiden Blöcke), `rechnung`) und ist **die einzige** Stelle für T19: **Block E5 ruft sie aus dem Abgleich** (`nachAboEnde: false`), `erstattungMelden` o. Ä. gibt es nicht. **Idempotenz (`meldeErstattung` und `meldeZahlungFall`):** unter `withLocks(['feature-meldung:' + doc.$id])` (nie innerhalb einer `feature-paid:*`-Sperre aufrufen, `withLocks` ist nicht wiederbetretbar) das Dokument **frisch lesen**; `zahlungMeldung === 'gemeldet'` ⇒ `{ sent: false, schonGemeldet: true }` ohne Versand; sonst senden und **erst bei `sent === true`** `zahlungMeldung: 'gemeldet'` schreiben (auch wenn dort vorher ein offener Fall stand: die Wiederholung sendet kein zweites T19). `sendAdminNotice` wirft nie; die Funktionen werfen deshalb nicht selbst. **`onFeaturePaid` wirft** bei den Fällen `bereits_aktiv`/`abgeschaltet`/`abo_ende`, wenn weder `sent` noch `schonGemeldet` zutrifft: der Webhook antwortet 500, Stripe wiederholt, `markPaid` liefert den noch offenen Fall erneut (B10a) und der nächste Versuch sendet genau einmal. Die Mailfehler der Normalpfade (K15, T16 beim `created`) bleiben wie bisher gefangen. Annahme für E: der Abgleich Schritt 1b meldet T19 nur für **bezahlte** (`setup`) Dokumente; ein unbezahltes `ordered` wird nur abgeschaltet, die spätere Zahlung meldet dieser Task.
- [ ] Tests, **Review Focus #1** (Testaufbau laut Kopf; für „Abo läuft“ überschreibt der Test `sub_h` von `c1` mit `wartungDoc`; `featurePaid.test.js`, Termine, echter Bestellweg über die Route oder `markOrdered`, Webhook über `postWebhook`; Hilfsprüfung `spur()`: `Tickets + Team-Mails ≥ 1` und `Tickets ≤ 1` nach jedem Fall): (1) `ordered` + Abo läuft ⇒ `setup`, ein Ticket „Terminplaner einbauen – …“, K15 „Deine Bestellung: Terminplaner“ an den Kunden, T16 genau einmal **ohne** `kein_abo`-Text; (2) `ordered` + Abo beendet (`canceled`) ⇒ `setup`, Ticket, K15, T16 mit `kein_abo`-Text; (3) `laeuft` scheitert (`failNext` auf `subscriptions`) ⇒ Webhook 200, Ticket, K15, T16 ohne `kein_abo`; (4) Staff aktiviert die `ordered`-Bestellung (`adminActivate`), dann Zahlung ⇒ Dokument bleibt `active`, **kein** Ticket, **keine** K15, T16 mit `bereits_aktiv`/„freigeschaltet“ genau einmal, `purchaseStatus 'paid'`, `unlockedByPurchase true`, Rechnung gespeichert, `adminNote` beginnt mit der System-Zeile, `console.error` mit Sitzungs-ID, `zahlungMeldung 'gemeldet'`; zweite Zustellung desselben Ereignisses **und** zwei parallele Zustellungen (`Promise.all`) ⇒ weiterhin genau ein T16; (5) Team schaltet die `ordered`-Bestellung ab, dann Zahlung ⇒ `disabled` bleibt (`disabledAt` unverändert), T16 „abgeschaltet“ einmal, Wiederholung stumm; (6) `disabled` mit `disabledReason: 'abo_ende_vor_einrichtung'` (geseedet) + Zahlung ⇒ T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“ einmal mit Block `zahlung_nach_ende`, **kein** T16, kein Ticket, keine K15, Wiederholung stumm (auch `async_payment_succeeded` nach `completed`); (7) Stripe-Sitzung läuft ab (`off`), Zahlung kommt doch ⇒ Übernahme, `setup`, ein Ticket, K15 + T16 je einmal; (8) Neubestellung nach Ablauf, die **alte** Sitzung zahlt ⇒ Übernahme in die laufende Bestellung, ein Ticket; die **neue** Sitzung zahlt danach ⇒ T16 „ungeklärt“ (bestehendes Verhalten), kein zweites Ticket; (9) SEPA: `completed` ohne `paid` ⇒ nichts, `async_payment_succeeded` ⇒ wie (1), `async_payment_failed` ⇒ T16 `zahlung_fehlgeschlagen` mit „Terminplaner“; (10) **Versandfehler in den Fallpfaden:** T16/T19 scheitert beim Versand (SMTP wirft) ⇒ Webhook **500** (Stripe wiederholt), die Zahlung ist gebucht (`purchaseStatus 'paid'`, `adminNote`-Spur), `zahlungMeldung` noch offen; die Wiederholung (Versand geht) ⇒ genau eine Mail, `zahlungMeldung 'gemeldet'`, weitere Wiederholungen stumm; K15/T16 beim normalen `created` scheitern weiterhin gefangen (Webhook 200); (11) News unverändert (alle bisherigen Fälle in `funktionenRoutes.test.js` grün, zusätzlich News + Staff-Aktivierung + Zahlung ⇒ T16 `bereits_aktiv`, die heute stille Lücke); (12) `meldeErstattung` direkt (der Aufruf aus Block E5): bezahltes `setup`-Dokument ⇒ ein T19 mit Block `ticket_offen`, `zahlungMeldung 'gemeldet'`, zweiter und paralleler Aufruf ⇒ `schonGemeldet`, nie zwei Mails; offener Fall `abo_ende` + Aufruf des Abgleichs + Webhook-Wiederholung ⇒ insgesamt genau ein T19; Mail scheitert ⇒ `{ sent: false }`, kein Marker, kein Wurf.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)“
### Task B11: Freischaltung über das Register (`activateClosedTickets`, `freischaltbar`) – alleinige Besitzerin
**Files:** Modify `server/services/siteFeatures.js` (`activateClosedTickets` Z. 359–387; `findActiveBySiteKey` unverändert), `server/services/featureRegistry.js` (`ticketFreischaltungKeys`, Standard-Haken `freischaltbar` für `termine`, mit dynamischen Importen von `kontext.js` und `aboStatus.js`), `test/siteFeatures.test.js` (**Z. 235**, Test „activateClosedTickets bleibt bei News (TP2)…“: er pinnt heute, dass nur `news` geöffnet wird; er wird auf „Funktionen mit `ticketFreischaltung`; News verhält sich unverändert“ umgestellt). Create `test/featureFreischaltung.test.js`. Block D6 ändert `activateClosedTickets` **nicht** mehr.
**Consumes:** B1, B5 `laeuft`, B9a (K16 je Funktion), `ladeEinstellung`.
**Produces:** `ticketFreischaltungKeys() ⇒ string[]` (Register-Schlüssel mit `ticketFreischaltung: true`); `activateClosedTickets({ now, customerId })` filtert `Query.equal('featureKey', ticketFreischaltungKeys())` statt fest `news` und ruft **unter der Sperre `feature-activate:<id>` nach dem frischen Lesen** den optionalen Haken `featureHaken(key).freischaltbar(doc) ⇒ Promise<boolean>` (der Standard für `termine` darf einen zweiten Parameter `{ abo }` annehmen, D6); `false` oder Fehler ⇒ Dokument bleibt `setup` (Log; **einmal** ein `adminNote`-Eintrag „Freischaltung wartet: <Grund>“, **kein** Mailversand alle 5 Minuten – der T16-Hinweis `kein_abo` kam bei der Zahlung (B10b), T19 vom Abgleich (E5)). Termine-Standardhaken: Einstellung vorhanden (`ladeEinstellung(doc.$id)`) **und** `laeuft(doc.customerId)` (Spec 4.5: Aktivierung bleibt gesperrt, bis ein Abo läuft). Block D6 zieht diese Prüfung nach `termine/freischaltung.js` (`freischaltHindernis(doc, { abo })`) und der Standardhaken delegiert dorthin; die Tests dieses Tasks bleiben dabei unverändert grün. `adminActivate` (Staff-Knopf) bleibt Sache von Block D/E9/WOMS.
- [ ] Tests (Testaufbau laut Kopf, `seedFixtures`-Falle: Wartungs-Abo für `c1` durch Überschreiben von `sub_h`): „News unverändert“ (bisherige Tests grün, **Z. 235 wie oben angepasst**); „Termine: Ticket geschlossen + Einstellung + Abo ⇒ `active`, K16 „Dein Terminplaner ist bereit“ mit Knopf „Termine öffnen“ und Link `…#termine`“; „kein Abo ⇒ bleibt `setup`, `adminNote` enthält „Freischaltung wartet“, zweiter Lauf schreibt keinen zweiten Eintrag und sendet keine Mail; Abo kommt zurück ⇒ nächster Lauf aktiviert und sendet K16“; „keine Einstellung ⇒ bleibt `setup`“; „Abo-Lesefehler ⇒ bleibt `setup`, `runActivation` wirft nicht, andere Dokumente werden trotzdem bearbeitet“; „Abo endet zwischen Listenlesen und Sperre (der Haken meldet beim frischen Lesen `false`) ⇒ bleibt `setup`“; „Ticket `Cancelled` ⇒ bleibt `setup`“; „`customerId`-Filter“; „Hook gilt nur für seine Funktion“ (News-Dokument im selben Lauf wird aktiviert); „parallele Läufe“ (`runActivation()` und `runActivation({ customerId })` ⇒ ein K16); „Dokument anderer Funktion mit `setup` (z. B. `analyse`) bleibt unberührt“.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Freischaltung ueber das Register mit Pruefung von Einstellung und Abo“
### Task B12: Register-Ansicht im Kundenportal (`GET /api/funktionen`, News-Seite)
**Files:** Modify `server/routes/funktionen.js` (`GET /` Z. 88–114), `public/funktionen.js` (`PRICE` Z. 9, `newsTile` Z. 81–85, `initNews` Z. 220, `renderBooking` Z. 261–297), `test/funktionenRoutes.test.js` (`liste_zustaende` Z. 60ff.), `test/funktionenTiles.test.js`.
**Consumes:** B1 `oeffentlicheAnsicht`, B7 (Checkout-Vertrag).
**Produces:** `GET /api/funktionen` ⇒ wie bisher plus `features: { news: <Ansicht>, termine: <Ansicht> }` (`oeffentlicheAnsicht`, auch wenn `termine.verfuegbar` falsch ist: nur öffentliche Bestelltexte); `setupEstimate`/`consentText` bleiben als News-Alias. Zusätzlich `ustHinweis: config.ustHinweis` (die einzige Stelle für den Preishinweis auf Bestellseiten; F2/F8 ergänzen ihn nicht noch einmal). Die Bestellseite (Block C8) bekommt dieselben Registerwerte über `bestellungAnsicht()` (aus `oeffentlicheAnsicht`, C2) in `GET /api/termine/einstellungen → bestellung`, nicht aus dieser Liste, und schickt `zustimmung: { version, haken }` an den Checkout; „Zahlung fortsetzen“ schickt `{ projectId, fortsetzen: true }` (B6/B7: die Route liefert die offene Sitzung nur, wenn sie den Nachweis dazu kennt, sonst 409 `zustimmung_fehlt` und die Seite zeigt wieder das Formular). `public/funktionen.js`: Preis und Zustimmungstext der News-Seite aus `features.news` (`priceLabel`, `consent.items[0].text`, `consent.knopf`), Rückfall auf die bisherigen Konstanten, wenn `features` fehlt; News sendet weiter `{ projectId, consent: true }` (Alias B6), das Verhalten bleibt gleich; der Wortlaut „inklusive Mehrwertsteuer“ (Z. 272) bleibt hier stehen (Korrektur in Block F).
- [ ] Tests: `liste_zustaende` + „`features.news.priceLabel === '30 €'`, `features.termine.priceLabel === '149 €'`, `ustHinweis === config.ustHinweis`, Items vollständig, kein Feld mit Funktion/`ticket`/`mails`, `features.termine` auch bei `verfuegbar: false`“; `funktionenTiles.test.js`: „Rückfall ohne `features`“ (die bisherigen `render`-Hilfen liefern nur `{ projects }`, Tests unverändert grün) und „Preis aus dem Register“ (`features.news.priceLabel: '31 €'` ⇒ Kachel-Knopf „Einrichten lassen · 31 €“); statische Prüfung „News postet weiter `consent: true` an `/api/funktionen/news/checkout`“.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen-Seite: Preis und Bestelltexte aus dem Register“
---
**Abschlussprüfung Block B (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); `command grep -rn "STUB (Block" server` zeigt genau zwei Treffer (`server/services/termine/fragebogen.js`, `server/services/termine/ticketDetails.js`), `aboStatus.js` trägt die Marke `// KERNVERSION (Block B) – Block E baut aus`; Blöcke C, D und E entfernen ihre Marken. Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Festverdrahtetes News ist nur noch im Register: `command grep -rn "dashboard.html#news\|config.newsSetup\|DISABLED_MESSAGE" server` trifft ausschließlich die Definitionen in `server/config.js`, den Alias-Export in `server/services/siteFeatures.js` und den Katalog in `stripeBilling.js` (`catalogFor`); `config.funktionen` wird nur noch in `server/routes/funktionen.js` (GET-Alias) gelesen. Die Beschriftungen „News-Pinnwand“ in `routes/abo.js` (Widerruf, Block F/4.7), `retention.js` und `routes/admin/funktionenAdmin.js` sind **nicht** Teil von B.
**Übergaben an andere Blöcke (verbindlich für deren Tasks):**
- **A:** `disable()` idempotent mit `reason`, das Schema (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`) im neuen Skript `feature-setup.mjs` (A11; B3 ergänzt dort `featureConsents`) und die explizite Liste statt der Wildcard liefert A; B berührt `activateClosedTickets` (B11), nicht `disable`/`activate`. B-Tests seeden `disabledReason` nur.
- **E:** erweitert `aboStatus.js` (Modify, Marke `KERNVERSION` entfällt; gleiche drei Funktionen, `aboMemo` je Lauf, dazu `aboLage` und `aboMemo().lage` aus E1; die Reihenfolge der Gründe `kein_abo` → `gekuendigt` → `nicht_aktiv` steht hier fest); `aboMeldung` bleibt in `featureOrder.js` (E definiert keine eigene); ruft `meldeErstattung(doc, { customer, nachAboEnde: false })` (B10b, einzige T19-Stelle) aus dem Abgleich 1b (nur für bezahlte `setup`-Dokumente) und schickt K22/K23; `unlockedByPurchase === true` ist nach B10a auch bei „Zahlung nach Staff-Aktivierung“ gesetzt (diese Dokumente unterliegen dann dem Abgleich); E8 setzt beim Zurücksetzen `zahlungMeldung` auf `''`.
- **C:** ersetzt den Stub `termine/fragebogen.js` (`bestellpruefung`, `BRANCHEN`, …) und darf `ladeEntwurf` in `featureOrder.js` durch seinen Lader ersetzen (Signatur `(featureId) ⇒ { einstellung, fragebogen }` bleibt); die Bestellseite `funktion-bestellen.js` liest `bestellung` aus `GET /api/termine/einstellungen` (Registerwerte über `oeffentlicheAnsicht`), schickt `{ projectId, zustimmung: { version, haken } }`, zeigt bei 409 `fassung_veraltet` die neue Fassung aus `body.consent` und bei `bestellung_offen` die Punkte aus `body.offen`; „Zahlung fortsetzen“ sendet `{ projectId, fortsetzen: true }` (409 `zustimmung_fehlt` ⇒ Formular); der Rücksprung liefert `#termine?bestellt=1&projekt=<id>`, die Abbruch-Adresse `#termine?projekt=<id>`; die Abo-Codes sind `kein_abo` | `gekuendigt` | `nicht_aktiv`. Der T16-Warnblock `gesundheit` (Spec 3.5) ist in B9b deklariert und wird von Block D4 gefüllt (nicht von C).
- **D:** ersetzt den Inhalt von `termine/ticketDetails.js` (`termineTicketDetails(ctx)`, `ctx = { feature, doc, project, customer }`, `now` im Rumpf), füllt den T16-Block `gesundheit` (D4), zieht die Freischalt-Prüfung nach `termine/freischaltung.js` (D6; B11 bleibt Besitzerin von `activateClosedTickets`), blendet im WOMS „Aktivieren (ohne Zahlung)“ für Termine in `ordered`/`setup` aus (der Fall `bereits_aktiv` aus B10b bleibt als Netz); `anleitungsLink` kommt aus `featureRegistry.js` (B8), T16 und Ticket verlinken also schon mit `&anleitung=<projektId>`; `createFeatureTicket` ändert D nicht (D5 entfällt).
- **F:** ersetzt alle `ENTWURF`-Platzhalter im Termine-Eintrag (`consent`-Texte und -Fassung bei bereits festen IDs, `leistungsbeschreibung`, `avv`, `widerruf`, `mails.danach/bereit` ausgenommen: das ist die Mailrunde des Inhabers; `mails.preisHinweis` bleibt aus `config.ustHinweis`; die K15-Anhänge hängt F8 direkt in `onFeaturePaid` an (`featureLegalAttachments(doc.featureKey)`), nicht über einen Registerhaken), korrigiert die News-Texte (§ 356 Abs. 5 Nr. 2, Wertersatz, § 19 UStG in `consent.items[0].text`, `mails.preisHinweis`, `public/funktionen.js:272`) und **hebt dabei `consent.version` an** (der Nachweis speichert die Fassung; die Pins in B1/B7/B9a/B9b lesen `config`, F2 passt nur den Einfrier-Test in `featureMails.test.js` an); Widerruf je Funktion (4.7: `withdrawFeature`-Frist je Funktion, K04/T09 `{{produkt}}`/`{{preis}}`, `widerrufen.html`) ist nicht in B, B4 liefert dafür `featureKey` und `vertragAt` aus `paidFeatureOrders` (F9 nutzt genau diese); der `todo`-Test aus B1 muss mit F1/F7 grün werden.
---
## Block E: Abo-Kopplung
Ein gemeinsamer Abo-Baustein (`aboStatus.js`) trägt Analyse und Terminplaner. Der tägliche Abgleich `gleicheAboAb` (Spec 6 und 4.6) schaltet den Terminplaner ab, wenn das Wartungs-Abo endet, meldet fällige Erstattungen (T19), holt K22 nach und schaltet innerhalb der Frist wieder ein (K23); die Löschung nach 30 Tagen prüft vorher frisch und setzt das Dokument auf `off` zurück. **Reihenfolge:** A → B → E → C → D → F. B bringt `aboStatus.js` als Kernversion (B5), A11 `disabledReason`/`disable()`; E1 erweitert die Kernversion, E3–E12 folgen in dieser Reihenfolge. E11 und E12 liegen vor C und D: C4a/C4b/C10b/D2 bzw. D9/D10 setzen auf deren Stand auf und erhalten ihn. Der Abgleich liest nur den Appwrite-Spiegel `subscriptions`, nie Stripe; nichts läuft gegen Produktion.
**Festlegungen des Blocks** (gelten für E4–E10): stehen im Kopf, Abschnitt „Grundlagen der Blöcke“.
---
### Task E1: Abo-Baustein `aboStatus.js` ausbauen, Analyse darauf umgestellt
**Files:** Modify `server/services/aboStatus.js` (B5-Kernversion; die Marke `// KERNVERSION (Block B) – Block E baut aus` entfällt), `test/aboStatus.test.js` (B5, **erweitern statt neu anlegen**); Modify `server/services/analyse.js` (Z. 11: Import `ACTIVE_STATUSES, getCurrentSubscription` entfällt, neu `laeuft` aus `./aboStatus.js`; Z. 43–46 `hasWartungsAbo` wird `(id) => laeuft(id, { altbestand: false })`), `test/analyse.test.js` (+1 Test; bestehende Zeilen bleiben unberührt).
**Consumes:** `getCurrentSubscription` (`stripeBilling.js:178`), `ACTIVE_STATUSES` (`:24`), `listContractEvents` (`contractEvents.js:120`).
**Produces** (Namen und Signaturen aus dem Plankopf unverändert, Zusätze mit „+“; ein privater Lader `lage(customerId, { altbestand })` liefert `{ laeuft, art, endedAt, abo }`, **alle** Prädikate bauen darauf, damit Bestell-Gate, Freischaltung, T16 `kein_abo` und Abgleich nie verschieden urteilen):
- `laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>`: aktuelles Abo ist ein Wartungs-Abo mit Status ∈ `ACTIVE_STATUSES` (active, trialing, past_due; vorgemerkte Kündigung zählt noch als laufend). Wirft bei jedem Lesefehler, gibt bei einem Fehler nie `false`. **Zwei Regeln:** `altbestand: true` (Vorgabe, Terminplaner) ist die großzügige Regel: Produktschlüssel `productKey || 'wartung'` wie `routes/abo.js:68` **und** die Wechsel-Regel unten; `altbestand: false` (Analyse) ist die heutige Regel unverändert: `=== 'wartung'` (ein Spiegel ohne `productKey` zählt nicht), keine Ereignisabfrage (Spec: „`analyseEligibility` ändert sein Verhalten nicht“). Der Unterschied betrifft nur Spiegel ohne `productKey` und den Wechsel-Widerruf; `stripeBilling.js:246` schreibt `productKey` immer, nur Altbestand könnte es fehlen (Produktion ungeprüft ⇒ offene Frage).
- `bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>`: **Reihenfolge der Gründe (einmal für alle Blöcke, wie B5):** nicht `laeuft` (großzügige Regel inkl. Wechsel-Regel) ⇒ `kein_abo`; `cancelAtPeriodEnd || cancelAt` (Muster `routes/abo.js:96`) ⇒ `gekuendigt`; Status ≠ `active` (trialing, past_due) ⇒ `nicht_aktiv`; sonst `{ ok: true }`. Wirft bei Lesefehler.
- `aboMemo() ⇒ { laeuft(id), bestellbar(id), lage(id) }`: je Aufruf von `aboMemo()` ein eigener Speicher (Promise je `customerId`, eine Ablehnung bleibt für diesen Lauf stehen). Nie für Prüfungen unter einer Sperre.
- + `aboLage(customerId) ⇒ Promise<{ laeuft: boolean, art: 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet', endedAt: string | null }>` (= der private Lader mit `altbestand: true`, kein eigener Zweig): **Wechsel-Regel:** sagt der Spiegel „Wartung läuft“ und hat das jüngste `wechsel`-Ereignis dieses Abos Status `widerrufen` ⇒ `laeuft: false`, `art: 'wechsel_widerrufen'`. Grund: Der Widerruf eines Wechsels (Hosting→Wartung) setzt den Spiegel nicht zurück, das tut das Team von Hand (Kommentar `routes/abo.js:992`); ohne diese Regel schaltete der tägliche Abgleich einen per Sofortpfad (E9) beendeten Terminplaner am nächsten Tag wieder ein, **und** ein Kunde mit widerrufenem Wechsel dürfte bestellen (149 €) und freischalten, der Abgleich schaltete am Folgetag ab und löste T19 aus. Ereignisse werden nur gelesen, wenn der Spiegel „Wartung läuft“ sagt (und nur bei `altbestand: true`). Ohne laufendes Wartungs-Abo: `art: 'hosting'`, wenn das aktuelle Abo ein laufendes Hosting-Abo ist, sonst `'beendet'`; `endedAt` = `endedAt || canceledAt` des Abos oder `null`.
- `aboMeldung` gibt es hier **nicht** (die einzige Quelle der Abo-Hinweistexte ist `featureOrder.js`, B6).
- [ ] Tests `aboStatus.test.js` (Testaufbau und `seedFixtures`-Falle laut Kopf; die B5-Fälle bleiben; Mock: `subscriptions` und `contractEvents` mit `schemaKeys`, Dokumente aus `subscriptionDoc`/`wartungDoc`/`hosting2Doc` in `test/support/fixtures.js`):
- `laeuft`: Wartung `active`/`trialing`/`past_due` ⇒ true; `canceled`/`unpaid`/`incomplete_expired` ⇒ false; Hosting aktiv ⇒ false; kein Dokument ⇒ false; aktives Abo mit `cancelAtPeriodEnd: true` ⇒ **true**; zwei Dokumente (altes `canceled`, neues `active`) ⇒ true.
- **Altbestand-Unterschied:** Dokument ohne `productKey` ⇒ `laeuft(id)` true, `laeuft(id, { altbestand: false })` false.
- **Lesefehler:** `aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })` ⇒ `laeuft`, `bestellbar`, `aboLage` lehnen ab (nie `false`/`{ ok: false }`).
- `bestellbar`: Tabelle (active ohne Kündigung ⇒ ok; `cancelAtPeriodEnd`, nur `cancelAt` in der Zukunft ⇒ `gekuendigt`; trialing, past_due ⇒ `nicht_aktiv`; canceled, Hosting, kein Abo ⇒ `kein_abo`; trialing **und** past_due **mit** Kündigung ⇒ `gekuendigt`, Kündigung vor Status).
- `aboLage`: `art` je Fall (`laeuft`, `hosting` mit aktivem Hosting-Abo, `beendet` mit `endedAt`, `wechsel_widerrufen` mit `wechsel`-Ereignis `widerrufen`; ein **neueres** `wechsel`-Ereignis `erledigt` hebt es auf ⇒ `laeuft`); bei `beendet` und `hosting` keine Ereignis-Abfrage (Anfragen zählen per Wrapper um `globalThis.fetch` wie `mitEinwurf` in `termineJobs.test.js:234`).
- **Wechsel-Regel in allen Prädikaten:** Spiegel Wartung `active` + jüngstes `wechsel`-Ereignis `widerrufen` ⇒ `laeuft(id)` false, `bestellbar(id)` `{ ok: false, grund: 'kein_abo' }`, `aboLage.art 'wechsel_widerrufen'`; `laeuft(id, { altbestand: false })` bleibt true und fragt **keine** Ereignisse ab. Integration: Checkout (B6) ⇒ 409 `kein_abo`, kein Dokument entstanden; `activateClosedTickets` (B11) mit Ticket `Closed` ⇒ Dokument bleibt `setup`; `adminActivate` (E9) ⇒ 409.
- `aboMemo`: drei Aufrufe für denselben Kunden ⇒ eine Abfrage auf `subscriptions`; zweiter Kunde ⇒ eine weitere; ein neuer `aboMemo()` liest wieder; eine Ablehnung wird im selben Memo nicht neu gelesen (keine zusätzliche Anfrage).
- `analyse.test.js` neuer Test „Altbestand ohne productKey: Analyse bleibt streng (`kein_abo`), `aboStatus.laeuft` großzügig“ (pinnt den Unterschied).
- [ ] Beweis „Analyse unverändert“: `node --test test/analyse.test.js test/analyseRoutes.test.js test/analyseView.test.js test/funktionenRoutes.test.js test/publicForms.test.js` vor der Umstellung grün, danach mit **keiner geänderten bestehenden Testzeile** grün (`git diff test/analyse*.test.js` zeigt nur den neuen Test); `grep -n "ACTIVE_STATUSES\|getCurrentSubscription" server/services/analyse.js` leer.
- [ ] FAIL → `aboStatus.js` ausbauen (ein privater Lader `lage(customerId, { altbestand })` über `getCurrentSubscription` und `listContractEvents`; die Prädikate darauf; Memo je Aufruf in einer `Map` von Promises) → PASS → `npm test` → Commit „Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn“
### Task E2: Anzeige von Abschaltgrund und Löschfrist (`featureView`, `frist.js`)
**Files:** Create `server/services/termine/frist.js`; Modify `server/services/siteFeatures.js` (`featureView` Z. 116); Tests `test/siteFeatures.test.js`, `test/funktionenTermine.test.js` (Z. 158). **Nicht Teil von E2** (liegt in A11): idempotentes `disable()`, `DISABLED_REASONS`, `activate()`-Reset, `disableCustomerFeatures(reason)`, `customerAdmin.js` `kunde_geloescht`, die Schema-Attribute (`feature-setup.mjs`) und deren Tests.
**Consumes:** A11 (`disabledReason`, `disabledAt`, `aboEndeMailAt`, `reason`).
**Produces:** `featureView` liefert zusätzlich `disabledReason`, `disabledAt` und `loeschungAm` (Berliner Tag `'JJJJ-MM-TT'`, nur bei `state === 'disabled'` und vorhandenem `disabledAt`, sonst `null`; bei anderen Zuständen alle drei `null`). `frist.js`: `LOESCHFRIST_TAGE = 30`, `loeschzeit(disabledAt) ⇒ Date | null` (`disabledAt` + 30 × 24 h, dieselbe Rechnung wie `jobs.js:50`), `loeschTag(disabledAt) ⇒ 'JJJJ-MM-TT' | null` (Berliner Kalendertag über `ausUtc` aus `zeit.js`); nur Importe aus `zeit.js`/`util.js` (kein Kreis mit `siteFeatures.js`). F4/F5 prüfen ihre Fristsätze gegen `LOESCHFRIST_TAGE`. Sperre und Mails kommen später (E4ff.).
- [ ] Tests: `featureView`/`GET /api/funktionen` (`funktionenTermine.test.js:158`): disabled+`abo_ende` mit `disabledAt: '2026-10-04T23:30:00.000Z'` ⇒ `loeschungAm === '2026-11-04'` (Berliner Tag, nicht der UTC-Tag `2026-11-03`); `team` ⇒ Grund `team` und ebenfalls ein Datum; active ⇒ alle drei Felder `null`; Altbestand ohne Grund ⇒ `disabledReason: null`. `frist.js`: `loeschTag('2026-10-04T23:30:00.000Z') === '2026-11-04'`; `loeschzeit` = exakt 30 × 24 h später; ungültig/leer ⇒ `null`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abschaltgrund und Loeschfrist in der Funktionsansicht (featureView, frist.js)“
### Task E3: Mailvorlagen K22, K23 und Block in K02/K03
**Files:** Create `server/mail-templates/K22 Terminplaner beendet.html`, `K23 Terminplaner laeuft wieder.html` (Layout wörtlich aus `K19 Analyse beendet.html`, Kopfkommentar im Format der vorhandenen); **T19 legt B9b an**, nicht E; Modify `server/mail-templates/K02 Kuendigungsbestaetigung.html`, `K03 Eingang Kuendigung.html`, `server/routes/abo.js` (K02-Aufruf Z. 469: Block `funktionen: isWartung(sub)`; K03-Aufruf Z. 937: `funktionen: !storage && isWartung(sub)`), `test/mailTemplates.test.js` (Z. 8–11: nur `ids('K', 23)`; die T-Liste bleibt `ids('T', 19)` plus `'T20'` aus B9b/A10a1, nicht überschreiben; Kopfkommentar um „K22, K23 Terminplaner beendet/läuft wieder“ ergänzen).
**Consumes:** `renderMail`/`loadTemplate` (`mailTemplates.js`). Texte sind Entwurf für die Mail-Überarbeitung des Inhabers (Spec 2); nur Platzhalter und Blöcke sind verbindlich.
**Produces:**
- **K22** Betreff `Dein Terminplaner wurde beendet – {{projekt}}`; Platzhalter `kunde_name, projekt, enddatum, loeschdatum, preis, portal_url`; Blöcke `abo_ende` (Wartungs-Abo beendet), `hosting` (Hosting-Abo reicht nicht), `vor_einrichtung` (Abo endete vor der Einrichtung, Einrichtung wird voll erstattet, nennt `{{preis}}`), `daten` (Kasten „Was jetzt gilt“, nur für die ersten beiden: bis `{{loeschdatum}}` ansehen und CSV-Export im Portal, Gäste können ihre Termine weiter absagen, bestehende Termine bleiben eingetragen und Erinnerungen an Gäste laufen bis zur Löschung weiter, **bitte Gäste selbst informieren**, ab `{{loeschdatum}}` Löschung, **Rückkehr:** läuft bis dahin wieder ein Wartungs-Abo, schalten wir ihn automatisch und ohne Gebühr wieder ein; nach der Löschung braucht es neuen Fragebogen und neue `{{preis}}`). Du-Form.
- **K23** Betreff `Dein Terminplaner läuft wieder – {{projekt}}`; Platzhalter `kunde_name, projekt, portal_url`; keine Blöcke.
- **T19** gehört B9b (eine Vorlage: Platzhalter `kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url`, Blöcke `zahlung_nach_ende, ticket_offen, rechnung`); E4/E5/E10 senden sie ausschließlich über `meldeErstattung` (B10b).
- **K02/K03:** neuer Block `funktionen` (im Kopfkommentar „Optionale Blöcke“ ergänzen; in K03 **innerhalb** von `abo`): „Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.“ Sie-Form, weil K02/K03 selbst Sie-Form sind (Abweichung vom Du-Grundsatz, offene Frage).
- [ ] Tests (`mailTemplates.test.js`): `templateIds()` = G01–G08, K01–K23, T01–T20 (T19/T20 aus B9b/A10a1); die schleifenweisen Tests (Kopfkommentar = `<title>`, deklarierte = verwendete Platzhalter/Blöcke, Rendern ohne Reste mit allen Blöcken an/aus, WEBklar-Logo) laufen automatisch für die zwei neuen IDs K22 und K23. Neu: `K22` je Variante mit nur diesem Block an (`abo_ende`: „Wartungs-Abo“ und kein „erstatten“; `hosting`: „Hosting-Abo“; `vor_einrichtung`: enthält den Wert von `preis` und „erstatten“, **kein** Löschdatum-Kasten; `daten` zeigt `loeschdatum` und den Satz zu den Gästen); K22/K23 ohne `Sie`/`Ihr` (Du-Form); K23 nennt `projekt` und führt zu `portal_url`; `K02`/`K03`: `funktionen` an ⇒ Text enthält „Terminplaner“, aus ⇒ nicht; K03 mit `abo: false, funktionen: true` ⇒ Text fehlt (Speicher-Kündigung); bestehende K02-Hosting-Prüfung (`/Wartungs-Abo/` darf nicht vorkommen, Z. 388) bleibt grün.
- [ ] FAIL → Vorlagen schreiben, Aufrufstellen in `routes/abo.js` ergänzen → PASS → `npm test` → Commit „Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03“
### Task E4: `gleicheAboAb` Schritt 1 (aktive abschalten) und Schritt 2 (K22 nachholen)
**Files:** Create `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`; Modify `server/services/siteFeatures.js` (`customerOf` und `projectName` aus Z. 420–435 exportieren statt ein drittes Mal zu kopieren; B10b hat sie schon exportiert, dann entfällt der Eintrag).
**Consumes:** E1 `aboMemo`, `aboLage`; A11 `disable({ reason })`; E2 `loeschzeit`, `loeschTag`; E3 K22; `listAllDocuments`, `withLocks`, `getDocument`/`updateDocument` (`appwriteAdmin.js`), `datumKurz` (`termine/zeit.js:120`), `config.features.termine.priceLabel` (Block B, „149 €“; Wert für `{{preis}}` in K22).
**Produces:** `gleicheAboAb(now) ⇒ Promise<{ geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }>` (wirft nie; nur Zählungen). `geprueft` = bewertete Dokumente (aktiv mit `unlockedByPurchase === true`, `ordered`/`setup`, `disabled` mit Grund `abo_ende*`); Pilot-/Kulanz-Dokumente zählen nicht dort, sondern in `ausgenommen` (aktive Dokumente mit `unlockedByPurchase !== true`: der Abgleich fasst sie nicht an, der Tagesbericht macht sie für den Inhaber sichtbar; Kanzleifrage Q7). Intern/exportiert für E5/E9: `meldeAboEnde(doc, { lage, now }) ⇒ Promise<boolean>` – **der** gemeinsame Melder für das Ende (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie `meldeErstattung` oder K22 einzeln). E4 baut ihn für den Grund `abo_ende` (K22 und Marker); E5 erweitert dieselbe Funktion um `abo_ende_vor_einrichtung` (T19, K22, Marker). Rückgabe `true` = nichts mehr offen (Marker gesetzt oder nichts zu melden), `false` = Versand gescheitert (der nächste Tag wiederholt).
Ablauf: **eine** `listAllDocuments(portalFeatures, [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')])` (die vielen `off`-Entwürfe bleiben ungelesen; `notEqual` mit Einzelwert aus A2, der Rest der Filter im Code), gruppiert je `customerId`, ein `memo = aboMemo()`; je Kunde `lage = await memo.lage(id)` in `try/catch`. **Schritt 1:** Dokumente mit `state === 'active' && unlockedByPurchase === true` bei `!lage.laeuft`: `withLocks(['feature-admin:' + projectId])` ⇒ Dokument frisch lesen, noch `active` und `unlockedByPurchase === true`, **frische** `aboLage` noch `!laeuft` ⇒ `disable(id, { reason: 'abo_ende', adminNote: 'Abgeschaltet: Wartungs-Abo beendet' })`. **Schritt 2 (Nachlauf):** für jedes `disabled`-Dokument mit Grund `abo_ende` ohne `aboEndeMailAt` (auch die eben abgeschalteten) und weiter `!lage.laeuft`: `meldeAboEnde`: K22 an `customers.email` (Block `hosting`, wenn `lage.art` `hosting` oder `wechsel_widerrufen`, sonst `abo_ende`; `daten: true`), Werte `enddatum` = Berliner Tag von `disabledAt`, `loeschdatum` = `datumKurz(loeschzeit(disabledAt))`, `preis`, `portal_url` = `${publicBaseUrl}/dashboard.html#termine`; **erst nach Erfolg** `aboEndeMailAt = now`. Kunde fehlt oder ohne E-Mail ⇒ Marker setzen (kein Wiederholen), Log ohne Adresse. Läuft das Abo wieder, überspringt Schritt 2 (E6 übernimmt).
- [ ] Tests `termineAboAbgleich.test.js` (Aufbau wie `termineJobs.test.js`: Mock mit `portalFeatures`/`subscriptions`/`termineConfig`/`termineBookings`, `mailSink`, feste `JETZT`, Logs auf Gast-/Kundendaten prüfen, Umgebung per `useTestEnv({ appwriteUrl, stripeUrl: 'http://127.0.0.1:1' })`; Abo-Dokumente aus `test/support/fixtures.js` per dynamischem Import):
- **Abo beendet ⇒ abgeschaltet:** `status: 'canceled'` ⇒ Dokument `disabled`, `enabled false`, `disabledReason 'abo_ende'`, `disabledAt === JETZT`, Notiz „Abgeschaltet: Wartungs-Abo beendet“; Ergebnis `{ geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 0, fehler: 0 }`; K22 an den Kunden (Betreff `Dein Terminplaner wurde beendet – <Projekt>`, Löschdatum = `disabledAt` + 30 Tage), `aboEndeMailAt === JETZT`. **Zweiter Lauf am Folgetag:** nichts mehr, `disabledAt` unverändert, keine zweite Mail (Frist wird nie verschoben).
- Kein Abo-Dokument ⇒ abgeschaltet; nur Hosting-Abo aktiv ⇒ abgeschaltet, K22 mit Block `hosting` („Hosting-Abo“).
- **Läuft weiter (nie abschalten):** `past_due`, `trialing`, `active` mit `cancelAtPeriodEnd: true` und `cancelAt` in 10 Tagen ⇒ Dokument bleibt `active`, 0 Mails; `unpaid`, `incomplete_expired`, `canceled` ⇒ abgeschaltet (Tabelle).
- **Lesefehler:** `failNext({ collection: 'subscriptions', method: 'GET', status: 500 })` ⇒ bleibt `active`, `fehler: 1`, keine Mail; derselbe Fehler erst bei der **frischen** Prüfung unter der Sperre (`after: 1`) ⇒ bleibt `active`; der folgende Lauf ohne Fehler schaltet ab. Fehler beim Lesen der Dokumentliste ⇒ `gleicheAboAb` wirft nicht (Ergebnis mit `fehler`), nichts geändert.
- **Pilot/Kulanz:** `unlockedByPurchase: false` und fehlend, Kunde ohne Abo ⇒ unverändert `active`, nicht in `geprueft`, `ausgenommen` zählt sie (2 bei beiden Fällen), keine Mail; `off`-Entwürfe werden nicht gelesen (`listCalls` zeigt `notEqual state off`).
- **Rennen:** zwischen Liste und Sperre kommt ein aktives Abo (Einwurf wie `mitEinwurf`, `termineJobs.test.js:234`) ⇒ frische Prüfung ⇒ bleibt `active`; zwischen Liste und Sperre schaltet das Team ab (`reason: 'team'`) ⇒ Grund bleibt `team`, `disabledAt` unverändert, kein K22; Sperre: hält der Test `feature-admin:<projectId>`, bleibt der Abgleich offen, bis sie frei ist.
- **Memo:** ein Kunde mit drei Projekten ⇒ genau eine Abfrage auf `subscriptions`, drei abgeschaltete Dokumente, drei K22 (je Projekt eine).
- **K22-Nachlauf:** Transport wirft ⇒ `disabled`, `aboEndeMailAt` leer, `fehler 1`, `mails 0`; nächster Tag ohne Fehler ⇒ K22 geht raus, Marker gesetzt, `disabledAt` und Löschdatum im Text unverändert; Kunde gelöscht/ohne E-Mail ⇒ kein Wurf, Marker gesetzt, am Folgetag nichts.
- Alt-`disabled` ohne Grund, `team`, `kunde_geloescht` ⇒ nie angefasst, nie K22.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen“
### Task E5: Schritt 1b (Abo endet vor der Einrichtung) und T19 über `meldeErstattung`
**Files:** Modify `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`. (`siteFeatures.js` bleibt unberührt: es gibt **keine** eigene `erstattungMelden`.)
**Consumes:** E1–E4; B10b `meldeErstattung(doc, { customer, sitzung, nachAboEnde })` (**die** einzige T19-Stelle: Sperre `feature-meldung:<docId>`, Marker `zahlungMeldung === 'gemeldet'` erst nach `sent === true`, wirft nie); Plankopf/B11 `activateClosedTickets` nur als Prüfpunkt im Test.
**Produces:** Schritt 1b in `gleicheAboAb`: Dokumente mit `state ∈ {'ordered','setup'}` bei `!lage.laeuft` ⇒ unter `feature-admin:<projectId>` frisch lesen, noch `ordered`/`setup`, frische `aboLage` noch `!laeuft` ⇒ `disable(id, { reason: 'abo_ende_vor_einrichtung', adminNote: 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' })`. Schritt 2 erweitert sich auf Grund `abo_ende_vor_einrichtung`; die Meldelogik liegt **in `meldeAboEnde`** (E4), nicht in der Schleife, damit der Sofortpfad E9 dasselbe tut. `meldeAboEnde` für `abo_ende_vor_einrichtung`: bei `unlockedByPurchase === true` **erst** `meldeErstattung(doc, { customer, nachAboEnde: false })` (T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“; Aufruf **außerhalb** jeder `feature-admin`/`feature-paid`-Sperre), dann K22 mit Block `vor_einrichtung` (`daten: false`), dann Marker `aboEndeMailAt`; `sent === false` ohne `schonGemeldet` ⇒ Rückgabe `false`, `fehler += 1`, **weder K22 noch Marker** (der nächste Tag wiederholt T19 und K22 in dieser Reihenfolge). Ein **unbezahltes** `ordered`-Dokument (`unlockedByPurchase !== true`) wird nur abgeschaltet: **kein** T19, **kein** K22, **kein** Marker, kein Schreibzugriff (es ist nichts zu erstatten; kommt die Zahlung später, löst B10b/E10 T19 aus, und Schritt 2 sendet dann K22). Die offene Stripe-Sitzung wird nicht storniert (Spec 6 Schritt 1b). Das Ticket wird nicht angefasst.
- [ ] Tests:
- **Bezahlt, im Aufbau (`setup`, `unlockedByPurchase`, `stripeSessionId 'cs_test_1'`, Ticket vorhanden) und kein Abo ⇒** `disabled`, Grund `abo_ende_vor_einrichtung`; T19 **genau einmal** an `config.chatNotify.emails` (Betreff `[Abo] Erstattung fällig: Terminplaner-Einrichtung`, Text nennt Kunde, Projekt, `cs_test_1`, Rechnungslink, Ticketnummer), `zahlungMeldung === 'gemeldet'` und `purchaseStatus` weiter `paid`; K22 mit Block `vor_einrichtung` (Preis genannt, kein Löschkasten); das Ticket bleibt `Open` (Dokument in `workorders` unverändert); zweiter Lauf ⇒ weder T19 noch K22.
- **T19 schlägt fehl** (Transport wirft; `sendAdminNotice` fängt ab ⇒ `sent: false`) ⇒ `zahlungMeldung` bleibt leer, Dokument trotzdem `disabled`, `fehler ≥ 1`, weder K22 noch `aboEndeMailAt` bis dahin; nächster Lauf sendet T19 und danach K22 nach, danach nie wieder. Ein anschließendes Stripe-Ereignis zur selben Sitzung (`async_payment_succeeded`) ⇒ kein zweites T19.
- **Unbezahlt `ordered`** ohne Abo ⇒ `disabled`, **kein** T19, kein K22, `aboEndeMailAt` leer, `stripeSessionId` unverändert.
- `setup` ohne `ticketId` („Ticket fehlt“) ⇒ T19 ohne Block `ticket_offen`.
- Läuft das Abo (`past_due`) ⇒ `ordered`/`setup` bleiben unberührt; Lesefehler ⇒ unberührt.
- **Review Focus #1, „nie ohne Spur“:** ein `setup`-Dokument, dessen Abo endet, und das Ticket wird danach geschlossen (`workorders.status = 'Closed'`) ⇒ `activateClosedTickets` aktiviert das `disabled`-Dokument **nicht** (Zustand bleibt `disabled`).
- Rennen: Team aktiviert zwischen Liste und Sperre (Zustand `active`) ⇒ Schritt 1b greift nicht (frisch gelesen).
- **`meldeAboEnde` direkt** (der Aufruf, den E9 nutzt): bezahltes `setup`-Dokument mit Grund `abo_ende_vor_einrichtung` ⇒ ein T19, ein K22 `vor_einrichtung`, Marker gesetzt, Rückgabe `true`; unbezahltes `ordered`-Dokument ⇒ weder T19 noch K22 noch Marker, Rückgabe `true`, Dokument unverändert; zweiter Aufruf ⇒ nichts mehr.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22“
### Task E6: Schritt 3 Wiederkehr und K23
**Files:** Modify `server/services/termine/aboAbgleich.js`, `test/termineAboAbgleich.test.js`.
**Consumes:** E1–E5; `ladeEinstellung(featureId)` (`kontext.js:75`, prüft den Modus nicht und liefert `null`, wenn keine lesbare Einstellung da ist), `activate` (A11).
**Produces:** Schritt 3 in `gleicheAboAb`: Dokumente mit `state === 'disabled'`, Grund **`abo_ende`** und `lage.laeuft === true`: unter `feature-admin:<projectId>` Dokument frisch lesen (noch `disabled`/`abo_ende`), frische `aboLage` noch `laeuft`, `await ladeEinstellung(doc.$id)` ≠ `null` (Daten da), dann `activate(id, { adminNote: 'Wieder eingeschaltet: Wartungs-Abo läuft wieder' })` (Daten und Einstellungen bleiben, `activate` leert Grund/Marker/`disabledAt`) und K23 **nur, wenn K22 ging** (`aboEndeMailAt` vor dem `activate` gelesen); scheitert K23, bleibt die Reaktivierung bestehen (`fehler += 1`). Festlegung: Die Rückkehr gilt, solange die Einstellung noch da ist, auch wenn der Lauf erst nach Tag 30 kommt (Abgleich läuft vor der Löschung im selben Tagesjob; sonst blieben Löschung und Rückkehr gegenseitig blockiert). Nach der Löschung ist das Dokument `off` (E8) und wird nie automatisch aktiv. Nicht automatisch: Grund `abo_ende_vor_einrichtung` (Erstattung läuft; Wiedereinschalten entscheidet das Team per WOMS), `team`, `kunde_geloescht`, Altbestand ohne Grund.
- [ ] Tests:
- **Rückkehr innerhalb von 30 Tagen:** `disabled`/`abo_ende`, `disabledAt` = JETZT − 10 Tage, `aboEndeMailAt` gesetzt, Einstellung und eine künftige Buchung vorhanden, neues Wartungs-Abo `active` ⇒ Dokument `active`, `enabled true`, `activatedAt === JETZT`, `disabledAt`/`disabledReason`/`aboEndeMailAt` `null`; Einstellung (Version) und Buchung **unverändert**; K23 genau einmal (Betreff `Dein Terminplaner läuft wieder – <Projekt>`); zweiter Lauf ⇒ nichts; Ergebnis `wiederEin: 1`.
- K22 ging nie (Marker leer, Abo kam vorher zurück) ⇒ reaktiviert, **kein** K22, **kein** K23.
- **Nach der Löschung nie automatisch:** Dokument `off` mit laufendem Abo ⇒ unberührt; `disabled`/`abo_ende` ohne Einstellung (`termineConfig` fehlt) ⇒ bleibt `disabled`, kein K23.
- `team`, `kunde_geloescht`, ohne Grund, `abo_ende_vor_einrichtung` + laufendes Abo ⇒ unberührt (auch kein Wegschreiben von Feldern).
- Rennen: Abo endet wieder zwischen Liste und Sperre ⇒ frische Prüfung ⇒ bleibt `disabled`; Lesefehler ⇒ bleibt `disabled`, `fehler 1`.
- `disabledAt` = JETZT − 35 Tage, Einstellung noch da, Abo läuft ⇒ reaktiviert; derselbe Tagesjob löscht danach nichts (siehe E8).
- **Wechsel widerrufen (Spiegel noch Wartung):** `wechsel`-Ereignis `widerrufen` ⇒ ein aktives Dokument wird abgeschaltet (Schritt 1, K22 Block `hosting`) und ein `disabled`/`abo_ende`-Dokument bleibt `disabled` (kein Flattern); ein neueres, wirksames `wechsel`-Ereignis hebt das auf ⇒ Rückkehr.
- K23 schlägt fehl ⇒ Dokument bleibt `active`, `fehler 1`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Abgleich: Rückkehr innerhalb der Frist schaltet wieder ein (K23)“
### Task E7: Einbindung in den Tagesjob, geteilter Schalter-Guard, Bericht
**Files:** Modify `server/services/termine/jobs.js` (`fuehreAus` Z. 91, `taeglicherJob` Z. 420, `tagesbericht` Z. 437, Kopfkommentar Z. 20–37), `test/termineJobs.test.js` (Test „Modus off“ Z. 277, Tagesjob Z. 867, Berichtszeile Z. 905).
**Consumes:** E4–E6 `gleicheAboAb`; A12 (`fuehreAus(job, now, arbeit, { teil })` mit Sperre je `job:teil`), A3a/A3b/A10a3 (`gedeckelt`, `geloescht`, `aktiveBetriebe`, Berichtssegmente); `termineAus` (`schalter.js`). Block A hat Tagesjob und `tagesbericht` schon erweitert: E7 hängt nur sein Segment an und ordnet den Schritt ein.
**Produces:** `fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {})` (erweitert das Optionsobjekt aus A12; `teil` und die Überlappungssperre je `job:teil` bleiben unverändert): bei Modus `off` läuft der Job nur, wenn `await trotzAus?.()` wahr ist; wirft `trotzAus`, wird geloggt und `{ aus: true }` geliefert. `taeglicherJob` übergibt `trotzAus` = eine Abfrage `listDocuments(portalFeatures, [equal('featureKey','termine'), equal('unlockedByPurchase', true), limit(1)])`. **Modus off mit bezahlten Dokumenten** (eingeschränkter Lauf): nur `gleicheAboAb` und `loescheAbgeschaltete`, Ergebnis `{ eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt: { loeschfristen: 0, abgeschaltet: <n>, zellen: 0 }, geloescht: <abgeschaltet.buchungen>, aktiveBetriebe: null, fehler }` (dieselben Felder, die `meldeTagesjob` (A10a3) und `letzterTagesjob` der Status-Route lesen, damit dort nichts `undefined` ist); alle übrigen Schritte (Löschfristen der Buchungen, Outbox, Zellen) bleiben aus (Annahme, offene Frage). **Sonst** (pilot/on) neuer Schritt `gleicheAboAb(now)` nach `loeschfristen` und **vor** `loescheAbgeschaltete`, in `schritt(lauf, 'Abo-Abgleich', …, ersatz)` (ein Fehler dort stoppt weder die Löschung noch den Rest; `lauf.fehler += aboAbgleich.fehler`); Ergebnis erhält `aboAbgleich`. `tagesbericht` hängt `Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein / N Mails / N ausgenommen` vor `N Fehler` an (nur Zahlen, keine IDs). **Reihenfolge der Segmente (verbindlich für A3b, A10a3, C6, E7):** `…, N Mails nicht zugestellt, [Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen, ][Entwürfe n gelöscht (m offen), ]Abo-Abgleich …, N Fehler, n aktive Betriebe`; die vollständige Zeile in E7 (Entwürfe fehlen hier noch, C6 fügt sie zwischen Deckel und Abo-Abgleich ein): `[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe`; für den eingeschränkten Lauf eine eigene Zeile `[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): …`.
- [ ] Tests:
- Test „Modus off“ angepasst: `minutenJob`/`fuenfMinutenJob` ⇒ `{ aus: true }` und **0** Anfragen; `taeglicherJob` ohne bezahlte Dokumente ⇒ `{ aus: true }` und **genau 1** Anfrage (die Guard-Abfrage), sonst nichts; schlägt die Guard-Abfrage fehl ⇒ `{ aus: true }`, Warnung, keine Löschung.
- **Modus off, bezahltes Dokument:** abgeschaltet seit 31 Tagen mit Buchungen ⇒ Buchungen/Zellen/Einstellung gelöscht und Dokument `off` (E8); aktives bezahltes Dokument ohne Abo ⇒ abgeschaltet, K22 geht raus; verfallene Buchung eines anderen Betriebs bleibt (kein `loeschfristen`-Schritt); Ergebnis hat `eingeschraenkt: true`, `tagesbericht` liefert die eingeschränkte Zeile ohne IDs.
- Pilot-Modus: `taeglicherJob` ⇒ `erg.aboAbgleich` mit den vier Zählern; Reihenfolge (Abgleich vor Löschung) per Test: ein Dokument, das heute zurückkehrt (Abo läuft, `disabledAt` vor 40 Tagen, Einstellung da), wird **nicht** gelöscht; `erg.fehler` enthält die Fehler des Abgleichs; wirft der Abgleich (Liste nicht lesbar), läuft die Löschung weiter (Fehler gezählt, `ersatz` mit Nullen).
- Berichtszeile (Z. 905) gleicht gegen das Literal oben (zusätzlich eine Variante mit `gedeckelt` > 0 und dem Deckel-Segment), weiter ohne `b_`, `f_`, `@`; Z. 867 prüft zusätzlich `erg.aboAbgleich`; der eingeschränkte Lauf liefert `gedeckelt`, `geloescht` und `aktiveBetriebe: null`, und `meldeTagesjob`/`statusDaten` lesen daraus ohne `undefined`.
- Überlappung: der eingeschränkte Lauf nutzt dieselbe Sperre wie der volle Tagesjob (zweiter paralleler Aufruf ⇒ `{ uebersprungen: true }`); die Sperren der beiden `minutenJob`-Teile (`job:teil`, A12) bleiben davon **unabhängig** (hängender Tagesjob ⇒ Verfall und Mailversand laufen weiter, und umgekehrt).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Tagesjob: Abo-Abgleich vor der Löschung, auch bei TERMINE_ENABLED=off mit bezahlten Dokumenten“
### Task E8: Löschung nach 30 Tagen: frische Abo-Prüfung und Zurücksetzen auf `off`
**Files:** Modify `server/services/termine/jobs.js` (`ABGESCHALTET_BEHALTEN_MS` Z. 50 aus `LOESCHFRIST_TAGE`, `nochAbgeschaltet` Z. 325, `loescheAbgeschaltete` Z. 347), `test/termineJobs.test.js` (Z. 753–780, 822, 846).
**Consumes:** E1 `aboLage`, E2 `LOESCHFRIST_TAGE`, A11 `disabledReason`; Block A (seitenweise Löschung und Deckel: die beiden Änderungen hängen an `nochAbgeschaltet` und an der Stelle, wo die Einstellung gelöscht wird, nicht an der Seitenlogik).
**Produces:** `nochAbgeschaltet` prüft bei Grund `abo_ende` zusätzlich **frisch** `(await aboLage(frisch.customerId)).laeuft`; läuft das Abo wieder ⇒ `false` mit Log („Abo läuft wieder, Löschung abgebrochen“); ein Lesefehler dabei ⇒ `false` und `melde` (im Zweifel wird nichts gelöscht). Die Prüfung gilt vor jedem unumkehrbaren Schritt (Buchungsliste, alle 50 Buchungen, Einstellung); für `team`, `kunde_geloescht`, `abo_ende_vor_einrichtung`, Altbestand gibt es **keine** Abo-Abfrage. **Zurücksetzen:** sind alle Buchungen weg (`vollstaendig`) und die Einstellung gelöscht **oder schon nicht mehr da (404)**, setzt der Job unter `withLocks(['feature-admin:<projectId>', 'termine-cfg:<id>'])` nach nochmaligem Prüfen das Feature-Dokument zurück: `stateFields('off')`, `unlockedByPurchase false`, `purchaseStatus 'zurueckgesetzt'` (dauerhafte Marke „hier wurde nach dem Abo-Ende gelöscht“: Block C6 löscht ein so markiertes Funktionsdokument **nie**, nur seine Einstellung, damit der `siteKey` bestehen bleibt; weder `paid` noch Meldezustand), `zahlungMeldung ''`, `ticketId ''`, `activatedAt null`, `disabledAt null`, `disabledReason null`, `aboEndeMailAt null`, `adminNote 'Daten gelöscht (Löschfrist abgelaufen)'`; **`siteKey` bleibt** (das eingebettete Snippet bleibt gültig), Bestell-/Rechnungsfelder bleiben bis zur nächsten Bestellung stehen. Gilt für jeden Grund. Der Zähler hat die Form `{ funktionen, buchungen, gedeckelt }` (A4; ein reines Zurücksetzen ohne Löschung zählt nicht). Ist nur noch das Zurücksetzen offen (Einstellung weg, keine Buchungen), entfällt die Abo-Prüfung (nichts Unumkehrbares mehr).
- [ ] Tests (`termineJobs.test.js`, Abo-Dokumente über `subscriptions` im Mock-Schema ergänzen):
- Bestehender Test Z. 753: die Zeile „das Funktionsdokument selbst bleibt“ (Z. 776) wird zu: Dokument vorhanden mit `state 'off'`, `enabled false`, `unlockedByPurchase false`, `purchaseStatus 'zurueckgesetzt'`, `zahlungMeldung ''`, `ticketId ''`, `disabledAt null`, `siteKey 'abcdefghijklmnop'` unverändert; die Dokumente der anderen Funktionen (noch nicht abgelaufen, wieder aktiv, andere `featureKey`, ohne `disabledAt`) bleiben unverändert; zweiter Lauf `{ funktionen: 0, buchungen: 0, gedeckelt: 0 }` (die `deepEqual`-Pins der TP1-Tests tragen `gedeckelt: 0` seit A4) und das Dokument wird **nicht mehr gelistet** (keine tägliche Neuprüfung); Entwurfsjob (C6) nach 91 Tagen ⇒ das zurückgesetzte Dokument behält den `siteKey` (Test in C6).
- **Abo-Vorprüfung:** Grund `abo_ende`, Abo läuft **wieder**, `disabledAt` vor 31 Tagen ⇒ nichts gelöscht (Buchungen, Zellen, Einstellung da), Dokument bleibt `disabled`, Warnung im Log; dasselbe Dokument mit Grund `team` ⇒ gelöscht und **0** Abfragen auf `subscriptions`; Lesefehler auf `subscriptions` ⇒ nichts gelöscht, `fehler` gezählt; das Abo kommt erst nach 60 Buchungen zurück (Einwurf nach der 50. Löschung, Muster Z. 822) ⇒ Löschung endet dort, Einstellung bleibt.
- **Zurücksetzen:** scheitert eine Buchungslöschung (`failNext` DELETE `termineBookings`) ⇒ Dokument bleibt `disabled`, Einstellung bleibt; nächster Lauf löscht und setzt zurück. Scheitert das Zurücksetzen (`failNext` PATCH `portalFeatures`) ⇒ Einstellung ist weg, Dokument noch `disabled`; nächster Lauf setzt zurück, **auch wenn das Abo inzwischen läuft** (kein Unumkehrbares mehr offen). Wird das Dokument zwischen Löschen der Einstellung und Zurücksetzen vom Team aktiviert (Einwurf), bleibt es `active` (frisch gelesen, nicht überschrieben).
- Nach dem Zurücksetzen ist eine Neubestellung möglich: `markOrdered` (Block B) auf dem `off`-Dokument ⇒ `ordered` (Verweis auf B-Test, hier nur der Zustand `off`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off“
### Task E9: Sofortpfad Widerruf Wechsel und Schutz der Team-Aktivierung
**Files:** Modify `server/services/termine/aboAbgleich.js` (+ `endeTermineFuerKunde`), `server/routes/abo.js` (Z. 1051–1058, nach dem Analyse-Block), `server/services/siteFeatures.js` (`adminActivate` Z. 351), Tests `test/publicForms.test.js` (neben Z. 422), `test/funktionenTermine.test.js`, `test/termineAboAbgleich.test.js`.
**Consumes:** E4–E6, E1 `aboLage`.
**Produces:** `endeTermineFuerKunde(customerId, { now = new Date() } = {}) ⇒ Promise<{ beendet: string[] }>` (wirft nie, wie `endAnalyseForCustomer`): alle `termine`-Dokumente des Kunden (`listCustomerFeatureDocs`), `active` mit `unlockedByPurchase === true` ⇒ `disable({ reason: 'abo_ende', adminNote: 'Abgeschaltet: Wechsel widerrufen' })`, `ordered`/`setup` ⇒ `abo_ende_vor_einrichtung`; je Dokument unter `feature-admin:<projectId>` mit frischem Lesen, **ohne** Abo-Prüfung (der Spiegel ist veraltet); danach sofort `meldeAboEnde` (E4/E5: `abo_ende` ⇒ K22; `abo_ende_vor_einrichtung` mit `unlockedByPurchase === true` ⇒ erst T19 über `meldeErstattung` (B10b), dann K22 `vor_einrichtung` und Marker; ein unbezahltes `ordered`-Dokument wird nur abgeschaltet und bekommt weder T19 noch K22 noch Marker), ein Fehler bei einem Dokument stoppt die anderen nicht. `routes/abo.js` ruft sie per dynamischem Import in einem eigenen `try/catch` nach dem Analyse-Block (`inTime && type === 'wechsel'`); der Widerruf antwortet auch bei einem Fehler dort 200. `adminActivate`: für `termine` und ein Dokument mit `unlockedByPurchase === true` ist das Aktivieren nur erlaubt, wenn `(await aboLage(customerId)).laeuft`; sonst 409 „Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.“ (verhindert, dass der nächste Abgleichslauf die Kulanz zurücknimmt und K22 erneut sendet); Lesefehler ⇒ Fehler, nicht aktiviert. Nicht betroffen: `news`, Dokumente mit `unlockedByPurchase !== true` (Pilot/Kulanz ohne Kauf).
- [ ] Tests:
- `publicForms.test.js` (Muster Z. 401–440, Dokument `termine` statt `analyse`): Widerruf des Wechsels in der Frist ⇒ aktives bezahltes Termine-Dokument `disabled`/`abo_ende`, K22 in der Senke, Analyse-Verhalten unverändert; wirft die Termine-Abschaltung (Dokument-Update scheitert), bleibt der Widerruf 200; Widerruf nach Fristende oder Zusatzspeicher ⇒ Termine unverändert (`wf_feat`-Test erweitert).
- **Bezahlter Aufbau über den Sofortpfad (Review Focus #1):** Termine-Dokument `setup` (`unlockedByPurchase`, `stripeSessionId 'cs_test_1'`, Ticket vorhanden), Widerruf des Wechsels in der Frist ⇒ `disabled`/`abo_ende_vor_einrichtung`, **genau ein** T19 (Block `ticket_offen`), K22 mit Block `vor_einrichtung`, `aboEndeMailAt` gesetzt, `zahlungMeldung 'gemeldet'`; der folgende `gleicheAboAb`-Lauf sendet nichts mehr (weder T19 noch K22). Unbezahltes `ordered` ⇒ `disabled`/`abo_ende_vor_einrichtung`, **kein** T19, kein K22, kein Marker; kommt die Zahlung danach, meldet B10b T19 genau einmal und der Abgleich K22. T19-Versand scheitert ⇒ der Widerruf antwortet trotzdem 200, K22 und Marker fehlen, der Abgleich am Folgetag sendet T19 und K22 nach.
- **Kein Flattern:** nach dem Sofortpfad (Spiegel `sub_h` auf `productKey 'wartung'`, `active`) läuft `gleicheAboAb` ⇒ Dokument bleibt `disabled`, kein K23, kein zweites K22.
- `funktionenTermine.test.js`: Team-„Aktivieren“ bei bezahltem `disabled`-Dokument ohne laufendes Abo ⇒ 409 mit dem Text, Zustand unverändert; mit laufendem Abo ⇒ 200, `active`, Grund `null`; Dokument ohne Kauf (`unlockedByPurchase: false`) ohne Abo ⇒ 200; `news` ⇒ unberührt; Abo-Lesefehler ⇒ 5xx, nicht aktiviert.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo“
### Task E10: Zahlung nach dem Abo-Ende (T19 statt Ticket) – Zusatztests gegen B10
**Files:** Modify `test/termineAboAbgleich.test.js` (nur Tests). **Kein eigener Code** und kein eigener Zweig in `onFeaturePaid`: das Verhalten (kein Ticket, kein K15/T16, T19 genau einmal, Webhook 500 bei Versandfehler, Wiederholung sendet genau einmal) baut B10a/B10b.
**Consumes:** B10a (`markPaid`-Fall `abo_ende`, Marker `zahlungMeldung`), B10b (`meldeErstattung`, `onFeaturePaid`), E4–E6 (`gleicheAboAb`).
**Produces:** Tests für das Zusammenspiel von Zahlungs-Webhook und täglichem Abgleich (Review Focus #1: nie Geld ohne Spur, nie doppelt).
- [ ] Tests (`handleFeatureCheckoutEvent` direkt, Hilfen `event`/`paidSession` wie in `funktionenRoutes.test.js:51–58`, Dokument `termine` im Zustand `ordered`, danach `gleicheAboAb` ohne Abo):
- Zahlung nach Abschalten ⇒ genau ein T19 (Block `zahlung_nach_ende`, Sitzungs-ID), **kein** Eintrag in `workorders`, kein K15/T16, `unlockedByPurchase true`, `purchaseStatus 'paid'`, `zahlungMeldung 'gemeldet'`, Zustand bleibt `disabled`; dasselbe Event noch einmal und danach `async_payment_succeeded` ⇒ kein zweites T19 (nie doppelt).
- T19-Versand schlägt fehl ⇒ `handleFeatureCheckoutEvent` wirft (Webhook 500), `unlockedByPurchase` ist trotzdem `true`, `zahlungMeldung` offen; zweiter Aufruf mit funktionierendem Versand ⇒ genau ein T19.
- Folgetag-Abgleich ⇒ K22 `vor_einrichtung` an den Kunden, `aboEndeMailAt` gesetzt, **kein** zweites T19 (Marker `gemeldet`).
- Abgleich **vor** der verspäteten Zahlung-Wiederholung: Schritt 2 meldet T19 (das Dokument ist durch die erste Verbuchung bezahlt), die Wiederholung des Webhooks danach bleibt stumm; parallel (`Promise.all`) ⇒ insgesamt ein T19.
- Zahlung für ein Dokument mit Grund `team` bleibt beim Verhalten aus B10b (T16 „abgeschaltet“, kein T19); Zahlung bei `setup`/`ordered` (Abo läuft) unverändert (Ticket, K15, T16).
- Rennen Reihenfolge: Zahlung **vor** dem Abgleich (Dokument `setup`, Ticket da) und Abgleich danach ⇒ T16/Ticket **und** ein T19 (mindestens eine Spur, T19 höchstens einmal); anschließend Stripe-Retry desselben Ereignisses ⇒ stumm.
- [ ] FAIL → Tests schreiben (bei Rot: den Fehler in B10 beheben, nicht hier umgehen) → PASS → `npm test` → Commit „Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)“
### Task E11: Meldungen und Banner im Portal nach Abschaltgrund
**Files:** Modify `server/routes/termine.js` (`MELDUNG.abgeschaltet` Z. 59 und Z. 152; `GET /einstellungen` Z. 201), `public/termine-view.js` (+ `abschaltText`), `public/termine.js` (`hinweisAbgeschaltet` Z. 66, Aufrufe Z. 96–98 und Z. 200); Tests `test/termineRoutes.test.js` (Z. 994), `test/termineView.test.js`, `test/ui/termine-portal.ui.mjs` (Z. 1024, Playwright, nicht in `npm test`).
**Consumes:** E2 `loeschTag`, `featureView`-Felder. **Eigentum:** E11 **besitzt** Banner, Meldungstexte und das Feld `abgeschaltet` (Reihenfolge A → B → E → C → D): Block C (C4 schreibt `MELDUNG`, `withTermine` und GET `/einstellungen` neu, C10b ändert `zeigeProjekt`) und Block D (D2 verschiebt den Snippet-Aufruf in GET `/einstellungen`) setzen auf diesen Stand auf und **erhalten** `MELDUNG.abgeschaltet`, das Feld `abgeschaltet`, `abschaltText` und `hinweisAbgeschaltet`; C baut keinen eigenen disabled-Banner und kein eigenes Löschdatum.
**Produces:** `GET /api/termine/einstellungen` liefert zusätzlich `abgeschaltet: { grund, seit, loeschungAm } | null` (nur bei `state === 'disabled'`; `grund` = `disabledReason` oder `null`, `seit` = `disabledAt`, `loeschungAm` = `'JJJJ-MM-TT'`). Die 409-Meldung bei Schreibversuchen (Code bleibt `abgeschaltet`) nennt je Grund das Löschdatum (`dd.mm.yyyy`): `abo_ende` „Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren. Ab dem 04.11.2026 löschen wir die Daten.“, `abo_ende_vor_einrichtung` „…Abo endete, bevor der Terminplaner eingerichtet war…“, sonst der bisherige Satz plus (mit Datum) „Ab dem … löschen wir die Daten.“. `abschaltText({ grund, loeschungAm }) ⇒ { davor, danach, mitExport }` in `termine-view.js` (rein, Du-Form, `datumKurz` für das Datum): `mitExport` falsch nur bei `abo_ende_vor_einrichtung`; `hinweisAbgeschaltet(projectId, abgeschaltet)` setzt `[davor, exportLink(projectId, 'exportieren'), danach]` (Linktext und genau ein Link bleiben, die UI-Prüfung und der Link-Wächter in `termineView.test.js` ~Z. 290 gelten weiter), Text nur über `h()`. Wortlaut ist Vorschlag für die Rechtsrunde (Block F).
- [ ] Tests:
- `termineRoutes.test.js` (Test Z. 994 erweitert): disabled ohne Grund/`disabledAt` ⇒ `abgeschaltet: { grund: null, seit: null, loeschungAm: null }` und der **bisherige** Satz unverändert; `abo_ende` mit `disabledAt: '2026-10-04T23:30:00.000Z'` ⇒ `loeschungAm '2026-11-04'`, jede Schreibroute 409 mit `code 'abgeschaltet'` und Text mit „Wartungs-Abo“ und „04.11.2026“; `abo_ende_vor_einrichtung` ⇒ Text mit „eingerichtet“; `active` ⇒ `abgeschaltet: null`; Lesen und Export bleiben 200.
- `termineView.test.js`: `abschaltText` je Grund (abo_ende, abo_ende_vor_einrichtung, team, kunde_geloescht, `null`) mit und ohne Datum; `mitExport` nur bei vor_einrichtung falsch; kein „Sie“/„Ihr“ (die Dateischleife Z. 277 bleibt grün, keine neue Datei).
- UI (`nice`, vorher `free -m`): Fixture `disabled` + `abo_ende` ⇒ Banner nennt „Wartungs-Abo“ und das Datum, der Export-Link bleibt der einzige Link (`.tm-box-warn a`, Text „exportieren“); Fixture ohne Grund ⇒ wie bisher (Z. 1039).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner-Banner und Meldung nach Abschaltgrund mit Löschdatum“
### Task E12: WOMS „Abgeschaltet (Abo beendet), Löschung am …“
**Files:** Modify `src/lib/funktionenView.js` (+ `abgeschaltetText`), `src/components/customer/CustomerFunktionen.jsx` (Statuszelle, Z. 96–98), `tests/unit/funktionenView.test.mjs`.
**Consumes:** E2 (`disabledReason`, `loeschungAm` in `features[key]` über `GET /api/team/funktionen/:customerId`; `adminView` reicht die Felder von `listCustomerFeatures` durch, kein Portal-Eingriff), `fmtYmd` (`src/lib/format.js:41`); Block D (D9/D10) ändert dieselbe Komponente **nach** E12 (Anleitung, `aktionen(state, key)`) und **erhält** die Zeile mit `abgeschaltetText` in der Statuszelle; E12 ergänzt nur diese Zeile und die reine Funktion.
**Produces:** `abgeschaltetText(feature) ⇒ string` (rein): `''`, wenn `state !== 'disabled'`; sonst `Abgeschaltet` + ` (Abo beendet)` bei `abo_ende`, ` (Abo endete vor Einrichtung, Erstattung prüfen)` bei `abo_ende_vor_einrichtung`, ` (Team)` bei `team`, ` (Kunde gelöscht)` bei `kunde_geloescht`, nichts ohne Grund; mit `loeschungAm` anschließend `, Löschung am ${fmtYmd(loeschungAm)}`. Die Komponente zeigt den Text als `area-muted`-Zeile unter dem Abzeichen (das Abzeichen bleibt `ZUSTAND.disabled`, der Test dort bleibt unverändert). Das Team-„Aktivieren“ meldet bei fehlendem Abo (E9) den Servertext im bestehenden Fehlerbereich.
- [ ] Tests (`funktionenView.test.mjs`): `abgeschaltetText({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-11-04' }) === 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026'`; die drei anderen Gründe; ohne Grund und Datum `'Abgeschaltet'`; ohne Grund mit Datum `'Abgeschaltet, Löschung am …'`; `active`/`setup`/`off`/`undefined` ⇒ `''`; unbekannter Grund wird wie „ohne Grund“ behandelt; bestehende Tests (`ZUSTAND`, `aktionen`, `mergeFunktion`) unverändert grün. `npm test` (WOMS) und einmal `nice npx vite build`.
- [ ] FAIL → implementieren → PASS → WOMS `npm test` + `nice npx vite build` → Commit „Funktionen-Reiter: Abschaltgrund und Löschdatum des Terminplaners“
**Abschlussprüfung Block E (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (E12): `npm test` im WOMS-Worktree und einmal `nice npx vite build`. Check vor dem Livegang: der Abgleich läuft zunächst nur beobachtend (Block G: solange kein Dokument `unlockedByPurchase === true` hat, schaltet er nichts ab).
---
## Block C: Fragebogen
Block C macht aus dem TP1-Einstellungsdokument den Fragebogen: In `off` ist `termineConfig.einstellung` der Entwurf und zugleich die spätere Live-Einstellung, dazu das neue Attribut `termineConfig.fragebogen` (Schrittbestätigungen, Einbauwünsche), acht Schritte aus den TP1-Editoren und die Bestellseite „Prüfen und bestellen“. Der strikte Gate `bestellpruefung` ersetzt den Stub aus Block B. Reihenfolge: Server C1–C6 (C4a, C4b), Client C7a–C10b (C7a, C7b, C8, C9a, C9b, C10a, C10b), Playwright C11 (Voraussetzung: A, B, E sind fertig; C vor D und F).
Gelesen und geprüft (Portal-Worktree): `server/routes/termine.js` (`withTermine` :137-157, GET :201-216, Vorlage :230-252, PATCH :254-272), `server/services/termine/{kontext,einstellungen,vorlagen,schema,jobs}.js`, `public/termine*.js`, `public/funktionen.js` (Termine-Kachel :139-197), `server/routes/funktionen.js:109`, `server/services/customerAdmin.js:285-292`, UI-Testserver `test/ui/termineTestServer.mjs`. Abweichungen von der Spec: (1) `BRANCHEN` liegt nach der Schnittstellenliste in `fragebogen.js`, nicht in `vorlagen.js`; (2) das Attribut kommt in `server/services/termine/schema.js` (das Setup-Skript `scripts/termine-setup.mjs` und `test/support/schema.js` lesen von dort, beide bleiben unverändert); (3) `feiertage(t)` aus `termine-team-ausnahmen.js` wird nicht übernommen, Schritt 4 braucht ein Bundesland ohne Vorauswahl und ein Speichern je „Weiter“.
**Annahmen an andere Blöcke (abgestimmt, verbindlich):**
- **B Register:** `featureConfig('termine')` hat die Felder aus B1 (`consent: { version, knopf, items: [{ id, text, haken }] }`, `leistungsbeschreibung`, `avv`, `widerruf`, `priceLabel`, `setupEstimate`). Die Widerrufsbelehrung liefert der Haken `featureHaken('termine').belehrung` (F7); C erfindet keine Registerfelder `pflichtinfo` oder `widerruf{belehrung,muster}`. Nur `bestellungAnsicht()` (C2) bildet das Register für die Bestellseite ab.
- **B Checkout** `POST /api/funktionen/termine/checkout`: Body `{ projectId, zustimmung: { version, haken: [id, …] } }` (`haken` = angehakte `consent.items[].id` mit `haken: true`); „Zahlung fortsetzen“ in `ordered` sendet `{ projectId, fortsetzen: true }` und bekommt die offene Sitzung nur, wenn für sie ein Nachweis vorliegt, sonst 409 `zustimmung_fehlt` (die Seite zeigt wieder das Formular); Antwort `{ url }`. Bei nicht leerer `bestellpruefung` antwortet B mit 409 `bestellung_offen` und `body.offen` (Liste `{ schritt, text }`), bei veralteter Fassung mit 409 `fassung_veraltet` und `body.consent`, bei Abo-Mangel mit 409 und `code` `kein_abo` | `gekuendigt` | `nicht_aktiv` (Abo-Stufe (7) in B6, nicht über das Gate).
- **B (Checkout) und C4a (Vorschau)** rufen `bestellpruefung` mit `ladeEinstellung(featureId)` (C3 liefert dort jetzt auch `fragebogen`): B6 im Checkout als `bestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), mitAbo: false })` (ohne `abo`, die Abo-Stufe (7) liegt in B6), C4a im GET als `bestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), abo: await bestellbar(customerId) })` (bei Lesefehler ohne `abo` ⇒ Punkt (h) „unbekannt“). Block D liest die Einbauwünsche über denselben Weg. Abo-Hinweistexte kommen aus `aboMeldung(feature, grund)` (`featureOrder.js`, B6); C definiert keine eigenen Wortlaute.
- **E** liefert `aboStatus.laeuft`/`bestellbar` (E1) und **besitzt** Abschalt-Banner, Meldungstexte und das GET-Feld `abgeschaltet` (E11). C4a/C4b, C10b und D2 setzen darauf auf und erhalten `MELDUNG.abgeschaltet`, das Feld `abgeschaltet`, `abschaltText` und `hinweisAbgeschaltet`.
- **A** `kundenansichtGuard`: `PUT /api/termine/fragebogen` bleibt außerhalb der Erlaubnisliste; Einfügepunkt für C6 ist `taeglicherJob` nach `loescheAbgeschaltete` (auch A und E ändern diese Funktion und `tagesbericht`; Segmentreihenfolge siehe E7).
### Task C1: Fragebogen-Attribut, Branchen, Schritte, Normalisierung
**Files:** Create `server/services/termine/fragebogen.js` (Teil 1; hat Block B die Datei schon mit dem Stub angelegt, wird sie ergänzt), `test/termineFragebogen.test.js`. Modify `server/services/termine/schema.js` (`termineConfig`: `str('fragebogen', 20000)` hinter `updatedBy`), `test/termineSetup.test.js` (Attributliste in „Schema: Attribute je Collection“ um `fragebogen` am Ende; Größen-Test; neuer `--check`-Test).
**Consumes:** `text` aus `einstellungen.js`; `VORLAGEN` aus `vorlagen.js`.
**Produces:** `SCHRITTE = ['branche','wer','leistungen','zeiten','regeln','gastfelder','benachrichtigung','website']`; `BRANCHEN = [{ key, name, vorlage: string|null, gesundheit: boolean }]` (Reihenfolge von `vorlagenListe()`, danach der Eintrag `{ key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }`; Schlüssel `gesundheit` und Name „Gesundheitsbereich“ gelten wörtlich für C2, C5, C9a und C11); `leerFragebogen()`; `normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler: { [pfad]: text } }`; `FRAGEBOGEN_MAX = 16000`. Form: `{ schema: 1, branche: <BRANCHEN-key>|null, bestaetigt: [Schrittschlüssel], bundeslandGewaehlt: boolean, preiseGeprueft: boolean, einbau: { modus: 'inline'|'knopf'|null, knopfText ≤ 40, stelle ≤ 300, gestaltung ≤ 1000, bemerkung ≤ 1000 } }`. `branche` ist gegenüber der Spec ergänzt, sonst kennt der Gate (f) die Branche nicht.
- [ ] Tests: `SCHRITTE` genau diese acht; `BRANCHEN`: jede `vorlage` steht in `VORLAGEN`, alle neun Vorlagen kommen genau einmal vor, genau ein Eintrag `gesundheit: true` ohne Vorlage, `sonstiges` bleibt wählbar (D6). `normalisiereFragebogen`: `{}` ⇒ `leerFragebogen()`; unbekannte Schlüssel und `__proto__` fliegen raus; `bestaetigt: ['website','branche','branche','quatsch']` ⇒ `['branche','website']` (Reihenfolge der Schritte, ohne Doppelte und Fremde); `knopfText` mit 41 Zeichen wird auf 40 gekürzt; Steuerzeichen (`\u0000`, `‮`) in `gestaltung` verschwinden, Absätze bleiben; `modus: 'popup'` ⇒ `fehler['einbau.modus']`; `bundeslandGewaehlt: 'ja'` ⇒ Fehler; Nicht-Objekt ⇒ `fehler._`; idempotent (`normalisiere(normalisiere(x).fragebogen)` gleich); größte gültige Eingabe bleibt unter `FRAGEBOGEN_MAX`. Setup: `keysOf('termineConfig')` endet auf `fragebogen`; Größe 20000 (Inhalt ≤ 16 000 erzwingt der Server, die größere Spalte wird in Appwrite eine Textspalte und zählt nicht zur Zeilenbreite; Annahme, vor Produktion `termine-setup.mjs --check` und Anlegen gegen eine Kopie); `main({ check: true })` meldet in einer vorhandenen Collection ohne `fragebogen` „termineConfig.fragebogen FEHLT“, der Lauf ohne `--check` legt genau dieses Attribut an, der zweite nichts.
- [ ] FAIL → implementieren (`normalisiereFragebogen` baut ein neues Objekt Feld für Feld, Texte über `text()`; `stelle`/`gestaltung`/`bemerkung` mehrzeilig) → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Attribut, Branchen, Schritte, Normalisierung“
### Task C2: Bestell-Gate und Gesundheits-Stichwortprüfung
**Files:** Modify `server/services/termine/fragebogen.js` (Teil 2, ersetzt den Stub aus B; die Marke `STUB` entfällt), `server/services/termine/einstellungen.js` (`sichtbareLeistungen` :568 und `MELDUNG_PFLICHT` :562 exportieren, sonst unverändert), `test/termineFragebogen.test.js`. **Delete** `test/termineFragebogenStub.test.js` (B5; sie prüft den Stub und bliebe rot). Create `test/support/fragebogenStand.js`. B-Tests, die nur mit dem Stub (`[]`) bestellbar waren (`grep -l checkout test/*.test.js`), bekommen `bestellbarerStand()`.
**Produces:** `bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>` (`schritt` ∈ `SCHRITTE` ∪ `'bestellen'`, sortiert nach Schrittfolge; `feature` = Register-Eintrag, nur für `label.name` und `aboMeldung` (aus `featureOrder.js`, B6, Argumente `(feature, grund)`); `abo` = Ergebnis von `bestellbar`, gelesen nur bei `mitAbo !== false`); `SCHRITT_TITEL`; `gesundheitsWarnungen(einstellung) ⇒ string[]`; `bestellungAnsicht(feature) ⇒ Promise<{ preis, preisHinweis: config.ustHinweis, einrichtung, consent: { version, knopf, items: [{ id, text, haken }] }, dokumente: [{ id, titel, version, url }], belehrung } | null>` (reine Abbildung von `oeffentlicheAnsicht(feature.key)` (B1) für die Bestellseite: `consent` unverändert **inklusive `knopf` und `haken`-Flag** (der Knopf zeigt genau diesen Text, B speichert genau diesen Text), `dokumente` aus den Registerfeldern `leistungsbeschreibung`/`avv`/`widerruf` mit gesetzter `url`, `belehrung` = Ergebnis von `featureHaken(feature.key).belehrung()` (derselbe Text, den B7 im Nachweis speichert; bis F7 der Platzhalter `BELEHRUNG_PLATZHALTER` aus `featureRegistry.js` (B7), „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“); keine Felder `pflichtinfo`/`widerruf`). Testhelfer: `bestellbarerStand({ now }) ⇒ { einstellung, fragebogen }` (Vorlage `salon`, Telefon, Anschrift, Impressum, Datenschutz, alle Schritte bestätigt, `bundeslandGewaehlt`, `preiseGeprueft`, `einbau.modus: 'inline'`, `branche: 'salon'`) und `entwurfDoc(featureId, { customerId, projectId, stand })` (Seed für `termineConfig`).
Gate (a)–(h), jeder Punkt ein eigener Eintrag: (a) `fehlendePflichtangaben` (Telefon ⇒ Schritt `website`, Anschrift ⇒ `branche`, Text aus `MELDUNG_PFLICHT`); (b) keine `sichtbareLeistungen` ⇒ `leistungen`; (c) je unbestätigtem Schritt ein Punkt „Bitte bestätige den Schritt „…“.“, dazu `leistungen`, wenn eine sichtbare Leistung einen Preis hat und `preiseGeprueft` fehlt; (d) `bundeslandGewaehlt !== true` ⇒ `zeiten` (der Serverstandard `RP` zählt nicht); (e) `einbau.modus` fehlt, oder `knopf` ohne `knopfText` ⇒ `website`; (f) `branche` fehlt oder Gesundheit ⇒ `branche`; (g) `impressumUrl` oder `datenschutzUrl` fehlt ⇒ `website`; (h) nur bei `mitAbo !== false` (der Checkout in B6 ruft mit `mitAbo: false` und ohne `abo`; dort liefert die Abo-Stufe (7) die Codes): `abo.ok !== true` ⇒ `bestellen` mit `aboMeldung(feature, abo.grund)`, `abo` fehlt (Lesefehler) ⇒ derselbe Schritt mit `aboMeldung(feature, 'unbekannt')` (im Zweifel nicht bestellbar). `einstellung: null` ⇒ ein Punkt `branche`.
Stichwortprüfung (Regel: Treffer nur am **Wortanfang**, vor dem Treffer steht kein Buchstabe `\p{L}`; „Gesichtsbehandlung“ ist keine Behandlung). Gruppe 1, Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zählen): `physio`, `therap` (Therapie, Therapeut/in), `arzt`, `ärzt`, `zahnarzt`, `zahnärzt`, `heilpraktik`, `krankengymnast`, `osteopath`, `diagnos`. Gruppe 2, Stamm plus höchstens eine Beugungsendung aus `e`, `en`, `n`, `s`, `es` und dann Wortende: `rezept`, `beschwerde`, `behandlung` (so trifft „Rezepte“, „Beschwerden“, „Behandlungen“, aber **nicht** „Rezeption“ oder „Rezeptur“: `rezept` + `ion`/`ur` ist keine erlaubte Endung); geprüft werden Leistungsnamen, Beschreibungen, Zusatzfeld-Beschriftungen und -Auswahlen, ergänzt um den Betriebsnamen; höchstens 10 Meldungen, ohne Doppelte, ohne Gästedaten. Nur Warnblock für das Team (T16-Block `gesundheit` und Ticket-Kurzfassung, beide füllt Block D4), dem Kunden zeigt Block C nichts.
- [ ] Tests: `bestellbarerStand()` mit `abo: { ok: true }` ⇒ `[]`; je Kriterium (a)–(h) ein Fall mit genau einem Punkt und dem erwarteten `schritt` (Tabelle im Test), u. a. Telefon leer ⇒ `{ schritt: 'website' }`, Anschrift leer ⇒ `'branche'`, `bestaetigt: []` ⇒ acht Punkte in Schrittfolge, `bundeslandGewaehlt: false` bei `einstellung.bundesland === 'RP'` ⇒ trotzdem Punkt, alle Preise `keiner` ⇒ kein Preis-Punkt, `branche: 'gesundheit'` ⇒ Punkt in `branche`, `abo: { ok: false, grund }` für die drei Gründe ⇒ Text == `aboMeldung(feature, grund)`, `abo: undefined` ⇒ `aboMeldung(feature, 'unbekannt')`, `mitAbo: false` ⇒ kein (h)-Punkt, auch bei `abo: undefined` und bei `abo: { ok: false, grund: 'kein_abo' }`; Reihenfolge stabil; alle Texte Du-Form (kein `\bSie\b`). `gesundheitsWarnungen`: je Stichwort ein Treffer (Name, Beschreibung, Zusatzfeld), Groß-/Kleinschreibung und Umlaute; **Regeltest** (Treffer: „Rezept“, „Rezepte“, „Behandlungen“, „Beschwerden“, „Physiotherapie“, „Therapiestunde“, „Zahnärztin“, „Krankengymnastik“, „Osteopathie“; kein Treffer: „Rezeption“, „Rezeptur“, „Gesichtsbehandlung“, „Beratung“, „Besichtigung“); **alle neun `ausVorlage`-Vorlagen ergeben `[]`** (schützt „Gesichtsbehandlung“, „Beratung“, „Besichtigung“); `null` ⇒ `[]`. `bestellungAnsicht`: gleicht `oeffentlicheAnsicht('termine')` (Items mit `haken`-Flag, `consent.knopf` wörtlich, `dokumente` nur mit `url`), `belehrung` == Ergebnis des Hakens, `null` für unbekannte Funktion, enthält nie interne Felder (`ticket`, `mails`). **Integration Checkout (B6, mit dem echten Gate):** Kunde ohne Abo mit `bestellbarerStand()` ⇒ 409 `kein_abo` (nicht `bestellung_offen`); Kunde ohne Abo mit offenem Fragebogen ⇒ 409 `bestellung_offen` ohne Eintrag `bestellen`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung“
### Task C3: Fragebogen in Kontext und Speicher
**Files:** Modify `server/services/termine/kontext.js` (`liesDokument` :58, `ladeEinstellung` :75, `baueKontext` :82, `speichereEinstellung` :156), `test/termineKontext.test.js`.
**Consumes:** C1 (`leerFragebogen`, `normalisiereFragebogen`).
**Produces:** `ctx.fragebogen` (normalisiert; fehlt oder kaputtes JSON ⇒ `leerFragebogen()`, kaputtes JSON mit einer Logzeile, ohne Wurf); `ladeEinstellung(featureId) ⇒ { einstellung, version, fragebogen } | null` (additiv, B und D nutzen es); `kontextFuerPortal(feature) ⇒ Promise<ctx>` (portalFeatures-Dokument in jedem Zustand, kein Modus-/Zustandscheck, den macht der Aufrufer; `kontextPerSiteKey` und `kontextPerFeature` bleiben unverändert); `speichereFragebogen(feature, aendere, { by, now }) ⇒ { fragebogen }` (unter `termine-cfg:<id>`; `aendere(bisher) ⇒ Eingabe`, wird normalisiert, Fehler tragen `err.fehler`; schreibt nur `fragebogen`, `updatedAt`, `updatedBy`, **ohne** `version`; fehlt das termineConfig-Dokument ⇒ 409 `code: 'keine_einstellung'`); neue Option `fragebogen` bei `speichereEinstellung(feature, …, { version, by, now, fragebogen })` schreibt Einstellung und Fragebogen in derselben Sperre (Neuanlage mit Branche, „Vorlage ersetzen“ mit zurückgesetzten Bestätigungen). Beide leeren den Zwischenspeicher.
- [ ] Tests: `speichereFragebogen` ändert `fragebogen`/`updatedAt`/`updatedBy`, `version` und `einstellung` bleiben bytegleich, `ladeEinstellung` liefert den neuen Stand; `speichereEinstellung` ohne Option lässt `fragebogen` stehen; mit Option auf leerer Collection entsteht ein Dokument mit beidem; gleichzeitig `speichereEinstellung` (Version 1) und `speichereFragebogen` ⇒ beide gelten, Version steigt genau einmal; ohne Dokument ⇒ `keine_einstellung`, nichts angelegt; kaputtes Fragebogen-JSON ⇒ leerer Fragebogen und gültige Einstellung; `kontextFuerPortal` liefert für `off`, `ordered`, `setup`, `active`, `disabled` einen Kontext, **`kontextPerSiteKey` für `off`, `ordered`, `setup`, `disabled` mit vollständiger Einstellung `null`** (Review-Focus 3: Entwurf nie öffentlich), nach `activate` den Kontext.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen im Kontext und Speicher“
### Task C4a: Entwurfszugriff – lesen in jedem Zustand, GET ohne Dokument, GET-Felder
**Files:** Modify `server/routes/termine.js` (`MELDUNG`, `withTermine`, GET `/einstellungen`), `test/termineRoutes.test.js` (Test „Funktion aus, noch nicht bestellt oder ohne Dokument“ :247-264 wird ersetzt), Create `test/termineFragebogenRoutes.test.js` (Teil 1).
**Consumes:** C1–C3, `aboMemo` (`bestellbar`, E1), `aboMeldung(feature, grund)` aus `featureOrder.js` (B6), `featureConfig` (B), `betriebAusKonto` (bestehend), `analyseHosts`; **E11 bleibt erhalten:** `MELDUNG.abgeschaltet` (Funktion nach Grund), das GET-Feld `abgeschaltet` und `hinweisAbgeschaltet` werden von C4a **nicht** neu geschrieben, sondern übernommen (C4a ersetzt nur die Zweige für `off`/`ordered`/`setup`).
**Produces:** `withTermine({ schreiben, entwurf })`: ohne `entwurf` unverändert (`active|disabled`). Mit `entwurf: true` (nur GET/PATCH `/einstellungen`, POST `/einstellungen/vorlage`, PUT `/fragebogen`): **lesen** in jedem Zustand; Dokument fehlt ⇒ virtueller Kontext `off`, nichts wird angelegt (die Schreibregeln folgen in C4b). Alle anderen Routen behalten die Schranke `active|disabled`. GET liefert zusätzlich `branchen: [{ key, name, gesundheit }]`, `fragebogen`, `kundenansicht: boolean`, in `off`: `abo: { ok, grund? } | null`, `offen: bestellpruefung({ einstellung, fragebogen, feature, abo }) | null` (mit `abo`, also inklusive Punkt (h); bei Lesefehler ohne `abo` ⇒ (h) „unbekannt“), `bestellung: await bestellungAnsicht(…)`; ohne Einstellung `betriebVorschlag: { name, adresse, email, telefon, websiteUrl }` (Konto, Website nur aus `liveDomain`); `einbau.snippet` und `feeds` nur in `active`/`disabled`.
- [ ] Tests (Zustandsmatrix je Zeile Kunde/Kundenansicht, Cookie via `kv()` wie im vorhandenen Kundenansicht-Test): GET ohne Dokument ⇒ 200 `{ state: 'off', einstellung: null, version: 0 }` und `aw.docs('portalFeatures').size` unverändert; GET in `ordered`/`setup` 200, aber `/buchungen`, `/zeiten`, `/export.csv`, `POST /ressourcen/:id/feed`, `POST /buchungen` ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; **Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf GET**; Abo: ohne Abo GET 200 mit `abo.ok === false`/`grund 'kein_abo'`, `cancelAtPeriodEnd` ⇒ `gekuendigt`; `aw.failNext` auf `subscriptions` ⇒ GET `abo: null` mit offenem Punkt „nicht geprüft“ (Text aus `aboMeldung(…, 'unbekannt')`); `bestellung.consent.knopf` == Registertext; **E11 unverändert:** `disabled` ⇒ `abgeschaltet`-Feld und Meldung nach Grund wie in den E11-Tests (sie bleiben grün).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder“
### Task C4b: Entwurfszugriff – Schreibregeln, PATCH mit `fehlt`, nie öffentlich
**Files:** Modify `server/routes/termine.js` (`withTermine` Schreibregeln, PATCH `/einstellungen`), `test/termineRoutes.test.js` (PATCH-Antwort mit `fehlt` in :399, die übrigen `fehlt`-Prüfungen :282, :316, :357, :478 bleiben), `test/termineFragebogenRoutes.test.js` (Teil 1b).
**Consumes:** C4a, `laeuft` (E1), `aboMeldung(feature, grund)` aus `featureOrder.js` (B6).
**Produces:** Schreibregeln für `entwurf: true`: `off` und `active` ja, `ordered`/`setup` nur mit `req.session.viewAs` (sonst 409 `code: 'gesperrt'`: „Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“), `disabled` 409 wie bisher (Text von E11); in `off` zusätzlich `laeuft(customerId)` – **nicht** `bestellbar`: eine Kündigung oder offene Zahlung sperrt nur die Bestellung (Gate (h)), nicht das Speichern des Entwurfs (bewusste Abweichung von Spec 6, im Plan-Kopf vermerkt) –, sonst 409 `code: 'kein_abo'` mit Text aus `aboMeldung`, bei Lesefehler 503 `abo_pruefung` (Entwurf entsteht nur für Kunden mit laufendem Wartungs-Abo). Der erste Schreibzugriff legt das Dokument über `ensureFeatureDoc(customerId, projectId, 'termine')` erst im Handler an, nach der Eingabeprüfung. PATCH ruft `normalisiereEinstellung(…, { entwurf: state !== 'active' })` und antwortet mit `fehlt: fehlendePflichtangaben(…)`.
- [ ] Tests (Zustandsmatrix wie C4a): PATCH `off` ⇒ 200, `active` ⇒ 200, `ordered`/`setup` Kunde ⇒ 409 `gesperrt` (Dokument unverändert), Kundenansicht ⇒ 200 und `termin_einstellungen` protokolliert, `disabled` ⇒ 409 auch in der Kundenansicht; **Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf PATCH, `termineConfig` danach unverändert**; PATCH `off` ohne Telefon/Anschrift ⇒ 200 mit `fehlt: ['betrieb.telefon','betrieb.adresse']`, dieselbe Eingabe in `active` ⇒ 400, ungültige URL in `off` ⇒ weiter 400 (der Entwurf überspringt nur die Pflichtangaben); Abo: ohne Abo PATCH ⇒ 409 `kein_abo`, kein Dokument entstanden; `cancelAtPeriodEnd` oder `past_due` ⇒ PATCH weiter 200 (Entwurf bleibt speicherbar; die Bestellung sperrt Gate (h)); `aw.failNext` auf `subscriptions` ⇒ PATCH 503; öffentlich: für `off`, `ordered`, `setup` mit vollständiger Einstellung liefern `GET /api/public/termine/v1/:siteKey/config` und `POST …/buchungen` `{ aktiv: false }` und der Kalender-Link mit dem richtigen Token 404, nach Aktivierung geht es; **Einbauwünsche nie öffentlich:** `fragebogen.einbau.stelle = 'GEHEIMWUNSCH'` taucht in `JSON.stringify` der öffentlichen Config nicht auf, ebenso nicht `fragebogen`, `einbau`, `knopfText`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich“
### Task C5: Branche und Vorlage im Entwurf, `PUT /fragebogen`
**Files:** Modify `server/routes/termine.js` (POST `/einstellungen/vorlage` :230-252, neue Route `PUT /fragebogen`), `test/termineFragebogenRoutes.test.js` (Teil 2); die bestehenden Vorlage-Tests in `termineRoutes.test.js` müssen unverändert grün bleiben.
**Consumes:** C1–C4b, `ausVorlage` mit `betrieb`-Feldern (bestehend).
**Produces:** `POST /api/termine/einstellungen/vorlage { projectId, branche | vorlage, betrieb?: { name, adresse, telefon, email, websiteUrl }, ersetzen?: true, version?: int }`: `branche` ⇒ Vorlage laut `BRANCHEN`; unbekannt ⇒ 400; **`gesundheit` ⇒ 400 `code: 'gesundheitsbereich'`, es wird nichts angelegt** (D6, harter Ausschluss); `vorlage` ohne `branche` wie bisher (TP1). Die Betriebsfelder überschreiben nur nichtleere Kontowerte. Eine Einstellung ist nur mit `ersetzen: true`, `version` (muss stimmen, sonst 409 `EINSTELLUNG_VERALTET`) und Zustand `off` ersetzbar (neue IDs und Feed-Token, `fragebogen` neu mit gewählter `branche`, `bestaetigt: []`, `preiseGeprueft: false`, `bundeslandGewaehlt: false`, `einbau` bleibt), in einem Schreibvorgang über die Option `fragebogen` aus C3. Antwort 201 mit `einstellung` (ohne Feed-Token), `version`, `fehlt`, `fragebogen`. `PUT /api/termine/fragebogen { projectId, fragebogen }`: erst `req.session.viewAs` ⇒ 403 „In der Kundenansicht kann nichts bestätigt werden.“, dann `withTermine({ entwurf: true, schreiben: true })` (Abo-Gate in `off`, in `ordered`/`setup`/`disabled` 409), `limitWrites`, `normalisiereFragebogen` (400 mit `fehler`), `speichereFragebogen`; Antwort `{ fragebogen, offen }`; ohne Einstellung 409 `keine_einstellung`; kein Eintrag im Kundenansicht-Protokoll.
- [ ] Tests: Vorlage mit `branche: 'salon'` legt Feature-Dokument `off` (mit dauerhaftem siteKey) und `termineConfig` mit `fragebogen.branche === 'salon'` an; `betrieb.name` überschreibt das Kontowert, leeres Feld nicht; `branche: 'gesundheit'` ⇒ 400, `portalFeatures` und `termineConfig` unverändert (auch ohne vorher vorhandenes Dokument); Vorlage zweimal ⇒ 409; `ersetzen` ohne/mit falscher `version` ⇒ 400/409, in `active` ⇒ 409, in `off` ⇒ neue Leistungen, neue Feed-Token, `bestaetigt` leer, `einbau` erhalten; PUT: speichert, `version` der Einstellung bleibt, `updatedBy` = `kunde:<id>`; zwei Tabs mit offenem Editor (alte `version`) können danach weiter per PATCH speichern (kein 409); ungültiger Modus ⇒ 400 mit `fehler['einbau.modus']`; PUT in `ordered`, `setup`, `disabled` ⇒ 409; **PUT in der Kundenansicht ⇒ 403 `view_blocked` über den Wächter und 403 mit Text direkt am Router (zweite App ohne Wächter)**; Mandant c2 ⇒ 403; Schreibbudget: ein kompletter Durchlauf (1 Vorlage, 8 PATCH, 8 PUT, 5 Editor-Speicherungen) bleibt unter 120 je Stunde, der 121. Aufruf ist 429.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen“
### Task C6: Entwürfe aufbewahren und löschen
**Files:** Create `server/services/termine/entwuerfe.js`, `test/termineEntwuerfe.test.js`. Modify `server/services/termine/jobs.js` (`taeglicherJob` nach `loescheAbgeschaltete`; Rückgabe `entwuerfe`; `tagesbericht` bekommt das Segment `Entwürfe <n> gelöscht (<m> offen)` nach dem Deckel-Segment (A3b) und vor `Abo-Abgleich` (E7); vollständige Zeile: `[termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe`), `server/services/customerAdmin.js` (nach `disableCustomerFeatures`, Fehler nur loggen), `test/termineJobs.test.js` (Test „taeglicherJob: Löschfristen, …“ :867-906 und die festgenagelte Berichtszeile), `test/funktionenTermine.test.js` (Test „Kunde loeschen …“ :173).
**Produces:** `loescheEntwuerfe(now) ⇒ { geprueft, geloescht, uebrig, fehler }` und `loescheEntwuerfeVonKunde(customerId) ⇒ { geloescht }`; Konstanten `ENTWURF_TAGE = 90`, `MAX_GEPRUEFT = 2000`, `MAX_GELOESCHT = 100`. Kandidaten: `portalFeatures` mit `featureKey 'termine'` und `state 'off'`, seitenweise (`limit 100`, `orderAsc('$id')`, `cursorAfter`). Frist: der jüngere Zeitpunkt aus `$updatedAt` des Funktionsdokuments und `termineConfig.updatedAt` ist älter als 90 Tage. Vor jedem Löschen unter `withLocks([feature-order:<kunde>:<projekt>, feature-admin:<projekt>, termine-cfg:<id>])` das Dokument frisch lesen (noch `off`, Frist noch überschritten, keine Buchung zur Funktion), dann `termineConfig` löschen (404 ist ok), das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war (kein `activatedAt`, `ticketId`, `disabledAt`, kein `unlockedByPurchase`, `purchaseStatus` weder `paid` noch `zurueckgesetzt`; sonst bleibt es wegen des siteKeys). `zurueckgesetzt` ist die dauerhafte Marke, die E8 nach der Löschung bei Abo-Ende setzt (E8 leert `activatedAt`, `ticketId`, `disabledAt` und `unlockedByPurchase` wieder, damit das Dokument neu bestellbar ist, und lässt den `siteKey` stehen, damit das eingebettete Snippet gültig bleibt): ein so markiertes Dokument verliert höchstens seine Einstellung, **nie** das Funktionsdokument. Nie `ordered`/`setup`. Deckel: nach 100 Löschungen zählt der Rest der Seite als `uebrig`, nach 2000 geprüften Dokumenten endet der Lauf mit einer Logzeile (ohne IDs); gelöschte Kandidaten fehlen am nächsten Tag in der Liste, der Überlauf läuft also an Folgetagen weiter. Einzelfehler zählen in `fehler`, brechen den Lauf nicht ab. Kundenlöschung entfernt alle `off`-Entwürfe des Kunden vollständig (Funktionsdokument und Einstellung).
- [ ] Tests: 89 Tage alt bleibt, 91 Tage ⇒ `termineConfig` und Funktionsdokument weg; frisch bearbeiteter Entwurf (alte `$updatedAt`, neue `termineConfig.updatedAt`, nur `PUT /fragebogen`) bleibt; `ordered`, `setup`, `active`, `disabled` bleiben unabhängig vom Alter; Funktionsdokument mit `activatedAt` verliert nur die Einstellung; **von E8 zurückgesetztes Dokument** (`off`, `purchaseStatus 'zurueckgesetzt'`, sonst alle Spuren leer) behält nach 91 Tagen ohne Änderung sein Dokument und den `siteKey` unverändert (eine vorhandene Einstellung wird gelöscht); nach fehlgeschlagener Zahlung (`purchaseStatus: 'failed'`) ist der Entwurf nach 90 Tagen weg; **Rennen:** der Test hält `withLocks(['feature-order:<kunde>:<projekt>'])`, startet `loescheEntwuerfe`, schreibt das Dokument auf `ordered` und gibt die Sperre frei ⇒ der Lauf liest frisch und löscht nichts; Buchungen zur Funktion ⇒ nichts gelöscht; 130 Kandidaten ⇒ erster Lauf `geloescht 100, uebrig 30`, zweiter `30/0`; Einzelfehler (`aw.failNext` DELETE) ⇒ `fehler 1`, die übrigen werden gelöscht; Tagesjob enthält `entwuerfe`, die Berichtszeile hat keine IDs, kein `@`, keine Namen; bei `TERMINE_ENABLED=off` läuft der Schritt nicht (Entwürfe sind nie bezahlt; bei der Aufteilung von `fuehreAus` durch Block E gehört er zum stillstehenden Teil); Kunde löschen entfernt seine `off`-Entwürfe samt Einstellung, die eines anderen Kunden nicht, ein Fehler dabei hält das Löschen nicht auf (Logzeile).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen“
### Task C7a: Client-Bausteine 1 – reine Helfer in `termine-view.js`
**Files:** Modify `public/termine-view.js` (reine Helfer), `test/termineView.test.js` (Dateiliste, „Spiegel“-Tests), Create `test/termineFragebogenView.test.js`.
**Produces:** in `termine-view.js`: `FRAGEBOGEN_SCHRITTE = [[key, titel]]` (Spiegel von `SCHRITTE`), `schrittStatus(fragebogen) ⇒ [{ key, titel, erledigt }]`, `ersterOffenerSchritt(fragebogen) ⇒ key | 'bestellen'`, `ressourcenAusNamen({ bisher, typ, namen, neueId }) ⇒ { ressourcen, entfernt, neu }` (trimmt, entfernt Doppelte ohne Rücksicht auf Groß-/Kleinschreibung, höchstens `LIMITS.ressourcen`; gleiche Namen behalten ID und Plan, übrige alte Einträge werden in Reihenfolge umbenannt, weitere Namen kopieren den Plan des ersten, `typ` gilt für alle), `buchungsartFuerAlle(leistungen, art)`, `buchungsartLage(leistungen) ⇒ 'sofort'|'anfrage'|'gemischt'`, `zusammenfassung(einstellung, fragebogen, branchen) ⇒ [{ schritt, titel, zeilen: [string] }]` (acht Gruppen, nur Text). **Kein** `loeschDatum`: Löschdatum und Abschalt-Texte gehören E11 (`abschaltText`).
- [ ] Tests: `FRAGEBOGEN_SCHRITTE` gleich `SCHRITTE` des Servers (Spiegel-Test), `schrittStatus`/`ersterOffenerSchritt` für leer, teilweise, vollständig; `ressourcenAusNamen`: „Stylist/in“ → „Anna“ behält ID und Plan, zweiter Name `Ben` bekommt neue ID und den Plan von Anna, Entfernen liefert die IDs, Doppelte/Leere fallen weg, über 20 Namen werden gekürzt, `typ: 'platz'` gilt für alle, Ergebnis besteht `normalisiereEinstellung` (mit Leistungen, die allen zugeordnet sind); `buchungsartFuerAlle` ändert nur `buchungsart`; `zusammenfassung` für die Vorlage `salon` enthält Name, Leistungen mit Preis, Wochenplan, Bundesland nur bei `bundeslandGewaehlt`, nie `feedToken`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Helfer im Client“
### Task C7b: Client-Bausteine 2 – Exporte der Reiter, `wochenplanFeld`, `fehlt`
**Files:** Modify `public/termine-tab-einstellungen.js` (Abschnitte exportieren), `public/termine-tab-team.js` (Wochenplan-Feld herauslösen), `public/termine-speichern.js:116`, `public/termine.js` (nur `ctx`-Bau herauslösen, Verhalten gleich), `test/termineView.test.js`, `test/termineFragebogenView.test.js` (statische Teile und Verhalten); Create `test/support/fakeDom.js`.
**Consumes:** C7a.
**Produces:** Exporte aus `termine-tab-einstellungen.js`: `betrieb(ctx, e, { nur, titel, hinweis })` (`nur` = Teilmenge von `name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl`, `lese` schreibt nur diese Felder; ohne `nur` wie bisher), `darstellung`, `regeln`, `gastFelder`, `erinnerung`, `storno`, `rechtliches`, `zahlung`. Aus `termine-tab-team.js`: `wochenplanFeld(r, pfad) ⇒ { el, lese() ⇒ { plan } | { fehler } }` (die Tagesfelder :81-86 und der Wochenplan-Block :108-111 aus `formular`, das ihn danach selbst nutzt). `termine-speichern.js` übernimmt `fehlt: antwort.fehlt ?? []` statt `[]`. Testhilfe `test/support/fakeDom.js`: `installFakeDom({ antworten = [] } = {}) ⇒ { document, fetchLog, finde(baum, vergleich), alle(baum, vergleich), text(baum), klick(el), setze(el, wert), restore() }` – ein Minimal-`document` auf `globalThis` (`createElement`, Elemente mit `setAttribute`, `append`, `addEventListener`, `dataset`, `className`, `value`, `checked`, `children`) und ein Fake-`fetch`, das jeden Aufruf als `{ method, url, body }` in `fetchLog` schreibt und die nächste Antwort aus `antworten` (`{ status, body }`, Standard 200 `{}`) liefert; `h` aus `termine-dom.js` liest `document` bei jedem Aufruf, deshalb braucht der Client keine Einhängepunkte. C8, C9a, C9b und C10b nutzen dieselbe Datei.
- [ ] Tests: statisch: `termine-speichern.js` setzt `fehlt` aus der Antwort; `termine-tab-einstellungen.js` exportiert die acht Abschnitte, `betrieb` ohne `nur` baut weiter alle neun Felder, `wochenplanFeld` wird von `termine-tab-team.js` selbst benutzt (gemeinsame bestehende UI-Skripte `termine-portal.ui.mjs` laufen unverändert grün). **Verhalten mit Fake-DOM** (`termineFragebogenView.test.js`): (a) `betrieb(ctx, e, { nur: ['email'] })` baut nur das Feld `email`, `lese()` liefert nur `{ email }` (Schlüsselmenge geprüft); ohne `nur` entstehen weiter alle neun Felder; (b) `wochenplanFeld(r, pfad)`: gültige Fenster in zwei Tagesfeldern ⇒ `lese()` liefert `{ plan }` mit genau diesen Fenstern, ein ungültiges Fenster ⇒ `{ fehler }` mit Text, ein leerer Tag ⇒ geschlossen; (c) `termine-speichern.js`: antwortet der Fake-`fetch` auf den PATCH mit `fehlt: ['betrieb.telefon']`, enthält das Ergebnis des Speicherns `fehlt: ['betrieb.telefon']` (ohne `fehlt` in der Antwort `[]`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen“
### Task C8: Bestellseite und Statuskarten (`funktion-bestellen.js`)
**Files:** Create `public/funktion-bestellen.js` (heißt bewusst nicht `termine*.js`: einzige Datei mit `href`-Links, wird relativ importiert, Regel R4 bleibt; **diese** Datei entsteht hier, F8 ändert sie nicht), `test/termineFragebogenView.test.js` (Teil), Modify `public/termine.css` (`tm-bestellen*`, `tm-rechtstext`, `tm-statuskarte`).
**Consumes:** C2 (`bestellung` aus `GET /api/termine/einstellungen`), C7a (`zusammenfassung`), B (`POST /api/funktionen/termine/checkout`), `h`, `request` aus `termine-dom.js`.
**Produces:** `sicherLink(url) ⇒ string` (nur `https:` oder Pfade unter `/rechtliches/`, sonst `''`); `bestellKoerper({ projectId, bestellung, gewaehlt }) ⇒ { projectId, zustimmung: { version, haken } }` (`version` = `bestellung.consent.version`, `haken` = die angehakten IDs der Items mit `haken: true`, nie Texte; das ist der Vertrag aus B6); `fortsetzenKoerper(projectId) ⇒ { projectId, fortsetzen: true }`; `renderBestellen(root, { ctx, daten, zumSchritt })` und `statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht }, { weiter })`. Es gibt **keine** Knopftext-Konstante im Client: der Knopf zeigt `bestellung.consent.knopf` (derselbe Text, den B7 als Eintrag `knopf` im Nachweis speichert; Review Focus 5: gezeigter Text = gespeicherter Text). Reihenfolge auf der Seite: Zusammenfassung (je Gruppe Knopf „Ändern“ ⇒ `zumSchritt(key)`), offene Punkte aus `daten.offen` (je Punkt „Zum Schritt“), Preis mit `bestellung.preisHinweis` und Einrichtungsdauer, die **vollständige Widerrufsbelehrung samt Muster-Formular** aus `bestellung.belehrung` als Text (scrollbarer Bereich, **nie eingeklappt, kein `<details>`**; der Text kommt vom Server: bis F7 der Platzhalter `BELEHRUNG_PLATZHALTER` (B7), ab F7 die Belehrung; F8 ändert diese Datei nicht), Links aus `bestellung.dokumente` (versionierte Adressen, nur über `sicherLink`), die Zustimmungs-Haken in Registerreihenfolge (Wortlaut aus `bestellung.consent.items`; Items mit `haken: true` sind Haken, andere reiner Text), zuletzt der Knopf. Knopf gesperrt, solange ein Haken fehlt, `offen` nicht leer ist oder `kundenansicht` (dann Hinweis „In der Kundenansicht kann nicht bestellt werden.“); ein Klick sperrt ihn, sendet `bestellKoerper` und setzt `window.location.href` nur auf eine `https:`-Adresse; bei Fehler Meldung am Knopf, bei 409 lädt die Seite `daten` neu und setzt die Haken zurück: `bestellung_offen` zeigt die Punkte aus `body.offen`, `fassung_veraltet` die neue Fassung aus `body.consent`, ein Abo-Code den Text aus der Antwort. `statusKarte`: `ordered` ⇒ Pille „Zahlung offen“, Bestelldatum, Knopf „Zahlung fortsetzen“ (POST `fortsetzenKoerper`, Navigation zur offenen Sitzung; antwortet die Route 409 `zustimmung_fehlt`, lädt die Seite neu und zeigt wieder das Bestellformular; Hinweis, dass SEPA einige Tage dauern kann), Chat-Knopf; `setup` ⇒ „In Einrichtung“, Ticketnummer (`ticketWoid`, ohne Ticket „Wir legen dein Ticket gerade an.“), Bestelldatum, Rechnungslink über `sicherLink`, Zeiteinschätzung, Hinweis „Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“; `danke` (Rückkehr von Stripe) zeigt „Danke! Sobald die Zahlung da ist, legen wir los.“
- [ ] Tests (Node, ohne DOM): `sicherLink` lässt `https://x.de/a` und `/rechtliches/avv-terminplaner-v1.html` durch, weist `javascript:`, `data:`, `http:`, `//evil.example` und Leerraum-Tricks ab; `bestellKoerper` liefert **genau** `{ projectId, zustimmung: { version, haken } }` mit nur den angehakten IDs der `haken: true`-Items (kein `consent`, keine Texte; die gesendeten IDs sind Teilmenge der Register-IDs, Querprüfung gegen `featureConfig('termine').consent.items` in `termineFragebogenView.test.js`); `fortsetzenKoerper` liefert `{ projectId, fortsetzen: true }` ohne `zustimmung`; statisch (wie die `termine*.js`-Regeln): kein `innerHTML`/`confirm`/`alert`, **kein Literal „Zahlungspflichtig“ im Client** (der Knopftext kommt aus `bestellung.consent.knopf`), Du-Form im Code (die Rechtstexte kommen vom Server), jedes `href` nur über `sicherLink(`, `location.href =` nur nach Prüfung auf `https:`, kein `localStorage`, kein `<details>` für die Belehrung; Datei ist in `termine*.js` nicht enthalten und wird von keinem absoluten Import geladen (`funktionen.js` behält genau `/analyse-view.js` und `/geraete.js`); Reihenfolge im erzeugten Baum (Fake-DOM aus C7b): Belehrungstext steht vor dem Knopf; Knopftext == `bestellung.consent.knopf` (Integration „Knopftext == Registertext == gespeicherter `knopf`-Eintrag“ in C11).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Bestellseite und Statuskarten“
### Task C9a: Fragebogen-Oberfläche 1 – Gerüst und Schritte 1 bis 3
**Files:** Create `public/termine-fragebogen.js`, Modify `public/termine.css` (`tm-schritte`, `tm-schritt`, Handy-Ansicht), `test/termineView.test.js` (Dateiliste: `termine-fragebogen.js` zwischen `termine-formulare.js` und `termine-gaestetexte.js`; neue Regeln unten), `test/termineFragebogenView.test.js` (Verhalten mit Fake-DOM aus C7b).
**Consumes:** C4a/C4b, C5 (Routen), C7a/C7b, C8, `neuerSpeicher` (einziger PATCH-Weg), `render` aus `termine-tab-leistungen.js`.
**Produces:** `renderFragebogen(root, ctx)` (ctx wie bei den Reitern, `hinweise` leer): Fortschrittsleiste `<ol>` mit `aria-current="step"` (Handy: „Schritt 3 von 8“ + Liste), Start beim ersten offenen Schritt (alles erledigt ⇒ Bestellseite), freie Navigation, ein `<form>` je Schritt, „Weiter“ = **ein** PATCH über den `neuerSpeicher` des Schritts (nur wenn der Schritt Felder hat) und danach `PUT /fragebogen` mit dem Schritt in `bestaetigt`; Fehler am Feld (`setzeFehler`), Fokus auf die Schrittüberschrift beim Wechsel, Hinweis „Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.“ Schritte 1 bis 3:
1 **Branche und Betrieb:** ohne Einstellung Radiogruppe aus `daten.branchen` plus `betrieb(…, { nur: ['name','adresse','kleinunternehmer'] })` mit `betriebVorschlag`, „Mit dieser Branche starten“ ⇒ `POST vorlage { branche, betrieb }` ⇒ PUT; **„Gesundheitsbereich“ ⇒ Erklärtext statt Weiter, es wird keine Anfrage gesendet**; mit Einstellung: „Branche wechseln“ mit Rückfrage in der Seite („ersetzt Leistungen, Zeiten und Regeln, alle Bestätigungen gehen zurück“) ⇒ `ersetzen: true` + `version`.
2 **Wer wird gebucht:** Einzelperson / Team / Plätze, Namen je Zeile (`ressourcenAusNamen`); neue Einträge werden im selben PATCH allen Leistungen zugeordnet, entfernte aus Leistungen und Abwesenheiten genommen; mindestens ein Name.
3 **Leistungen:** `render` aus `termine-tab-leistungen.js` unverändert (speichert selbst) plus Haken „Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)“ ⇒ `preiseGeprueft`; Weiter braucht eine sichtbare Leistung und bei Preisen den Haken; nur PUT.
`daten.kundenansicht === true`: PATCH bleibt möglich, „Weiter“ geht ohne PUT weiter, die Bestellseite ist gesperrt (Review-Focus 3, Kundenansicht bestätigt nichts). Nach Schritt 8 (C9b) folgt `renderBestellen`.
- [ ] Tests: statisch in `termineView.test.js`: Datei in der Liste, kein `innerHTML`, Du-Form, kein `href:` (der einzige bleibt `exportUrl`), `'PATCH'` weiter nur in `termine-speichern.js`, `'PUT'` nur in `termine-fragebogen.js`, kein Zugriff auf `localStorage`; Eintrag per ID statt Stelle (bestehende Regel gilt auch hier); `renderFragebogen` wird nur über `termine.js` geladen. **Verhalten in Node** (`test/termineFragebogenView.test.js`, Fake-DOM und Fake-`fetch` aus C7b): (a) Schritt 1, Auswahl „Gesundheitsbereich“: der Erklärtext steht im Baum, es gibt keinen Knopf „Mit dieser Branche starten“ und kein „Weiter“, `fetchLog` bleibt leer (kein einziger Aufruf); (b) „Weiter“ in einem Schritt mit Feldern (Schritt 7): `fetchLog` ist genau `[PATCH /api/termine/einstellungen, PUT /api/termine/fragebogen]` in dieser Reihenfolge, der PUT-Body enthält `projectId` und `fragebogen.bestaetigt` mit dem Schritt; antwortet der PATCH mit 400 und `fehler`, folgt **kein** PUT und der Fehler steht am Feld (`data-fehler`); (c) Schritt 3 mit Preisen ohne den Haken „Preise geprüft“ ⇒ „Weiter“ meldet den Fehler und ruft nichts auf, mit Haken ⇒ nur ein PUT (kein PATCH) mit `preiseGeprueft: true`; (d) `daten.kundenansicht === true`: „Weiter“ sendet den PATCH, aber **keinen** PUT und wechselt trotzdem zum nächsten Schritt; (e) Start beim ersten offenen Schritt (`aria-current="step"` am richtigen Listenpunkt); (f) Schritt 2 mit den Namen „Anna“ und „Ben“ ⇒ der PATCH-Body enthält beide Ressourcen und jede Leistung ist beiden zugeordnet. Browser-Layout und echter Server in C11 (Playwright), reine Teile in C7a.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3“
### Task C9b: Fragebogen-Oberfläche 2 – Schritte 4 bis 8 und Bestellseite
**Files:** Modify `public/termine-fragebogen.js`, `public/termine.css`, `test/termineView.test.js` (zusätzliche Importregeln), `test/termineFragebogenView.test.js` (Verhalten mit Fake-DOM aus C7b).
**Consumes:** C9a, C7b (`wochenplanFeld`, `betrieb`, `darstellung`, `regeln`, `gastFelder`, `erinnerung`, `storno`, `rechtliches`, `zahlung`), C8 (`renderBestellen`), `abwesenheiten`/`sonderoeffnungen` aus `termine-team-ausnahmen.js`.
**Produces:** Schritte 4 bis 8:
4 **Zeiten:** je Person/Platz `wochenplanFeld`, Bundesland **ohne Vorauswahl** (leere erste Option) und „An Feiertagen geschlossen“, im PATCH; danach PUT mit `bundeslandGewaehlt: true`; darunter `abwesenheiten(t)` und `sonderoeffnungen(t)` (speichern selbst, mit eigenem Container und eigenem `zeichne`, damit ungespeicherte Zeiten oben beim Neuzeichnen nicht verloren gehen).
5 **Regeln:** Sammelknopf „Alle sofort buchbar / Alle nur als Anfrage“ (`buchungsartFuerAlle`, Anzeige `buchungsartLage`), `regeln`, `storno(ctx, e, regeln.absage)` (das Muster folgt der Absagefrist), `rechtliches`, `zahlung`.
6 **Gastfelder:** `gastFelder`. 7 **Benachrichtigung:** `betrieb(…, { nur: ['email'] })` (einzige Adresse, auch Reply-To), `darstellung`, `erinnerung`. 8 **Website:** `betrieb(…, { nur: ['telefon','impressumUrl','datenschutzUrl','agbUrl','websiteUrl'] })` plus Einbauwünsche (Modus „direkt auf der Seite / hinter einem Knopf“, Knopftext bei Knopf, Stelle, Gestaltung, Bemerkung; Hinweis, dass ein zweites Widget, Knöpfe je Leistung und Sonderdesign nach Stunden gehen) im PUT. Danach `renderBestellen`.
- [ ] Tests: statisch in `termineView.test.js`: `termine-fragebogen.js` importiert `abwesenheiten`/`sonderoeffnungen` aus `termine-team-ausnahmen.js` und `wochenplanFeld` aus `termine-tab-team.js`, kein zweiter Zugriff auf `'PATCH'`/`'PUT'` außerhalb der erlaubten Dateien, Bundesland-Auswahl hat keine Vorauswahl (leere erste Option im Quelltext). **Verhalten in Node** (`test/termineFragebogenView.test.js`, Fake-DOM und Fake-`fetch`): (a) Schritt 4: die erste Option des Bundesland-`select` hat `value ''` und keine Option ist `selected`; „Weiter“ ohne Wahl ⇒ Fehler am Feld, `fetchLog` leer; mit Wahl `RP` ⇒ PATCH (`bundesland: 'RP'`, Wochenplan aus `wochenplanFeld`), danach PUT mit `bundeslandGewaehlt: true` und `bestaetigt` mit `zeiten`; (b) Schritt 7: der PATCH enthält aus `betrieb` nur `email` (Schlüsselmenge geprüft), Schritt 8 nur `telefon`, `impressumUrl`, `datenschutzUrl`, `agbUrl`, `websiteUrl`; der PUT enthält `einbau` mit `modus`, `knopfText`, `stelle`, `gestaltung`, `bemerkung`, bei Modus „hinter einem Knopf“ ohne Knopftext ⇒ Fehler am Feld und kein Aufruf; (c) nach Schritt 8 zeigt `renderBestellen` die Bestellseite (Knopfbeschriftung == `bestellung.consent.knopf`). Browser-Layout und echter Server in C11 (Playwright).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Fragebogen-Schritte 4 bis 8 und Bestellseite“
### Task C10a: Kachel mit Bestellweg und Abo-Felder in `GET /api/funktionen`
**Files:** Modify `public/funktionen.js` (`termineTile` :151-180, `pickTermineProject`), `server/routes/funktionen.js:109`, `test/funktionenTiles.test.js` (Tests ab :82 „Aus ⇒ …“ und „Abgeschaltet“), `test/funktionenTermine.test.js` (Test „GET /api/funktionen: termine.verfuegbar …“ :144), `test/ui/termine-portal.ui.mjs` (Prüfungen `fx_kachel_aus_aktiv_abgeschaltet` :306 und `fx_projektwahl` :377 pinnen die alten Texte).
**Consumes:** E1 (`bestellbar`, `aboMeldung` über `featureOrder.js`, B6), E11 (Texte der Abschaltung bleiben unberührt), B (`featureConfig('termine')`).
**Produces:** `GET /api/funktionen` ⇒ `termine: { verfuegbar: false }` unverändert; bei `true` zusätzlich `bestellbar: boolean|null`, `grund: 'kein_abo'|'gekuendigt'|'nicht_aktiv'|null`, `preis` (`priceLabel`), `einrichtung` (`setupEstimate`); ein Lesefehler im Abo ergibt `bestellbar: null` und kostet die Liste nichts. **Kachel** (Pille / Knopf): `off` mit Abo ⇒ „Aus“ / „Terminplaner einrichten · 149 €“, mit vorhandenem Entwurf (Funktionsdokument da, `siteKey` gesetzt) „Fragebogen fortsetzen“; `off` ohne Abo ⇒ kein Knopf, „Im Wartungs-Abo enthalten. Zu Verträge“ (`kein_abo`), „Dein Wartungs-Abo ist gekündigt …“, „… gerade nicht aktiv …“, bei `bestellbar === null` „Das Abo konnte gerade nicht geprüft werden.“; `ordered` ⇒ „Zahlung offen“ / „Status ansehen“; `setup` ⇒ „In Einrichtung“ / „Status ansehen“; `active` ⇒ „Aktiv“ / „Termine öffnen“; `disabled` ⇒ „Abgeschaltet“ / „Termine ansehen“. Zielprojekt: aktiv, dann `ordered`/`setup`, dann abgeschaltet, dann Entwurf, dann erstes.
- [ ] Tests: `funktionenTermine`: `{ verfuegbar: false }` exakt wie bisher, bei `true` die vier neuen Felder für Kunde mit Wartungs-Abo, ohne Abo (`kein_abo`), mit `cancelAtPeriodEnd` (`gekuendigt`), Abo-Lesefehler (`bestellbar: null`, Projekte kommen trotzdem); `funktionenTiles` (Node, `fetch`-Attrappe wie bisher): jede Zeile der Kacheltabelle mit Pillenklasse und Knopftext, `ordered`/`setup` ohne Zahl-Abfrage an `/api/termine`, Mehrprojekt-Auswahl, Entwurf ⇒ „Fragebogen fortsetzen“, kein `data-view="termine"` ohne Abo, Projektname maskiert; die E11-Tests zu `disabled` bleiben unverändert grün; die zwei Playwright-Prüfungen oben sind auf die neuen Kachel-Texte angepasst und grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Kachel mit Bestellweg, Abo-Felder in der Funktionen-Liste“
### Task C10b: Ansicht `#termine` nach Zustand und Rückkehr von Stripe
**Files:** Modify `public/termine.js` (`zeigeProjekt` :75-101, `initTermine`, Reiterliste), `test/funktionenTiles.test.js` (Rückkehr-Tests), `test/termineFragebogenView.test.js` (Ansicht je Zustand mit Fake-DOM aus C7b), `test/ui/termine-portal.ui.mjs` (Fixture um `abo`, `branchen` erweitern).
**Consumes:** C4a (GET-Felder), C8, C9a/C9b, C10a (Kachel führt in die Ansicht), E1 (`bestellbar`), E11 (Banner und Texte der Abschaltung bleiben unberührt), B (`featureConfig('termine')`).
**Produces:** **Ansicht** (`zeigeProjekt`): `off` ⇒ ohne `abo.ok` Hinweis wie in der Kachel (kein Fragebogen), sonst `renderFragebogen`; `active` ohne Einstellung weiter die TP1-Vorlagenauswahl; `ordered`/`setup` ⇒ `statusKarte` und darunter die Reiter Leistungen, Team & Plätze, Einstellungen schreibgeschützt (`nurLesen`, der Reiter „Termine“ entfällt, er bekäme 409); `disabled` ⇒ unverändert der Banner aus E11 (`hinweisAbgeschaltet`/`abschaltText`, Löschdatum aus `abgeschaltet.loeschungAm`); C10b baut **keine** eigenen Abschalttexte und kein eigenes Löschdatum. Rückkehr `#termine?bestellt=1&projekt=<id>` (B2 `success_url`) **und** Abbruch `#termine?projekt=<id>` (B2 `cancel_url`): der Parameter `projekt` wird in beiden Fällen ausgewertet (Projekt wählen), `danke` nur mit `bestellt=1`; Flag und Projekt einmal lesen, Hash per `history.replaceState` bereinigen (wie `initNews`).
- [ ] Tests: **Ansicht je Zustand** (Fake-DOM und Fake-`fetch` aus C7b, der Fake liefert die GET-Antwort von `/api/termine/einstellungen`): `off` mit Abo ⇒ `renderFragebogen` (Fortschrittsleiste im Baum), `off` ohne `abo.ok` ⇒ Hinweis und **kein** Fragebogen, `active` ohne Einstellung ⇒ TP1-Vorlagenauswahl, `ordered`/`setup` ⇒ `statusKarte` und die schreibgeschützten Reiter ohne Reiter „Termine“, `disabled` ⇒ Banner aus E11 (`hinweisAbgeschaltet`) mit dem Löschdatum aus `abgeschaltet.loeschungAm`; Rückkehr: `#termine?bestellt=1&projekt=p2` wählt p2 und zeigt `danke`, `#termine?projekt=p2` (Abbruch) wählt p2 **ohne** `danke`, in beiden Fällen ist der Hash bereinigt; die E11-Tests zu `disabled` bleiben unverändert grün; Playwright-Fixture-Skripte angepasst und grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe“
### Task C11: UI-Testserver für Entwürfe und Playwright „Fragebogen bis Bestellklick“
**Files:** Modify `test/ui/termineTestServer.mjs` (Schema um `subscriptions`, `featureConsents`; `zuruecksetzen({ entwurf: true })`: kein Funktionsdokument, keine Einstellung, Wartungs-Abo `wartungDoc` für `c1`; Aufräumliste um beide Collections; `srv.abo(zustand)` und `srv.kundenansichtCookie()`), Create `test/ui/termine-fragebogen.ui.mjs`. Nicht Teil von `npm test`; vorher `free -m`, Aufruf `nice node test/ui/termine-fragebogen.ui.mjs`, Bilder nach `/root/reports/terminplaner-ui/fragebogen-*.png`.
**Consumes:** C4a–C10b, B (Collection `featureConsents` über `feature-setup.mjs`/`schemaKeys`, Checkout-Route), E (`aboStatus`), fake Stripe (`checkout.stripe.test`, wird per `page.route` abgefangen, kein Netz).
Prüfungen (je eine, nacheinander, `check()`-Rahmen wie in `termine-portal.ui.mjs`: fester Browser-Takt Montag 2026-10-05 10:00, Portal-Cookie `srv.portalCookie()`, Seitenfehler und Browser-Dialoge sind Fehler, Hosts außerhalb `127.0.0.1`/`localhost`/`checkout.stripe.test` sind Fehler):
- [ ] `ohne_abo`: Kachel „Im Wartungs-Abo enthalten“, Ansicht zeigt den Hinweis, es gibt keinen Fragebogen, `portalFeatures` bleibt leer.
- [ ] `gesundheit_blockt`: Kachel „Terminplaner einrichten · 149 €“ ⇒ Branche „Gesundheitsbereich“ ⇒ Erklärtext, kein „Weiter“, **kein einziger Schreibaufruf**, kein Dokument in Appwrite.
- [ ] `fragebogen_komplett_bis_bestellklick`: Branche „Friseur und Salon“ (Name/Anschrift aus dem Konto) ⇒ Start legt Funktionsdokument `off` und Einstellung an, Schritte 2 bis 8 ausfüllen (Team mit zwei Namen, Preise geprüft, Bundesland „Rheinland-Pfalz“ gewählt, „Alle sofort buchbar“, Telefon, Impressum, Datenschutz, Modus „hinter einem Knopf“ mit Text) und nach jedem „Weiter“ steht der Schritt in `fragebogen.bestaetigt` (aus `termineConfig` gelesen), `version` der Einstellung steigt nur durch PATCH; Bestellseite: offene Punkte leer, Knopf gesperrt, **Widerrufsbelehrung und Muster-Formular stehen im DOM vor dem Knopf** (`compareDocumentPosition`; bis F7 der Platzhaltertext `BELEHRUNG_PLATZHALTER`, B7), nach allen Haken aktiv; Klick ⇒ Navigation zu `https://checkout.stripe.test/c/pay/cs_test_…`; danach Funktionsdokument `ordered` mit `stripeSessionId`, genau ein `POST /v1/checkout/sessions` mit `metadata.featureKey 'termine'` und `projectId`, ein `featureConsents`-Dokument mit den gezeigten Texten (Haken, Belehrung, **Knopf**: gespeicherter `knopf`-Eintrag == `bestellung.consent.knopf` == Beschriftung des geklickten Knopfes), Fassungen und Zeit, gespeichert **bevor** die Navigation lief (Zeitstempel kleiner als der Aufruf der Stripe-Attrappe; die Sitzung selbst entsteht davor, B7).
- [ ] `offene_punkte_sperren`: Fragebogen mit fehlendem Impressum ⇒ Bestellseite listet den Punkt mit „Zum Schritt“, der Knopf bleibt gesperrt, Klick auf den Punkt öffnet Schritt 8; ohne Bundesland ⇒ Punkt im Schritt „Zeiten“, obwohl der Serverstandard `RP` gespeichert ist.
- [ ] `rueckkehr_und_zahlung_fortsetzen`: Seed `ordered` mit Stripe-Sitzung **und** dem `featureConsents`-Dokument dieser Sitzung; Seite `#termine?bestellt=1&projekt=p1` ⇒ Statuskarte „Zahlung offen“ mit „Danke!“, Hash bereinigt, Einstellungen schreibgeschützt (Felder gesperrt, Reiter ohne „Termine“); „Zahlung fortsetzen“ (Body `{ projectId, fortsetzen: true }`) ⇒ Navigation zur selben Sitzung, es gibt weiterhin genau eine Checkout-Sitzung und weiterhin genau ein `featureConsents`-Dokument; ohne das `featureConsents`-Dokument (Seed) ⇒ keine Navigation, die Seite zeigt wieder das Bestellformular (409 `zustimmung_fehlt`); Abbruch-Rückkehr `#termine?projekt=p1` ohne „Danke!“; ein PATCH direkt am Router ⇒ 409 `gesperrt`; Zustand `setup` per Seed ⇒ Karte mit Ticketnummer und Rechnungslink.
- [ ] `entwurf_bleibt_beim_neuladen`: nach Schritt 4 Seite neu laden ⇒ Fragebogen startet bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern; Branche wechseln mit Rückfrage ⇒ Bestätigungen zurückgesetzt, `einbau` bleibt.
- [ ] `kundenansicht`: Portal mit Kundenansicht-Cookie (`srv.kundenansichtCookie()`, neu, baut auf `kundenansichtCookie` aus `test/support/fixtures.js`; der Wächter hängt im UI-Testserver nicht, geprüft wird die Absicherung am Router selbst) ⇒ Schritte lassen sich lesen und per „Weiter“ durchklicken, `fragebogen.bestaetigt` bleibt unverändert, Bestellknopf gesperrt mit Hinweis, `PUT /fragebogen` direkt ⇒ 403.
- [ ] `handy_390`: Fortschritt „Schritt n von 8“, keine seitliche Rollleiste (`scrollWidth <= innerWidth`) auf Schritt 1, 4, 8 und Bestellseite; Bilder.
- [ ] FAIL → implementieren (nur Testserver und Skript, keine Produktivcode-Änderung) → alle Prüfungen grün → vorhandene UI-Skripte `termine-portal.ui.mjs` und `funktionen.ui.mjs` einmal nacheinander mit durchlaufen lassen → Commit „Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick“
**Abschlussprüfung Block C (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. Playwright-Läufe (`termine-fragebogen.ui.mjs`, `termine-portal.ui.mjs`, `funktionen.ui.mjs`) nacheinander, vorher `free -m`, mit `nice`, nicht Teil von `npm test`.
---
## Block D: Ticket, Einbauanleitung, WOMS
Aus Einstellungen und Fragebogen entsteht eine reine, deterministische Einbauanleitung (`einbauanleitung.js`): vollständig im WOMS-Panel mit Kopierknöpfen, als Kurzfassung (≤ 7000 Zeichen) in `ticket.details`. Die Freischaltung über das Register verallgemeinert Block B (B11, Termine: Einstellung vorhanden **und** Abo läuft); D liefert die Hindernis-Codes (`termine/freischaltung.js`) und die Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“. Das Ticket selbst (`createFeatureTicket`: Deckel, gezielte Wiederholung, Notfall-Details) gehört Block B (B8); ein eigener D5 entfällt. Nie `feedToken` in Ausgaben, Kundentexte nur als Text, kein Termine-Modus-Gate und kein Zugriff aus der Kundenseite.
Vorbedingungen aus früheren Blöcken: B (`featureConfig`, `config.features.termine.{ priceLabel, ticket, ticketFreischaltung }`, B8 `ticketTexte`/`anleitungsLink`/`kuerze`/`TICKET_DETAILS_MAX` in `featureRegistry.js` und das Gerüst `termine/ticketDetails.js`, B9b T16 mit Block `gesundheit`, B9a K15/K16, B11 `activateClosedTickets` samt Standardhaken, `markPaid` ruft `ticket.topic/details`, K16/T16 mit `{{funktion}}`), C (`termineConfig.fragebogen`, `gesundheitsWarnungen`), E (`aboStatus.laeuft`, `aboMemo`). Reihenfolge D1 → D10 (D1–D8 Portal, D9–D10 WOMS; D3a vor D3b; D5 entfällt).
### Task D1: Datenschutz-Absatz als Rechtstext-Baustein
**Files:** Modify `server/services/termine/rechtstexte.js` (neu `datenschutzAbsatz`, `RECHTSTEXTE_ENTWURF`; Kopfkommentar „ohne config“ ersetzen durch „nur Hilfsfunktionen aus einstellungen.js“), `test/termineRechtstexte.test.js`. `docs/rechtliches/termine-datenschutz-absatz.md` (Fassung 2026-10-02) bleibt inhaltlich unverändert; den endgültigen Wortlaut setzt Block F in Markdown **und** Funktion gemeinsam.
**Consumes:** `zeigtWiderruf(einstellung)` aus `einstellungen.js` (Bedingung „Gäste Verbraucher“; deckt sich mit dem Widerrufsknopf im Widget), `RECHTSTEXTE_VERSION`. **Produces:** `datenschutzAbsatz(einstellung, anrede) ⇒ string` (reiner Text, Absätze durch Leerzeile, erste Zeile „Online-Terminbuchung“, nicht HTML-maskiert wie `widerrufsbelehrung`; `anrede === 'du'` ⇒ Du-Fassung, sonst Sie wie `formularHinweis`), `RECHTSTEXTE_ENTWURF = true` (Block F setzt `false`, sobald die ENTWURF-Köpfe fallen). Bedingte Sätze nach der Markdown-Quelle: Telefon-Feld (`gastFelder.telefon !== 'aus'`), Nachricht/Zusatzfelder (`gastFelder.nachricht !== 'aus' || gastFelder.zusatz.length > 0`), Erinnerung (`erinnerung.anbieten`), Widerruf (`zeigtWiderruf`), Telefon hinterlegt (`betrieb.telefon`). Platzhalter: `betrieb.name/adresse/email/telefon`, `aufbewahrungTage` (ungültig ⇒ 90 wie `formularHinweis`). Fehlen Name oder E-Mail ⇒ `TypeError` wie `betriebsdaten`.
- [ ] Tests: (a) Pin gegen die Quelle: aus „Fassung Sie“/„Fassung Du“ den Blockquote lesen (`> `, `**`, `###` entfernt, Leerraum normalisiert), `[[wenn …: X]]` bei „alle Bedingungen an“ durch X ersetzen, bei „alle aus“ streichen, `{{Firma}}`/`{{Anschrift}}`/`{{E-Mail}}`/`{{Telefon}}`/`{{Tage}}` durch Testwerte ersetzen; das Ergebnis ist gleich `datenschutzAbsatz(…)` (ebenfalls normalisiert) für beide Zustände und beide Anreden. (b) Jede Bedingung schaltet genau ihren Satz (z. B. `anbieten: false` ⇒ kein „Erinnerung“, `gaesteSind: 'unternehmer'` ⇒ kein „Vertrag widerrufen“, `telefon: 'aus'` ⇒ kein „Telefonnummer“). (c) `aufbewahrungTage: 365` ⇒ „365 Tage nach dem Termin“, `'viel'` ⇒ 90. (d) Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter (Regexe aus dem bestehenden Datenschutz-Absatz-Test wiederverwenden). (e) Ohne Name oder E-Mail ⇒ `TypeError`; leere Anschrift ⇒ „Verantwortlich ist X, E-Mail Y.“ ohne leeres Komma. (f) `RECHTSTEXTE_ENTWURF === true` (Block F10b2 setzt die Konstante auf `false` und ändert diese Assertion zusammen mit den Köpfen `KOPF` der Dokumente).
- [ ] FAIL → implementieren (Absätze als kleine Funktionen mit Bedingung, je Anrede eine Textliste; kein Lesen der Markdown-Datei zur Laufzeit) → PASS → `npm test` → Commit „Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein“
### Task D2: Einbau-Snippet (verschoben, mit Knopf) und Farbvorschlag mit WCAG-Kontrast
**Files:** Create `server/services/termine/einbauanleitung.js` (zunächst nur diese Bausteine), `test/termineEinbauanleitung.test.js`; Modify `server/routes/termine.js` (`:182-194` lokales `esc` und `einbauSnippet(ctx)` entfernen, Aufrufstelle `:212` ⇒ `einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon })`; Modus bleibt `inline`, die Kundenansicht ändert sich nicht; Merge-Hinweis: Block C ändert dieselbe Datei an `withTermine` und GET `/einstellungen`), `server/services/termine/einstellungen.js` (`VORGABE_FARBE` exportieren), `test/termineEmbedStatic.test.js` (Farbparität).
**Produces:** `esc(text)`, `einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText })`, `luminanz(hex)`, `kontrast(hexA, hexB)`, `textFarbeAuf(hex) ⇒ '#ffffff' | '#111827' | '#000000'`, `cssVorschlag(farbe) ⇒ { css, akzent, akzentText, kontrastText, kontrastSeite, hinweise: string[] }`. Snippet: wie bisher (`<div data-webklar-termine=… data-api=… data-modus=…>`, Ausweichtext mit Telefon, `<script src="…/embed/termine.js" defer>`); Modus `knopf` ergänzt `data-knopf="…"` nur bei gesetztem Text (Widget-Vorgabe „Termin buchen“); Text ≤ 40 Zeichen, Zeilenumbrüche zu Leerzeichen, durch `esc` maskiert; unbekannter Modus ⇒ `inline`. `cssVorschlag`: nur gültige `#rrggbb` (sonst `VORGABE_FARBE`, nie Rohtext im CSS), Kommentar + `[data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }`; `hinweise` warnt, wenn der Akzent gegen Weiß unter 3 : 1 liegt (Rahmen freier Tage, `termine-einbau.md` Abschnitt 3).
- [ ] Tests: Snippet inline ist für `{ siteKey: 'abcdefghijklmnop', apiBase: 'https://project.webklar.com', telefon: '06301 1234' }` zeichengleich zu Abschnitt 1 von `docs/funktionen/termine-einbau.md`; `telefon: '06301 <12>'` ⇒ `&lt;12&gt;`; ohne Telefon „Termine telefonisch vereinbaren.“; Modus `knopf` mit `knopfText: 'Jetzt "buchen" & mehr'` ⇒ `data-modus="knopf" data-knopf="Jetzt &quot;buchen&quot; &amp; mehr"`; Knopf ohne Text ⇒ kein `data-knopf`; Text mit 60 Zeichen und `\n` ⇒ 40 Zeichen, keine Umbrüche; Modus `'dialog'` ⇒ `inline`. `luminanz('#ffffff') === 1`, `kontrast('#000000', '#ffffff') === 21`, `textFarbeAuf('#0f766e') === '#ffffff'`, `textFarbeAuf('#f5c542') === '#111827'`; für alle 4096 Farben mit Kanalwerten in 17er-Schritten ist `kontrast(farbe, textFarbeAuf(farbe)) >= 4.5`; **Parität**: dieselben 4096 Farben liefern in `public/embed/termine.js` (Marken `@farben-anfang`/`@farben-ende`, per `vm` wie `farbFunktionen()` im Bestandstest) und hier dasselbe `textFarbeAuf`. `cssVorschlag('#9d4b6f').css` enthält `--wkt-akzent:#9d4b6f;` und `--wkt-akzent-text:#ffffff;`; `'red'`, `'#fff'`, `undefined`, `'#9d4b6f;}</style>'` ⇒ `#7c3aed` und der Rohtext steht nirgends in der Ausgabe; `cssVorschlag('#f5c542').hinweise` nennt den Kontrast gegen Weiß. Bestehende `termineRoutes.test.js`-Assertions zum Snippet (`:283-305`) bleiben unverändert grün.
- [ ] FAIL → implementieren (Farbfunktionen 1:1 aus `public/embed/termine.js:192-216`, nicht aus dem Widget importieren) → PASS → `npm test` → Commit „Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben“
### Task D3a: Generator `einbauanleitung()` 1 – Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen
**Files:** Modify `server/services/termine/einbauanleitung.js`, `test/termineEinbauanleitung.test.js`.
**Consumes:** `einstellungen.js` (`preisText`, `zahlungText`, `zeigtWiderruf`, `hatPreis`), `zeit.js` (`heute`, `datumKurz`), B `config.features.termine.priceLabel` (hier „149 €“), `config.publicBaseUrl`. **Produces:** `einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) ⇒ { stand: { version, updatedAt }, warnungen, abschnitte, text, kurz }` (`feature` = **`portalFeatures`-Dokument** (der Registereintrag heißt in B `feature`, der Aufrufer D4 übergibt `ctx.doc`), `project` = `websiteProjects`-Dokument, `fragebogen` = geparstes Objekt oder `null`, `kunde` = `{ name }`, die Anrede kommt aus `einstellung.anrede` (`'sie'` | `'du'`, kein eigener Parameter); **rein = kein I/O**, `config` wird beim Aufruf gelesen; `kurz` kommt in D4), hier die Abschnitte `modell`, `leistungen`, `ressourcen`, `regeln`, `gastfelder`, `benachrichtigungen` (feste Reihenfolge, `kopierbar: false`) und `stand`. Der Link ins WOMS ist `anleitungsLink(customerId, projectId)` aus `featureRegistry.js` (B8). **Feldweißliste:** gelesen werden ausdrücklich benannte Felder, nie Objekte kopiert (kein `feedToken`, keine Betriebs-E-Mail außer im Datenschutz-Absatz). Kundentexte (Leistungsnamen, Ressourcenbezeichnungen) nur als Text. Ablaufende Dinge (Abwesenheiten, Sonderöffnungen) nur ab `heute(now)`.
- [ ] Tests (Fixture `ausVorlage('salon', BETRIEB, NOW)` mit Farbe `#9d4b6f`, Telefon, Anschrift, Impressum-/Datenschutz-URL): die sechs Abschnitt-IDs in fester Reihenfolge, alle `kopierbar: false`; Leistungen mit Name, Dauer, `preisText`, Buchungsart, Personen, inaktive markiert; Ressourcen mit Wochenplan („Mo 09:00–12:30, 14:00–18:00“), nur künftige Abwesenheiten; Regeln mit Raster, Vorlauf, Horizont, Absagefrist, `maxOffeneJeEmail`, Storno-Text samt Fassung, `zahlungText`; **feedToken-Test:** jede Ressource trägt einen echten Token, `JSON.stringify(ergebnis)` enthält weder einen Token noch `feedToken`; **Weißlistentest:** Zusatzfelder `geheim: 'XYZ-GEHEIM'` an Betrieb, Ressource und Leistung (und `notiz` an der Leistung) tauchen nirgends auf. Determinismus: gleiche Eingabe und `now` ⇒ `deepEqual`; anderes `now` ändert nur die „erzeugt am“-Zeile und die Filterung vergangener Abwesenheiten. `stand` gleich `{ version, updatedAt }`.
- [ ] FAIL → implementieren (je Abschnitt eine kleine Funktion `(daten) ⇒ { id, titel, text, kopierbar }`; Hilfsfunktionen für Wochenplan-Text und Kürzen mit „und N weitere“) → PASS → `npm test` → Commit „Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen)“
### Task D3b: Generator `einbauanleitung()` 2 – Einbau, Snippet, CSS, Datenschutz, Checkliste, Abrechnung, Warnungen, Volltext
**Files:** Modify `server/services/termine/einbauanleitung.js`, `test/termineEinbauanleitung.test.js`.
**Consumes:** D3a, D1 `datenschutzAbsatz`, `RECHTSTEXTE_ENTWURF`; D2 Bausteine; `einstellungen.js` (`fehlendePflichtangaben`, `zeigtWiderruf`, `hatPreis`), C `gesundheitsWarnungen(einstellung) ⇒ string[]` aus `fragebogen.js`, `config.womsBaseUrl`. **Produces:** `ENTWURF_BANNER` = „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“; die restlichen Abschnitt-IDs in fester Reihenfolge hinter D3a: `einbau`, `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`, `checkliste`, `abrechnung` (zusammen die 13 IDs `modell`, `leistungen`, `ressourcen`, `regeln`, `gastfelder`, `benachrichtigungen`, `einbau`, `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`, `checkliste`, `abrechnung`); `kopierbar: true` nur für `snippet`, `css`, `datenschutz_sie`, `datenschutz_du`. `text` = alle Abschnitte mit Überschrift, aber nur die zur Anrede (`einstellung.anrede`, D3a) passende Datenschutz-Fassung (die Abschnitte `datenschutz_sie` und `datenschutz_du` rufen `datenschutzAbsatz(einstellung, 'sie')` bzw. `(einstellung, 'du')`). Kundentexte (Wünsche aus `fragebogen.einbau`: `stelle`, `gestaltung`, `bemerkung`, `knopfText`) nur als Text, gesammelt in einem Block „Kundenwunsch (Text des Kunden)“ im Abschnitt `einbau`, nie in `snippet`/`css`; Grenzen 40/300/1000/1000 erneut angewandt. Ohne Fragebogen: Modus `inline`. Abnahme-Checkliste bedingt: `zeigtWiderruf` ⇒ Widerrufsbelehrung aufklappbar, Haken für vorzeitigen Beginn bei Preis, Knopf „Vertrag widerrufen“; Modus `knopf` und `zeigtWiderruf` ⇒ Widerrufsknopf dauerhaft neben dem Buchungsknopf, Modus `knopf` ⇒ `--wkt-akzent` immer setzen; immer: Widget unsichtbar bis zur Freischaltung, Ausweichtext, Datenschutz-Absatz auf der Website, URLs von Impressum/Datenschutz, „Freischalten (Abnahme)“, danach Probebuchung bis „Fast geschafft“ und Stornierung (die öffentliche Schnittstelle antwortet erst bei `active`), Kontrast, 390 px, Tastatur (Punkte aus Abschnitt 7 von `termine-einbau.md`). Abrechnung: „Einrichtung bezahlt (149 €, Rechnung <URL> | 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“; bei `feature.unlockedByPurchase !== true` „ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung“. Datenschutz-Absatz in der Checkliste: bis Block F steht der Zusatz „liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden“; **F10b2 ersetzt ihn durch die Endregel aus F4** (WEBklar fügt den Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet).
**Warnungen** (Reihenfolge): `ENTWURF_BANNER` genau dann, wenn `entwurf`; fehlende Pflichtangaben (Telefon/Anschrift, Gäste können nicht buchen); fehlende Impressum-/Datenschutz-URL; `gesundheitsWarnungen`; Kontrasthinweise aus `cssVorschlag`; Datenschutz-Absatz nicht erzeugbar (kein Name/keine E-Mail ⇒ statt Wurf Hinweistext im Abschnitt + Warnung). Ohne Einstellung (`einstellung === null`): Warnung „Keine Einstellungen gespeichert“ und nur der Abschnitt `snippet` (mit Modus aus dem Fragebogen, falls vorhanden).
- [ ] Tests (Fixture wie D3a): Abschnitt-IDs (alle 13) und `kopierbar`-Flags exakt wie oben; `text` enthält genau eine Datenschutz-Fassung (Sie bei `einstellung.anrede === 'sie'`, Du bei `'du'`), `abschnitte` beide; Fragebogen `{ einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: '…', bemerkung: '<script>alert(1)</script>' } }` ⇒ Snippet mit `data-modus="knopf" data-knopf="Termin sichern"`, die Wünsche stehen nur im Kundenwunsch-Block und das Skript-Tag erscheint nirgends in `snippet`/`css`; Texte über Grenze werden gekürzt. Warnungen: Banner genau bei `entwurf` (Standard und `entwurf: false` getrennt prüfen; der Standard hängt an `RECHTSTEXTE_ENTWURF`, F10b2 stellt die Standard-Assertion um), `betrieb.telefon: ''` ⇒ Pflichtangaben-Warnung, fehlende URL ⇒ Warnung, Leistung „Physiotherapie“ ⇒ Warnung mit „Physio“ (Liste gehört C). Checkliste: `gaesteSind: 'unternehmer'` ⇒ keine Widerrufspunkte; Verbraucher + Modus `knopf` ⇒ dauerhafter Widerrufsknopf, Verbraucher + `inline` ⇒ kein solcher Punkt; „Freischalten (Abnahme)“ und „stornieren“ immer. Abrechnung mit `invoiceUrl` ⇒ URL, „149 €“, „nicht auf Abo-Stunden“; ohne ⇒ „Rechnung folgt“; `unlockedByPurchase: false` ⇒ „keine Abrechnung“. `einstellung: null` ⇒ `abschnitte.map(a => a.id)` gleich `['snippet']` und Warnung „Keine Einstellungen gespeichert“. Fehlende E-Mail im Betrieb ⇒ kein Wurf, Abschnitt mit Hinweis.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)“
### Task D4: Kurzfassung für `ticket.details`, T16-Gesundheitsblock und Registry-Anbindung
**Files:** Modify `server/services/termine/einbauanleitung.js` (`kurz`, `ticketStand`, `driftWarnung`), `server/services/termine/kontext.js` (`ladeStand`), `server/services/termine/ticketDetails.js` (B8 hat den Stub angelegt: der Inhalt wird ersetzt; `server/config.js` bleibt unberührt, denn `features.termine.ticket.details` ruft die Datei schon lazy auf), `server/services/siteFeatures.js` (`teamNotice`: den von B9b deklarierten T16-Block `gesundheit` füllen), Create `test/termineTicket.test.js`; Tests `test/termineEinbauanleitung.test.js`, `test/termineKontext.test.js`, `test/featureMails.test.js` (T16-Fall).
**Consumes:** D3a/D3b; B8: `featureConfig('termine').ticket.details(ctx)` mit `ctx = { feature, doc, project, customer }` (`feature` = Register-Eintrag, `doc` = `portalFeatures`-Dokument, `customer` darf `null` sein; `now` setzt D4 im Rumpf), `TICKET_DETAILS_MAX`, `anleitungsLink`, `kuerze` aus `featureRegistry.js`; B9b (T16-Platzhalter `gesundheit_text`, Block `gesundheit`); C `gesundheitsWarnungen` und Attribut `termineConfig.fragebogen` (JSON-String, in `test/support/schema.js` über `schemaKeys('termineConfig')` enthalten). **Produces:** `kurz` (Klartext, ≤ `TICKET_DETAILS_MAX` aus B8; die Konstante wird hier nicht neu definiert), `ladeStand(featureId) ⇒ Promise<{ einstellung|null, version, updatedAt, fragebogen|null } | null>` (`null` nur ohne `termineConfig`-Dokument; unlesbares JSON ⇒ jeweiliges Feld `null`), `ticketStand(details) ⇒ number | null` (liest `^Konfigurationsstand: Version (\d{1,9})`), `driftWarnung(ticketVersion, jetzt) ⇒ string | null` („Einstellungen seit der Bestellung geändert (Ticket Version N, jetzt M).“ nur bei `jetzt > N`), `termineTicketDetails(ctx) ⇒ Promise<string>` (wirft **nie**: bei Lesefehlern Kopfzeilen + „Anleitung konnte nicht erzeugt werden, im WOMS öffnen“ + Link; setzt `now = new Date()` im Rumpf und ruft `einbauanleitung({ feature: ctx.doc, project: ctx.project, … })` auf – in D3 ist `feature` das `portalFeatures`-Dokument, den Registereintrag `ctx.feature` braucht der Generator nicht; `siteKey` und `$id` kommen aus `ctx.doc`). **T16-Block `gesundheit`:** `teamNotice` füllt `gesundheit_text` aus `gesundheitsWarnungen((await ladeEinstellung(doc.$id))?.einstellung)` (zusammengefügt, höchstens 500 Zeichen) und schaltet den Block ein, wenn der Text nicht leer ist; ein Lesefehler schaltet den Block aus, T16 geht trotzdem raus (nie Geld ohne Spur). Aufbau von `kurz` (Absätze durch Leerzeile, **erster Absatz = Kopf**): Kopf (`Funktion: Terminplaner`, Projekt, Domain = `liveDomain || previewUrl || subdomain` wie `createFeatureTicket`, Kunde, `siteKey`, `Konfigurationsstand: Version N, TT.MM.JJJJ` bzw. `Version 0 (noch keine Einstellungen gespeichert)`); Kundenwunsch-Block (Modus/Stelle/Gestaltung/Bemerkung); Zählungen mit ersten 8 Namen („12 Leistungen (11 aktiv): A, B, … und 4 weitere“, Ressourcen, Zusatzfelder); Warnungen (höchstens 5, ohne Banner-Text-Doppel); die Gesundheitswarnungen stehen in einem eigenen Absatz „Gesundheitsbereich prüfen“ und zählen nicht zu den 5; Snippet; 6 Schritte (WOMS-Panel öffnen, Code einfügen, Gestaltung/Kontrast, Datenschutz-Absatz, „Freischalten (Abnahme)“ + Probebuchung + Stornierung, Ticket erledigen und Zeit buchen, „Nicht auf das Stundenkontingent anrechnen“); Verweis `anleitungsLink`. Ohne Datenschutz-Absatz, ohne Tokens. Passt die Summe nicht, fallen erst Zählungen, dann die übrigen Warnungen, dann der Kundenwunsch-Block weg (Kopf, Snippet, Schritte, Verweis **und der Gesundheitsabsatz** bleiben immer; eine Gesundheitswarnung verschwindet nie unter Platzdruck).
- [ ] Tests: `kurz.length <= 7000` bei Maximalbefüllung (50 Leistungen mit 100 Zeichen, 20 Ressourcen, 5 Zusatzfelder, Wünsche 300/1000/1000, 5 Warnungen und 10 Gesundheitswarnungen); enthält Kopf, Snippet mit `siteKey`, Verweis, „Nicht auf das Stundenkontingent anrechnen“ und **nicht** „Online-Terminbuchung“ (Datenschutz-Absatz) oder einen `feedToken`; erster Absatz endet vor der ersten Leerzeile mit der `Konfigurationsstand`-Zeile; `ticketStand('…Konfigurationsstand: Version 7, 03.10.2026…') === 7`, ohne die Zeile oder mit `Version 99999999999` ⇒ `null`; `driftWarnung(7, 9)` Text wie oben, `driftWarnung(9, 9)`, `(9, 7)`, `(null, 9)` ⇒ `null`. `ladeStand`: ohne Dokument `null`, mit Dokument alle Felder, kaputtes `fragebogen`-JSON ⇒ `fragebogen: null` bei gültiger Einstellung. `termineTicketDetails`: mit Dokument ⇒ enthält `Konfigurationsstand: Version 1`; ohne `termineConfig` ⇒ String mit „noch keine Einstellungen“ und Snippet; `aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })` ⇒ löst auf (kein Wurf) mit Kopf und Link. Registry: `featureConfig('termine').ticket.details(ctx)` liefert denselben Text wie `termineTicketDetails`, mit `ctx` genau in der Form, die `markPaid` übergibt (`{ feature, doc, project, customer }`; `siteKey` aus `ctx.doc`, nicht aus `ctx.feature`). **Gesundheit:** Leistung „Physiotherapie“ + Maximalbefüllung ⇒ `kurz` enthält „Physio“ und bleibt ≤ 7000; T16 (`teamNotice`) für ein Termine-Dokument mit dieser Leistung enthält den Warnblock, ohne Treffer kein Block, `ladeEinstellung` scheitert (`failNext`) ⇒ T16 geht ohne Block raus. **Integration (Review Focus 1):** `markOrdered` + `markPaid` für `termine` ⇒ `workorders`-Dokument mit `details` ≤ 7000, enthält `data-webklar-termine="<siteKey>"`; derselbe Ablauf mit kaputtem `termineConfig`-Lesen (failNext) legt das Ticket trotzdem an und liefert `created: true` (Zahlung nie ohne Ticket oder T16).
- [ ] FAIL → implementieren (Budgetliste mit Prioritäten statt Abschneiden mitten im Satz) → PASS → `npm test` → Commit „Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock“
### Task D5: entfällt (vollständig in B8 aufgegangen)
Die Härtung von `createFeatureTicket` (Titel auf 128 Zeichen, Details auf 7000 mit einheitlicher Schlusszeile, surrogatsicheres Kürzen, **gezielte** Wiederholung nur bei `status 400` und `details.length > 1000` mit den Minimal-Details, kein Doppel-Ticket bei 5xx) und ihre Tests liegen in **Task B8**; `webhook_retry_setzt_ticketlose_einrichtung_fort` (`funktionenRoutes.test.js:321-345`) bleibt dort grün. Die Konstanten `TICKET_TOPIC_MAX`, `TICKET_DETAILS_MAX`, `LANGE_DETAILS` und `kuerze` stehen in `featureRegistry.js` (B8); D4 und D3 nutzen sie. Block D ändert `ticketAdmin.js` nicht.
### Task D6: Freischalt-Hindernisse und Freigabe-Mail (aus B11 herausgelöst)
**Files:** Create `server/services/termine/freischaltung.js`; Modify `server/services/siteFeatures.js` (`runActivation` Z. 578–593: den K16-Versand in die neue Funktion `meldeFreigeschaltet` herauslösen), `server/services/featureRegistry.js` (der Standardhaken `freischaltbar` für `termine` delegiert an `freischaltHindernis`); Test neu `test/termineFreischaltung.test.js`. **`activateClosedTickets`, der Hook `freischaltbar`, die Behandlung einer blockierten Freischaltung (ein `adminNote`-Eintrag, kein Mailversand) und die Anpassung des Tests `siteFeatures.test.js:235` gehören Block B (B11)**; D6 ändert weder `config.js` noch `activateClosedTickets`.
**Consumes:** B11 (`activateClosedTickets`, `featureHaken`, Standardhaken mit der Prüfung „Einstellung vorhanden **und** `laeuft`“), B9a (K16 mit `{{funktion}}`/`{{portal_url}}`/Knopftext aus dem Register), E1 `aboMemo()`/`laeuft`, `ladeEinstellung(featureId)` (C3). **Produces:** `freischaltHindernis(doc, { abo = aboMemo() } = {}) ⇒ Promise<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'>` (Reihenfolge: Abo zuerst, dann Einstellung vorhanden; `laeuft` wirft ⇒ `'abo_unbekannt'`, also nie freischalten bei Lesefehler); der B11-Standardhaken wird `freischaltbar(doc) = (await freischaltHindernis(doc)) === null` (Verhalten und Tests von B11 bleiben unverändert); `meldeFreigeschaltet(doc, { attachments = [] } = {})` (K16 an den Kunden, Fehler geloggt und nie geworfen; `attachments` nimmt die PDFs der Staff-Aktivierung aus F13a auf); `runActivation` ruft sie für jedes aktivierte Dokument.
- [ ] Tests (Review Focus 2): `freischaltHindernis`-Tabelle: Wartungs-Abo da + Einstellung da ⇒ `null`; kein Abo ⇒ `'kein_abo'` (auch ohne Einstellung: Abo zuerst); Abo da, keine Einstellung ⇒ `'keine_einstellung'`; Abo-Lesefehler (`failNext` auf `subscriptions`) ⇒ `'abo_unbekannt'`; Abo `past_due` zählt als laufend (`null`); Wechsel-Widerruf (E1) ⇒ `'kein_abo'`. `meldeFreigeschaltet`: K16 mit „Terminplaner“ im Betreff und Link `#termine`; mit `attachments` steht das PDF in der gesendeten Mail (`mailSink`); Versandfehler ⇒ kein Wurf, `console.error`. Delegation: **die Tests von B11 laufen unverändert grün**; `runActivation` sendet für ein aktiviertes Termine-Dokument genau ein K16.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert“
### Task D7: Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“
**Files:** Modify `server/services/siteFeatures.js` (neu `freischaltenNachAbnahme`, `ticketNachlegen`), `server/routes/admin/funktionenAdmin.js` (POST `/:customerId/:key`, `:73-103`: erlaubte `action` ⇒ `activate | disable | abnahme | ticket`; bei `abnahme`/`ticket` zusätzlich `featureConfig(key)?.ticketFreischaltung` sonst 409; `adminNote` wie bisher über `composeAdminNote`), Test `test/funktionenTermine.test.js`.
**Consumes:** D6 (`freischaltHindernis`, `meldeFreigeschaltet`), B11 (`activateClosedTickets` bleibt unberührt), B (Ticketanlage mit Register-Text in `markPaid`: dort `createFeatureTicket({ …, topic, details })` in eine interne Funktion `legeTicketAn(doc, project, customer)` herauslösen, falls B das nicht schon getan hat), `handleFeatureCheckoutEvent(event)` und `retrieveCheckoutSession(id)`. **Produces:** `freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now }) ⇒ { doc, activated: boolean }`: nur aus `setup` (bereits `active` ⇒ no-op mit `activated: false`, Antwort 200; `off`/`ordered`/`disabled` ⇒ 409 „Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.“; ohne Dokument 409 „Für dieses Projekt gibt es noch {keine}“), Hindernis ⇒ 409 mit Klartext (`kein_abo`: „Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.“, `keine_einstellung`: „Es sind noch keine Einstellungen gespeichert.“, `abo_unbekannt` ⇒ 503 „Das Abo konnte gerade nicht geprüft werden.“); Schreiben unter `feature-activate:<id>` mit erneutem Lesen und Hook; setzt nur `active`, `activatedAt`, `adminNote` (**Ticket und `unlockedByPurchase` bleiben**, das Ticket wird nicht geschlossen), danach `meldeFreigeschaltet` (K16). `ticketNachlegen({ customerId, projectId, key, by, adminNote }) ⇒ { doc, created: boolean }`: nur in `setup`; (a) `ticketId` leer ⇒ Zahlung bei Stripe prüfen (`retrieveCheckoutSession(doc.stripeSessionId)`, `payment_status === 'paid'` sonst 409 „Die Zahlung ist noch nicht eingegangen.“, Stripe nicht erreichbar ⇒ 503) und **denselben Weg wie der Webhook** laufen lassen (`handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })`), damit Ticket, K15 und T16 genau einmal entstehen und ein späterer Stripe-Retry nichts doppelt sendet; (b) `ticketId` gesetzt, Ticket aber gelöscht ⇒ neues Ticket über `legeTicketAn`, `ticketId` ersetzen, **keine** Mails (K15/T16 gingen beim ersten Mal raus); (c) Ticket vorhanden ⇒ no-op `created: false`. Keine Sperre `feature-paid:<session>` um (a) halten (`withLocks` ist nicht wiederbetretbar, `markPaid` nimmt sie selbst); (b) läuft unter dieser Sperre.
- [ ] Tests: `abnahme` auf `setup` mit Einstellung und Abo ⇒ 200, `termine.state === 'active'`, `activatedAt` gesetzt, `ticketId` und Ticket-Status `Open` unverändert, `unlockedByPurchase === true`, `adminNote` passt auf `/Andrej: abnahme/`, K16 genau einmal; zweiter Klick ⇒ 200 ohne zweite Mail; zwei parallele Klicks ⇒ eine Mail; gleichzeitig `runActivation` mit geschlossenem Ticket ⇒ eine Mail insgesamt; `off`/`ordered`/`disabled` ⇒ 409; kein Abo ⇒ 409 und `setup` bleibt; keine Einstellung ⇒ 409; Abo-Lesefehler ⇒ 503; Projekt eines anderen Kunden ⇒ 403; Kundensitzung statt JWT ⇒ 401/403; `news` + `abnahme` funktioniert ohne Abo-Prüfung (Hook fehlt), `analyse` ⇒ 404 wie bisher. `ticket`: (a) `setup` ohne `ticketId`, Sitzung in `st.checkoutSessions` bezahlt ⇒ Ticket mit Kurzfassung (D4), `ticketId` gesetzt, K15 und T16 je einmal, zweiter Klick ⇒ `created: false`, keine weiteren Mails, ein folgender Webhook mit derselben Sitzung ⇒ ebenfalls nichts Neues (Review Focus 1: nie doppelt); unbezahlte Sitzung ⇒ 409; Stripe-Ausfall ⇒ 503; (b) gelöschtes Ticket ⇒ neues Ticket, neue `ticketId`, keine Mail; (c) vorhandenes ⇒ no-op; `ordered`/`active` ⇒ 409; zwei parallele Klicks ⇒ ein Ticket. Bisherige Tests dieser Datei (`Pruefungen wie bei News`: `action: 'loeschen'` ⇒ 400) bleiben grün.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)“
### Task D8: Team-Route Anleitung
**Files:** Modify `server/routes/admin/funktionenAdmin.js` (neu `GET /:customerId/termine/anleitung`; `adminView` `:50-62` ergänzt `hindernis` über `freischaltHindernis` für `termine`-Dokumente in `setup`, ohne `portalFeatures` erneut zu lesen), `docs/funktionen/termine-einbau.md` (Einleitung um drei Sätze: Anleitung je Kunde im WOMS-Panel, Kurzfassung im Ticket; alle von `termineEmbedStatic.test.js:212-228` gepinnten Strings bleiben), Test `test/funktionenTermine.test.js`.
**Consumes:** D3a/D3b, D4 (`ladeStand`, `ticketStand`, `driftWarnung`), D6 (`freischaltHindernis`, `aboMemo` je Anfrage), `assertOwnProject`, `getFeatureDoc`, B-Meldungstext „Für dieses Projekt gibt es noch keinen Terminplaner“ (`FEATURE_LABELS.termine.keine`). **Produces:** `GET /api/team/funktionen/:customerId/termine/anleitung?projectId=` ⇒ `{ stand: { version, updatedAt }, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis }` (ohne `kurz`; `hindernis` nur in `setup`, sonst `null`). Hinter `requireStaffJwt` (bestehender Mount), **kein** `TERMINE_ENABLED`-Gate, **kein** `customerViewLog`, kein Pfad unter `/api/funktionen` (Kundenrouter). 400 ohne `projectId`, 403 fremdes Projekt (`assertOwnProject`), 409 ohne Funktionsdokument (Text wie die bestehende Meldung), sonst 200 auch in `off`/`ordered`/`setup`/`active`/`disabled`; fehlt die Einstellung ⇒ 200 mit Warnung und nur Snippet. Drift: Ticket über `feature.ticketId` lesen (404 ⇒ keine Warnung, kein Fehler), `ticketStand(details)` gegen `stand.version`, Warnung am Ende von `warnungen`. Rechnung und Preis stammen live aus dem Dokument (`backfillInvoiceLinks` ergänzt den Link später, die Anleitung zeigt ihn dann auch bei altem Ticket).
- [ ] Tests: ohne JWT 401, mit Kundencookie 401/403, `GET /api/funktionen/termine/anleitung` (Kundenrouter) 404; fehlendes `projectId` 400; Projekt von c2 für c1 ⇒ 403; Projekt ohne Dokument ⇒ 409; `config.termine.mode = 'off'` ⇒ trotzdem 200; `customerViewLog` unverändert; Antwort-JSON enthält keinen der seeded `feedToken` und kein `kurz`; mit Einstellung ⇒ `abschnitte.map(a => a.id)` wie D3b, `stand.version === 1`; ohne Einstellung ⇒ nur `snippet` und die Warnung; Ticket-Details „Konfigurationsstand: Version 1“, Einstellung inzwischen Version 3 ⇒ Warnung „Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).“; Ticket gelöscht oder ohne `ticketId` ⇒ keine Warnung; `setup` ohne Abo ⇒ `hindernis: 'kein_abo'`, mit Abo `null`, `active` ⇒ `null`; `GET /api/team/funktionen/:customerId` liefert `hindernis` je `setup`-Dokument und liest `portalFeatures` weiterhin einmal (Bestandstest „liest die Funktionsdokumente des Kunden einmal“ grün); Rechnungs-Link erscheint im Abschnitt `abrechnung`, sobald `invoiceUrl` im Dokument steht.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Team-Route fuer die Terminplaner-Einbauanleitung“
### Task D9: WOMS – Ansicht-Logik und API-Client
**Files:** Modify `src/lib/funktionenView.js`, `src/lib/teamApi.js`, `tests/unit/funktionenView.test.mjs` (Worktree `/home/webklar/apps/ticket.webklar.com/dev-termine`, Branch `feature/terminplaner-tp2`).
**Consumes:** D7/D8 (Routen und `action`-Werte `abnahme`, `ticket`; Feld `hindernis` in der Funktionsliste). **Produces:** `aktionen(state, key, { ticketMissing = false } = {})`: News unverändert (`off` `['activate']`, `ordered`/`setup` `['activate','disable']`, `active` `['disable']`, `disabled` `['activate']`; ohne `key` wie News), Termine `off` `['activate']`, `ordered` `['disable']` (kein „Aktivieren (ohne Zahlung)“, sonst verschluckt das Portal eine spätere Zahlung), `setup` `['abnahme','disable']` bzw. mit `ticketMissing` `['ticket','abnahme','disable']`, `active` `['disable']`, `disabled` `['activate']`; `FUNKTIONEN[termine].bestaetigen` um `abnahme` („Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.“) und `ticket` („Fehlendes Einrichtungs-Ticket neu anlegen?“) erweitert; `AKTION_LABEL = { activate: 'Aktivieren (ohne Zahlung)', disable: 'Abschalten', abnahme: 'Freischalten (Abnahme)', ticket: 'Ticket nachlegen' }`; `hindernisText(code)` (`kein_abo` „Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.“, `keine_einstellung` „Noch keine Einstellungen gespeichert.“, `abo_unbekannt` „Abo gerade nicht prüfbar.“, sonst `''`); `anleitungKopien(antwort) ⇒ [{ id, label, text }]` (zuerst `alles` mit `antwort.text`, dann alle `kopierbar`-Abschnitte; feste Beschriftungen für `snippet` „Einbau-Code kopieren“, `css` „CSS-Variablen kopieren“, `datenschutz_sie` „Datenschutz-Absatz (Sie) kopieren“, `datenschutz_du` „… (Du) kopieren“, unbekannte kopierbare ⇒ „<Titel> kopieren“; `null` oder ohne Text ⇒ `[]`); `anleitungPfad(customerId, projectId)` ⇒ `/funktionen/<id>/termine/anleitung?projectId=<id>` (kodiert); `teamApi.getTermineAnleitung(customerId, projectId) = request(anleitungPfad(…))`; **`panelModell(antwort, feature) ⇒ { kopf, ticketPfad, warnungen, hinweis, kopien, abschnitte }`** (rein, die gesamte Entscheidungslogik des Panels aus D10): `kopf` = `Stand Version N, TT.MM.JJJJ` (aus `antwort.stand`, ohne Antwort `''`), `ticketPfad` = `/tickets?woid=<ticketWoid>` (ohne `antwort.ticketWoid` `null`), `warnungen` = `antwort.warnungen` (sonst `[]`), `hinweis` = `hindernisText(feature.hindernis)`, `kopien` = `anleitungKopien(antwort)`, `abschnitte` = `[{ id, titel, text }]` in Antwortreihenfolge; `null`-Antwort ⇒ alles leer; **`anleitungOffen(search, projectId) ⇒ boolean`** (`true`, wenn die Adresse `?anleitung=<projectId>` trägt, kodiert oder nicht, sonst `false`).
- [ ] Tests (WOMS `npm test`): `FUNKTIONEN`-Test an die neuen Texte angepasst; `aktionen` für alle Zustände × {`news`, `termine`} inkl. `ticketMissing`, ohne `key` und mit `undefined`-Zustand wie bisher (`funktionenView.test.mjs:48-55` anpassen, News-Zeilen unverändert); `AKTION_LABEL` exakt; `hindernisText` für alle Codes und `'x'`; `anleitungKopien` mit einer Beispielantwort (Reihenfolge, Beschriftungen, nicht kopierbare Abschnitte fehlen, `null` ⇒ `[]`); `anleitungPfad` kodiert `projectId` mit Sonderzeichen; `panelModell`: Beispielantwort ⇒ alle sechs Felder (Kopf `Stand Version 3, 03.10.2026`, Ticketpfad, Warnliste, `hinweis` bei `hindernis: 'kein_abo'`, Abschnitte in Reihenfolge, Kopien), `null` ⇒ leeres Modell, Antwort ohne `ticketWoid` ⇒ `ticketPfad: null`; `anleitungOffen('?tab=funktionen&anleitung=p%2F1', 'p/1') === true`, anderes Projekt, leere und fehlende Suche ⇒ `false`; `mergeFunktion`/`siteKeyOf`-Tests unverändert grün.
- [ ] FAIL → implementieren (`teamApi.js` bleibt dünn, weil es `appwrite` lädt und nicht unter Node testbar ist) → PASS → `npm test` → Commit „WOMS: Aktionen und Anleitungs-Logik fuer den Terminplaner“
### Task D10: WOMS – Anleitungs-Panel und Aktionsknöpfe
**Files:** Modify `src/components/customer/CustomerFunktionen.jsx` (Zeilen `:80-124`), `src/styles/admin-areas.css` (`.funktionen-panel*`).
**Consumes:** D9 (`aktionen`, `AKTION_LABEL`, `hindernisText`, `anleitungKopien`, `panelModell`, `anleitungOffen`, `getTermineAnleitung`), bestehend `copyText` (`CopyableCredential`), `Card/Badge/Button`. **Produces:** je Termine-Zeile mit Funktionsdokument (Zustand ≠ `off`) ein Knopf „Anleitung“, der ein Panel unter der Zeile aufklappt (Zeilen werden `Fragment` mit `key`, das Panel ist eine eigene Zeile außerhalb der Gitterspalten); Panel (alle Inhalte und Entscheidungen kommen aus `panelModell` und `anleitungOffen` in D9, die Komponente verdrahtet nur): Kopfzeile `modell.kopf` + Ticketlink `modell.ticketPfad`, `modell.warnungen` als Liste (`area-error`), `modell.hinweis` als Hinweis, Knopfreihe aus `modell.kopien` (Kopieren mit „Kopiert“-Rückmeldung wie `SiteKey`), darunter die Abschnitte als aufklappbare Blöcke (`<details>`, Text in `<pre>` mit `pre-wrap`, ausschließlich React-Textknoten, kein `dangerouslySetInnerHTML`, nie als Link oder HTML); Lade-/Fehlerzustand mit „Erneut laden“; nach jeder Aktion (`save`) für dieselbe Zeile neu laden, weil sich Version und Hindernis ändern; Panel öffnet automatisch, wenn `anleitungOffen(window.location.search, projectId)` wahr ist (T16 und Ticket verlinken so); `aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing })` und Beschriftung aus `AKTION_LABEL`; in `setup` neben dem Status `hindernisText(feature.hindernis)`.
- [ ] Prüfung: `npm test` grün (die Panel-Logik ist in D9 getestet: `panelModell`, `anleitungOffen`, `anleitungKopien`, `aktionen`, `hindernisText`; `CustomerFunktionen.jsx` enthält keine eigene Entscheidungslogik); einmal `nice npx vite build` (vorher `free -m`) ohne Fehler; keine neuen Abhängigkeiten. Sichtprüfung des Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) gehört zum Livegang-Test (Plan-Kopf Schritt 8), nicht in `npm test`.
- [ ] Implementieren → Build → Commit „WOMS: Einbauanleitung als Panel mit Kopierknoepfen, Abnahme-Knopf und Ticket nachlegen“
**Bewusst nicht in Block D (Spec 7.3, letzter Punkt):** Die Textform-Pflicht für `adminActivate` bei Termine ab Modus `on` (außer Pilot-Adressen, wie `activateAnalyse` mit `validateTextform` und Zielgruppen-Haken) ist keine kleine Ergänzung: sie braucht Eingabefelder (Datum, Kanal, Haken) in der Bestätigungszeile des WOMS, Modusweichen und eigene Tests. Der Einhängepunkt ist vorbereitet: Zweig `action === 'activate'` in `funktionenAdmin.js` (POST `/:customerId/:key`) und die Bestätigungszeile in `CustomerFunktionen.jsx` (`isConfirming`). Block F (oder ein eigener Task nach D10) setzt dort an; „Freischalten (Abnahme)“ ist davon nicht betroffen, weil die Zahlung samt Zustimmungsnachweis (`featureConsents`) vorliegt.
**Abschlussprüfung Block D (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (D9, D10): `npm test` im WOMS-Worktree und einmal `nice npx vite build` (vorher `free -m`); Sichtprüfung des Panels gehört zum Livegang-Test (Block G).
---
## Block F: Rechtstexte, Kanzlei-Paket, News-Korrektur
Block F liefert den Textsatz, ohne den `TERMINE_ENABLED=on` nicht zu verantworten ist: Leistungsbeschreibung (LB), AVV und Widerrufsbelehrung des Terminplaners (Markdown → HTML → PDF), Datenschutz 6c samt §6/§7, endgültige Gäste- und Bestelltexte, die Korrektur der News-Texte, den Widerruf je Funktion, die Textform-Sperre für die Staff-Aktivierung, die WOMS-Karten und das Kanzlei-Paket. Alle Texte sind „Endfassung zur Kanzlei-/Hausprüfung“, keine Rechtsberatung; jede Aussage, die die Kanzlei bestätigen muss, steht im Kanzlei-Paket (F15a, F15b) als `⚖ Q<n>` (in den veröffentlichten Dateien stehen keine Marker).
Fassungsdatum `<F>`: siehe Kopf, Abschnitt „Grundlagen der Blöcke“ (Berliner Kalendertag des F1-Commits, die drei Stände, was beim Anheben neu entsteht). Dateien einer ausgelieferten Fassung werden nie überschrieben: Änderung nach der Kanzlei = neues Datum + neue Dateien. Review-Fokus 5 (Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt, gespeichert vor der Umleitung auf Stripe; Widerrufs-Anker = Klick) gilt für F7–F9; „nie Geld ohne Spur“ (Fokus 1) für den Ausfall der K15-Anhänge in F8. Reihenfolge: F1, F2 · F3–F6 (Dokumente) · F7–F9 (Verdrahtung, nach B) · F10a, F10b1, F10b2, F11, F12a, F12b · F13a (nach B/D), F13b · F14 · F15a, F15b zuletzt · Block G (Livegang-Checkliste, Controller) am Ende dieser Datei.
**Befunde beim Lesen des Codes (ergänzen oder korrigieren die Spec):**
- „inklusive Mehrwertsteuer“ steht nur in `public/funktionen.js:272`; die News-LB sagt „inklusive Umsatzsteuer“ (`funktionen-leistungsbeschreibung.html:49,60`), K15 „(brutto, einmalig; … kein Abo)“, `stripeBilling.js:642` und `funktionen-news-design.md:78` „brutto“. Alle fallen unter die Korrektur (F2). „§ 356 Abs. 4“ steht in `config.js:192,196`, LB `:51,73`, `funktionen-news-design.md:73`, Test `legalTexts.test.js:485`.
- Die Analyse-Rechtstexte liegen in `public/rechtliches/` (nicht unter `vertragsunterlagen/`); ihr HTML ist von Hand gebaut, eine Markdown-Bibliothek gibt es nicht (F6 baut einen kleinen Generator). `analyseLegal.js` kennt nur die Analyse; `build-legal-pdfs.sh` nur `all|analyse`.
- `ablauf.js:231` (`stornoNoetig`) hängt nur am Stornotext, nicht am Preis der Leistung; im Widget zeigen `embed/termine.js:1215-1219,1259,1279` Stornobox, Haken und Prüfung ebenso preisunabhängig.
- `paidFeatureOrders` liefert seit **B4** `featureKey` und `vertragAt` (Zeit der Zustimmung zur bezahlten Sitzung); `withdrawFeature` (`abo.js:1088`) nimmt aber noch das Maximum über alle Funktionen (F9 filtert je Funktion); `ORDERS`/`orderLabel` (`abo.js:707,782`) kennen nur `funktion`.
- Das Wort ENTWURF ist gepinnt in `termineRechtstexte.test.js:226` (Kopf der vier Dokumente), `termineEmbedStatic.test.js:219` (+ `docs/funktionen/termine-einbau.md:127`) und im Datenschutz-Wächter `termineRechtstexte.test.js:267`.
- WOMS `scripts/setup-legal.mjs` `DOCUMENTS` (Z. 89-96) hat sechs Karten; die Analyse-Karten (README nennt „datenschutz-website-analyse“) stehen nicht im Skript. Nicht Teil dieses Auftrags, dem Inhaber melden.
- Der gesetzliche Wertersatz (§ 357a Abs. 2 BGB, Anlage 1: Anteil am Gesamtpreis) ist nicht D3 „nach Stundenaufwand“; die Belehrung trägt den Gesetzeswortlaut, der Aufwand ist nur das Maß für den Anteil (⚖ Q5).
- `adminActivate` (`siteFeatures.js:350`) prüft weder Einwilligung noch AVV; `validateTextform` (`analyse.js:117`) ist exportiert und nimmt `minDate`.
---
### Task F1: Fassungskonstante und Dokument-Registry
**Files:** Create `server/services/legalDocs.js`, `test/legalDocs.test.js`; Modify `server/config.js` (`termine.avvVersion`; `features.termine.leistungsbeschreibung` und `.avv` als Getter `{ version: config.termine.avvVersion, url: '/rechtliches/leistungsbeschreibung-terminplaner-<version>.html' | '/rechtliches/avv-terminplaner-<version>.html' }`, sie ersetzen die ENTWURF-Platzhalter aus B1; `config.js` darf `legalDocs.js` nicht importieren, die Adresse wird dort aus Slug und Version gebaut, ein Test gleicht sie mit `legalPaths` ab; `ustHinweis` gibt es seit B1), `server/services/analyseLegal.js` (dünner Wrapper), `server/index.js` (`:41,114`: `app.use('/rechtliches', legalRouter())`).
**Consumes:** B1 (`features.termine`, `config.ustHinweis`). Der letzte Test nutzt B `featureConfig` aus `server/services/featureRegistry.js`.
**Produces:** `config.termine.avvVersion` (= `<F>`, der echte Berliner Kalendertag des F1-Commits laut Kopf, kein Platzhalter und nicht vor `2026-10-03`) und die Getter `features.termine.leistungsbeschreibung`/`.avv` (Version und Adresse folgen der Konstante auch zur Laufzeit). In `legalDocs.js`: Sätze `analyse` (avv, lb) und `termine` (lb, avv, widerruf; Slugs `leistungsbeschreibung-terminplaner`, `avv-terminplaner`, `widerrufsbelehrung-funktionen`; PDF-Namen `Leistungsbeschreibung-Terminplaner-<v>.pdf`, `AVV-Terminplaner-<v>.pdf`, `Widerrufsbelehrung-Muster-Widerrufsformular-<v>.pdf`); `legalPaths(satz, version) ⇒ { [doc]: '/rechtliches/<slug>-<v>.html' }`; `legalPdfFiles(satz, version) ⇒ { [doc]: { filename, path } }`; `legalPdfAttachments(satz, version, { ohne = [] } = {}) ⇒ Promise<Array<{ filename, content, contentType }>>` (Reihenfolge termine: lb, avv, widerruf; analyse: avv, lb; wirft bei fehlender Datei); `featureLegalAttachments(featureKey, opts) ⇒ Promise<Array>` (`termine` ⇒ Satz `termine` in aktueller Fassung, `news` ⇒ `[]`); `featureLegalReady(featureKey) ⇒ Promise<boolean>`; `legalRouter()` (Adressen ohne Fassung für beide Sätze, liefert die Fassung aus `config`; **nur** für Menschen und WOMS-Karten, keine Mail und kein Zustimmungstext darf sie verlinken).
`analyseLegal.js` behält `legalPaths(version) ⇒ { avv, lb }`, `legalPdfFiles`, `legalPdfAttachments`, `analyseLegalRouter` (rufen die Registry mit `'analyse'`), damit `analyseLegal.test.js` und `analyseRoutes.test.js:115` ohne Änderung grün bleiben. Versionsprüfung bleibt `^\d{4}-\d{2}-\d{2}$`.
- [ ] Tests (`legalDocs.test.js`): `legalPaths('termine', V)` nennt drei versionierte Adressen; `'../x'` und unbekannter Satz werfen; `legalPdfFiles` Dateinamen; `legalPdfAttachments('termine','1999-01-01')` rejected; `featureLegalAttachments('news')` = `[]`, `featureLegalReady('news')` = true; `config.termine.avvVersion` passt auf das Datumsmuster und liegt nach `'2026-10-02'` (der Altfassung der Gästetexte), `config.ustHinweis` wörtlich (aus B1, nur gelesen); Analyse-Tests unverändert grün; (nach B) `featureConfig('termine').leistungsbeschreibung` und `.avv` = `{ version: config.termine.avvVersion, url: legalPaths('termine', version).lb | .avv }` (Drift-Wächter gegen die in `config.js` gebaute Adresse; ändert ein Test `config.termine.avvVersion`, folgt der Getter); `featureLegalAttachments('news')` ist `[]` (die News-Rechtsdokumente `leistungsbeschreibung`/Fassung 2026-09-30 aus B1 bleiben unberührt und gehören F2).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Rechtstexte: Dokument-Registry und Fassungskonstante des Terminplaners“
### Task F2: News-Korrektur (Zitat, Wertersatz, Umsatzsteuer-Wortlaut)
**Files:** Modify `server/config.js` (`features.news.consent`, Alias `funktionen.consentText`, Kommentar `:192`), `public/vertragsunterlagen/funktionen-leistungsbeschreibung.html` (`:49,51,60,73`, „Stand“), `public/funktionen.js:272` (bzw. die Stelle, an die B den Preis verschoben hat), `server/services/stripeBilling.js:642` (Kommentar), `docs/superpowers/specs/2026-09-30-funktionen-news-design.md` (`:73,:78`, Korrekturvermerk); `features.news.mails.preisHinweis` steht in `config.js` (die K15-Vorlage ändert F2 **nicht**, B9a hat sie schon auf `{{preis_hinweis}}` umgestellt; `ustHinweis` im GET liefert B12). Tests `test/legalTexts.test.js:485`, `test/funktionenRoutes.test.js` (~153), neu `test/funktionenRecht.test.js`; **angepasste B-Pins:** `test/featureMails.test.js` (B9a: der eingefrorene News-Wortlaut „(brutto, einmalig; …)“ und die K15-Preiszeile bekommen den neuen Satz ohne „brutto“), `test/featureRegistry.test.js` (B1: der Wortlaut-Pin des News-Satzes und der Fassung; die übrigen Pins lesen `config.features.news`), `test/funktionenCheckout.test.js`/`test/funktionenRoutes.test.js` (B7: News-Nachweis liest Text und Fassung aus `config`).
**Consumes:** B-Register (`featureConfig('news').consent`), F1 `config.ustHinweis`.
**Produces:** News-Zustimmung (ein Haken, id `beginn`, `consent.version` = `<F>`): „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).“ (⚖ Q3; Aufbau wie der geprüfte Gästetext `ZUSTIMMUNG_VORZEITIG`). LB: Kopf-Tabelle und Abschnitt 2 „30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG)“, Abschnitt 6 „… erlischt nach § 356 Abs. 5 Nr. 2 BGB …“ plus Satz zum Wertersatz mit „(§ 357a Abs. 2 BGB)“; die LB bleibt „ENTWURF, Freigabe Nutzer ausstehend“ (Freigabe ist Sache des Inhabers). Bestellseite: „30 € · ohne Umsatzsteuer gemäß § 19 UStG“ aus `ustHinweis` (B12); K15: nur der Registerwert `features.news.mails.preisHinweis` verliert „brutto“ („einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo“). Bestehende News-Bestellungen behalten Text und Fassung (keine Migration).
- [ ] Tests (`funktionenRecht.test.js`): `news_zustimmung_wortlaut` (Register = Alias = exakter Text, enthält `Abs. 5 Nr. 2` und `§ 357a Abs. 2`, nicht `Abs. 4`); `waechter_zitat_und_preistext` (über config.js, News-LB, funktionen.js bzw. Bestellseite, K15, stripeBilling.js kein Treffer auf `/§ 356 Abs\. 4/`, `/inklusive (Mehr|Umsatz)steuer/i`, `/\bbrutto\b/i`; LB nennt `ohne Umsatzsteuer gemäß § 19 UStG` zweimal); `bestellseite_hinweis` (`GET /api/funktionen` ⇒ `ustHinweis === config.ustHinweis` (B12), Client setzt ihn per `textContent`); `k15_news` (gerenderter Text: `30,00 €`, Hinweis, kein „brutto“); `alte_bestellung_unveraendert` (Dokument mit altem `consentText` bleibt beim Lesen unverändert, neue Bestellung speichert Register-Text und Fassung). `legalTexts.test.js:485` prüft `['30,00 €','§ 356 Abs. 5 Nr. 2 BGB','§ 357a Abs. 2 BGB','1–2 Wochen','keine laufenden Kosten']`; der ENTWURF-Pin der News-LB (`:481`) bleibt.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „News: Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz und § 19 UStG statt brutto“
### Task F3: Widerrufsbelehrung der Funktionen (Text aus Code, Dokument gepinnt)
**Files:** Modify `server/services/termine/rechtstexte.js` (die Absatz-Bausteine nehmen die „Widerrufsfunktion“ als Parameter), Create `docs/rechtliches/widerrufsbelehrung-funktionen.md`; Test `test/termineRechtstexte.test.js` (erweitern, Hilfsfunktionen `musterZeilen`/`fristBaustein` unverändert nutzen).
**Produces:** `widerrufsbelehrungFunktion() ⇒ string` (Sie-Form, Text mit Zeilenumbrüchen wie `widerrufsbelehrung`): WEBklar als Unternehmer (`WEBKLAR_KONTAKT = { name: 'WEBklar GbR', adresse: 'Am Schwimmbad 10, 67722 Winnweiler', telefon: '0176 23726355', email: 'support@webklar.com' }`), Fristbeginn „des Vertragsabschlusses.“, Widerrufsfunktion im Satzrahmen der Anlage 1 mit der Adresse `https://project.webklar.com/widerrufen.html` (Wortlaut wie im Abo, `abo-bedingungen.html` Abschnitt „Widerrufsbelehrung“), Wertersatz für Dienstleistungen, Muster-Widerrufsformular mit „An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:“. Die Ausgabe von `widerrufsbelehrung(betrieb)` (Gäste) bleibt byte-gleich.
Dokument `widerrufsbelehrung-funktionen.md`: `# Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)`, `**Fassung <F>**`, Hinweis „Das Widerrufsrecht gilt nur für Verbraucher“ (D1: Belehrung und Haken erhalten trotzdem alle Besteller, ⚖ Q1), Belehrung + Formular wörtlich aus `widerrufsbelehrungFunktion()`, dann „Hinweise zum Widerruf“ (nicht Teil der gesetzlichen Belehrung): 1. gilt je Bestellung einer Funktion und je Website-Projekt; 2. der Widerruf der Einrichtung beendet die Funktion, das Wartungs-Abo bleibt unberührt (⚖ Q7); 3. hat WEBklar auf ausdrücklichen Wunsch vor Fristende mit der Einrichtung begonnen, schuldet der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung, bemessen als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand (⚖ Q5); 4. Rückzahlung über das ursprüngliche Zahlungsmittel (Stripe).
- [ ] Tests: `belehrung_funktion_muster_woertlich` (jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt); `belehrung_funktion_widerrufsfunktion` (Satzrahmen aus Anlage 1 wörtlich, Lücke = die Adresse); `belehrung_funktion_formular` (Anlage 2 wörtlich, „An“-Zeile mit WEBklar); `belehrung_gleich_abo` (die Absätze zwischen „Widerrufsrecht“ und „Ende der Widerrufsbelehrung“ in `public/abo-bedingungen.html` sind nach Normalisierung der Absatzgrenzen wortgleich zur Funktionsausgabe, wobei Kontaktklammer und „An“-Zeile vorher in beiden durch einen Platzhalter ersetzt werden, weil die Gäste-Bausteine „Telefon: …, E-Mail: …“ mit Doppelpunkt setzen; Drift-Wächter zwischen den beiden WEBklar-Belehrungen); `belehrung_dokument_gepinnt` (Textblock in der Datei = Funktionsausgabe, Kopf trägt `Fassung ${config.termine.avvVersion}`, kein `[`, `…`, `{{`); Gäste-Belehrung unverändert (bestehende Tests grün).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Rechtstexte: Widerrufsbelehrung der Funktionen aus dem Gesetzesmuster“
### Task F4: Leistungsbeschreibung Terminplaner (Markdown)
**Files:** Create `docs/rechtliches/leistungsbeschreibung-terminplaner.md`, `test/termineVertragsunterlagen.test.js`.
**Aufbau** (Nummern verbindlich: AVV, Paket und Mails verweisen darauf): `# Leistungsbeschreibung Terminplaner`, `**Fassung <F>** · Zusatzbedingungen zum Wartungs-Abo` und Abschnitte 1 Geltung · 2 Leistung · 3 Nicht enthalten · 4 Voraussetzungen und Pflichten des Kunden · 5 Einrichtung und Preis · 6 Verfügbarkeit und Störungen · 7 Haftung für verpasste Termine · 8 Ende der Leistung · 9 Änderungen und Einstellung · 10 Widerruf · 11 Hinweise. Dritte Person („der Kunde“) wie die Analyse-LB; Gerüst und Tonfall aus `leistungsbeschreibung-website-analyse.md`.
**Inhalt (Festlegungen):**
- 1: Zusatz zum Wartungs-Abo, **Hosting-Abo genügt nicht**; vereinbart mit der Bestellung und der Zustimmung zu LB, AVV Terminplaner und Belehrung (AVV Vorbemerkung regelt den Ablauf); Rangfolge: LB vor Abo-Bedingungen, in Datenschutzfragen der AVV; LB B Nr. 3 der Abo-Bedingungen („Rechtstexte nicht enthalten“) gilt nicht für Datenschutz-Absatz und Gästetexte.
- 2: je Website-Projekt ein Widget an einer Stelle (inline oder Knopf), Gestaltung nach Fragebogen; Leistungen, Personen/Plätze, Zeiten, Regeln; Portal-Ansicht, CSV-Export; E-Mails (Bestätigungslink, Bestätigung, Änderung, Absage, Erinnerung nur mit Haken, Eingang eines Widerrufs); geheimer Kalender-Link (ICS); Missbrauchsschutz; Löschfristen nach AVV Nr. 9; **nur Zahlung vor Ort**; Gästetexte, Datenschutz-Absatz und Belehrung für Gäste sind Mustertexte der Standardkonfiguration.
- 3: nicht enthalten: SMS, Online-Zahlung, Kalender-Sync (nur ICS-Abo), Konten der Gäste, Marketing-Mails, Gruppen, Mehrsprachigkeit, **Gesundheitsbereich**; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden (§ 4 Abo-Bedingungen).
- 4: laufendes Wartungs-Abo; Betriebsangaben samt Impressum- und Datenschutz-Adresse vor der Bestellung; kein Angebot im Gesundheitsbereich (WEBklar darf Einrichtung ablehnen oder abschalten, ⚖ Q11); kein Angebot gezielt an Kinder unter 16; Telefon-Ausweichlösung; Haken- und Hinweistexte unverändert lassen; Beschäftigte über Namen/Zeiten und Kalender-Link informieren; Pflichten gegenüber Gästen (Preisangaben, AGB/Stornobedingungen, Einstufung Freizeit/Verbraucher) trägt der Kunde. **Datenschutz-Absatz:** liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar ihn beim Einbau ein, Teil der Einrichtung und nicht auf das Stundenkontingent angerechnet; sonst Textbaustein, den der Kunde selbst einfügt (gleiche Regel wie Analyse-LB Nr. 4 Abs. 4, ⚖ Q18).
- 5: Einrichtung nach Fragebogen, Einbau, Datenschutz-Absatz, Abnahme, Freischaltung; **149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG**; die voraussichtliche Dauer nennt die Bestellseite (Einschätzung, keine Zusage); Zahlung über Stripe, Rechnung; Preis nicht auf Stunden anrechenbar.
- 6: Verfügbarkeitsziel (§ 5 Abo-Bedingungen) gilt für die Website, nicht für die Funktion; bei Ausfall zeigt das Widget den Ausweichtext mit Telefonnummer; Zeiten in Europe/Berlin; keine Gewähr für Zustellung und Lesen von E-Mails (Postfach des Empfängers, Mailgrenzen des Anbieters); gesetzliche Rechte, §§ 12, 13 Abo-Bedingungen bleiben.
- 7: (1) Hilfsmittel; Einhaltung der Termine bleibt Sache des Kunden. (2) keine Gewähr für Zustellung der E-Mails. (3) Entgangener Gewinn und Folgeschäden aus verpassten Terminen 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 (⚖ Q8).
- 8: (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.“ (⚖ Q7). (2) Wirkung: keine neuen Buchungen, Ausweichtext; Termine bleiben im Portal sichtbar, CSV-Export; 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.“ (⚖ Q9). (4) Rückkehr (D2): „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) Erstattung (D3): „Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.“ (⚖ Q6). (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab; eine Erstattung des Einrichtungspreises folgt daraus nicht (außer Widerruf, ⚖ Q6). Hinweis zur Kanzleifrage Q7: Dokumente, die WEBklar **ohne Bestellung** freigeschaltet hat (Pilot/Kulanz, auch F13a), nimmt der tägliche Abgleich aus und zählt sie im Tagesbericht als `ausgenommen`; sie bleiben bis zur Abschaltung durch WEBklar aktiv.
- 9: Technik verbessern, solange der Leistungsumfang nicht schlechter wird; Einstellung nur bei Rechtslage oder nicht behebbarer Sicherheitslücke mit Frist (Muster Analyse-LB Nr. 8 Abs. 2 und 3); **keine** Einstellung ohne Grund; Rechte nach § 327r BGB bleiben (Erstattung bei Einstellung durch WEBklar offen, ⚖ Q8).
- 10: Verbraucher widerrufen die Bestellung der Einrichtung nach der Belehrung (Anlage); Erlöschen und Wertersatz nach der Zustimmung bei der Bestellung (⚖ Q3, Q5); der Widerruf beendet die Funktion.
- 11: Keine Rechtsberatung: Mustertexte für die Standardkonfiguration, Einstufung (Verbraucher/Unternehmer, Freizeit) trägt der Kunde; **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.“ (⚖ Q22; kein axe-/Screenreader-Lauf in TP1, daher keine Konformitätszusage).
- [ ] Tests (`termineVertragsunterlagen.test.js`, LB-Teil): `lb_aufbau` (h2-Liste exakt, Kopf `Fassung ${config.termine.avvVersion}`); `lb_sauber` (kein `ENTWURF`, `Prüfpunkt`, `TODO`, `{{`, `<!--`); `lb_festwerte` (`149 €`, `§ 19 UStG`, „Hosting-Abo genügt nicht“-Satz, die Sätze von Nr. 8 Abs. 1, 3, 4, 5 wörtlich); `lb_preis_gleich_register` (`149 €` = `featureConfig('termine').priceLabel`); `lb_fristen_gleich_code` (30 Tage Abschalten = `LOESCHFRIST_TAGE` aus `termine/frist.js` (E2), kein Regex-Lesen von `jobs.js`); `lb_verweise` (jedes „Nr. N“ auf LB/AVV und „§ N Abo-Bedingungen“ existiert); `lb_ohne_du` (keine Du-Form); `lb_bfsg_ohne_konformitaetszusage` (kein „WCAG … entspricht“).
- [ ] FAIL → Text schreiben → PASS → `npm test` → Commit „Rechtstexte: Leistungsbeschreibung Terminplaner“
### Task F5: AVV Terminplaner (Markdown)
**Files:** Create `docs/rechtliches/avv-terminplaner.md`; Test `test/termineVertragsunterlagen.test.js` (AVV-Teil).
**Aufbau** wie `avv-website-analyse.md` (eigenes Geschwister, **kein Zusatz** zum AVV der Analyse, damit Analyse-Kunden nicht neu zustimmen): `# Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)`, `**Fassung <F>**`, Parteien, Vorbemerkung, Nr. 1–12, Anlage TOM.
**Inhalt (Festlegungen):**
- Vorbemerkung: Abschluss per Haken bei der Bestellung zusammen mit der LB; das Portal speichert Fassung, Zeitpunkt, Konto und Person (`featureConsents`); danach schickt WEBklar K15 mit den PDFs. Mitarbeiter dürfen die Funktion nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat (F13a); der Kunde erhält die Unterlagen anschließend per E-Mail; Verhältnis zu Anlage 1 der Abo-Bedingungen: für den Terminplaner geht dieser Vertrag vor.
- Nr. 1 Dauer: ab Einschalten bis Abschalten, spätestens Ende des Wartungs-Abos (Abschalten spätestens am Folgetag); Pflichten bis zur Löschung. Nr. 2: Termine verwalten, E-Mails im Auftrag, Missbrauchsschutz; keine eigenen Zwecke, keine Profile.
- Nr. 3 Daten: Gäste (Name, E-Mail; optional Telefon, Nachricht, Zusatzfelder des Betriebs; Leistung, Person/Platz, Termin; Status mit Zeitpunkten; Nachweis der Zustimmungen als Zeitpunkt und Hashes der gezeigten Texte; Erinnerungswunsch; Zustellstatus der E-Mails) und Beschäftigte des Kunden (Namen/Bezeichnungen, Arbeitszeiten, Abwesenheiten, geheimer Kalender-Link). **Art. 9 DSGVO ausgeschlossen** (Gesundheitsbereich; Pflicht Nr. 11). IP-Adresse und Browser sieht der Server technisch, Mengenbegrenzung nur im Arbeitsspeicher, **mit der Buchung wird keine IP gespeichert**; Server-Protokolle 14 Tage (Anlage 2 der Abo-Bedingungen).
- Nr. 4 Weisungen (Einstellungen im Portal, sonst Textform), Nr. 5 Vertraulichkeit, Nr. 6 TOM (Anlage), Nr. 7 Unterauftragsverarbeiter: **Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen: Falkenstein (Server, Sicherung) und Nürnberg (Webhosting-SMTP für die Buchungsmails)**, keine Drittländer, Änderung mit vier Wochen Vorlauf und zwei Wochen Widerspruch (Muster Analyse-AVV Nr. 7), Stripe betrifft nur den Vertrag Kunde–WEBklar (⚖ Q10).
- Nr. 8 Betroffenenrechte: Anfragen von Gästen leitet WEBklar an den Kunden weiter; Unterstützung durch die Aktion „Gastdaten löschen“ (`anonymisieren`) und den CSV-Export; Datenpannen unverzüglich, möglichst binnen 24 Stunden; **Archiv der Fassungen der Gästetexte** (`docs/rechtliches/archiv/termine-gaestetexte-<V>.md`) bis drei Jahre nach Ende (Nachweis speichert nur Hashes, ⚖ Q26).
- **Nr. 9 Löschung genau wie gebaut:** nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten; abgesagte/abgelehnte Termine 30 Tage; bestätigte und erledigte Termine `aufbewahrungTage` (Vorgabe 90, einstellbar 14–365) nach Terminende; Versandwarteschlange: Empfängeradresse und Inhalt nach dem Versand geleert, Eintrag nach 7 Tagen gelöscht, nicht zustellbare nach 48 Stunden; **Abgeschaltet oder Ende des Abos: alle Termindaten und Einstellungen 30 Tage danach** (CSV-Export bis dahin, Rückkehr nach LB Nr. 8 Abs. 4); Entwürfe aus dem Fragebogen ohne Bestellung 90 Tage nach der letzten Änderung; Sicherungen: gelöschte Daten bleiben bis zu 7 Tage (§ 3 Abs. 5 Abo-Bedingungen, ⚖ Q9).
- Nr. 10 Nachweise/Kontrollen (wie Analyse), Nr. 11 Pflichten des Kunden (Verantwortlicher; Information der Gäste nach Art. 13 mit Datenschutz-Absatz und Hinweis am Formular; Beschäftigte informieren; kein Gesundheitsbereich; Erinnerungs-Haken nicht verändern), Nr. 12 Haftung (§ 13 Abo-Bedingungen, Art. 82), Änderungen mit sechs Wochen Vorlauf, im Übrigen § 16.
- Anlage TOM (Art. 32): TLS; **keine Cookies, nichts im Browser des Gastes** (gesichert durch `termineEmbedStatic.test.js:24`); Zugriff: Portal-Sitzung, Kunde sieht nur eigene Daten, öffentliche Schnittstelle nur über Site-Key, Kalender-Link mit geheimem Token (konstantzeitiger Vergleich); Missbrauchsschutz (Honeypot, Mindestzeit, Grenzen und Mailbudget nur im Arbeitsspeicher, `limits.js`); Datenminimierung; Trennung je Funktions-Dokument; Löschlauf täglich; Protokolle ohne Gast-Daten; Sicherung Hetzner (7 Tagessicherungen); Rechenzentrum ISO/IEC 27001; Organisation (Verpflichtung, Überprüfung jährlich). Jede Zeile der Anlage nennt in der Test-Tabelle ihre Fundstelle.
- [ ] Tests (AVV-Teil): `avv_aufbau` (Überschriften Vorbemerkung, 1–12, Anlage; Kopf mit Fassung); `avv_sauber` (wie LB); `avv_unterauftragsverarbeiter` (Nr. 7 nennt Hetzner mit Anschrift, `Falkenstein` **und** `Nürnberg`, kein Drittland); `avv_loeschfristen_gleich_code` (48 h, 30 Tage, Vorgabe 90 / 14 / 365, Outbox 7 Tage / 48 h; die 30 Tage Abschalten aus `LOESCHFRIST_TAGE` (`termine/frist.js`, E2), die übrigen Werte aus exportierten Konstanten bzw. per Regex aus `jobs.js`/`outbox.js`/`AUFBEWAHRUNG_*`, soweit nicht exportiert); `avv_keine_ip_in_buchung` (`TERMINE_COLLECTIONS.termineBookings.attributes` in `schema.js` enthält kein Attribut, dessen Name auf `/^(ip|ipAdresse|clientIp|userAgent)$/i` passt – ein loses `/ip/` träfe `stripeSessionId` und `stripePaymentIntent`; der `nachweis` einer über `halte` gehaltenen Buchung enthält keinen Schlüssel `ip`/`userAgent`; `baueNachweis` selbst ist nicht exportiert); `avv_verweise_auf_lb` (jedes „Nummer N der Leistungsbeschreibung“ existiert); `avv_ohne_analyse_bezug` (Analyse-Kunden brauchen keine neue Zustimmung: kein Verweis auf eine Änderung des Analyse-AVV).
- [ ] FAIL → Text schreiben → PASS → `npm test` → Commit „Rechtstexte: AVV Terminplaner“
### Task F6: HTML-Generator, PDF-Modus und ausgelieferte Dateien
**Files:** Create `scripts/build-legal-html.mjs`, `test/buildLegalHtml.test.js`, `public/rechtliches/{leistungsbeschreibung-terminplaner,avv-terminplaner,widerrufsbelehrung-funktionen}-<F>.{html,pdf}`; Modify `scripts/build-legal-pdfs.sh` (Modi `termine` und `datenschutz`, Variable `TERMINE_VERSION` mit Vorgabe `<F>`; die Zeile `pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf` bleibt in `all`), `docs/rechtliches/archiv/README.md` (Tabelle „Terminplaner“: Fassung, Dateien, „gültig ab“ offen bis Livegang).
**Consumes:** F1 `legalPaths`, F3–F5 Markdown.
**Produces:** `node scripts/build-legal-html.mjs termine [--check]` rendert die drei Markdown-Quellen deterministisch (keine Datumsangabe) in dieselbe Seitenhülle wie die Analyse-Seiten (Navigation zu den anderen Dokumenten, `@page`-Kopf „WEBklar · <Titel>“, Seite x von y, `meta` mit Fassung); Teilmenge von Markdown (h1–h3, Absätze, `- `-Listen, `**fett**`, `` `code` ``, Tabellen); jede andere Syntax (``` , `>`, Bilder, rohes HTML) bricht laut ab. `--check` vergleicht mit den eingecheckten Dateien (Exit 1). `render(md, meta) ⇒ html` wird exportiert. `build-legal-pdfs.sh termine` erzeugt die drei PDFs auf dem Host (vorher `free -m`, mit `nice`, Chrome aus `~/.cache/ms-playwright`); PDFs und HTML werden explizit gestaged.
- [ ] Tests (`buildLegalHtml.test.js`): `html_gleich_generator` (eingecheckte HTML = `render(md)` für alle drei); `html_text_gleich_markdown` (jeder Absatz der Quelle steht nach Tag-Entfernung und Normalisierung im HTML); `html_sauber` (kein `<!--`, `Entwurf`, `Prüfpunkt`, `<script`, `on…=`; `Fassung <F>` im Kopf; Links nur relativ oder `https:`); `generator_lehnt_unbekannte_syntax_ab`; `generator_escaped` (`<`, `&`, `"` in Text und Attributen); `pdf_dateien` (`%PDF`, > 20 KB je Datei, Dateinamen aus `legalPdfFiles`); `anhaenge_drei` (`legalPdfAttachments('termine', F)` liefert lb, avv, widerruf mit `application/pdf`); `router_alias` (`/rechtliches/avv-terminplaner.html` = Bytes der Fassungsdatei, unbekannte Fassung 404, `-1999-01-01.html` 404); `script_modus` (`build-legal-pdfs.sh` enthält die Modi `termine`, `datenschutz` und die unveränderte `all`-Zeile).
- [ ] FAIL → implementieren, Generator laufen lassen, PDFs bauen → PASS → `npm test` → Commit „Rechtstexte: HTML-Generator und PDFs des Terminplaners“
### Task F7: Bestelltexte (Zustimmungen), Dokumente und Belehrung im Register
**Files:** Modify `server/config.js` (`features.termine.consent`; `features.termine.widerruf` als Getter `{ version: config.termine.avvVersion, url: '/rechtliches/widerrufsbelehrung-funktionen-<version>.html' }` wie in F1), `server/services/featureRegistry.js` (Standardhaken `featureHaken('termine').belehrung` = `widerrufsbelehrungFunktion()`, F3); Test neu `test/featureConsentTexte.test.js`; **angepasste B-Tests** (gezielt, ihre Pins hängen an Texten und Fassung): `test/featureRegistry.test.js` (B1: Item-Texte und Fassung; der `todo`-Test „Bestelltexte ohne ENTWURF“ wird grün; die Item-IDs `unterlagen`/`gesundheit`/`beginn`/`information` stehen seit B1), `test/featureConsents.test.js` (B4: `nachweisTexte` mit `belehrung`, `zustimmungsfassungen` mit `widerruf`), `test/funktionenCheckout.test.js` (B6/B7: vier Haken, Nachweis mit `belehrung` und `knopf`).
**Consumes:** B-Register (`featureConfig`, `server/services/featureRegistry.js`: `consent: { version, knopf, items }`, `priceLabel`, `setupEstimate`, `leistungsbeschreibung`), B `featureConsents.js` und Checkout (Reihenfolge laut B7: Stripe-Sitzung erzeugen, Nachweis speichern, **dann** die URL zurückgeben), C2 (`bestellungAnsicht` liest `belehrung` aus demselben Haken), F1, F3.
**Produces:** `features.termine.consent = { version: config.termine.avvVersion, knopf: 'Zahlungspflichtig bestellen · 149 €', items }` mit vier Haken (alle Besteller, D1; alle `haken: true`, ohne `url`; die B-/C-Oberfläche rendert jedes Item mit `haken: true` als Haken, die ids sind für Tests und Nachweis verbindlich):
1. `unterlagen`: „Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version} zu.“ (die versionierten Links stehen daneben: C8 rendert sie aus `bestellung.dokumente`, F1/F7 liefern die Adressen)
2. `gesundheit`: „Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).“
3. `beginn`: „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).“ (⚖ Q3, Q5)
4. `information`: „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 ${priceLabel} einmalig (${ustHinweis}), voraussichtliche Dauer ${setupEstimate}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.“ (⚖ Q20)
Die dynamischen Teile setzt `config.js` einmal beim Laden ein, gespeichert wird immer der fertige Text. Knopf: „Zahlungspflichtig bestellen · 149 €“ (⚖ Q19). **Nachweis (Review Focus 5):** B7 speichert zusätzlich zu den Haken den Knopftext als Eintrag `{ id: 'knopf', text }` und die **gezeigte** Widerrufsbelehrung samt Muster-Formular (Hook `belehrung`) als Eintrag `{ id: 'belehrung', text }`; `zustimmungsfassungen` trägt `widerruf: version`. Die Bestellseite (C8) zeigt `bestellung.belehrung` aus demselben Hook, gezeigter Text und gespeicherter Text sind also dieselbe Zeichenkette.
- [ ] Tests (`featureConsentTexte.test.js`): `termine_haken_woertlich` (ids genau `['unterlagen','gesundheit','beginn','information']`, alle `haken true`, Texte exakt); `termine_version_gleich_dokumente` (= `config.termine.avvVersion`, `Fassung` in Haken 1); `information_nennt_register_werte` (`149 €`, `ohne Umsatzsteuer gemäß § 19 UStG`, `setupEstimate`); `keine_urls_im_text`; `textstand_pin` (SHA-256 über die verbundenen Texte beider Funktionen steht im Test, Kommentar „bei Textänderung neues Fassungsdatum **und** neuen Hash“: Textänderung ohne Fassungswechsel fällt auf); `nachweis_vor_umleitung` (Integration über B7: `POST /api/funktionen/termine/checkout` legt `featureConsents` mit `texte` = Register-Haken + `belehrung` + `knopf`, `fassungen` (mit `widerruf`) und Zeitpunkt an, `acceptedAt` == Klickzeit; das Dokument existiert, **bevor die Antwort mit `url` zurückkommt**, die Stripe-Sitzung entsteht davor (B7); schlägt das Speichern fehl ⇒ 500 ohne `url` und ohne `ordered`, die verwaiste Sitzung läuft nach 24 h ab); `belehrung_gezeigt_gleich_gespeichert` (gespeicherter `belehrung`-Text == `bestellung.belehrung` aus `GET /api/termine/einstellungen` == `widerrufsbelehrungFunktion()`); `knopf_gleich_register_und_nachweis` (`bestellung.consent.knopf` == Registertext == gespeicherter `knopf`-Eintrag); `alte_fassung_409` (Body mit alter Fassung ⇒ 409 mit neuer Fassung).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Bestelltexte des Terminplaners (vier Zustimmungen, Belehrung im Nachweis, Fassung)“
### Task F8: K15-Anhänge, Preisangabe und Unterlagen in der Bestellseite
**Files:** Modify `server/services/siteFeatures.js` (K15-Versand in `onFeaturePaid`: lazy `import('./legalDocs.js')` und `featureLegalAttachments(doc.featureKey)`; **kein** Registerhaken `mails.anhaenge`, B9a legt keinen an), `server/services/featureRegistry.js` (der Haken `unterlagenBereit` für `termine` = `featureLegalReady('termine')`), `server/routes/funktionen.js` (GET-Feld `legal`), `server/mail-templates/K15 Bestellung Funktion.html`, `server/mail-templates/T16 Funktion bestellt.html` (Block `anhang_fehlt`), `test/mailTemplates.test.js` (Platzhalter/Blöcke), Tests `test/funktionenRecht.test.js` (erweitern), `test/funktionenRoutes.test.js`, `test/featureMails.test.js` (**B9a/B9b-Pin „Platzhalter-/Blocklisten exakt“**: K15 um `fassung`, `lb_url`, `avv_url`, `widerruf_url`, `zustimmungen_text` und die Blöcke `anlagen`, `zustimmungen`, T16 um den Block `anhang_fehlt` ergänzen). Die Bestellseite `public/funktion-bestellen.js` (Darstellung der Belehrung und der Links) gehört C8; F8 ändert sie nicht.
**Consumes:** F1 `featureLegalAttachments`, `featureLegalReady`, `legalPaths`; F3 `widerrufsbelehrungFunktion`; B `onFeaturePaid`, `zustimmungFuerSitzung` (B4).
**Produces:** K15 trägt für `termine` drei PDF-Anhänge (LB, AVV, Belehrung mit Muster-Formular; dauerhafter Datenträger, § 312f BGB; Muster `legalPdfAttachments`/K18). **Mechanismus (einziger Weg):** `onFeaturePaid` ruft vor dem K15-Versand `await featureLegalAttachments(doc.featureKey)` (lazy `import()` aus `legalDocs.js`; `news` ⇒ `[]`) und übergibt das Ergebnis als `attachments`; ein Registerhaken `mails.anhaenge` besteht nicht. Neue K15-Werte (Texte in Du-Form wie die übrigen K-Vorlagen; `preis_hinweis` hat B9a schon deklariert und mit `mails.preisHinweis` gefüllt): `fassung`, `lb_url`, `avv_url`, `widerruf_url` (absolute, **versionierte** Adressen), `zustimmungen_text` (Wortlaut und Zeitpunkt der erteilten Zustimmungen aus `zustimmungFuerSitzung(session.id)`, B4: die Zustimmung zur **bezahlten** Sitzung); Blöcke `anlagen` und `zustimmungen` nur für Funktionen mit Rechtsdokumenten (⚖ Q20). Sind die PDFs beim Versand nicht lesbar, geht K15 **ohne** Anhänge raus (Bestätigung vor Anhang; die Zahlung hat Spur) und T16 meldet im Block `anhang_fehlt` „Unterlagen von Hand nachsenden“; Fehler loggen. Checkout: der B6-Haken `unterlagenBereit` ist für `termine` an `featureLegalReady('termine')` gehängt (Stufe 5b in B6: `false` ⇒ 503 `unterlagen_fehlen` „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“, vor jeder Stripe-Sitzung; die Stufe steht im Reihenfolge-Test von B6). `GET /api/funktionen` ergänzt `legal: { termine: { version, links: { lb, avv, widerruf } (absolut, https), belehrung: widerrufsbelehrungFunktion() }, news: null }` (`ustHinweis` liefert schon B12). Dieselben Werte (die versionierten Adressen über `dokumente`, die Belehrung über den Haken aus F7) fließen in `bestellungAnsicht` (C2), aus der die Bestellseite (C8) rendert: Links mit `rel="noopener noreferrer"` und `target="_blank"`, die Belehrung samt Formular als Text im **nicht einklappbaren** Bereich über dem Knopf (Absätze per `textContent`, **kein `<details>`**).
- [ ] Tests: `k15_termine_anhaenge` (drei Anhänge in der Reihenfolge lb, avv, widerruf, Namen aus `legalPdfFiles`, alle `%PDF`; `Object.hasOwn(featureConfig('termine').mails, 'anhaenge') === false`); `k15_news_ohne_anhaenge` (News unverändert, kein Block `anlagen`); `k15_links_versioniert` (Text enthält `-${F}.html`, **keine** Adresse ohne Fassung); `k15_anhang_nicht_lesbar` (`legalPdfAttachments` wirft ⇒ K15 ohne Anhänge, T16 mit Block `anhang_fehlt`, Dokument trotzdem `setup`, Ticket angelegt, Webhook 200); `k15_zustimmungen_text` (Text der vier Haken und Zeit im Mail-Text); `checkout_503_ohne_unterlagen` (keine Stripe-Sitzung, kein `ordered`-Dokument); `liste_legal` (`legal.termine.links` alle `https://` und versioniert, `belehrung` = Funktionsausgabe); `bestellseite_statisch` (kein `innerHTML` für die Belehrung, kein `<details>`, `rel="noopener noreferrer"`); `mailTemplates.test.js`: deklarierte = verwendete Platzhalter/Blöcke für K15 und T16; `featureMails.test.js`: die exakten Listen aus B9a/B9b sind um die oben genannten Werte ergänzt (kein zweiter Test dafür).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf“
### Task F9: Widerruf je Funktion (Formular, Frist, K04/T09)
**Files:** Modify `public/widerrufen.html` (`:23`), `server/routes/abo.js` (`ORDERS :707`, `orderLabel :782`, `withdrawFeature :1088`, `:1118`, `:1143`, `:1166`), `server/mail-templates/K04 Eingang Widerruf.html`, `server/mail-templates/T09 Widerruf eingegangen.html`, `test/mailTemplates.test.js` (`:254-258`, Kopfkommentar), Tests `test/publicForms.test.js` (neben `:518`).
**Consumes:** B4 `paidFeatureOrders(customerId)` mit `featureKey` und `vertragAt` (Zeit der Zustimmung zur **bezahlten Sitzung** = Klick, Review-Fokus 5; B4 stellt `withdrawFeature` bereits auf `vertragAt || orderedAt` um), `featureConfig(key)` (`label.name`, `priceLabel`). F9 baut keinen zweiten Anker (kein `contractAt`, kein `letzteZustimmung`: eine spätere, unbezahlte Zustimmung zu einer neuen Sitzung würde den Anker verfälschen).
**Produces:** `widerrufen.html`: neuer Radioknopf `value="termine"` mit `data-text="Terminplaner (Einrichtung)"`, der bestehende `funktion` („Funktion (z. B. News-Pinnwand)“) bleibt unverändert. `ORDERS` + `termine`; `FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' }`; `orderLabel('termine') = 'Terminplaner (Einrichtung)'`. `withdrawFeature(d, receivedAt, featureKey)` filtert die Bestellungen aus `paidFeatureOrders` auf die gewählte Funktion (`featureKey`) und rechnet die Frist **aus deren `vertragAt`** (spätestes Fristende je Funktion, Zeilen mit eigenem Fristende je Projekt). `produkt` = `featureConfig(key).label.name` (statt fest „News-Pinnwand“), neu `preis` = `featureConfig(key).priceLabel` in T09. K04 Block `funktion` (Sie-Form bleibt): Wertersatz-Satz für „begonnene“ statt „abgeschlossene“ Einrichtung, Verweis § 357a Abs. 2 BGB. T09 Block `funktion`: „Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig) …“; Handgriffe: Erstattung in Stripe binnen 14 Tagen, ggf. abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), Funktion im Reiter „Funktionen“ abschalten; bei Terminplaner: Wartungs-Abo bleibt unberührt.
- [ ] Tests: `widerruf_termine_frist_je_funktion` (News vor 20 Tagen, Terminplaner vor 3 Tagen bestellt, Widerruf `termine` ⇒ T09 `RECHTZEITIG`, Fristende des Terminplaners; umgekehrt Widerruf `funktion` ⇒ `NACH Fristende`; Regression des bisherigen Maximums); `widerruf_termine_mails` (K04 nennt „Terminplaner (Einrichtung)“, T09 „149 €“ und das Projekt; `Funktion (z. B. News-Pinnwand)`-Test bei `funktion` unverändert grün); `widerruf_anker_vertragAt` (`orderedAt` später überschrieben, Zustimmung zur bezahlten Sitzung früher ⇒ Frist aus `vertragAt`; ohne `featureConsents`-Eintrag Rückfall auf `orderedAt`; die Grundlage prüft der B4-Test, hier nur die Funktionsfilterung darauf); `widerruf_unbekannte_funktion` (kein bezahltes Dokument ⇒ „ungeprüft (keine bezahlte Funktion gefunden)“); `mailTemplates.test.js`: T09 deklariert `preis`, K04/T09 Blöcke unverändert; `widerrufen_html_radio` (Wert `termine`, Beschriftung wörtlich).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Widerruf: Terminplaner als eigene Auswahl, Frist und Mails je Funktion“
### Task F10a: Gästetexte – Archiv-Skript und eingefrorene Altfassung 2026-10-02
**Files:** Create `scripts/termine-texte-archiv.mjs`, `docs/rechtliches/archiv/termine-gaestetexte-2026-10-02.md` (Altfassung, aus den **unveränderten** Texten erzeugt, **eigener Commit vor jeder Textänderung**), `test/termineRechtstexteArchiv.test.js`; Modify `docs/rechtliches/archiv/README.md` (Tabelle „Terminplaner-Gästetexte“).
**Consumes:** `server/services/termine/rechtstexte.js` im Stand 2026-10-02 (noch ohne Änderung durch F10b1).
**Produces:** `termine-texte-archiv.mjs [--check]` schreibt `docs/rechtliches/archiv/termine-gaestetexte-<V>.md` aus `rechtstexte.js` mit Platzhalter-Betrieb: Fassung, Formular-Hinweis (Sie/Du), alle Haken, Gesundheitshinweis, Knopftexte (Tabelle), Belehrung (Platzhalter-Fassung), Hinweis „Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt“ (⚖ Q26); `--check` vergleicht mit der eingecheckten Datei (Exit 1). Die README-Tabelle „Terminplaner-Gästetexte“ nennt die Fassung 2026-10-02.
- [ ] Tests: `archiv_aktuelle_fassung` (Skriptausgabe = eingecheckte Datei für die aktuelle `RECHTSTEXTE_VERSION`; Datei und README nennen die Fassung); `archiv_altfassung_eingefroren` (die Datei 2026-10-02 enthält den alten Erinnerungstext **ohne** den Widerrufssatz, ihr SHA-256 ist im Test festgenagelt).
- [ ] FAIL → implementieren, Altfassung erzeugen → PASS → `npm test` → Commit „Gaestetexte: Archiv-Skript und eingefrorene Fassung 2026-10-02“ (**vor** F10b1)
### Task F10b1: Gästetexte final 1 – Fassung <V>, Erinnerungs- und Abo-Ende-Satz, Archiv
**Files:** Modify `server/services/termine/rechtstexte.js` (`RECHTSTEXTE_VERSION`, `ZUSTIMMUNG_ERINNERUNG`, `datenschutzAbsatz` aus D1), `docs/rechtliches/termine-datenschutz-absatz.md` (Satz (c); Quelle der D1-Pins, Funktion und Markdown ändern gemeinsam), `docs/rechtliches/archiv/README.md`; Create `docs/rechtliches/archiv/termine-gaestetexte-<V>.md` (mit dem Skript aus F10a); Tests `test/termineRechtstexte.test.js` (Version, Sätze (b) und (c), D1-Pins), `test/publicTermine.test.js` (Nachweis-`fassung`), `test/termineRechtstexteArchiv.test.js`.
**Consumes:** F10a, D1 `datenschutzAbsatz(einstellung, anrede)`, F1 (`config.termine.avvVersion` = `<F>`).
**Produces:** `RECHTSTEXTE_VERSION = '<V>'` (= `<F>`, Literal in `rechtstexte.js`, nicht `2026-10-02`). Änderungen gegenüber `2026-10-02`: (b) `ZUSTIMMUNG_ERINNERUNG(n)` = „Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO, ⚖ Q17; der Hash im Nachweis deckt den Satz); (c) Datenschutz-Absatz Sie/Du: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ (Quelle `datenschutzAbsatz()` und Markdown gemeinsam); `termine-texte-archiv.mjs` (F10a) erzeugt `docs/rechtliches/archiv/termine-gaestetexte-<V>.md`. Die README-Tabelle „Terminplaner-Gästetexte“ nennt beide Fassungen. Die ENTWURF-Köpfe und `RECHTSTEXTE_ENTWURF` bleiben bis F10b2 unverändert.
- [ ] Tests: `archiv_aktuelle_fassung` (aus F10a, jetzt für `<V>`: Skriptausgabe = eingecheckte Datei, `RECHTSTEXTE_VERSION` in Datei und README); `archiv_altfassung_eingefroren` (aus F10a, unverändert grün); `textstand_pin` (SHA-256 der aktuellen Archivdatei im Test: Textänderung ohne neues Datum fällt auf); `erinnerung_widerrufssatz` (Haken-Text, Dokument und `texte.erinnerung` der öffentlichen Konfiguration wörtlich gleich); `absatz_abo_ende` (Funktion und Markdown, Sie und Du, nennen „30 Tage nach dem Abschalten“; bestehende Regex-Pins bleiben grün); `version_gleich_register` (`RECHTSTEXTE_VERSION === config.termine.avvVersion` und ungleich `'2026-10-02'`); Nachweis-`fassung` einer neuen Buchung = `RECHTSTEXTE_VERSION`, Altbuchungen behalten `2026-10-02`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Gästetexte des Terminplaners: Fassung <V>, Erinnerungs- und Abo-Ende-Satz, Archiv je Fassung“
### Task F10b2: Gästetexte final 2 – Entwurf-Kennzeichnung entfernt
**Files:** Modify `server/services/termine/rechtstexte.js` (Kopf, `RECHTSTEXTE_ENTWURF = false` (D1)), `server/services/termine/einbauanleitung.js` (Checklisten-Text zum Datenschutz-Absatz, D3b), `docs/rechtliches/termine-hinweise.md`, `-datenschutz-absatz.md`, `-widerrufsbelehrung-muster.md`, Rename `termine-datenschutz-6c-entwurf.md` → `termine-datenschutz-6c.md` (hier nur Kopf entfernt, Inhalt in F12a), `docs/funktionen/termine-einbau.md` (`:127`); Tests `test/termineRechtstexte.test.js` (`:49`, Liste `DOKUMENTE :219` mit neuem 6c-Namen, `:226`, Pfad im Wächter `:267`, **D1-Test (f)**: `RECHTSTEXTE_ENTWURF === false`), `test/termineEinbauanleitung.test.js` (D3b: die Banner-Tests mit Standardwert drehen um, Checklisten-Text), `test/termineEmbedStatic.test.js:219`.
**Consumes:** F10a, F10b1 (Version, Sätze und Archiv stehen), D `RECHTSTEXTE_ENTWURF` und `einbauanleitung` (der Banner hängt nach D1/D3b an `RECHTSTEXTE_ENTWURF`; er fällt hier weg).
**Produces:** `RECHTSTEXTE_ENTWURF = false`. Änderungen: (a) ENTWURF-Köpfe aus allen vier Dokumenten und die „Für die Rechtsprüfung offen“-Listen aus den drei Gäste-Dokumenten entfernt (die offenen Punkte stehen als Fragen im Kanzlei-Paket); (d) `termine-einbau.md` Abschnitt 6 ohne die Pilot-Einschränkung; (e) der Checklisten-Text der Einbauanleitung nennt die Endregel aus F4 Nr. 4 (WEBklar fügt den Datenschutz-Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet) statt der Zwischenregel aus D3b.
- [ ] Tests: `kein_entwurf_mehr` (kein `ENTWURF` im Sinne von `/ENTWURF(?![_A-Z])/` in den vier Dokumenten (Köpfe), in den Texten von `rechtstexte.js` und in `docs/funktionen/termine-einbau.md`; der Bezeichner `RECHTSTEXTE_ENTWURF` ist erlaubt und steht auf `false`; das alte Kopf-Gebot `termineRechtstexte.test.js:226` wird zum Verbot); `einbau_ohne_banner` (nach D: die Anleitung im Standardaufruf enthält kein ENTWURF-Banner; D3b-Banner-Tests mit explizitem `entwurf: true` bleiben); `checkliste_datenschutz_endregel` (Text der Checkliste = Endregel, die Zwischenregel „liegt die Datenschutzerklärung nicht in unserem Repo“ ist weg); `termineEmbedStatic.test.js:219` prüft statt `'ENTWURF'` das Wort `'Fassung'`.
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Gästetexte des Terminplaners: Entwurf-Kennzeichnung entfernt“
### Task F11: Portal-Hinweistexte und Storno nur bei Preis
**Files:** Modify `public/termine-tab-einstellungen.js` (`WIDERRUF_HINWEIS`, `FREIZEIT_HINWEIS` `:16-23` exportieren; Storno-Abschnitt ~`:222`), `server/services/termine/ablauf.js` (`:231,:282`), `server/services/termine/vorlagen.js` (`ausVorlage` ~`:209`), `public/embed/termine.js` (`:1215-1219,:1259,:1279`); Tests `test/termineAblaufGast.test.js`, `test/termineEinstellungen.test.js`, `test/termineView.test.js`, `test/termineEmbedStatic.test.js`, (`test/ui/termine-widget.ui.mjs` von Hand).
**Produces:** `FREIZEIT_HINWEIS` (Du-Form): „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. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.“ (nimmt Massage und Weiterbildung aus der Liste „keine Freizeitleistung“, die die Recherche nicht trägt, ⚖ Q15). `WIDERRUF_HINWEIS` bleibt, erhält nach dem dritten Satz: „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).“ Storno: `stornoNoetig` nur bei `hatPreis(leistung)`; bei kostenlosen Leistungen kein Pflichthaken, **kein** `nachweis.storno`, im Widget weder Stornobox noch Haken noch Prüfung (nach `l.preis` der gewählten Leistung); `ausVorlage` setzt `storno.text = ''`, wenn keine Leistung der Vorlage einen Preis hat; Abschnitt „Stornobedingungen“ ergänzt: „Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.“ (⚖ Q16). **Muster-Kennzeichnung (Spec 7.3):** der vorgeschlagene Stornotext (Vorgabe 50 % / 24 h) wird im Editor als Muster gekennzeichnet, mit einem Hinweis neben dem Feld: „Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.“ (Du-Form, nur im Portal, nicht im Gästetext).
- [ ] Tests: `storno_haken_nur_bei_preis` (`termineAblaufGast`: Leistung mit Preis ohne Haken ⇒ 400 wie bisher; `S_GRATIS` ohne Haken ⇒ Buchung gehalten, `nachweis.storno` fehlt; mit `storno: true` bei Gratis ⇒ ebenfalls ohne `nachweis.storno`; Handeintrag unverändert); `vorlage_ohne_preis_ohne_storno` (Vorlage mit nur kostenlosen Leistungen ⇒ `storno.text === ''`, Vorlagen mit Preisen unverändert); `widget_storno_je_leistung` (`termineEmbedStatic`: Box, Haken und Prüfung hängen an `l.preis`); `freizeit_hinweis_text` (nennt weder „Massage“ noch „Weiterbildung“ als Beispiel für „keine Freizeitleistung“, enthält „keine Rechtsberatung“, Du-Form); `widerruf_hinweis_wertersatz`; `storno_abschnitt_text`; `storno_muster_gekennzeichnet` (Hinweis steht neben dem Stornofeld, wörtlich, Du-Form; er erscheint nicht in den Gästetexten und nicht im Hash des Nachweises); Playwright-Lauf des Widgets bleibt außerhalb von `npm test` (einmal von Hand, `nice`, nach `free -m`).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Terminplaner: Hinweistexte nach Recherche, Storno nur bei Preis“
### Task F12a: Datenschutzerklärung (6c, §6, §7) und PDF
**Files:** Modify `public/datenschutz.html` (`Stand`; §6 `:82`; nach 6b `:93-95` neu `<h2 id="termine">`; §7 `:99`), `docs/WEBklar-Datenschutzerklaerung.pdf` (neu per `build-legal-pdfs.sh datenschutz`); `docs/rechtliches/termine-datenschutz-6c.md` (aus F10b2 umbenannt; Markdown-Quelle des Abschnitts, ohne „Einbau in TP2“, ohne „nicht eingebaut“, ohne „Für die Rechtsprüfung offen“); Tests `test/legalTexts.test.js` (`:494-` Kundenansicht-Pin, Stand-Pin), `test/termineRechtstexte.test.js` (`:267` Wächter entfernen).
**Consumes:** B (`config.collections.featureConsents`), A/C (Entwurfs-Löschung nach 90 Tagen), E (Abo-Ende, Rückkehr, K22), F10b2 (Umbenennung).
**Produces:** **6c final** (`<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>` zwischen 6b und §7, kein Kommentar im HTML): Rolle (Auftragsverarbeiter für Gäste; Verantwortliche für Betriebsdaten, Fragebogen-Entwurf und Bestellung); Daten wie Entwurf; Einstellungen und Entwurf: nicht bestellte Entwürfe werden 90 Tage nach der letzten Änderung gelöscht (auch bei Kundenlöschung); E-Mail-Versand über Hetzner (Rechenzentrum Nürnberg, Abschnitt 8), Server in Falkenstein; „mit der Buchung speichern wir die IP-Adresse nicht“ (belegt durch `avv_keine_ip_in_buchung`); Aufruf: Skript und Texte beim Laden der Seite, Termine im Modus `inline` beim Durchklicken, im Modus `knopf` erst nach Klick (`termine-einbau.md` §6); Fristen wie AVV Nr. 9; **Ende des Abos:** „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“; Sicherungen halten gelöschte Daten bis zu 7 Tage. **§6:** „… 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.“ (Sperre für Bestellungen, Zahlungen, Fragebogen-Bestätigung bleibt). **§7:** neuer Absatz: „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.“ (⚖ Q23; Spec 3.7: 6c und §7 nennen die Entwurfsdaten und die Frist). `Stand` = Änderungstag.
- [ ] Tests: `legal_datenschutz_termine` (Reihenfolge `id="analyse"` < `id="termine"` < `<h2>7. `; Pflichtsätze: Auftragsverarbeiter, `Falkenstein`, `Nürnberg`, `48 Stunden`, `30 Tage`, `90 Tage`, `sieben Tage`, „nicht dauerhaft“; kein `<!--` im Block; Nummerierung 7–15 unverändert); die bestehenden Wächter bleiben grün, weil 6c ihre Verbote meidet: `legal_datenschutz_news` schneidet von 6a bis §7 (also auch 6c; verboten sind dort „Abschnitt 6 und 7“, „spätestens nach 24 Stunden“, „Endet die Zusammenarbeit, schalten wir“), `Datenschutz: Abos, produktneutral` verbietet im ganzen Dokument u. a. „Unterauftragsverarbeiter“ und „im Rahmen des Wartungs-Abos“; `datenschutz_6c_gleich_markdown` (HTML-Block = Markdown-Quelle); `legal_datenschutz_kundenansicht` angepasst (neuer Satz wörtlich, `Stand` per Regex `/Stand \d{2}\.\d{2}\.2026/` und ≥ 01.10.2026 und ≥ Datum aus `termsVersion`); `legal_datenschutz_funktionsbestellung` (§7-Absatz wörtlich, **einschließlich des Satzes zu den Entwurfsdaten und den 90 Tagen**); `wiederkehr_satz_gleich_lb` (Satz zur Rückkehr in 6c/LB/AVV/K22 enthält jeweils „30 Tage“ und „wieder ein“, K22 per Glob `server/mail-templates/K22 *.html`); der 6c-Wächter `termineRechtstexte.test.js:267` entfällt; `build-legal-pdfs.sh datenschutz` erzeugt das PDF (> 20 KB, `%PDF`).
- [ ] FAIL → implementieren, PDF bauen → PASS → `npm test` → Commit „Datenschutz: Abschnitt 6c Terminplaner, Kundenansicht und Nachweise bei Funktionsbestellung“
### Task F12b: Aufbewahrung der Zustimmungsnachweise (Retention)
**Files:** Modify `server/services/retention.js` (`findDueDeletions :74` samt Bedingung `if (!posts.length && !features.length) continue`, `applyDeletions :172`, `formatReport :244`); Test `test/retention.test.js`.
**Consumes:** B3 (`config.collections.featureConsents`), F12a (der §7-Satz „drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“).
**Produces:** `findDueDeletions` plant die `featureConsents` fälliger Kunden mit (`plan.news[].consentIds`), `applyDeletions` löscht sie (`deleted.featureConsents`), `formatReport` nennt sie (damit der §7-Satz stimmt; Kunden ohne Abo, z. B. nur News, bleiben wie bisher außerhalb der Retention, siehe offene Fragen).
- [ ] Tests: `retention_loescht_featureConsents` (fälliger Kunde ⇒ Einträge geplant und gelöscht, nicht fälliger Kunde unberührt, Bericht nennt Anzahl; ein Kunde ohne Abo, z. B. nur News, bleibt außerhalb der Retention wie bisher).
- [ ] FAIL → implementieren → PASS → `npm test` → Commit „Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen“
### Task F13a: Staff-Aktivierung nur mit Textform-Nachweis (Modus `on`) – Portal
**Files:** Modify `server/services/termine/schalter.js`, `server/routes/admin/funktionenAdmin.js` (POST + GET), `server/services/siteFeatures.js` (`adminActivate`), Tests `test/termineSchalter.test.js`, `test/funktionenTermine.test.js`.
**Consumes:** B4 `speichereZustimmung` (erlaubt leeres `stripeSessionId`/`ip`/`userAgent` und Texte ohne `haken`), D6 `meldeFreigeschaltet(doc, { attachments })`; `validateTextform(textform, { minDate })` aus `analyse.js`; F1 `legalPaths`, `featureLegalAttachments`.
**Produces:** `termineTextformNoetig(email) ⇒ boolean` (`termineModus() === 'on'` **und** die Adresse steht nicht in `config.termine.pilotEmails`; `'*'` zählt nicht als Pilotadresse). `POST /api/team/funktionen/:customerId/termine` mit `action: 'activate'` verlangt dann `textformDate` (JJJJ-MM-TT, nicht vor `config.termine.avvVersion`, nicht in der Zukunft) und `textformChannel` (`chat` | `email`), sonst 400. Bei Erfolg: `speichereZustimmung({ customerId, projectId, featureKey: 'termine', stripeSessionId: '', fassungen: { leistungsbeschreibung: v, avv: v }, texte: [{ id: 'textform', text: 'Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung <v>) am <TT.MM.JJJJ> per <Chat im Kundenportal | E-Mail> in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.' }], ip: '', userAgent: '', konto: 'Mitarbeiter: <Name>', at })`, danach K16 über `meldeFreigeschaltet(doc, { attachments })` mit den beiden PDFs (LB, AVV; `featureLegalAttachments('termine', { ohne: ['widerruf'] })`), damit der Kunde die Unterlagen auf einem dauerhaften Datenträger erhält (⚖ Q21). `GET /api/team/funktionen/:customerId` ⇒ zusätzlich `termine: { textformNoetig, avvVersion, links }`. Hinweis: Dokumente, die so ohne Bestellung freigeschaltet werden (`unlockedByPurchase` bleibt falsch), nimmt der Abo-Abgleich aus und zählt sie im Tagesbericht als `ausgenommen` (Kanzleifrage Q7).
- [ ] Tests (Portal): `textform_pflicht_modus_on` (Kunde fremd ⇒ ohne Angaben 400, mit gültigen 200, `active`, `featureConsents` mit Datum, Weg, Fassung, Mitarbeitername, leerem `stripeSessionId`, K16 mit zwei `%PDF`-Anhängen in `mailSink`); `textform_entfaellt` (Modus `off`/`pilot`, Modus `on` mit Pilotadresse, News, Abschalten); `textform_datum` (vor Fassung, Zukunft, ungültiger Weg ⇒ 400, nichts angelegt); `get_admin_termine_feld`; `termineTextformNoetig` je Modus; `chat_vorlage_versioniert` (nur Adressen mit `-${v}.html`); `zustimmungFuerSitzung('')` findet den Staff-Nachweis nicht (B4).
- [ ] FAIL → implementieren → PASS → `npm test` (Portal) → Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal“
### Task F13b: Staff-Aktivierung nur mit Textform-Nachweis (Modus `on`) – WOMS
**Files (WOMS, Worktree `dev-termine`):** Modify `src/lib/funktionenView.js`, `src/components/customer/CustomerFunktionen.jsx`, `src/lib/teamApi.js`; Tests `tests/unit/funktionenView.test.mjs`.
**Consumes:** F13a (Feld `termine.textformNoetig`, Body `textformDate`/`textformChannel`), D9/D10 (Bedienung „Aktivieren (ohne Zahlung)“ nur in `off`/`disabled`, Panel in `CustomerFunktionen.jsx`).
**Produces:** im Bestätigungsdialog „Terminplaner ohne Zahlung aktivieren?“ erscheinen bei `textformNoetig` Haken (Text wie in F13a), Datum und Weg (Wiederverwendung von `CHANNELS`/`canSubmitTextform` aus `analyseView.js`), `termineChatTemplate(version, links)` (Kopierknopf): „Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version}: ${lb} · ${avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.“ (versionierte Adressen); News und „Abschalten“ unverändert.
- [ ] Tests (WOMS): `termineChatTemplate` wörtlich, `canSubmitTextform` mit `minDate` = Fassung, Dialogtext, Body `{ …, textformDate, textformChannel }`; zusätzlich einmal `nice npx vite build`.
- [ ] FAIL → implementieren → PASS → `npm test` (WOMS) und `nice npx vite build` → Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), WOMS“
### Task F14: WOMS „Rechtliches“: Karten der Terminplaner-Unterlagen
**Files:** Modify `scripts/setup-legal.mjs` (`DOCUMENTS :89-96`) im WOMS-Worktree; Test `tests/setup-legal.test.mjs`.
**Produces:** drei neue Karten (sort 7–9): `leistungsbeschreibung-terminplaner` („Leistungsbeschreibung Terminplaner“, `liveUrl` `https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html`), `avv-terminplaner` („AVV Terminplaner (Auftragsverarbeitung)“, `…/avv-terminplaner.html`), `widerrufsbelehrung-funktionen` („Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)“, `…/widerrufsbelehrung-funktionen.html`); die Adressen ohne Fassung sind die Alias-Seiten aus F1. Das Skript legt nur die Karten an (idempotent); **Ausführung mit API-Key und Upload der Versionen (PDF/HTML der Fassung) macht der Inhaber** (Livegang-Schritt 7; `md` ist im Bucket nicht erlaubt, das Datenschutz-PDF geht als neue Version der vorhandenen Karte `datenschutz`). `--dry-run` listet sie. Die Analyse-Karten werden hier nicht ergänzt.
- [ ] Tests: `documents_keys_eindeutig_und_sort_lueckenlos` (Schlüssel `^[a-z0-9-]{1,64}$`, `sort` 1..n), `terminplaner_karten_vorhanden` (drei Schlüssel, Titel, `liveUrl` beginnt mit `https://project.webklar.com/rechtliches/` und endet ohne Fassungsdatum), `bestehende_karten_unveraendert` (die sechs alten Einträge deepEqual), `dry_run_listet_karten` (Skript mit `--dry-run`, keine Netzaufrufe).
- [ ] FAIL → implementieren → PASS → `npm test` (WOMS) → Commit „Rechtliches: Karten für Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners“
### Task F15a: Kanzlei-Paket 1 – Aufbau, Texte, Ablauf, Fundstellen (Abschnitte 1–3 und 5–7)
**Files:** Create `docs/rechtliches/termine-kanzlei-paket.md`, `test/termineKanzleiPaket.test.js`.
**Consumes:** alle Texte aus F2–F14 (Zitate), Register, Code-Fundstellen.
**Aufbau** (Überschriften verbindlich): `## 1. Zweck und Umfang` (Kernpaket nach D5: Widerrufsbelehrung + Muster-Formular der 149-€-Bestellung, Zustimmungssatz, AVV, Haftungs- und Ende-mit-Abo-Klauseln der LB, Gästetexte mit Gesetzlichkeitsfiktion; Hausrunde: 6c, Portal-Hinweise, Mails; keine Rechtsberatung durch WEBklar; Fassungen `<F>`/`<V>`) · `## 2. Produktablauf in Kurzform` (Abo-Prüfung → Fragebogen-Entwurf → Bestellung mit vier Haken → Nachweis → Stripe → Ticket/Einbau → Freischaltung → Betrieb; Gast: Haltezeit 30 Min., Bestätigungslink, Widerrufsfunktion; Ende des Abos: Abschalten spätestens am Folgetag, 30 Tage, Rückkehr, Erstattung vor Freischaltung; Zahlen 149 €, 14 Tage, 48 Stunden, 90 Tage, 7 Tage) · `## 3. Texte in Endfassung` (wörtlich als Zitatblöcke `> `: vier Bestell-Haken + Knopf, Widerrufsbelehrung + Formular der Funktionen, LB Nr. 5, 7, 8, 11, AVV Nr. 7 und 9, Gäste-Belehrung, Gäste-Haken, Datenschutz-Hinweis am Formular, Knopftexte; AVV und LB in voller Länge als PDF-Anlagen A/B; hinter jedem Zitat `⚖ Q<n>`) · `## 4. Fragen an die Kanzlei` (`### Q1` … `### Q26`, je: Frage, Fundstelle im Text, unsere Annahme, Alternative) · `## 5. Fundstellen im Code` (Tabelle Thema | Datei | Funktion/Konstante, ohne Zeilennummern) · `## 6. Nicht Teil der Kanzleiprüfung` (Hausrunde; Liste 7.4 „Später“) · `## 7. Checkliste für den Inhaber` (Hetzner-AV für Cloud **und** Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an; Uploads in WOMS; Beauftragung der Kanzlei; Freigabe der Fassung vor dem ersten fremden Betrieb).
**Umfang dieses Tasks:** alle sieben Überschriften und die Abschnitte 1, 2, 3, 5, 6, 7 mit Inhalt; die Zitate in Abschnitt 3 tragen schon ihre `⚖ Q<n>`-Marker (die Nummern sind in F15b festgelegt). Abschnitt 4 besteht hier nur aus der Überschrift und der Zeile „Fragen Q1 bis Q26: Task F15b.“; F15b ersetzt sie.
- [ ] Tests (`termineKanzleiPaket.test.js`): `paket_aufbau` (sieben Überschriften exakt, Fassungen `config.termine.avvVersion` und `RECHTSTEXTE_VERSION` genannt); `paket_zitate_woertlich` (jeder `> `-Absatz steht nach Whitespace-Normalisierung in einer Quelle: Register-Haken, `rechtstexte.js`-Ausgaben, die drei Markdown-Dokumente, `config.ustHinweis`); `paket_fundstellen` (jede Datei der Tabelle existiert, der genannte Bezeichner steht darin); `paket_zahlen_gleich_code` (149 €, 14 Tage, 30 Minuten, 48 Stunden, 90 Tage, 7 Tage, 30 Tage aus Register bzw. den Konstanten); `paket_konsistenz_abo_ende` (LB Nr. 8, AVV Nr. 9, 6c, K22 nennen alle „30 Tage“ und die Rückkehr).
- [ ] FAIL → Paket schreiben → PASS → `npm test` → Commit „Kanzlei-Paket Terminplaner: Aufbau, Texte, Ablauf, Fundstellen“
### Task F15b: Kanzlei-Paket 2 – Fragen Q1 bis Q26 (Abschnitt 4)
**Files:** Modify `docs/rechtliches/termine-kanzlei-paket.md` (Abschnitt `## 4. Fragen an die Kanzlei`: `### Q1` … `### Q26`, je Frage, Fundstelle im Text, unsere Annahme, Alternative), `test/termineKanzleiPaket.test.js`.
**Consumes:** F15a (Aufbau und `⚖ Q<n>`-Marker in den Zitaten), alle Texte aus F2–F14.
**Fragen (feste Nummern):** Q1 Alle Besteller wie Verbraucher (D1): Haken und Belehrung für alle, Hinweis „gilt nur für Verbraucher“ ausreichend? · Q2 Fristbeginn: Klick auf „Zahlungspflichtig bestellen“ (unser Anker), Zahlungseingang oder K15? · Q3 Zustimmungssatz (Zählung § 356 Abs. 5 Nr. 2, Kenntnis-Bestätigung, Dauerhafter Datenträger durch K15) · Q4 **D7 News-Altkunden:** Bestellungen seit 2026-09-30 mit altem Text (Abs. 4, ohne Wertersatz-Hälfte, ohne Musterbelehrung): lief die Frist nach § 356 Abs. 3 BGB nicht an; Belehrung nachholen per Mail oder nur künftig; Anzahl der Fälle liefert der Inhaber · Q5 Wertersatz: gesetzlicher Anteil am Gesamtpreis (Anlage 1) vs. D3 „nach Stundenaufwand“ · Q6 volle Erstattung bei Ende des Abos vor Freischaltung (Klausel LB Nr. 8 Abs. 5, § 307 BGB) · Q7 Ende mit dem Abo, auch Wechsel und Widerruf; Verhältnis Widerruf des Abos zum Einrichtungsvertrag; Ausnahme: ohne Bestellung freigeschaltete Dokumente (Pilot/Kulanz, F13a) nimmt der Abgleich aus (`ausgenommen` im Tagesbericht): genügt „schaltet spätestens am folgenden Tag ab“ trotzdem, oder braucht es eine Kulanz-Klausel? · Q8 Haftung für verpasste Termine (LB Nr. 7, § 309 Nr. 7, Höchstbetrag gegenüber Unternehmern?), Erstattung bei Einstellung durch WEBklar · Q9 30 Tage: Anker `disabledAt`, Rückkehr gratis, Backup-Überhang 7 Tage; Alternative Modell der Analyse (Tag 61, Export bis Tag 46) · Q10 AVV: eigenes Geschwister statt Rahmen-AVV, Hetzner Falkenstein und Nürnberg, „keine Drittlandübermittlung“ trotz Kalender-Abo bei Google/Apple (Abonnement durch den Kunden) · Q11 Gesundheits-Ausschluss: Branchenfrage + Haken + Stichwort-Warnung + Ablehnungsrecht ausreichend (Art. 9)? · Q12 Gästebelehrung: Abweichungen vom Muster (Lieferkosten-Klammer, Widerrufsfunktion als „anderer geeigneter Hinweis“ ohne Adresse, „beginnen soll“ im Singular) und Gesetzlichkeitsfiktion · Q13 Gästetext vorzeitiger Beginn und Wertersatz-Satz · Q14 Anfrage mit Festpreis, Bestätigungslink und Seitenknopf „Zahlungspflichtig bestätigen“ als Bestellung (§ 312j Abs. 3) · Q15 Einstufung Freizeit/Massage/Fotograf/Coaching und der Portal-Hinweis · Q16 Storno-Muster 50 %/24 h (§ 307) und Wegfall bei kostenlosen Leistungen · Q17 Pflicht von Datenschutz-/Impressum-Link und „jederzeit widerrufen“-Satz am Erinnerungshaken · Q18 Datenschutz-Absatz auf der Kundenseite: Einfügen durch WEBklar im Preis, eine Regel für beide LB · Q19 Preisangabe „ohne Umsatzsteuer gemäß § 19 UStG“ (bei Fortbestehen des Kleinunternehmerstatus), „· 149 €“ am Knopf · Q20 Pflichtinformationen (Art. 246a § 1 EGBGB) als Haken und Wortlaut in K15 · Q21 Staff-Aktivierung mit Zustimmung in Textform und K16 mit PDFs · Q22 BFSG-Satz (keine Konformitätszusage) · Q23 Nachweis der Bestellung mit IP/Browser, Frist drei Jahre (Art. 6 Abs. 1 lit. f) · Q24 Rollenverteilung in 6c (Auftragsverarbeiter/Verantwortliche), Entwurfsaufbewahrung 90 Tage · Q25 Mindestlaufzeit des Wartungs-Abos und Terminplaner (kein Sonderkündigungsrecht) · Q26 Nachweis der Gäste: Hash + Archiv der Textfassung genügt (Betriebsdaten ändern sich später)?
- [ ] Tests (`termineKanzleiPaket.test.js`): `paket_marker_und_fragen` (jedes `⚖ Q<n>` hat `### Q<n>`, jede Frage ist mindestens einmal verwiesen, Nummern lückenlos 1–26, Q4 nennt „News“, „§ 356 Abs. 3“ und „Altkunden“).
- [ ] FAIL → Fragen schreiben → PASS → `npm test` → Commit „Kanzlei-Paket Terminplaner: Fragen Q1 bis Q26“
**Abschlussprüfung Block F (Controller):** `npm test` komplett grün (`test/previewDevRouter.test.js` bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): `docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)'` (`analyticsContract.test.js` importiert `../analytics/src` und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: `git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob'` bleibt leer. WOMS-Anteil (F13b, F14): `npm test` im WOMS-Worktree und einmal `nice npx vite build`. PDFs: `build-legal-pdfs.sh termine` und `datenschutz` laufen auf dem Host (vorher `free -m`, mit `nice`).
## Block G: Livegang-Checkliste (Controller, je Schritt Freigabe des Inhabers)
Kein Subagent-Block. Der Controller arbeitet die Schritte in dieser Reihenfolge ab (Spec Abschnitt 9) und holt vor jedem Schritt mit Schreibzugriff auf Produktion oder Stripe die Freigabe des Inhabers ein. Claude pusht nicht auf `main`; das macht der Inhaber. **Betriebsannahme:** genau ein Portal-Prozess (Sperren, Grenzen und das Mailbudget liegen im Speicher; jeder zweite Prozess bricht das; festhalten, nicht ändern). Die Schritte G1–G10 entsprechen 9.1–9.10 der Spec.
- [ ] **G1 Rechtsrunde (9.1):** Texte aus F2–F12 und das Kanzlei-Paket (F15a, F15b) fertigstellen; der Inhaber beauftragt die Kanzlei; Hausrunde für den Rest (Portal-Hinweise, Mails, 6c).
- [ ] **G2 Code in den Worktrees (9.2):** Blöcke A–F fertig, jede Abschlussprüfung grün (einschließlich Node-20-Lauf und Grep-Wächter), WOMS-Build ohne Fehler; die Test-Pins aus den Berichten sind mitgeändert (`funktionenRoutes`, `termineRoutes`, `termineSetup`, `termineView`, `kundenansichtGuard`, `legalTexts`, `mailTemplates`, `termineJobs`, dazu `featureRegistry`, `featureConsents`, `featureMails`, `funktionenCheckout`).
- [ ] **G3 Schema in der Produktions-Appwrite vor dem Code-Deploy (9.3, Freigabe):** `node scripts/termine-setup.mjs --check`, dann anwenden (`termineConfig.fragebogen`, Indizes `termineBookings(status,endsAt)` und `(status,updatedAt)`, `termineOutbox(bookingId)`); `node scripts/feature-setup.mjs --check`, dann anwenden (`portalFeatures.disabledReason`, `aboEndeMailAt`, `zahlungMeldung`, neue Collection `featureConsents` samt Indizes). `scripts/setup-abo-collections.mjs` wird **nicht** ausgeführt. Der Schritt liegt vor dem Deploy, weil `activate()` die neuen Felder schreibt (ohne Schema `Unknown attribute`); der Indexaufbau ist asynchron, die Abfragen funktionieren auch ohne.
- [ ] **G4 Stripe (9.4, Freigabe):** `node scripts/stripe-products.mjs --pruefen`, dann `--anlegen` für `termineSetup` (Lookup-Key `webklar_termine_einrichtung_14900`); der Inhaber prüft im Dashboard, dass der Live-Webhook `async_payment_succeeded`, `async_payment_failed` und `expired` abonniert hat (Annahme: ja, News läuft seit 2026-09-30).
- [ ] **G5 Messprüfungen (9.5, Spec 8 #7, Freigabe):** (1) tatsächliche Größe von `workorders.details` (trägt `TICKET_DETAILS_MAX = 7000`?); (2) Seiten-Listing über 500 Dokumente auf der Live-Appwrite 1.8.1 (die Seitenläufe aus A3a–A6 lesen mit `limit ≤ 100` ohne Cursor; im Nachbau bisher nur Spike); (3) `Query.notEqual` mit Liste und `total`/`countDocuments` (A2, A9a, A10b, E4) auf 1.8.1 mit derselben Bedeutung wie im Nachbau, `limit(1)`-Antwort mit `total` bis 5000; (4) tatsächliche Hetzner-Antwort bei mehr als 500 Mails je Stunde; (5) Hetzner-AV-Prüfung durch den Inhaber (Cloud **und** Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an). Ergebnisse ins Ledger; weicht ein Wert ab, geht der betroffene Task an seinen Block zurück.
- [ ] **G6 Deploy Portal + WOMS (9.6, der Inhaber pusht):** Modus bleibt `pilot` mit leerem `TERMINE_PILOT_EMAILS` plus Pilotkunde „test“. Der Abo-Abgleich läuft zunächst nur beobachtend: das ist durch den Datenstand gegeben, denn er schaltet nur Dokumente mit `unlockedByPurchase === true` ab, und davon gibt es vor der ersten bezahlten Bestellung keines (Pilotkunde und Kulanzfreigaben stehen im Tagesbericht als `ausgenommen`); ein eigener Beobachtungsschalter ist nicht gebaut. Tagesbericht und `GET /api/team/termine/status` beobachten.
- [ ] **G7 Rechtstexte live (9.7):** PDFs bauen (`build-legal-pdfs.sh termine` und `datenschutz`), HTML/PDF unter `/rechtliches`, 6c + PDF der Datenschutzerklärung, WOMS-Karten (`scripts/setup-legal.mjs` mit API-Key, Freigabe) und Uploads der Fassung durch den Inhaber, Gästetexte final (`RECHTSTEXTE_VERSION`).
- [ ] **G8 Ende-zu-Ende-Test (9.8)** mit einem Testkunden und echtem Wartungs-Abo: Fragebogen → Bestellung (echte 149 €, Rückerstattung durch den Inhaber danach; Annahme: es gibt kein Test-Konto für diesen Preis) → Ticket + Anleitung → „Freischalten (Abnahme)“ → Probebuchung → Abo-Ende-Simulation (Testkunde) → K22 → Rückkehr K23 → Löschlauf auf Wegwerfdaten. Dazu die Sichtprüfung des WOMS-Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) und die Playwright-Läufe (`termine-fragebogen.ui.mjs`, `termine-portal.ui.mjs`, `funktionen.ui.mjs`, `termine-widget.ui.mjs`), nacheinander, vorher `free -m`, mit `nice`.
- [ ] **G9 Erster fremder Kunde im Pilot (9.9)**, erst nach dem Kanzleibefund (Entscheidung D5 der Spec): nur seine Adresse in `TERMINE_PILOT_EMAILS`; Beobachtung von Tagesbericht, Mailbudget und Status-Route (Freigabe der Adresse).
- [ ] **G10 `TERMINE_ENABLED=on` (9.10)** als **letzter** Schritt (Compose-Änderung, nur `kundenbereich-server` neu erstellen); danach eine Woche Beobachtung, News-Altfälle (D7) und der Entwurfs-Löschjob werden geprüft.
---
@@ -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):
@@ -23,7 +23,7 @@ Die Funktion ersetzt die Kachel „Terminplaner · Kommt bald“ (`public/funkti
| # | Entscheidung |
|---|---|
| E1 | Abrechnung: **einmalige Einrichtung + nur solange ein Wartungs-Abo läuft** (wie Website-Analyse). Endet das Abo, endet der Terminplaner. Hosting-Abo reicht nicht. |
| E2 | Einrichtungspreis **149 €** (brutto, einmalig, Stripe Checkout wie News). |
| E2 | Einrichtungspreis **149 €** (ursprünglich „brutto“, seit TP2 ohne USt nach § 19 UStG; einmalig, Stripe Checkout wie News). |
| E3 | Umfang V1: **Personen und Plätze** (Einzelperson; Team mit Wahl der Person oder „egal“; buchbare Plätze wie Kabine, Hebebühne). Pro Leistung: **sofort verbindlich** oder **Anfrage** (Betrieb bestätigt). Gruppen/Kurse/Tische später. |
| E4 | Der Gast **bestätigt seine E-Mail-Adresse per Link**, bevor der Termin gilt; der Slot ist so lange 30 Minuten reserviert. |
| E5 | **Ein gemeinsames Widget**, je Kunde gestaltet (Farben, Schrift, Rundungen, Texte, Position) + offene, versionierte Schnittstelle für Sonderfälle. |
@@ -0,0 +1,301 @@
# Terminplaner – TP2 Verkauf: Design
Stand 2026-10-03. Basis: Portal `main` 3a0b008, WOMS `test` 65e9931, TP1-Spec
`docs/superpowers/specs/2026-10-02-terminplaner-design.md` (E1–E8, Abschnitt „TP2 Verkauf“), TP1-Ledger R1–R55
(`/root/reports/terminplaner-tp1/progress.md`). Erkundung: `/root/reports/terminplaner-tp2/erkundung-2026-10-03.json`.
Pfade: **P** = Portal-Repo (`kundenbereich.webklar.com`), **W** = WOMS-Repo (`ticket.webklar.com`).
Alles unten ist aus dem Code gelesen (nichts gegen Produktion oder Stripe gelaufen), außer Aussagen mit „(Annahme)“.
„Festlegung“ = technische Entscheidung. Die geschäftlichen und rechtlichen Entscheidungen D1–D8 hat der Inhaber am
2026-10-03 getroffen (Abschnitt 10); sie sind in alle Abschnitte eingearbeitet.
---
## 1. Ziel und Abgrenzung
**TP2 liefert:** Ein Kunde mit laufendem Wartungs-Abo bestellt den Terminplaner selbst: Kachel → Fragebogen (Entwurf) →
Bestellung mit Zustimmungen → Stripe (149 € einmalig) → automatisches Ticket mit Einbauanleitung → Einbau durch das Team →
Freischaltung → Abo-Kopplung (endet das Abo, endet der Terminplaner, Löschung nach 30 Tagen). Dazu der Rechtstexte-Satz
(Leistungsbeschreibung, AVV, Datenschutz 6c, Gästetexte final) und danach `TERMINE_ENABLED=on`.
**Bleibt für TP3:** Online-Zahlung der Gäste (Stripe Connect, Erstattungen, Connect-Webhook), Abo-Bedingungen v6 bzw.
Rahmen-AVV mit Anlage je Funktion, Stripe-Absatz in 6c, Nachweisdatensätze über 90 Tage.
**Pilotkunde „test“ (kenso.gri@gmail.com):** bleibt per WOMS „Aktivieren (ohne Zahlung)“ aktiv (`unlockedByPurchase=false`).
Festlegung: Der tägliche Abo-Abgleich (Abschnitt 6) nimmt solche Dokumente aus, sonst würde der Pilot beim ersten Lauf
abgeschaltet. Seine Daten und Einstellungen bleiben unverändert. Er dient als Prüfkunde für den Livegang-Test (Abschnitt 9).
**Festlegungen zum Umfang:**
- Ein Terminplaner gilt **je Website-Projekt** (Feature-Dokument je Projekt, wie News). 149 € fällt je Projekt an und
umfasst ein Widget an einer Stelle mit Gestaltung nach Fragebogen und den Einbau; Mehrwünsche (zweites Widget, Knöpfe je
Leistung, Sonderdesign) nach Stunden (D4).
- Nur der Modus „vor Ort bezahlen“ (E7). Keine Gruppen, kein Gesundheitsbereich (Spec „Nicht Teil“).
- News bleibt unverändert nutzbar; es wird nur auf das Register umgestellt (Abschnitt 4), Verhalten und Ticket-Text von News bleiben identisch.
---
## 2. Ablauf aus Kundensicht
| Zustand | Kachel (`P/public/funktionen.js`) | Ansicht `#termine` (`P/public/termine.js`) | Mail |
|---|---|---|---|
| ohne Wartungs-Abo | „Im Wartungs-Abo enthalten. Zu Verträge“ (wie `analyseTile`), kein Fragebogen | Hinweis | – |
| `off` (Entwurf, auch ohne Doc) | „Terminplaner einrichten · 149 €“ bzw. „Fragebogen fortsetzen“ | Fragebogen, 8 Schritte + „Prüfen und bestellen“ | – |
| `ordered` | „Zahlung offen“ | Statuskarte, Knopf „Zahlung fortsetzen“, Einstellungen nur lesbar | – |
| `setup` | „In Einrichtung“ | Statuskarte (Ticketnummer, Rechnung), Einstellungen nur lesbar | K15, T16 |
| `active` | „Termine öffnen“ | wie TP1 | K16 |
| `disabled` | „Termine ansehen“ | lesen + CSV-Export, Banner nach Grund (Abo beendet + Löschdatum) | K22 |
Schritte:
1. Kunde mit laufendem Wartungs-Abo (Prüfung Abschnitt 6) sieht die Kachel. Er startet den Fragebogen; jeder Schritt speichert sofort als Entwurf (Abschnitt 3).
2. „Prüfen und bestellen“: Zusammenfassung, offene Punkte (Server rechnet `bestellpruefung`), Zustimmungen (Abschnitt 4.3), Knopf „Zahlungspflichtig bestellen“.
3. `POST /api/funktionen/termine/checkout` legt `ordered` an, Stripe Checkout (Karte + SEPA wie News). Rücksprung `#termine?bestellt=1&projekt=<id>`.
4. Webhook (bestehender Pfad, `abo.js:520-525`) → `markPaid`: `setup`, Ticket, K15 an Kunde, T16 an Team.
5. Team baut ein (Anleitung im WOMS), Abnahme, Freischaltung durch Ticket „Erledigt“ oder Staff-Knopf (Abschnitt 5) → `active`, K16.
6. Ab dann Betrieb wie TP1. Endet das Abo: täglicher Abgleich → `disabled`, K22, nach 30 Tagen Löschung (Abschnitt 6).
Festlegung Sperre ab Bestellung: Ab `ordered` ist die Einstellung für den Kunden nur lesbar, weil das Team gegen den bezahlten
Stand baut (sonst driftet Ticket-Kurzfassung gegen Einstellung). Änderungen laufen über den Chat; Staff in Kundenansicht darf
weiter per PATCH ändern. Scheitert die Zahlung (`markCheckoutFailed`, `ordered` → `off`), ist der Entwurf wieder bearbeitbar.
Mails (alle Du-Form, Layouts der Inhaber-Vorlagen, nur Platzhalter ergänzen; Annahme: Inhaber akzeptiert das):
- **K15** Bestellbestätigung: `{{funktion}}`, `{{preis}}` aus Register, Block „kein Abo“ nur für News. Neu: Anhänge Leistungsbeschreibung + AVV (PDF, Fassung) und Widerrufsbelehrung mit Muster-Formular auf dauerhaftem Datenträger (Muster K18/`legalPdfAttachments`).
- **K16** „bereit“: `{{funktion}}`, `{{portal_url}}` = `#${returnHash}`, Knopftext aus Register.
- **T16** Team: `{{funktion}}`, Link zur Anleitung, neue Blöcke `kein_abo` und `bereits_aktiv` (Abschnitt 4.5).
- **K22** „Terminplaner beendet“ (Varianten Abo beendet, auf Hosting gewechselt, vor Einrichtung beendet mit Erstattung) und **K23** „läuft wieder“; **T19** an das Team „Erstattung fällig“ (4.6). IDs K22, K23, T19 sind frei (`test/mailTemplates.test.js` führt die Liste und den Kopfkommentar). Layouts nach den Vorlagen des Inhabers, Texte gehen in seine Mail-Überarbeitung.
- **K04/T09** (Widerruf): `{{produkt}}`, `{{preis}}` statt fester „News-Pinnwand, 30 €“-Texte.
---
## 3. Fragebogen
### 3.1 Speicherung (Festlegung: ein Dokument, kein Kopieren)
- Der Entwurf **ist** die spätere Live-Einstellung: `termineConfig.einstellung`, Doc-ID = Feature-ID (`kontext.js:12`). Kein Compile- oder Kopierschritt bei Bestellung oder Freischaltung.
- Neues Attribut `termineConfig.fragebogen` (JSON, 16 000 Zeichen, Schema 1): `bestaetigt[]` (Schrittschlüssel), `bundeslandGewaehlt`, `preiseGeprueft`, `einbau{modus inline|knopf, knopfText ≤40, stelle ≤300, gestaltung ≤1000, bemerkung ≤1000}`. Grund: `normalisiereEinstellung` verwirft unbekannte Schlüssel (`einstellungen.js:505-541`), und Einbauwünsche dürfen nie in die öffentliche Konfiguration. Setup über `scripts/termine-setup.mjs` (idempotent), `test/termineSetup.test.js:78` und `test/support/schema.js` mitziehen.
- Schritt „erledigt“ lässt sich nicht aus den Daten ableiten (Vorlagen machen jeden Stand gültig), deshalb die gespeicherte Bestätigung je Schritt.
- `PUT /api/termine/fragebogen` schreibt nur `fragebogen`, `updatedAt`, `updatedBy` unter `withLocks('termine-cfg:'+id)`, **ohne** `version` zu erhöhen (offene Editoren bleiben gültig). Nicht auf der Kundenansicht-Allow-Liste: Schritte bestätigt nur der Kunde selbst.
### 3.2 Zugriff in Entwurfszuständen
- `withTermine({ entwurf: true })` (`P/server/routes/termine.js:137-157`) nur für `GET /einstellungen`, `POST /einstellungen/vorlage`, `PATCH /einstellungen`, `PUT /fragebogen`. Erlaubt `off`, `active`; `ordered`/`setup` nur lesend (GET), Schreiben dort nur mit `req.session.viewAs`. `disabled` bleibt wie TP1. Alle anderen Routen behalten die Schranke `active|disabled` (Gäste- und Job-Pfade unberührt: `kontextPerFeature` bleibt, neuer Helfer `kontextFuerPortal`).
- Ohne Feature-Dokument antwortet GET 200 `{state:'off', einstellung:null, vorlagen, branchen}` und legt nichts an; der erste Schreibzugriff ruft `ensureFeatureDoc` (Doc `off`, dauerhafter siteKey).
- Entwürfe sind nie öffentlich: `kontextPerSiteKey` → `findActiveBySiteKey` verlangt `active` (`siteFeatures.js:390-400`).
- PATCH ruft `normalisiereEinstellung(..., {entwurf: state!=='active'})` (überspringt nur R13/R13a), Antwort enthält `fehlt: fehlendePflichtangaben(...)`; der Client (`termine-speichern.js:116`) übernimmt es statt `[]`. Tests `termineRoutes.test.js:247-264` ändern sich entsprechend.
### 3.3 Schritte und Wiederverwendung der TP1-Editoren
| # | Schritt | Umsetzung |
|---|---|---|
| 1 | Branche, Vorlage, Gesundheits-Ausschluss | `BRANCHEN`-Liste in `vorlagen.js`; Eintrag „Gesundheitsbereich“ ohne Vorlage blockt; `POST vorlage` mit `ersetzen:true` im Entwurf, optionalen `betrieb`-Feldern (Name, E-Mail, Telefon, Anschrift, Bundesland, Website) und Vorbelegung der Website aus dem Projekt |
| 2 | Wer wird gebucht | neue kleine Form: Einzelperson / Team / Plätze, Namen; setzt neue Ressourcen im selben PATCH allen Leistungen zu (Validator verlangt aktive Ressource) |
| 3 | Leistungen | `termine-tab-leistungen.js` `render` unverändert, plus Haken „Preise geprüft“ (Vorlagenpreise sind Platzhalter) |
| 4 | Zeiten, Bundesland, Urlaub | Wochenplan-Helfer aus `termine-tab-team.js:69` exportiert, `abwesenheiten/feiertage/sonderoeffnungen` (bereits exportiert); Bundesland **ohne** Vorauswahl (Server-Default `RP` bei fehlendem Wert, `einstellungen.js:512`) |
| 5 | Regeln, sofort/Anfrage | `regeln`, `storno`, `rechtliches` (gaesteSind, freizeit), `zahlung` aus `termine-tab-einstellungen.js` exportiert; Sammelknopf „sofort/Anfrage für alle“ |
| 6 | Gastfelder | `gastFelder` exportiert |
| 7 | Benachrichtigungen, Anrede, Erinnerung | `darstellung`, `erinnerung`, `betrieb.email` (einzige Adresse; sie ist auch Reply-To und Ziel von K20/K21) |
| 8 | Website | `betriebLinks` (Impressum, Datenschutz, AGB, Website), `telefon` (= Ausweichtelefon), Einbauwünsche-Form |
| 9 | Prüfen und bestellen | siehe Abschnitt 4 |
Festlegungen UI: neue Datei `P/public/termine-fragebogen.js` (Name in die Liste `test/termineView.test.js:277-280`; Du-Form, kein `innerHTML`, kein `href`); die Bestellseite wegen der Link-Regel (`termineView.test.js` ~290: nur ein `exportUrl`-href) in `P/public/funktion-bestellen.js` (heißt nicht `termine*.js`, wird relativ importiert, R4). Jeder Schritt = ein `<form>` mit Weiter = ein PATCH über `neuerSpeicher` (einziger PATCH-Pfad), danach `PUT /fragebogen`. Pro Betrieb nur ein Ressourcentyp (Annahme: Person+Platz gemischt ist im Validator nicht verboten, das Verhalten ist ungeprüft).
### 3.4 Bestell-Gate (serverseitig, `server/services/termine/fragebogen.js`, reine Funktion `bestellpruefung`)
Offen = Liste `{schritt, text}`; Checkout lehnt bei nicht-leerer Liste ab, GET zeigt sie. Festlegung strikt: bestellbar nur wenn
(a) `fehlendePflichtangaben` leer, (b) mind. eine sichtbare Leistung, (c) alle Schritte bestätigt, (d) Bundesland gewählt, (e) `einbau.modus` gesetzt, (f) Branche nicht Gesundheit, (g) `impressumUrl` und `datenschutzUrl` vorhanden (der Gästehinweis verweist auf die Datenschutzerklärung; das Widget lässt den Link sonst still weg, `embed/termine.js:519`), (h) Wartungs-Abo sauber laufend (Abschnitt 6). Offene Punkte klärt der Kunde vor der Bestellung, nicht per Chat danach.
### 3.5 Gesundheits-Ausschluss (D6)
Heute nur Hinweistexte (`rechtstexte.js:169`), keine Sperre. Umsetzung nach D6: Branchenfrage mit hartem Ausschluss (kein Entwurf),
Haken „Mein Angebot liegt nicht im Gesundheitsbereich“ im Bestellschritt (mit Zeit im Zustimmungsnachweis), und eine weiche
Stichwortprüfung über Leistungsnamen, Beschreibungen und Zusatzfeld-Beschriftungen (Liste in `fragebogen.js`, z. B. Physio,
Therapie, Rezept, Beschwerden, Diagnose, Behandlung, Arzt, Heilpraktiker): nur ein Warnblock in T16 und im Ticket, kein Fehler
für den Kunden. Das Team kann die Einrichtung ablehnen (Erstattung nach 4.6, Variante Widerruf/Kulanz). „Sonstiges“ bleibt wählbar.
### 3.6 Einbauwünsche
Modus `inline` oder `knopf` (Snippet erweitert um `data-modus`, `data-knopf`; Widget kann das, `embed/termine.js:232-234`), Knopftext, Stelle auf der Website in Worten, Gestaltungswunsch, Bemerkung. Festlegung V1: keine Knöpfe je Leistung (`data-leistung` nur auf Nachfrage über Bemerkung, Mehraufwand nach Stunden). Texte des Kunden werden im Ticket nur als Text und in einem Block „Kundenwunsch (Text des Kunden)“ ausgegeben.
### 3.7 Entwürfe aufbewahren und löschen
Heute löscht niemand einen Entwurf (Job `loescheAbgeschaltete` nur `disabled`; `disableCustomerFeatures` überspringt `off`, `siteFeatures.js:321-326`). Festlegung: Tagesjob-Schritt löscht `termineConfig` + Feature-Doc von Dokumenten in `off` (nie `ordered/setup`), die 90 Tage nicht geändert wurden (Annahme: 90 Tage passt zur Datenschutz-Aussage, Rechtsrunde bestätigt); Kundenlöschung (`customerAdmin.js:289`) entfernt Entwürfe ebenfalls. Die Datenschutzerklärung (6c, §7) nennt Entwurfsdaten und die Frist.
---
## 4. Bestellung und Zahlung
### 4.1 Register `config.features` (ersetzt feste News-Werte)
`P/server/config.js`: neben `newsSetup` neuer Katalogeintrag `termineSetup = {key, name:'Terminplaner – Einrichtung', amountCents:14900, lookupKey:'webklar_termine_einrichtung_14900', oneTime:true, priceNickname}`, Stripe-Metadaten-Schlüssel `termine-einrichtung`. Neu `config.features = { news:{…}, termine:{…} }` mit: `product`, `label{name,kurz,keine}`, `priceLabel`, `setupEstimate`, `consent{version, items[]}`, `leistungsbeschreibungVersion`, `returnHash`, `ticket{topic(project), details(ctx)}`, `mails{funktion, knopf, danach, preisHinweis}`, `requiresWartungsAbo`, `disabledMessage`, `orderedConflictMessage`. `config.funktionen.{setupEstimate,consentText}` bleiben als News-Alias, damit `funktionenRoutes.test.js:63-64` nur gezielt geändert wird.
Zu ersetzende News-Festverdrahtungen (alle verifiziert): `stripeBilling.js:54-61` (`PRODUCT_METADATA`, `catalogFor`), `:649` (`ensurePrice('newsSetup')`), `:661`, `:665-666` (URLs), `routes/funktionen.js:122,137,158-171`, `siteFeatures.js:17-18` (`DISABLED_MESSAGE`), `:360` (`activateClosedTickets` nur `news`), `:406` (`portalUrl` = `#news`), `:537-538`, `:584-587`, `ticketAdmin.js:80,85,95-102`, `public/funktionen.js:9,261-317`.
### 4.2 Stripe
`createFeatureCheckout(customer, {projectId, key, consentAt})` liest Preis, Rechnungsbeschreibung, Rücksprung und Abbruch-URL aus dem Register; `invoice_data.metadata` mit `featureKey`, `projectId`. SEPA bleibt wie bei News (Festlegung; Einbau startet erst bei `setup`, also nach Zahlungseingang). Der Preis `termineSetup` wird vor dem ersten Live-Kauf per `scripts/stripe-products.mjs --pruefen/--anlegen` angelegt (`KEYS` `:20` und `catalogEntry` `:30-34` ergänzen; Test nach Vorbild `stripeBilling.test.js:246-260`), sonst legt ihn der erste Kunden-Checkout lazy an (und `ensurePrice` sucht nur in den ersten 100 aktiven Produkten, `:81-82`).
### 4.3 Route und Zustimmungen
- `POST /api/funktionen/:key/checkout` (Schlüssel gegen Register; `/news/checkout` bleibt Alias). **Nicht** unter `/api/termine/`: dort erlaubt `kundenansichtGuard.js:19` jedes POST für Staff in Kundenansicht. Zusätzlich `req.session.viewAs` → 403 (wie `funktionen.js:139`).
- Reihenfolge: viewAs, Zustimmungen vollständig und Fassung = aktuelle Serverfassung (sonst 409 mit neuer Fassung), Projekt gehört dem Kunden, `termineFuerKunde(email)` (Pilot-/Modusschranke), `bestellpruefung` leer, Wartungs-Abo (Abschnitt 6), Lock `feature-order:<customer>:<project>`, Zustandsprüfung (Meldungen aus dem Register), `markOrdered`.
- Zustimmungen für Termine (Wortlaut ist Rechtsrunde, hier nur Struktur; nach D1 werden **alle** Besteller wie Verbraucher behandelt): (1) Leistungsbeschreibung + AVV Terminplaner, Fassung X, mit versionierten Links; (2) „kein Gesundheitsbereich“; (3) ausdrückliches Verlangen des Beginns vor Ablauf der Widerrufsfrist mit Hinweis auf Erlöschen (§ 356 Abs. 5 Nr. 2 BGB) **und** Wertersatz (§ 357a Abs. 2 BGB; die News-Zustimmung hat nur die erste Hälfte); (4) Pflichtinformationen (Leistung, Gesamtpreis, Ende mit dem Wartungs-Abo, Einrichtungsdauer) und volle Widerrufsbelehrung + Muster-Formular vor dem Knopf; (5) Knopf „Zahlungspflichtig bestellen“; ob „· 149 €“ am Knopf steht, entscheidet die Rechtsrunde (Recherche rät zu nichts außer den gesetzlichen Worten; die Spec schreibt „· 149 €“).
- Nachweis: neue append-only Collection `featureConsents` (Muster `analyseConsents`; Felder customerId, projectId, featureKey, stripeSessionId, Fassungen, vollständige gezeigte Texte, Hash, Zeit, IP, UA, Konto). Die 1000-Zeichen-Spalte `portalFeatures.consentText` reicht für den Satz nicht (`setup-abo-collections.mjs:277`). IP/UA kommen in Datenschutz §7.
- Widerrufs-Anker: Zeit des Klicks (Vertragsschluss) aus `featureConsents`, nicht `orderedAt` (der bei Neubestellung überschrieben wird). Annahme: so ist die Frist korrekt gedeutet, Rechtsrunde.
### 4.4 Rechnung
Stripe erzeugt sie nach Zahlung (`invoice_creation`), `backfillInvoiceLinks` ergänzt den Link später (K15 geht deshalb oft ohne Link, Block `rechnung`; unverändert). Preistext „149 € (ohne Umsatzsteuer gemäß § 19 UStG)“ statt „brutto/inklusive Mehrwertsteuer“ (Spec E2 sagt „brutto“; Stripe-Footer und Abo sagen § 19, Annahme: WEBklar bleibt Kleinunternehmer, Rechtsrunde bestätigt; gilt gleichzeitig für News).
### 4.5 Fehlerfälle
- **Verschluckte Zahlung (heute real):** Aktiviert Staff ein `ordered`-Dokument per „Aktivieren (ohne Zahlung)“, sieht der Webhook `created:false` und tut nichts (`siteFeatures.js:249,521`). Festlegung: `onFeaturePaid` schickt in dem Fall T16-Block `bereits_aktiv` (Zahlung ohne Ticket), und WOMS blendet die Aktion in `ordered`/`setup` für Termine aus (Abschnitt 5).
- **Zahlung ohne Abo** (Abo endete während der 24-h-Sitzung): Dokument geht trotzdem auf `setup` samt Ticket (Geld ist da), T16 mit Block `kein_abo`; Aktivierung bleibt gesperrt, bis ein Abo läuft. Nach D3 wird voll erstattet, wenn das Abo vor der Fertigstellung endet (siehe 4.6).
- **Ticket scheitert** nach Zahlung: bestehende Wiederaufnahme (`setup` ohne `ticketId`, Stripe-Retry) bleibt; neu zusätzlich Fallback auf Minimal-Details (Abschnitt 5).
- **Zahlung offen:** Knopf „Zahlung fortsetzen“ (derselbe Checkout liefert die offene Sitzung, `openOrderSession`); SEPA kann Tage in `ordered` bleiben.
### 4.6 Erstattung (D3)
- Gesetzlicher Widerruf: Erstattung abzüglich Wertersatz für einen schon begonnenen Einbau (nach Stundenaufwand, § 357a Abs. 2 BGB).
- Zusätzlich **volle Erstattung**, wenn das Wartungs-Abo endet oder widerrufen wird, bevor der Terminplaner `active` ist (Zustand `ordered`/`setup`).
Festlegung: Der tägliche Abgleich (Abschnitt 6) erkennt den Fall, setzt das Dokument auf `disabled` mit `disabledReason='abo_ende_vor_einrichtung'`,
schließt keine Tickets selbst, und meldet ihn dem Team per Mail **T19** „Erstattung fällig: Terminplaner-Einrichtung“ (T17/T18 sind vergeben) (Kunde, Projekt, Stripe-Sitzung,
Rechnung). Die Erstattung selbst löst der Inhaber in Stripe aus (keine automatische Rückzahlung, keine Live-Stripe-Aufrufe durch Code ohne Freigabe).
Der Kunde bekommt K22 in der Variante „vor Einrichtung beendet, Betrag wird erstattet“.
- Das Ticket bleibt offen, bis das Team es mit Vermerk schließt; die Freischaltung beim Schließen prüft Abo und Zustand und schaltet dann **nicht** frei.
### 4.7 Widerrufsformular
- Widerruf: `widerrufen.html` bekommt eine eigene Auswahl „Terminplaner (Einrichtung)“; `withdrawFeature`/`paidFeatureOrders` rechnen die Frist **je Funktion** (heute Maximum über alle Bestellungen, `abo.js:1091-1093`), T09 nennt die Funktion. Abwicklung bleibt Handarbeit des Teams (Rückerstattung, Abschalten, Ticket).
---
## 5. Ticket, Einbauanleitung, WOMS
**Ticket:** `markPaid` übergibt `topic`/`details` aus dem Register an `createFeatureTicket` (kann es schon, `analyse.js` nutzt es). Festlegungen: `topic` auf 128 Zeichen kürzen (heute ungekürzt, `ticketAdmin.js:85`), Details-Deckel 7000 Zeichen (Schema nennt 8000, **ungeprüft**, das Setup-Skript ist für `portalFeatures` nachweislich veraltet); scheitert `createDocument` mit langen Details, einmal mit Minimal-Details (Kopfzeilen + Verweis) wiederholen. Zuweisung: keine, wie News; T16 meldet das Ticket. Ticket-Hinweis „Nicht auf das Stundenkontingent anrechnen“ (nach `analyseTexts.js`).
**Generator:** `P/server/services/termine/einbauanleitung.js`, rein und deterministisch (`now` Parameter), liest `ladeEinstellung(featureId)` (`kontext.js:75`, kein Modus-/Zustandscheck), **nie `feedToken`** (Feldweißliste + Test). Ausgabe `{stand{version,updatedAt}, warnungen[], abschnitte[{id,titel,text,kopierbar}], text}` mit: Modell, Leistungen, Ressourcen + Zeiten, Regeln, Gastfelder, Benachrichtigungen, Einbau (Snippet mit siteKey; `einbauSnippet` aus `termine.js:185-196` hierher verschoben und um knopf erweitert; CSS-Variablen `--wkt-akzent` aus `farbe`, Kontrastfarbe per WCAG-Helfer; Ausweichtext; Impressum/Datenschutz-Links), Datenschutz-Absatz (Sie/Du nach `anrede`), Abnahme-Checkliste (bedingt: Verbraucher → Widerruf, Knopf → dauerhafter Widerrufsknopf), Abrechnung („Einrichtung bezahlt (149 €, Rechnung …), Zeit im Arbeitsblatt, nicht auf Abo-Stunden“). Die Quelle des Datenschutz-Absatzes wandert aus dem Markdown als `datenschutzAbsatz(einstellung, anrede)` nach `rechtstexte.js` (Test pinnt beide, wie bisher).
- **Kurzfassung** in `ticket.details` (Klartext, WOMS zeigt `pre-wrap`, `TicketCard.jsx:124-127`): Kopf (Funktion, Projekt, Domain, Kunde, siteKey, „Konfigurationsstand: Version N, Datum“), Modus/Stelle/Gestaltungswunsch, Zählungen + erste Namen mit Kürzung, Snippet, 5–6 Schritte, Verweis `womsBaseUrl/customers/<id>?tab=funktionen`. Ohne Datenschutz-Absatz (4,8 k Zeichen je Variante).
- **Route:** `GET /api/team/funktionen/:customerId/termine/anleitung?projectId=` in `funktionenAdmin.js` (`requireStaffJwt`, `assertOwnProject`, `getFeatureDoc`; kein TERMINE-Modus-Gate, kein viewLog, nie aus Kundenseite erreichbar). Vergleich mit dem Ticket-Stand liefert Warnung „Einstellungen seit der Bestellung geändert (Ticket Version N, jetzt M)“. Fehlen Einstellungen: 200 mit Warnung und nur Snippet.
- **Datenschutz-Absatz-Sperre:** Solange die Gästetexte als ENTWURF gekennzeichnet sind, druckt die Anleitung das Banner mit; mit der Freigabe (Abschnitt 7) fällt es weg.
**WOMS** (`W/src/components/customer/CustomerFunktionen.jsx`, `W/src/lib/funktionenView.js`, `teamApi.js`):
- Je Termine-Zeile Knopf „Anleitung“, klappt ein Panel unter der Zeile auf (Stand, Ticketlink, Warnungen, „Alles kopieren“, Teilkopien für Snippet/CSS/Datenschutz Sie/Du); Zeilen werden zu Fragmenten mit Key. Neu `teamApi.getTermineAnleitung`.
- `aktionen(state, key)`: für Termine kein „Aktivieren (ohne Zahlung)“ in `ordered`/`setup`; in `off`/`disabled` bleibt es. Neu in `setup`: **„Freischalten (Abnahme)“** (aktiviert wie Ticket-Schließen, schickt K16, setzt Ticket nicht zurück) und bei „Ticket fehlt“ **„Ticket nachlegen“** (idempotent). Tests `funktionenView.test.mjs:48-55` anpassen.
**Freischaltung (Festlegung):** `activateClosedTickets` filtert `Query.equal('featureKey', [Schlüssel mit Ticket-Freischaltung])` (heute fest `news`, `siteFeatures.js:360`). Für Termine verlangt sie vorhandene Einstellungen und ein laufendes Abo, sonst bleibt `setup` (Log + T16-Hinweis). Der Öffentlich-API-Zugriff gibt es nur bei `active` (`kontext.js:106`); eine echte Probebuchung vor dem Livegang ist damit nicht möglich. Deshalb der Ablauf: Einbau auf der Kundenseite (Widget bleibt unsichtbar, da `aktiv:false`) → „Freischalten (Abnahme)“ → Probebuchung nach Checkliste und Stornierung → Ticket „Erledigt“. Reines Schließen des Tickets aktiviert weiterhin automatisch (Fallback, Idempotenz via Lock `feature-activate:<id>`). Ein Kundenfreigabe-Schritt entfällt (Festlegung, wie News).
---
## 6. Abo-Kopplung
**Gemeinsame Prüfung (Festlegung):** `P/server/services/aboStatus.js` zieht `hasWartungsAbo` aus `analyse.js:43-46` heraus; liest nur den Appwrite-Spiegel `subscriptions` (`getCurrentSubscription`, `stripeBilling.js:178`), ruft Stripe nicht auf. Zwei Prädikate:
- `laeuft(customerId)` = `productKey||'wartung'` (wie `abo.js:68`; die Analyse vergleicht heute strikt `=== 'wartung'`, Annahme: Altbestand ohne `productKey` existiert, Produktion nicht geprüft) und Status ∈ `ACTIVE_STATUSES` (active, trialing, past_due). Dient dem **Weiterlaufen** und dem Abgleich. Lesefehler werfen (nie abschalten bei Fehler).
- `bestellbar(customerId)` = Status `active` und keine Kündigung vorgemerkt (`cancelAtPeriodEnd`/`cancelAt`, Muster Zusatzspeicher `abo.js` ~92-95). Dient Kachel, Checkout und `markPaid`. Verhindert 149 € in der letzten Woche eines gekündigten Abos oder in der Widerrufs-Probezeit. Meldungstexte je Funktion (`kein_abo(funktion)` statt „Website-Analyse“); `analyseEligibility` ändert sein Verhalten nicht. Pro Lauf Memo je `customerId` (Feature-Docs sind je Projekt, das Abo je Kunde).
**Schema (vor dem Deploy ausführen, sonst `Unknown attribute`):** `portalFeatures.disabledReason` (`abo_ende|team|kunde_geloescht|frist`), `aboEndeMailAt` (datetime), in `scripts/setup-abo-collections.mjs` (`test/support/schema.js` liest sie). `disable(reason)` wird idempotent: ein bereits `disabled` Dokument behält `disabledAt` (heute überschreibt es `siteFeatures.js:304-312` und würde die 30-Tage-Löschung endlos verschieben). `activate()` löscht zusätzlich `disabledReason/aboEndeMailAt` (R36 löscht `disabledAt` schon).
**Täglicher Abgleich** `gleicheAboAb` in `P/server/services/termine/aboAbgleich.js`, im `taeglicherJob` (`jobs.js:420-429`) **vor** `loescheAbgeschaltete`, Ergebnis nur als Zählung im Tagesbericht (`aboAbgleich{geprueft, abgeschaltet, wiederEin, mails, fehler}`), Listen mit `listAllDocuments`.
1. `active`-Termine gruppiert je Kunde; Kunde ohne `laeuft` → unter `withLocks('feature-admin:<projectId>')` neu lesen, noch aktiv und noch kein Abo → `disable('abo_ende', 'Abgeschaltet: Wartungs-Abo beendet')`. Ausgenommen: `unlockedByPurchase !== true` (Pilot/Kulanz, siehe Abschnitt 1). Lesefehler → Kunde überspringen.
1b. `ordered`/`setup`-Termine (bezahlt oder in Zahlung) eines Kunden ohne `laeuft` → unter derselben Sperre neu lesen →
`disable('abo_ende_vor_einrichtung')`, T19 an das Team (Erstattung nach D3, 4.6), K22 Variante „vor Einrichtung beendet“.
Eine offene Stripe-Sitzung (`ordered`) wird nicht storniert; kommt die Zahlung danach noch an, meldet `onFeaturePaid` den Fall
ebenfalls mit T19 statt ein Ticket anzulegen.
2. Mail-Nachlauf: `disabled` + `abo_ende` + kein `aboEndeMailAt` → K22 (Enddatum, Löschdatum = `disabledAt` + 30 Tage, was bleibt möglich: ansehen, CSV-Export, Gäste dürfen absagen; Betrieb soll seine Gäste informieren) und erst nach erfolgreichem Versand `aboEndeMailAt` setzen (Analyse sendet K19 nur einmal ohne Nachlauf, das wäre hier der Verlust der Löschmitteilung).
3. Wiederkehr: `disabled` + `abo_ende` + `laeuft` + `termineConfig` noch vorhanden → `activate()` (Daten und Einstellungen bleiben), K23. Nach der Löschung nie automatisch (Konfig fehlt).
**Sofortpfade:** Widerruf eines Wechsels in der Frist (`abo.js:1051-1058`, beendet heute schon die Analyse) → Termine ebenfalls sofort, `abo_ende`, K22. Kundenlöschung deaktiviert Termine bereits (`customerAdmin.js:285-292`).
**Löschung nach 30 Tagen:** unverändert (`loescheAbgeschaltete`, `nochAbgeschaltet`). Neu: vor jedem unumkehrbaren Schritt bei `abo_ende` frische Abo-Prüfung (bricht ab, wenn das Abo wieder läuft). Nach der Löschung setzt der Job das Feature-Doc auf `off` (`disabledAt` leer, `unlockedByPurchase=false`, `ticketId` leer), damit es nicht täglich neu gescannt wird (`final-deferred.md:25`) und neu bestellt werden kann (heute blockt `routes/funktionen.js:160` jedes `disabled`); der siteKey bleibt, das eingebettete Snippet bleibt gültig. Test `termineJobs.test.js` ~771 („Funktionsdokument bleibt“) ändert sich. Die Bestellung nach Löschung kostet 149 € erneut (D2).
**Modus:** Der Abgleich läuft im `taeglicherJob` und steht bei `TERMINE_ENABLED=off` still (`jobs.js:93`); im Pilot läuft er für alle Termine-Docs, nicht nur Pilot-Adressen. Festlegung: Abgleich und Löschung laufen auch bei `off`, solange es Dokumente mit `unlockedByPurchase=true` gibt (Aufteilung des Guards in `fuehreAus`), denn die Datenschutz-Zusage „30 Tage danach“ darf nicht an einem Schalter hängen. Der Abgleich muss vor `on` live sein (der Satz in 6c ist sonst falsch).
**Rückkehr (D2):** Innerhalb der 30 Tage schaltet der Abgleich automatisch und ohne Gebühr wieder ein (Daten und Einstellungen
bleiben). Nach der Löschung braucht es eine neue Einrichtung: neuer Fragebogen und neue 149 €. Dieser Satz steht in LB, AVV,
6c und K22.
**Mehr:** K02/K03 (Kündigungsbestätigungen) bekommen einen Block „Analyse und Terminplaner enden mit dem Abo“. Banner/Meldung in `public/termine.js:65-67` und `routes/termine.js:59` unterscheiden nach `disabledReason` und zeigen das Löschdatum; `featureView` liefert `disabledReason`/`disabledAt`, WOMS zeigt „Abgeschaltet (Abo beendet), Löschung am …“. Erinnerungen an Gäste (G05) laufen weiter, wie R34 entschieden (unverändert; K22 weist darauf hin).
---
## 7. Rechtstexte und Rechtsrunde
### 7.1 Zu erstellen (Quellen Markdown in `P/docs/rechtliches/`, HTML + PDF in `P/public/rechtliches/`)
1. **Leistungsbeschreibung Terminplaner** (Geschwister der LB Website-Analyse): Geltung/Rangfolge (Zusatz zum Wartungs-Abo, Hosting reicht nicht), Leistung, Nicht enthalten (SMS, Online-Zahlung, Kalender-Sync, Gesundheitsbereich), Voraussetzungen und Pflichten des Betriebs (Impressum/Datenschutz/AGB, Telefon-Ausweichlösung, Texte unverändert lassen, Beschäftigte informieren), Verfügbarkeit, Haftung für verpasste Termine, **Einrichtung 149 €** (Leistung, Ablauf per Ticket, Dauer, Preis nach § 19 UStG, nicht auf Abo-Stunden), **Ende mit dem Abo** (Abschalten spätestens am Folgetag, Mail, Export, Löschung nach 30 Tagen, auch bei Wechsel auf Hosting und Widerruf), Einstellungsrecht, keine Rechtsberatung/Mustertexte, BFSG-Satz.
2. **AVV Terminplaner** (Geschwister des AVV Analyse, kein Zusatz dazu, damit Analyse-Kunden nicht neu zustimmen müssen): eigene Nr. 1–3 (Gast-Name, E-Mail, optional Telefon/Nachricht/Zusatzfelder, Termin, Hashes, Mailstatus; Betroffene Gäste und Beschäftigte; Art. 9 ausgeschlossen), Nr. 7 Hetzner Falkenstein **und** Nürnberg (Buchungsmails über Webhosting-SMTP), Nr. 8 Betroffenenrechte (Anonymisieren, CSV), Nr. 9 Löschfristen genau wie gebaut (Abgeschaltet 30 Tage, 48 h/30 Tage/`aufbewahrungTage`, Outbox 7 Tage/48 h, Backups bis 7 Tage), TOM-Anlage; Abschluss per Haken + Staff nur mit Textform.
3. **Datenschutz 6c** aus `termine-datenschutz-6c-entwurf.md` als `<h2 id="termine">` in `P/public/datenschutz.html`, die 8 offenen Punkte klären; §6 (Kundenansicht: Staff darf Termine lesen und ändern, Protokoll) und §7 (Nachweise bei Funktionsbestellung, Entwurfsaufbewahrung) ergänzen; `docs/WEBklar-Datenschutzerklaerung.pdf` neu bauen; Wächtertest `termineRechtstexte.test.js:267-274` entfernen, `legal_datenschutz_kundenansicht` anpassen.
4. **Gästetexte final**: ENTWURF-Köpfe in `termine-hinweise.md`, `-widerrufsbelehrung-muster.md`, `-datenschutz-absatz.md` entfernen, `rechtstexte.js` und Markdown gemeinsam ändern (Tests pinnen wörtlich), `RECHTSTEXTE_VERSION` anheben, **Archiv je Fassung** (Nachweis speichert nur Hashes, `ablauf.js:279-290`).
5. **Bestelltexte** (Abschnitt 4.3) und Zustimmungsfassungen im Register; Widerrufsformular-Beschriftung; K15-Anhänge.
6. **WOMS „Rechtliches“**: Karten `leistungsbeschreibung-terminplaner`, `avv-terminplaner` (und ggf. Datenschutz) in `W/scripts/setup-legal.mjs` `DOCUMENTS`; Upload der Versionen macht der Inhaber (Löschen nicht möglich).
7. **Technik dazu:** `analyseLegal.js` zu kleiner Dokument-Registry verallgemeinern (bzw. paralleles Modul), eigene Fassungskonstante `config.termine.avvVersion` unabhängig von `config.analyse.avvVersion`, `build-legal-pdfs.sh` um Modus `termine`; Mails und Zustimmungstexte verlinken nur **versionierte** Adressen.
### 7.2 Prüfung durch eine Kanzlei (D5)
Das **Kernpaket** prüft eine Kanzlei, bevor der erste fremde Betrieb startet: Widerrufsbelehrung und Muster-Formular der
149-€-Bestellung, Zustimmungssatz (vorzeitiger Beginn, Erlöschen, Wertersatz), AVV Terminplaner, Haftungs- und
Ende-mit-dem-Abo-Klauseln der Leistungsbeschreibung, sowie die Gästetexte mit Gesetzlichkeitsfiktion (Widerrufsbelehrung
Gäste, Hinweise im Widget). Alles andere (6c, Portal-Hinweise, Mailtexte) prüft die Hausrunde wie bei der Analyse.
Lieferung von uns: **Kanzlei-Paket** `P/docs/rechtliches/termine-kanzlei-paket.md` mit den Texten in Endfassung, dem
Produktablauf in Kurzform, den konkreten Fragen (Liste 7.3) und den Fundstellen im Code; der Inhaber beauftragt die Kanzlei.
Befund und Änderungen fließen vor Livegang-Schritt 9 ein.
### 7.3 Rechtliche Punkte, **vor `on`** zu entscheiden oder zu erledigen (Fragen ans Kanzlei-Paket markiert mit ⚖)
- ⚖ Stand der Widerrufsbelehrung für Funktionen: LB News §6 hat keine gesetzliche Musterbelehrung, K15 keine Belehrung/Anhänge. Meine Lesart (kein Rechtsurteil): die Frist läuft für Verbraucher womöglich nicht an (§ 356 Abs. 3 BGB). Für News-Altkunden entscheidet der Befund (D7): Nur wenn die Frist danach nicht lief, wird die Belehrung per Mail nachgeholt; sonst gelten die neuen Texte nur künftig.
- Zitat „§ 356 Abs. 4“ → „Abs. 5 Nr. 2“ in `config.js:192,196`, `funktionen-leistungsbeschreibung.html:51,73`, `funktionen-news-design.md:73` und Test `legalTexts.test.js:485`; fehlende Wertersatz-Hälfte in der News-Zustimmung; „inklusive Mehrwertsteuer“ (`funktionen.js:272`, LB `:49,:60`) widerspricht § 19 UStG. Gleiche Runde (die Spec schiebt die Korrektur ausdrücklich nach TP2).
- Wer fügt den Datenschutz-Absatz auf der Kundenseite ein und ist das im Preis (Analyse LB Nr. 4 Abs. 4 sagt: WEBklar, nicht auf Stunden). Eine Regel für beide LB.
- Portalseitige Rechtsaussagen: `WIDERRUF_HINWEIS`, `FREIZEIT_HINWEIS` (Massage/Weiterbildung „in der Regel keine Freizeitleistung“ trägt die Recherche nicht, `termine-tab-einstellungen.js:16-23`), `stornoMuster`, Gesundheitshinweise; **Storno-Vorgabe 50 %/24 h gilt auch für kostenlose Leistungen** (Festlegung: bei Leistungen ohne Preis keine Storno-Vorgabe und kein Pflichthaken, Text als Muster kennzeichnen).
- Anfrage mit Festpreis und der Bestätigungslink als „Bestellung“ (G01-Knopf, Seitenknopf „Zahlungspflichtig bestätigen“), Einstufung Massage/Fotograf/Coaching (Spec Offene Punkte 2).
- Pflicht von Datenschutz-/Impressum-Link (hier als Bestell-Gate gesetzt) und der „jederzeit widerrufen“-Satz unter dem Erinnerungshaken.
- Abo-Ende-Texte (LB, AVV, 6c, K22) und Aussagen „Drittland“ (Kalender-Abos bei Google/Apple), „Falkenstein und Nürnberg“, Anker für „30 Tage“; BFSG-Satz nur so weit getestet (kein axe-/Screenreader-Lauf in TP1).
- Staff-Aktivierung (`adminActivate`, `siteFeatures.js:351-356`) prüft heute weder Einwilligung noch AVV. Festlegung: ab Modus `on` verlangt `adminActivate` für Termine einen Textform-Nachweis wie die Analyse (`analyse.js` ~262-270, WOMS-Chatvorlage nach `analyseView.js:55-90`), ausgenommen Pilot-Adressen.
- Owner-Prüfung bei Hetzner (nur im Konto prüfbar): AV-Vertrag für Cloud **und** Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an.
### 7.4 **Später**
Abo-Bedingungen v6 bzw. Rahmen-AVV mit Anlage je Funktion; TP3-Themen (Stripe-Absatz, ZAG/Connect, Vorauszahlung in AGB, PAngV); Geschäftsbrief-Fußzeilen § 37a HGB; Rechtsmissbrauch bei spätem Widerruf; Minderjährige; Mitgliedschaften; wiederholtes Versenden von G08/K21 nach Fristende; Widerruf nur vor Terminbeginn im Verwaltungslink.
---
## 8. Technische Voraussetzungen aus TP1
Skalen-Schätzungen (Annahmen: 6 Buchungen je Betrieb und Tag, 12 Zellen je Buchung, 20 Tage Vorlauf, Spitzenfaktor 6; echte Werte können um Faktor 2–3 abweichen). Heute reicht alles für 1 Betrieb; die Listen laufen bei 40–60 Betrieben (bei Aufbewahrung 365 Tage schon bei ~10) in `list_limit_exceeded`, der nur geloggt wird.
**MUSS vor `TERMINE_ENABLED=on` (ein fremder Betrieb im Pilot braucht #1, #3, #5, #6):**
| # | Maßnahme | Ort |
|---|---|---|
| 1 | Aufbewahrungs-Löschung begrenzt und seitenweise: je Feature `Query.limit(100)` + Index `(status, endsAt)`, Deckel je Lauf (z. B. 2000 je Feature), Rest läuft an Folgetagen; heute listet `loeschKandidaten` alle (Deckel 20 000, `buchungen.js:191`), Überlauf = **nie** Löschung von Gastdaten, nur Fehlerlog. Gleiches Muster für `abgesagt/abgelehnt/verfallen`, `loescheAbgeschaltete` und `export.csv` | `jobs.js:296-313,350-353`, `routes/termine.js:450-453` |
| 2 | Zellen-Sweep seitenweise über `idx_bis` (`lessThan('bis', jetzt-1d)`, Limit 100, Deckel 5000/Lauf) statt `alleZellen()` (Deckel 100 000); `Map.get().push()` statt quadratischem `[...old, zelle]` (`zellen.js:247`); Zeitfenster-Prüfung in `ungueltigeZellen` | `zellen.js:46,224-270,92-99` |
| 3 | Kundenansicht-Wildcard `POST /api/termine/.+` (`kundenansichtGuard.js:19`) durch die vier heutigen Routen ersetzen (vorlage, feed, buchungen, aktion) + Test „unbekanntes POST = 403“; neue TP2-Routen bleiben unter `/api/funktionen` | `kundenansichtGuard.js` |
| 4 | Mail-Fairness: Kontingent je Feature (z. B. 40/h unter globalen 200/h), Vorrang G01/G02/G08 vor G05/K20/K21, veraltete G01 (älter als Haltezeit + 1 min) verwerfen statt toter Link, Empfänger-Kappe über Betriebe (z. B. 3/h, 10/Tag je Adresse für G01 und Freitext-G04/G06), Frühausstieg in `verarbeite` bei Pause/leerem Budget und begrenzte Liste (`orderAsc('naechsterVersuch')`, `limit`, `select`; Query-Helfer und `appwriteMock.js:145` um `select/isNull/notEqual` erweitern); `HALTUNGEN_JE_SITE` 60 → ~25/h | `outbox.js`, `limits.js`, `appwriteAdmin.js` |
| 5 | Sichtbarkeit: Warnlog stündlich ab 70 % Budget, bei ältestem `wartet` > 10 min, bei SMTP-Pause (`pausiertBis()` ist ungenutzt), bei Tagesjob-Fehlern; `GET /api/team/termine/status` (Budget, Queue, Alter, Pause, letzter Tagesjob) und ein Staff-Mail je Vorfall | `jobs.js`, neuer Team-Route |
| 6 | `disable()` idempotent (Abschnitt 6) und `activateClosedTickets` verallgemeinert | `siteFeatures.js` |
| 7 | Messprüfungen mit Freigabe: Seiten-Listing > 500 Dokumente auf der Live-Appwrite 1.8.1 (bisher nur Mock/Spike), tatsächliche Hetzner-Antwort bei > 500 Mails/h, tatsächliche Größe `workorders.details` | siehe Abschnitt 9 |
**SOLLTE (TP2 oder kurz danach):** `minutenJob` in zwei unabhängig geschützte Jobs (Verfall, Mailversand) teilen; Transport `pool:true`, Parallelität der `einreihen`-Sendungen begrenzen (`mailer.js:25`); Budget nach Neustart aus der Outbox rekonstruieren; `/tage` und `/zeiten` außerhalb des Horizonts vor `imZeitraum` kurzschließen (`publicTermine.js:450,471`); Index `termineOutbox(bookingId)`; Hetzner-Überlimit-Antworten als Pause statt dauerhaft `fehler` einstufen (`outbox.js:82-88`); `mailSicher` schreibt bei Fehlschlag einen `fehler`-Marker, damit das Portal „nicht zugestellt“ zeigt; Tagesjob zu fester Nachtzeit (heute 180 s nach **jedem** Start, `index.js:220`); optional `TERMINE_TOKEN_SECRET` mit Fallback `SESSION_SECRET`; `engines >=20`; axe-/Screenreader-Lauf von Widget und Gästeseite; CSV-Export und `loescheAbgeschaltete` seitenweise für sehr große Einzelbetriebe. **Kann warten:** Lock-Wartezeiten, `halte` ohne `termine-cfg`-Lock, Fetch-Timeouts, Widget-Monolith.
**Mailweg (D8):** Terminplaner-Mails gehen weiter über den Hetzner-SMTP, mit Kontingent je Betrieb und Alarm (Punkt #4/#5).
Ab **25 aktiven Terminplaner-Betrieben** (Zählung im Tagesbericht; Warnlog und Team-Mail ab 20) wird der Umstieg auf einen
EU-Versanddienst geplant (eigenes kleines Vorhaben: Anbieterwahl, AVV/6c-Nachtrag, DKIM/SPF).
**Betriebsannahme:** Ein Portal-Prozess (Locks, Limiter, Mailbudget liegen im Speicher; Zellen bleiben durch atomares Bulk-Create sicher). Jeder zweite Prozess bricht das; im Livegang-Plan festhalten.
---
## 9. Livegang TP2 (je Schritt Freigabe des Inhabers)
1. **Rechtsrunde:** Texte (Abschnitt 7.1) und Kanzlei-Paket (7.2) fertigstellen; der Inhaber beauftragt die Kanzlei; Hausrunde für den Rest. Kein Push auf `main` durch Claude, das macht der Inhaber.
2. **Code in Worktrees** (Portal, WOMS), TDD je Block: Register + Checkout, Fragebogen, Anleitung/WOMS, Abo-Kopplung, TP1-Schuld (Abschnitt 8). Test-Pins aus den Berichten mit ändern (`funktionenRoutes`, `termineRoutes`, `termineSetup`, `termineView`, `kundenansichtGuard`, `legalTexts`, `mailTemplates`, `termineJobs`).
3. **Schema in Produktions-Appwrite** (Schreibzugriff, Freigabe): `scripts/termine-setup.mjs --check`, dann anwenden (`termineConfig.fragebogen`, Index `termineBookings(status,endsAt)`, `termineOutbox(bookingId)`); `setup-abo-collections.mjs` (`portalFeatures.disabledReason`, `aboEndeMailAt`, neue Collection `featureConsents`). **Vor** dem Code-Deploy (Indexaufbau ist asynchron, Abfragen funktionieren auch ohne).
4. **Stripe (Aufruf, Freigabe):** `stripe-products.mjs --pruefen`, dann `--anlegen` für `termineSetup`; Inhaber prüft im Dashboard, dass der Live-Webhook `async_payment_succeeded/failed` und `expired` abonniert hat (Annahme: ja, News läuft seit 2026-09-30).
5. **Messprüfungen** (Freigabe): `details`-Größe, Seitenlisting > 500, Hetzner-Mailantwort (Abschnitt 8 #7); Hetzner-AV-Prüfung durch den Inhaber (7.2).
6. **Deploy Portal + WOMS** (Inhaber pusht): Modus bleibt `pilot` mit leerem `TERMINE_PILOT_EMAILS` plus Pilotkunde „test“; Abo-Abgleich läuft zunächst nur beobachtend (Zählung im Tagesbericht, Pilotkunde ausgenommen).
7. **Rechtstexte live:** PDFs bauen, HTML/PDF unter `/rechtliches`, 6c + PDF-Datenschutz, WOMS-Karten (Skript mit API-Key, Freigabe) und Uploads durch den Inhaber, Gästetexte final (`RECHTSTEXTE_VERSION`).
8. **Ende-zu-Ende-Test** mit einem Testkunden und echtem Wartungs-Abo: Fragebogen → Bestellung (echte 149 €, Rückerstattung durch den Inhaber danach; Annahme, es gibt kein Test-Konto für diesen Preis) → Ticket + Anleitung → „Freischalten (Abnahme)“ → Probebuchung → Abo-Ende-Simulation (Testkunde) → K22 → Rückkehr K23 → Löschlauf auf Wegwerfdaten.
9. **Erster fremder Kunde im Pilot** (erst nach dem Kanzleibefund, D5): nur seine Adresse in `TERMINE_PILOT_EMAILS`; Beobachtung Tagesbericht, Mailbudget, Status-Route (Freigabe der Adresse).
10. **`TERMINE_ENABLED=on`** (Compose-Änderung, nur `kundenbereich-server` neu erstellen) als **letzter** Schritt; danach 1 Woche Beobachtung, News-Altfälle (D7) und Entwurfs-Löschjob prüfen.
---
## 9b. Umsetzung in einem Plan, sechs Blöcke
A Technik-Voraussetzungen aus TP1 (Abschnitt 8, MUSS-Liste) · B Register + Checkout + Zustimmungsnachweis (4) ·
C Fragebogen (3) · D Ticket, Einbauanleitung, WOMS (5) · E Abo-Kopplung (6) · F Rechtstexte + Kanzlei-Paket + News-Korrektur (7).
Reihenfolge A → B → E → C → D → F (F kann parallel zu C/D laufen, da überwiegend Text). Jeder Block endet mit grünen Tests;
der Livegang (Abschnitt 9) beginnt erst nach F.
## 10. Entscheidungen des Inhabers (2026-10-03)
| # | Frage | Entscheidung |
|---|---|---|
| D1 | Verbraucher oder Unternehmer bei der 149-€-Bestellung | **Alle wie Verbraucher**: Haken „Beginn vor Fristablauf, Erlöschen, Wertersatz“, volle Belehrung + Muster-Formular für alle (4.3) |
| D2 | Abo endet und kommt wieder | **Innerhalb von 30 Tagen automatisch und gratis wieder an**; nach der Löschung neuer Fragebogen und neue 149 € (Abschnitt 6) |
| D3 | Erstattung der 149 € | **Gesetzlicher Widerruf** (mit Wertersatz für begonnenen Einbau) **und volle Erstattung, wenn das Abo vor der Fertigstellung endet** (4.6) |
| D4 | Umfang der 149 € | **Je Website-Projekt ein Widget an einer Stelle**, Gestaltung nach Fragebogen, Einbau; Mehrwünsche nach Stunden (Abschnitt 1) |
| D5 | Rechtsprüfung | **Kanzlei für das Kernpaket**, Rest Hausrunde; erster fremder Betrieb erst nach dem Befund (7.2) |
| D6 | Gesundheits-Ausschluss | **Branchenfrage + Haken + Stichwort-Warnung ans Team**; Team kann ablehnen (3.5) |
| D7 | News-Altkunden nach Textkorrektur | **Nach dem Kanzleibefund entscheiden**; Belehrung nur nachholen, wenn die Frist nicht lief (7.3) |
| D8 | Mailweg bei mehr Betrieben | **Hetzner-SMTP mit Kontingent und Alarm, Umstieg auf EU-Versanddienst ab 25 aktiven Betrieben** (Abschnitt 8) |
## 11. TP3 (Ausblick, eigener Plan nach TP2)
Online-Zahlung der Gäste über Stripe Connect (Standard-ähnliches Konto, direkte Zahlungen, Hosted Checkout, eigener
Connect-Webhook, Erstattungen nach Regel des Betriebs; PayPal nicht möglich, SEPA ausgeschlossen; Haltezeit ≥ 32 min).
Voraussetzung: Connect-Plattform im Stripe-Live-Modus eingerichtet (bisher nur Sandbox, Konto acct_1UM7AVBONHfkVuDM).
+2 -1
View File
@@ -8,6 +8,7 @@ import { esc, safeUrl, request } from './fn-util.js'
import { analyseOf, pickAnalyseProject, analyseSel, bannerColor, BANNER_PREVIEW_TEXT, controllerLines } from './analyse-view.js'
import { headerHtml, tabsHtml, overviewHtml, rankCardHtml, skeletonHtml, errorHtml, settingsHtml, chartTipText } from './analyse-cards.js'
import { HEAT_DEVICES, deviceHtml, heatCaption } from './geraete.js'
import { ZU_VERTRAEGE } from './vertraege-link.js'
const PRESET_COLORS = ['#111111', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c']
const HEX_RE = /^#[0-9a-f]{6}$/i
@@ -44,7 +45,7 @@ export async function initAnalyse(root, { projectId } = {}) {
if (a.state === 'active') mountCockpit({ an: root.querySelector('.an'), stage, project, usable, reload })
else if (a.state === 'disabled') stage.innerHTML = '<section class="an-card an-state"><h3>Analyse ist abgeschaltet</h3><p>Für diese Website ist die Analyse im Moment gesperrt. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></section>'
else if (a.state === 'unknown') stage.innerHTML = '<section class="an-card an-state"><p>Die Daten konnten gerade nicht geladen werden.</p></section>'
else if (!a.eligible) stage.innerHTML = `<section class="an-card an-state"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ' <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button>' : ''}</p></section>`
else if (!a.eligible) stage.innerHTML = `<section class="an-card an-state"><h3>Analyse nicht verfügbar</h3><p>${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}${a.reason === 'kein_abo' ? ` <button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>` : ''}</p></section>`
else renderAnalyseActivation(stage, project, reload)
}
+11 -2
View File
@@ -44,7 +44,7 @@
<div class="brand">WEBKLAR</div>
<h1>Datenschutzerklärung</h1>
<p class="meta">Stand 01.10.2026</p>
<p class="meta">Stand 05.10.2026</p>
<p>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.</p>
@@ -79,7 +79,7 @@
<p>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.</p>
<h2>6. Kundenportal</h2>
<p>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.</p>
<p>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.</p>
<p id="chat-anhaenge">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.</p>
<p>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.</p>
@@ -94,9 +94,18 @@
<!-- Entwurf – vor Verwendung anwaltlich prüfen lassen (Prüfpunkt Anwalt P6: Rollenverteilung Auftragsverarbeiter) -->
<p>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.</p>
<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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.</p>
<p>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. Eine Ausnahme ist das Einrichtungs-Ticket, in dem wir die Einrichtung als Arbeitsnachweis festhalten (Abschnitt 6): Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen. 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).</p>
<h2>7. Abos und Zahlung über Stripe</h2>
<p>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.</p>
<p>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.</p>
<p>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. Bestellen Sie ohne Abo (zum Beispiel nur die News-Pinnwand), löschen wir diese Angaben spätestens drei Jahre nach Ablauf des Jahres der Bestellung. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.</p>
<p>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.</p>
<p id="uebernahmegutschrift">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.</p>
<p id="kuendigung-widerruf">Ü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.</p>
+14 -5
View File
@@ -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) {
+358
View File
@@ -0,0 +1,358 @@
/* Bestellseite und Statuskarten des Terminplaners im Kundenbereich (TP2, Task C8). Das Modul heißt bewusst nicht termine*.js: es ist die
einzige Datei der Ansicht mit href-Links (Unterlagen, Rechnung) und wird relativ importiert (der Fragebogen zeigt nach dem letzten Schritt
renderBestellen, die Ansicht #termine zeigt statusKarte). Jeder Link geht durch sicherLink, jede Navigation nur auf eine https-Adresse.
Es gibt keine Texte des Registers im Client: Knopf, Haken, Belehrung, Preis und Unterlagen kommen als `bestellung` aus
GET /api/termine/einstellungen (Server: bestellungAnsicht), und der Knopf zeigt genau bestellung.consent.knopf, denselben Text, den der
Server im Nachweis speichert. Fremdtext kommt nur über textContent in die Seite (h aus termine-dom.js), nie über innerHTML.
Zusammenfassung: zusammenfassung() aus termine-view.js. Checkout: POST /api/funktionen/termine/checkout (Server: routes/funktionen.js). */
import { fehlerKarte, h, request } from './termine-dom.js'
import { FRAGEBOGEN_SCHRITTE, zusammenfassung } from './termine-view.js'
const CHECKOUT = '/api/funktionen/termine/checkout'
const RECHTLICHES = '/rechtliches/'
const KUNDENANSICHT = 'In der Kundenansicht kann nicht bestellt werden.'
const ZAHLUNGSSEITE = 'Die Zahlungsseite konnte nicht geöffnet werden. Bitte versuch es noch einmal oder schreib uns im Chat.'
const DANKE = 'Danke! Sobald die Zahlung da ist, legen wir los.'
const SCHRITT_TITEL = new Map(FRAGEBOGEN_SCHRITTE)
/* ---------------------------------------------------------------- Links --- */
// Leerraum (auch Tabulator und Zeilenumbruch, die ein Browser aus Adressen entfernt), Steuerzeichen und Rückwärtsschrägstrich
const UNZULAESSIG = /[\s\u0000-\u001f\u007f-\u009f\\]/
/** Eine echte https-Adresse mit Rechnername, ohne Zugangsdaten und ohne Leerraum. */
function istHttps(url) {
if (typeof url !== 'string' || UNZULAESSIG.test(url) || !/^https:\/\/[^/]/i.test(url)) return false
try {
const adresse = new URL(url)
return adresse.protocol === 'https:' && adresse.hostname !== '' && adresse.username === '' && adresse.password === ''
} catch {
return false
}
}
/**
* Adresse, die als Link gesetzt werden darf: eine https-Adresse oder ein Pfad unter /rechtliches/ (die Rechtsunterlagen des Portals), sonst ''.
* Abgewiesen: javascript:, data:, http:, //fremd.example, Leerraum und Steuerzeichen, Rückwärtsschrägstriche, Zugangsdaten in der Adresse,
* Pfade mit .. und alles, was nicht unter /rechtliches/ liegt. Gültige Adressen kommen unverändert zurück.
*/
export function sicherLink(url) {
if (typeof url !== 'string' || UNZULAESSIG.test(url)) return ''
if (istHttps(url)) return url
if (url.startsWith(RECHTLICHES) && url.length > RECHTLICHES.length && !/\.\.|%2e/i.test(url)) return url
return ''
}
/** Zur Zahlungsseite gehen: nur auf eine https-Adresse (true), alles andere bleibt ohne Wirkung (false). */
function geheZu(url) {
if (!istHttps(url)) return false
window.location.href = url
return true
}
/* ----------------------------------------------------------- Körper der Anfragen --- */
/**
* Körper der Bestellung (der Vertrag des Checkouts): { projectId, zustimmung: { version, haken } }. version ist die Fassung der Erklärungen,
* die die Seite zeigt (bestellung.consent.version); haken sind die IDs der angehakten Einträge mit haken: true in Registerreihenfolge, nie Texte.
* gewaehlt: Array oder Set von IDs; unbekannte IDs, IDs reiner Texte und Doppelte fallen weg.
*/
export function bestellKoerper({ projectId, bestellung, gewaehlt }) {
const gesetzt = new Set(Array.isArray(gewaehlt) || gewaehlt instanceof Set ? gewaehlt : [])
const eintraege = Array.isArray(bestellung?.consent?.items) ? bestellung.consent.items : []
const haken = []
for (const eintrag of eintraege) {
if (eintrag?.haken === true && gesetzt.has(eintrag.id) && !haken.includes(eintrag.id)) haken.push(eintrag.id)
}
return { projectId, zustimmung: { version: bestellung?.consent?.version, haken } }
}
/** Körper von „Zahlung fortsetzen“: die offene Sitzung weiterführen, ohne neue Zustimmung. */
export const fortsetzenKoerper = (projectId) => ({ projectId, fortsetzen: true })
/* -------------------------------------------------------------- kleine Bausteine --- */
const text = (wert) => (typeof wert === 'string' ? wert : '')
/** Datum in Berlin als TT.MM.JJJJ, bei fehlendem oder ungültigem Wert ''. */
function datumTag(wert) {
const tag = new Date(wert)
if (!wert || Number.isNaN(tag.getTime())) return ''
return tag.toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin', day: '2-digit', month: '2-digit', year: 'numeric' })
}
const istConsent = (wert) => wert !== null && typeof wert === 'object' && typeof wert.version === 'string' && Array.isArray(wert.items)
const offeneListe = (wert) => (Array.isArray(wert) ? wert.filter((punkt) => punkt !== null && typeof punkt === 'object' && typeof punkt.text === 'string') : [])
const chatKnopf = () => h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Frage dazu? Chat öffnen')
/* ----------------------------------------------- Rückkehr von der Zahlungsseite --- */
/**
* Der Browser-Zurück-Knopf der Zahlungsseite stellt die Seite oft aus dem Speicher (bfcache) so wieder her, wie sie verlassen wurde: der Knopf bleibt
* gesperrt, es steht keine Meldung da. Darum laden Bestellseite und Statuskarte bei pageshow mit persisted neu (wie app.js beim Wechsel-Knopf).
* Gilt nur, solange das Element noch im Baum steht; danach entfernt sich der Handler beim nächsten pageshow selbst.
*/
function ladeBeiRueckkehrNeu(element) {
if (typeof window === 'undefined' || typeof window.addEventListener !== 'function') return
const beiAnzeige = (ev) => {
if (!element.isConnected) {
window.removeEventListener('pageshow', beiAnzeige)
return
}
if (ev?.persisted === true) window.location.reload()
}
window.addEventListener('pageshow', beiAnzeige)
}
/* ---------------------------------------------------------------- Bestellseite --- */
/**
* „Prüfen und bestellen“ in root zeichnen und die Seite zurückgeben. Reihenfolge: Zusammenfassung der Angaben (je Gruppe „Ändern“),
* offene Punkte (je Punkt „Zum Schritt“), Preis und Einrichtungsdauer, die vollständige Widerrufsbelehrung samt Muster-Formular als Text
* (ein scrollbarer Bereich, nie eingeklappt), die Unterlagen als Links, die Zustimmungen und zuletzt der Knopf.
* ctx = Kontext der Ansicht (projectId, aktuell(), neuLaden()), daten = Antwort von GET /api/termine/einstellungen im Zustand off
* (einstellung, fragebogen, branchen, offen, kundenansicht, bestellung), zumSchritt(key) springt in einen Schritt des Fragebogens (ist es keine Funktion,
* zeichnet die Seite weder „Ändern“ noch „Zum Schritt“).
* Der Knopf ist gesperrt, solange ein Haken fehlt, offene Punkte bleiben oder die Kundenansicht läuft. Ein Klick sperrt ihn, sendet
* bestellKoerper und geht zur Zahlungsseite. Bei 409 lädt die Seite die Daten neu und setzt die Haken zurück (bestellung_offen zeigt die
* Punkte aus der Antwort, fassung_veraltet die neue Fassung aus der Antwort, ein Abo-Code den Text der Antwort).
*/
export function renderBestellen(root, { ctx, daten, zumSchritt } = {}) {
return baueSeite(root, { ctx, daten, zumSchritt }, {})
}
function baueSeite(root, { ctx, daten, zumSchritt }, uebernahme) {
const roh = daten?.bestellung
if (!roh || !istConsent(roh.consent)) {
const karte = fehlerKarte('Die Bestellseite konnte gerade nicht geladen werden. Bitte lade die Seite neu.')
root.replaceChildren(karte)
return karte
}
// nach einer veralteten Fassung gilt die Fassung aus der Antwort der Bestellung, nicht die der Daten
const bestellung = istConsent(uebernahme.consent) ? { ...roh, consent: uebernahme.consent } : roh
const uebernommen = offeneListe(uebernahme.offen)
const offen = uebernommen.length > 0 ? uebernommen : offeneListe(daten.offen)
const eintraege = bestellung.consent.items.filter((eintrag) => eintrag !== null && typeof eintrag === 'object')
const hakenEintraege = eintraege.filter((eintrag) => eintrag.haken === true)
const kundenansicht = daten.kundenansicht === true
const gewaehlt = new Set()
let laeuft = false
// „Ändern“ und „Zum Schritt“ gibt es nur, wo es ein Ziel gibt: ohne zumSchritt zeichnet die Seite keine Knöpfe, die nichts tun
const springt = typeof zumSchritt === 'function'
const gehe = (schritt) => { if (springt) zumSchritt(schritt) }
// 1 Zusammenfassung: je Gruppe die Angaben als Text und „Ändern“
const gruppen = zusammenfassung(daten.einstellung, daten.fragebogen, daten.branchen).map((gruppe) => h('div', { class: 'tm-bestellen-gruppe', 'data-schritt': gruppe.schritt },
h('div', { class: 'tm-bestellen-gruppe-kopf' },
h('h4', null, gruppe.titel),
springt
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'aendern', 'data-schritt': gruppe.schritt, 'aria-label': `Ändern: ${gruppe.titel}`, onclick: () => gehe(gruppe.schritt),
}, 'Ändern')
: null),
gruppe.zeilen.length > 0 ? h('ul', null, gruppe.zeilen.map((zeile) => h('li', null, zeile))) : h('p', { class: 'tm-hinweis' }, 'Noch keine Angaben.')))
// 2 offene Punkte: „Zum Schritt“ nur dort, wo es einen Schritt des Fragebogens gibt (der Abo-Punkt gehört zu dieser Seite)
const offenBlock = offen.length === 0 ? null : h('div', { class: 'tm-bestellen-offen', role: 'note' },
h('h4', null, 'Das fehlt noch'),
h('p', { class: 'tm-hinweis' }, 'Erledige diese Punkte, dann kannst du bestellen.'),
h('ul', null, offen.map((punkt) => h('li', null,
h('span', null, punkt.text),
springt && SCHRITT_TITEL.has(punkt.schritt)
? h('button', {
type: 'button', class: 'kb-btn-outline tm-knopf', 'data-aktion': 'zum-schritt', 'data-schritt': punkt.schritt, 'aria-label': `Zum Schritt „${SCHRITT_TITEL.get(punkt.schritt)}“`, onclick: () => gehe(punkt.schritt),
}, 'Zum Schritt')
: null))))
// 3 Preis mit dem Hinweis des Servers und die Einrichtungsdauer
const preisBlock = h('div', { class: 'tm-bestellen-preis' },
h('h4', null, 'Preis und Einrichtung'),
h('p', { class: 'tm-bestellen-betrag' }, h('strong', null, text(bestellung.preis)), ' ', h('span', null, text(bestellung.preisHinweis))),
text(bestellung.einrichtung) ? h('p', null, `Einrichtung: ${bestellung.einrichtung}`) : null)
// 4 die vollständige Widerrufsbelehrung samt Muster-Formular: ein Textbereich zum Scrollen, kein Aufklappen
const belehrungBlock = text(bestellung.belehrung).trim() === '' ? null : h('div', { class: 'tm-bestellen-belehrung' },
h('h4', null, 'Widerrufsbelehrung'),
h('p', { class: 'tm-hinweis' }, 'Lies die Belehrung bitte ganz. Das Muster-Widerrufsformular gehört dazu.'),
h('div', { class: 'tm-rechtstext', tabindex: '0', role: 'region', 'aria-label': 'Widerrufsbelehrung mit Muster-Widerrufsformular' }, bestellung.belehrung))
// 5 Unterlagen: versionierte Adressen, nur sichere Links
const dokumente = (Array.isArray(bestellung.dokumente) ? bestellung.dokumente : []).filter((dokument) => text(dokument?.titel) !== '' && sicherLink(dokument?.url) !== '')
const unterlagenBlock = dokumente.length === 0 ? null : h('div', { class: 'tm-bestellen-unterlagen' },
h('h4', null, 'Unterlagen zu deiner Bestellung'),
h('ul', { class: 'tm-bestellen-dokumente' }, dokumente.map((dokument) => h('li', null,
h('a', { href: sicherLink(dokument.url), target: '_blank', rel: 'noopener noreferrer' }, dokument.titel)))))
// 6 Zustimmungen in Registerreihenfolge: Einträge mit haken: true sind Haken, die anderen reiner Text
const knopf = h('button', { type: 'button', class: 'kb-btn-primary tm-bestellen-knopf', 'data-aktion': 'bestellen', disabled: true, onclick: () => bestelle() }, text(bestellung.consent.knopf))
const meldungEl = h('p', { class: 'tm-fehler tm-bestellen-meldung', role: 'alert', 'data-meldung': 'bestellen', tabindex: '-1' })
const hinweisEl = h('p', { class: 'tm-hinweis', 'data-hinweis': 'bestellen' })
const hakenZeile = (eintrag) => {
const feld = h('input', { type: 'checkbox', 'data-haken': eintrag.id })
feld.addEventListener('change', () => {
if (feld.checked) gewaehlt.add(eintrag.id)
else gewaehlt.delete(eintrag.id)
aktualisiere()
})
return h('label', { class: 'tm-haken' }, feld, h('span', null, text(eintrag.text)))
}
const zustimmungBlock = h('div', { class: 'tm-bestellen-zustimmung' },
h('h4', null, 'Deine Zustimmungen'),
h('div', { class: 'tm-bestellen-haken' }, eintraege.map((eintrag) => (eintrag.haken === true ? hakenZeile(eintrag) : h('p', { class: 'tm-bestellen-text' }, text(eintrag.text))))))
const seite = h('section', { class: 'kb-panel tm-bestellen' },
h('h3', null, 'Prüfen und bestellen'),
h('p', { class: 'tm-abschnitt-text tm-hinweis' }, 'Lies alles in Ruhe durch. Mit „Ändern“ kommst du zu dem Schritt zurück, in dem du die Angabe gemacht hast.'),
h('div', { class: 'tm-bestellen-zusammenfassung' }, gruppen),
offenBlock,
preisBlock,
belehrungBlock,
unterlagenBlock,
zustimmungBlock,
h('div', { class: 'tm-bestellen-aktion' }, meldungEl, knopf, hinweisEl))
root.replaceChildren(seite)
ladeBeiRueckkehrNeu(seite)
function zeigeMeldung(meldung) {
meldungEl.textContent = meldung
}
function aktualisiere() {
const hakenFehlen = hakenEintraege.some((eintrag) => !gewaehlt.has(eintrag.id))
knopf.disabled = laeuft || hakenFehlen || offen.length > 0 || kundenansicht
if (kundenansicht) hinweisEl.textContent = KUNDENANSICHT
else if (offen.length > 0) hinweisEl.textContent = 'Du kannst bestellen, sobald die offenen Punkte erledigt sind.'
else hinweisEl.textContent = hakenFehlen ? 'Setze alle Haken, um zu bestellen.' : ''
}
// 409: Daten neu laden, die Seite mit leeren Haken neu zeichnen und die Meldung des Servers zeigen. Ist die Bestellung inzwischen
// aufgegeben (die Daten haben keine bestellung mehr), bleibt nur die Meldung mit dem Hinweis, die Seite neu zu laden.
async function nachKonflikt(err) {
let neu = null
try {
neu = typeof ctx?.neuLaden === 'function' ? await ctx.neuLaden() : null
} catch {
neu = null
}
if (neu === null || typeof neu !== 'object') {
zeigeMeldung(err.message)
meldungEl.focus({ preventScroll: true })
laeuft = false
aktualisiere()
return
}
if (typeof ctx.aktuell === 'function' && !ctx.aktuell()) return
if (!neu.bestellung) {
const karte = h('div', { class: 'kb-panel tm-info', role: 'status', tabindex: '-1' }, h('p', null, err.message), h('p', null, 'Lade die Seite neu, dann siehst du den aktuellen Stand.'))
root.replaceChildren(karte)
karte.focus({ preventScroll: true })
return
}
const neueSeite = baueSeite(root, { ctx, daten: neu, zumSchritt }, {
offen: err.code === 'bestellung_offen' ? err.body?.offen : undefined,
consent: err.code === 'fassung_veraltet' ? err.body?.consent : undefined,
})
const neueMeldung = neueSeite.querySelector('[data-meldung="bestellen"]')
if (neueMeldung) {
neueMeldung.textContent = err.message
neueMeldung.focus({ preventScroll: true })
neueMeldung.scrollIntoView({ block: 'nearest' })
}
}
async function bestelle() {
if (knopf.disabled) return
laeuft = true
aktualisiere()
zeigeMeldung('')
try {
const antwort = await request(CHECKOUT, { method: 'POST', body: bestellKoerper({ projectId: ctx?.projectId, bestellung, gewaehlt }) })
if (geheZu(antwort?.url)) return // die Seite wird verlassen, der Knopf bleibt gesperrt
zeigeMeldung(ZAHLUNGSSEITE)
} catch (err) {
if (err?.status === 409) {
await nachKonflikt(err)
return
}
zeigeMeldung(err?.message || ZAHLUNGSSEITE)
}
laeuft = false
aktualisiere()
}
aktualisiere()
return seite
}
/* ---------------------------------------------------------------- Statuskarte --- */
/**
* 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, 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, meldung } = {}, { weiter } = {}) {
const bestellt = bestellung?.state === 'ordered'
const datum = datumTag(bestellung?.orderedAt)
const ticket = bestellung?.ticketWoid
const hatTicket = ticket !== null && ticket !== undefined && String(ticket) !== ''
const rechnung = sicherLink(bestellung?.invoiceUrl)
const zeile = (name, wert) => h('div', null, h('dt', null, name), h('dd', null, wert))
const fakten = [
bestellt ? null : zeile('Ticketnummer', hatTicket ? `#${ticket}` : 'Wir legen dein Ticket gerade an.'),
datum ? zeile('Bestellt am', datum) : null,
!bestellt && text(einrichtung) ? zeile('Zeiteinschätzung', einrichtung) : null,
].filter(Boolean)
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
async function fortsetzen() {
if (fortsetzenKnopf.disabled) return
fortsetzenKnopf.disabled = true
meldungEl.textContent = ''
try {
const antwort = await request(CHECKOUT, { method: 'POST', body: fortsetzenKoerper(projekt?.projectId) })
if (geheZu(antwort?.url)) return // die Seite wird verlassen
meldungEl.textContent = ZAHLUNGSSEITE
} catch (err) {
const nachricht = err?.message || ZAHLUNGSSEITE
const zustimmungFehlt = err?.status === 409 && err.code === 'zustimmung_fehlt'
if (typeof weiter === 'function') {
try {
await weiter({ bestellen: zustimmungFehlt, meldung: nachricht })
return // die Ansicht zeichnet sich neu
} catch (fehler) {
// 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 = nachricht
}
}
fortsetzenKnopf.disabled = false
}
const karte = h('section', { class: 'kb-panel tm-statuskarte', 'data-state': bestellt ? 'ordered' : 'setup' },
danke ? h('p', { class: 'tm-danke', role: 'status', 'data-danke': true }, DANKE) : null,
h('span', { class: 'tm-pille tm-pille-wait' }, bestellt ? 'Zahlung offen' : 'In Einrichtung'),
h('h3', null, bestellt ? 'Deine Bestellung wartet auf die Zahlung' : 'Wir richten deinen Terminplaner ein'),
h('p', null, bestellt ? 'Sobald deine Zahlung bei uns ankommt, legen wir mit der Einrichtung los.' : 'Sobald die Einrichtung fertig ist, schalten wir deinen Terminplaner für dich frei.'),
fakten.length > 0 ? h('dl', { class: 'tm-fakten' }, fakten) : null,
bestellt
? h('p', { class: 'tm-hinweis' }, 'Zahlst du per SEPA-Lastschrift, kann es einige Tage dauern, bis die Zahlung bei uns ankommt.')
: h('p', { class: 'tm-hinweis' }, 'Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.'),
meldungEl,
bestellt && kundenansicht === true ? h('p', { class: 'tm-hinweis' }, KUNDENANSICHT) : null,
h('div', { class: 'tm-aktionen' },
fortsetzenKnopf,
!bestellt && rechnung ? h('a', { class: 'kb-btn-outline', href: sicherLink(rechnung), target: '_blank', rel: 'noopener noreferrer' }, 'Rechnung ansehen') : null,
chatKnopf()))
ladeBeiRueckkehrNeu(karte)
return karte
}
+77 -28
View File
@@ -5,8 +5,11 @@
import { esc, safeUrl, request } from '/fn-util.js'
import { analyseOf, analyseState, pickAnalyseProject, analyseSel } from '/analyse-view.js'
import { ZU_VERTRAEGE } from './vertraege-link.js'
const PRICE = '30 €'
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
@@ -42,6 +45,20 @@ function berlinIsoDay(offsetDays = 0) {
const selection = { projectId: '' }
const newsOf = (project) => project.features?.news || { state: 'off' }
// Preis, Knopf und Zustimmungstext der News-Buchung aus dem Register (features.news der Antwort von /api/funktionen);
// fehlt das Register oder ein Wert, gelten die bisherigen Werte (PRICE und der consentText der Antwort)
const textOr = (value, fallback) => (typeof value === 'string' && value.trim() ? value : fallback)
function newsOffer(features, consentFallback = '') {
const reg = features?.news
const price = textOr(reg?.priceLabel, PRICE)
return {
price,
knopf: textOr(reg?.consent?.knopf, `Zahlungspflichtig bestellen · ${price}`),
consentText: textOr(reg?.consent?.items?.[0]?.text, consentFallback),
}
}
const isSetup = (state) => state === 'ordered' || state === 'setup'
// Vorauswahl: zuerst ein aktives Projekt, dann eins in Einrichtung, sonst das erste
@@ -57,11 +74,11 @@ const ICONS = {
}
const svg = (name) => `<svg class="fn-ico" viewBox="0 0 24 24" aria-hidden="true">${ICONS[name]}</svg>`
function newsTile(projects) {
function newsTile(projects, price = PRICE) {
const states = projects.map((p) => newsOf(p).state)
const active = states.filter((s) => s === 'active').length
let pill = ['Aus', 'off']
let cta = `Einrichten lassen · ${PRICE}`
let cta = `Einrichten lassen · ${esc(price)}`
if (active) { pill = ['Aktiv', 'on']; cta = 'Beiträge schreiben' } else if (states.some(isSetup)) { pill = ['In Einrichtung', 'setup']; cta = 'Status ansehen' } else if (states.length && states.every((s) => s === 'disabled')) { pill = ['Abgeschaltet', 'off']; cta = 'Status ansehen' }
const target = pickProject(projects)
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
@@ -77,6 +94,10 @@ function newsTile(projects) {
</article>`
}
// 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 = `<button type="button" class="${ZU_VERTRAEGE.klasse}" data-view="${ZU_VERTRAEGE.view}">${ZU_VERTRAEGE.text}</button>`
/* Analyse: Kachel je nach Zustand der Websites (comingSoon = Schalter aus) */
const DEV_HINT = 'Im Dev-Modus wird nicht gemessen.'
const ANALYSE_TEXT = 'Sieh, wie viele Besucher deine Website hat und was sie sich ansehen.'
@@ -95,7 +116,7 @@ function analyseTile(projects) {
else if (state === 'disabled') { text = 'Bitte melde dich bei uns'; action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>' }
else if (state === 'active') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Auswertung ansehen</button>`
else if (state === 'off') action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="analyse" data-project="${esc(target.projectId)}">Einrichten</button>`
else if (a.reason === 'kein_abo') action = '<p class="fn-count">Im Wartungs-Abo enthalten. <button type="button" class="kb-link" data-view="vertraege">Zu Verträge</button></p>'
else if (a.reason === 'kein_abo') action = `<p class="fn-count">Im Wartungs-Abo enthalten. ${ZU_VERTRAEGE_LINK}</p>`
else action = `<p class="fn-count">${esc(a.reasonText || 'Für diese Website gerade nicht verfügbar.')}</p>`
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
@@ -110,34 +131,61 @@ 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.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, 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`)
// Entwurf: Funktionsdokument im Zustand Aus mit siteKey (entsteht beim ersten Speichern des Fragebogens)
const hatEntwurf = (project) => termineOf(project).state === 'off' && Boolean(termineOf(project).siteKey)
// Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann Entwurf, dann das erste
function pickTermineProject(projects) {
return projects.find((p) => termineOf(p).state === 'active') || projects.find((p) => termineOf(p).state === 'disabled') || projects[0]
return projects.find((p) => termineOf(p).state === 'active')
|| projects.find((p) => isSetup(termineOf(p).state))
|| projects.find((p) => termineOf(p).state === 'disabled')
|| projects.find(hatEntwurf)
|| projects[0]
}
function termineTile(projects) {
const states = projects.map((p) => termineOf(p).state)
const active = states.filter((s) => s === 'active').length
function termineTile(projects, angebot = {}) {
const active = projects.filter((p) => termineOf(p).state === 'active').length
const target = pickTermineProject(projects)
const disabled = !active && states.length > 0 && states.every((s) => s === 'disabled')
const pill = active ? ['Aktiv', 'on'] : disabled ? ['Abgeschaltet', 'off'] : ['Aus', 'off']
let extra
let action
if (active) {
const state = target ? termineOf(target).state : 'off'
const knopf = (text) => `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">${text}</button>`
let pill = ['Aus', 'off']
let extra = ''
let action = ''
if (state === 'active') {
pill = ['Aktiv', 'on']
extra = '<p class="fn-count" data-termine-count hidden></p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine öffnen</button>`
} else if (disabled) {
action = knopf('Termine öffnen')
} else if (state === 'ordered') {
pill = ['Zahlung offen', 'setup']
action = knopf('Status ansehen')
} else if (state === 'setup') {
pill = ['In Einrichtung', 'setup']
action = knopf('Status ansehen')
} else if (state === 'disabled') {
pill = ['Abgeschaltet', 'off']
extra = '<p>Der Terminplaner ist für diese Website abgeschaltet. Deine Termine kannst du noch ansehen und exportieren.</p>'
action = `<button type="button" class="kb-btn-primary fn-tile-cta" data-view="termine" data-project="${esc(target.projectId)}">Termine ansehen</button>`
action = knopf('Termine ansehen')
} else if (!target) {
extra = '<p class="fn-count">Sobald deine Website angelegt ist, kannst du den Terminplaner einrichten.</p>'
} else if (angebot.bestellbar === true) {
const preis = typeof angebot.preis === 'string' && angebot.preis.trim() ? ` · ${esc(angebot.preis)}` : ''
action = knopf(hatEntwurf(target) ? 'Fragebogen fortsetzen' : `Terminplaner einrichten${preis}`)
} else {
extra = '<p>Wir schalten den Terminplaner für dich frei.</p>'
action = '<button type="button" class="kb-btn-primary fn-tile-cta" data-view="chat">Chat öffnen</button>'
// 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 = `<p class="fn-count">${zeile}</p>`
}
const count = projects.length > 1 && active ? `<p class="fn-count">aktiv für ${active} von ${projects.length} Websites</p>` : ''
return `
@@ -172,11 +220,11 @@ const soonTile = (icon, title, text) => `
export async function initFunktionen(root) {
if (!root) return
const { projects = [], termine } = await request('/api/funktionen')
const { projects = [], termine, features } = await request('/api/funktionen')
root.innerHTML = `
<div class="fn-tiles">
${newsTile(projects)}
${termine?.verfuegbar ? termineTile(projects) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${newsTile(projects, newsOffer(features).price)}
${termine?.verfuegbar ? termineTile(projects, termine) : soonTile('calendar', 'Terminplaner', TERMINE_TEXT)}
${analyseTile(projects)}
</div>`
root.querySelector('[data-fn-tile="news"] .fn-tile-cta')?.addEventListener('click', (ev) => { selection.projectId = ev.currentTarget.dataset.project })
@@ -193,7 +241,7 @@ export async function initFunktionen(root) {
export async function initNews(root, { projectId } = {}) {
if (!root) return
if (projectId) selection.projectId = projectId
const { projects = [], setupEstimate = '', consentText = '' } = await request('/api/funktionen')
const { projects = [], setupEstimate = '', consentText = '', features, ustHinweis } = await request('/api/funktionen')
if (!projects.length) {
root.innerHTML = '<div class="kb-panel kb-empty">Noch keine Website-Projekte zugewiesen.</div>'
return
@@ -228,10 +276,10 @@ 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 = '<div class="kb-panel"><h3>News-Pinnwand ist abgeschaltet</h3><p>Für diese Website ist die Pinnwand im Moment nicht aktiv. <button type="button" class="kb-link" data-view="chat">Schreib uns im Chat</button>, dann klären wir das.</p></div>'
else renderBooking(stage, project, { setupEstimate, consentText, thanks })
else renderBooking(stage, project, { setupEstimate, offer: { ...newsOffer(features, consentText), ustHinweis: textOr(ustHinweis, UST_HINWEIS) }, thanks })
}
function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
function renderBooking(stage, project, { setupEstimate, offer, thanks }) {
stage.innerHTML = `
${thanks}
<div class="kb-panel fn-booking">
@@ -242,14 +290,15 @@ function renderBooking(stage, project, { setupEstimate, consentText, thanks }) {
<li>Beiträge jederzeit selbst schreiben, ändern und abnehmen</li>
<li>Nutzung danach kostenlos, kein Abo</li>
</ul>
<p class="fn-price"><strong>${PRICE}</strong> <span>einmalig, inklusive Mehrwertsteuer</span></p>
<p class="fn-price"><strong>${esc(offer.price)}</strong> <span>einmalig, <span id="news-ust-hinweis"></span></span></p>
${setupEstimate ? `<p class="fn-estimate">Einrichtung: ${esc(setupEstimate)}</p>` : ''}
<p class="fn-desc-link"><a href="${LEISTUNGSBESCHREIBUNG}" target="_blank" rel="noopener">Leistungsbeschreibung</a></p>
<label class="fn-consent"><input type="checkbox" id="news-consent"><span id="news-consent-text"></span></label>
<p class="error hidden" id="news-order-error" role="alert"></p>
<div class="abo-actions"><button type="button" class="abo-btn" id="news-order" disabled>Zahlungspflichtig bestellen · ${PRICE}</button></div>
<div class="abo-actions"><button type="button" class="abo-btn" id="news-order" disabled>${esc(offer.knopf)}</button></div>
</div>`
stage.querySelector('#news-consent-text').textContent = consentText
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')
@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-05">
<title>Auftragsverarbeitungsvertrag Terminplaner – WEBklar</title>
<style>
: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 · AVV Terminplaner"; 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; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html">Leistungsbeschreibung Terminplaner</a> · <a href="/rechtliches/avv-terminplaner-2026-10-05.html">AVV Terminplaner</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO)</h1>
<p class="meta"><strong>Fassung 2026-10-05</strong></p>
<p>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“).</p>
<h2>Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen</h2>
<p>(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.</p>
<p>(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 anschließend eine E-Mail mit diesen beiden Texten als PDF.</p>
<p>(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.</p>
<h2>1. Gegenstand und Dauer</h2>
<p>(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.</p>
<p>(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.</p>
<h2>2. Art und Zweck der Verarbeitung</h2>
<p>(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.</p>
<p>(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).</p>
<p>(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.</p>
<h2>3. Datenarten und Betroffene</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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).</p>
<p>(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).</p>
<p>(5) Der Terminplaner setzt keine Cookies und speichert nichts im Browser des Gastes.</p>
<p>(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.</p>
<h2>4. Weisungen</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<h2>5. Vertraulichkeit</h2>
<p>WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.</p>
<h2>6. Technische und organisatorische Maßnahmen</h2>
<p>(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.</p>
<p>(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.</p>
<h2>7. Unterauftragsverarbeiter</h2>
<p>(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.</p>
<p>(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).</p>
<p>(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).</p>
<p>(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.</p>
<h2>8. Unterstützung des Kunden, Betroffenenrechte</h2>
<p>(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.</p>
<p>(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. Dabei löscht WEBklar die E-Mails zu dieser Buchung, die noch auf den Versand warten, und die Links des Gastes werden ungültig. E-Mails, deren Versand endgültig fehlgeschlagen ist, behalten Empfängeradresse und Inhalt, bis WEBklar sie nach Nummer 9 Abs. 4 löscht (48 Stunden nach dem Fehlschlag). Eine noch nicht bestätigte Buchung verfällt von selbst (Nummer 9 Abs. 1).</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(6) Die Gäste informiert der Kunde (Art. 13 DSGVO; Nummer 11 Abs. 2).</p>
<h2>9. Löschung und Rückgabe</h2>
<p>(1) Nicht bestätigte Buchungen löscht WEBklar 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(6) Ist der Terminplaner abgeschaltet oder das Wartungs-Abo beendet, löscht WEBklar alle Termindaten und die Einstellungen 30 Tage danach. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen (zum Beispiel Betriebsangaben, Namen der Leistungen, die ersten acht Namen der Personen oder Plätze und die Wünsche des Kunden zum Einbau) und enthält keine Daten von Gästen; dafür gilt die Datenschutzerklärung von WEBklar (Abschnitt 6). Bis zur Löschung 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.</p>
<p>(7) Entwürfe aus dem Fragebogen, aus denen keine Bestellung geworden ist, löscht WEBklar 90 Tage nach der letzten Änderung.</p>
<p>(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.</p>
<p>(9) Gesetzliche Aufbewahrungspflichten bleiben unberührt.</p>
<h2>10. Nachweise und Kontrollen</h2>
<p>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.</p>
<h2>11. Pflichten des Kunden</h2>
<p>(1) Der Kunde ist Verantwortlicher für die Verarbeitung der Daten seiner Gäste und seiner Beschäftigten im Terminplaner.</p>
<p>(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.</p>
<p>(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).</p>
<p>(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).</p>
<p>(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.</p>
<h2>12. Haftung, Änderungen, Schlussbestimmungen</h2>
<p>(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.</p>
<p>(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.</p>
<p>(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.</p>
<p>(4) Im Übrigen gilt § 16 der Abo-Bedingungen.</p>
<h2>Anlage: Technische und organisatorische Maßnahmen des Terminplaners (Art. 32 DSGVO)</h2>
<table>
<thead><tr><th>Bereich</th><th>Maßnahmen</th></tr></thead>
<tbody>
<tr><td>Übertragung</td><td>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://.</td></tr>
<tr><td>Speicher im Browser</td><td>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.</td></tr>
<tr><td>Zugriffskontrolle</td><td>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.</td></tr>
<tr><td>Missbrauchsschutz</td><td>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.</td></tr>
<tr><td>Datenminimierung</td><td>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.</td></tr>
<tr><td>Trennung</td><td>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.</td></tr>
<tr><td>Löschung</td><td>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; das Einrichtungs-Ticket bleibt als Arbeitsnachweis mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6). 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.</td></tr>
<tr><td>Protokolle</td><td>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).</td></tr>
<tr><td>Datensicherung</td><td>Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen).</td></tr>
<tr><td>Rechenzentrum</td><td>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.</td></tr>
<tr><td>Organisation</td><td>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.</td></tr>
</tbody>
</table>
</main>
</body>
</html>
Binary file not shown.
@@ -0,0 +1 @@
3a10d5b588d05ef9f711fee796157b5c2a9170f39c5d7f2f4f015308d318e9ef
@@ -0,0 +1,179 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-05">
<title>Leistungsbeschreibung Terminplaner – WEBklar</title>
<style>
: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 · Leistungsbeschreibung Terminplaner"; 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; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html">Leistungsbeschreibung Terminplaner</a> · <a href="/rechtliches/avv-terminplaner-2026-10-05.html">AVV Terminplaner</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung Terminplaner</h1>
<p class="meta"><strong>Fassung 2026-10-05</strong> · Zusatzbedingungen zum Wartungs-Abo</p>
<h2>1. Geltung</h2>
<p>(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.</p>
<p>(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für den Terminplaner diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Terminplaner.</p>
<p>(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.</p>
<h2>2. Leistung</h2>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<p>(5) Der Terminplaner enthält einen Missbrauchsschutz gegen automatisierte Buchungen. Die Löschfristen für Buchungsdaten nennt Nummer 9 des AVV Terminplaner.</p>
<p>(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).</p>
<h2>3. Nicht enthalten</h2>
<p>(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).</p>
<p>(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).</p>
<h2>4. Voraussetzungen und Pflichten des Kunden</h2>
<p>(1) Der Kunde hat ein laufendes Wartungs-Abo.</p>
<p>(2) Der Kunde trägt vor der Bestellung seine Betriebsangaben ein, dazu die Adresse seines Impressums und die Adresse seiner Datenschutzerklärung.</p>
<p>(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.</p>
<p>(4) Richtet sich das Angebot des Kunden gezielt an Kinder unter 16 Jahren, nutzt der Kunde den Terminplaner nicht.</p>
<p>(5) Der Kunde nennt eine Telefonnummer, unter der Gäste Termine vereinbaren können, wenn das Widget nicht erreichbar ist (Nr. 6 Abs. 2).</p>
<p>(6) Er lässt die Texte der Haken und Hinweise im Widget unverändert.</p>
<p>(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.</p>
<p>(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.</p>
<p>(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.</p>
<h2>5. Einrichtung und Preis</h2>
<p>(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.</p>
<p>(2) Die Einrichtung kostet 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG.</p>
<p>(3) Die voraussichtliche Dauer der Einrichtung nennt die Bestellseite vor der Bestellung. Sie ist eine Einschätzung und keine verbindliche Zusage eines Termins.</p>
<p>(4) Der Kunde bezahlt bei der Bestellung im Kundenportal über Stripe und erhält eine Rechnung.</p>
<p>(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).</p>
<h2>6. Verfügbarkeit und Störungen</h2>
<p>(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).</p>
<p>(2) Ist der Terminplaner nicht erreichbar, zeigt das Widget einen Ausweichtext mit der Telefonnummer des Kunden (Nr. 4 Abs. 5).</p>
<p>(3) Alle Zeiten gelten in der Zeitzone Europe/Berlin.</p>
<p>(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.</p>
<p>(5) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.</p>
<h2>7. Haftung für verpasste Termine</h2>
<p>(1) Der Terminplaner ist ein Hilfsmittel. Die Einhaltung der Termine bleibt Sache des Kunden.</p>
<p>(2) WEBklar übernimmt keine Gewähr für die Zustellung der E-Mails (Nr. 6 Abs. 4).</p>
<p>(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.</p>
<p>(4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt.</p>
<h2>8. Ende der Leistung</h2>
<p>(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.</p>
<p>(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.</p>
<p>(3) WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten. Eine Ausnahme ist das Einrichtungs-Ticket, in dem WEBklar die Einrichtung als Arbeitsnachweis festhält: Es bleibt mit einer Kurzfassung der Einstellungen bestehen und enthält keine Daten von Gästen (Nummer 9 Abs. 6 des AVV Terminplaner).</p>
<p>(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).</p>
<p>(5) Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.</p>
<p>(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.</p>
<h2>9. Änderungen und Einstellung</h2>
<p>(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.</p>
<p>(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</p>
<ul>
<li>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</li>
<li>eine Sicherheitslücke sich nicht anders beheben lässt.</li>
</ul>
<p>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.</p>
<p>(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.</p>
<p>(4) Eine Einstellung ohne einen Grund nach Absatz 2 ist ausgeschlossen. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.</p>
<h2>10. Widerruf</h2>
<p>(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)“.</p>
<p>(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.</p>
<p>(3) Mit dem Widerruf endet der Terminplaner.</p>
<h2>11. Hinweise</h2>
<p>(1) <strong>Keine Rechtsberatung.</strong> 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.</p>
<p>(2) <strong>Barrierefreiheit.</strong> 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.</p>
</main>
</body>
</html>
@@ -0,0 +1 @@
da26e937921ef71a5d874551fd085ce63feb9b8663e37571f1a901e75b859473
@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="2026-10-05">
<title>Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen) – WEBklar</title>
<style>
: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 · Widerrufsbelehrung Funktionen"; 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; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/leistungsbeschreibung-terminplaner-2026-10-05.html">Leistungsbeschreibung Terminplaner</a> · <a href="/rechtliches/avv-terminplaner-2026-10-05.html">AVV Terminplaner</a> · <a href="/rechtliches/widerrufsbelehrung-funktionen-2026-10-05.html">Widerrufsbelehrung Funktionen</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen)</h1>
<p class="meta"><strong>Fassung 2026-10-05</strong></p>
<p>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.</p>
<h2>Widerrufsbelehrung</h2>
<h3>Widerrufsrecht</h3>
<p>Sie haben das Recht, binnen vierzehn Tagen ohne Angabe von Gründen diesen Vertrag zu widerrufen.</p>
<p>Die Widerrufsfrist beträgt vierzehn Tage ab dem Tag des Vertragsabschlusses.</p>
<p>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.</p>
<p>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.</p>
<p>Zur Wahrung der Widerrufsfrist reicht es aus, dass Sie die Mitteilung über die Ausübung des Widerrufsrechts vor Ablauf der Widerrufsfrist absenden.</p>
<h3>Folgen des Widerrufs</h3>
<p>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.</p>
<p>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.</p>
<h2>Muster-Widerrufsformular</h2>
<p>(Wenn Sie den Vertrag widerrufen wollen, dann füllen Sie bitte dieses Formular aus und senden Sie es zurück.)</p>
<p>– An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:</p>
<p>– Hiermit widerrufe(n) ich/wir (*) den von mir/uns (*) abgeschlossenen Vertrag über den Kauf der folgenden Waren (*)/die Erbringung der folgenden Dienstleistung (*)</p>
<p>– Bestellt am (*)/erhalten am (*)</p>
<p>– Name des/der Verbraucher(s)</p>
<p>– Anschrift des/der Verbraucher(s)</p>
<p>– Unterschrift des/der Verbraucher(s) (nur bei Mitteilung auf Papier)</p>
<p>– Datum</p>
<p>(*) Unzutreffendes streichen.</p>
<h2>Hinweise zum Widerruf</h2>
<p>Diese Hinweise gehören nicht zur gesetzlichen Widerrufsbelehrung. Sie erklären, wie sich das Widerrufsrecht auf die Bestellung einer Funktion auswirkt.</p>
<ul>
<li>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.</li>
<li>Widerruft der Kunde die Einrichtung einer Funktion, endet diese Funktion. Das Wartungs-Abo bleibt davon unberührt.</li>
<li>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.</li>
<li>Die Rückzahlung erfolgt über das ursprüngliche Zahlungsmittel; die Zahlung lief über Stripe.</li>
</ul>
</main>
</body>
</html>
@@ -0,0 +1 @@
a23b62f8dd5b6a1eb08ad4570e3bd797bb97be6636c400981377f4996690b1d8
+2
View File
@@ -34,6 +34,8 @@ export async function request(path, { method = 'GET', body } = {}) {
err.status = response.status
err.fehler = data.fehler || {}
err.code = data.code
// der ganze Body der Antwort (z. B. offen und consent der Bestellung in funktion-bestellen.js); immer ein Objekt
err.body = data !== null && typeof data === 'object' ? data : {}
throw err
}
return data
+728
View File
@@ -0,0 +1,728 @@
/* Terminplaner im Kundenbereich: Fragebogen vor der Bestellung (TP2). renderFragebogen(root, ctx) zeigt in der Huelle (termine.js) die acht Schritte
und danach die Bestellseite (funktion-bestellen.js). ctx ist der Kontext der Reiter (baueCtx in termine.js, hinweise() bleibt leer); ctx.daten ist die
Antwort von GET /api/termine/einstellungen im Zustand off (einstellung, version, fragebogen, branchen, betriebVorschlag, kundenansicht, offen, bestellung).
Aufbau: Fortschrittsleiste (<ol>, aria-current="step"; am Handy dazu „Schritt n von 8“), darunter der Schritt. Der Start ist der erste offene
Schritt (alles bestaetigt: die Bestellseite); die Leiste erlaubt freie Navigation, nur ohne Entwurf fuehrt sie allein in die Branchenwahl.
Jeder Schritt steht in BAU: key ⇒ (t) ⇒ Element mit t = { ctx, key, nr, titel, gehe(key), neuZeichnen(), weiter(), zurueck }. Ein Schritt mit
Feldern ist ein <form>; „Weiter“ (verdrahte) macht genau EINEN PATCH ueber den neuerSpeicher des Schritts (termine-speichern.js, der einzige
PATCH-Weg; nur wenn der Schritt Felder hat) und danach PUT /fragebogen mit dem Schritt in bestaetigt (bestaetige, der einzige PUT). Fehler stehen am
Feld (setzeFehler); beim Wechsel bekommt die Ueberschrift des Schritts den Fokus. Kundenansicht (daten.kundenansicht): PATCH bleibt moeglich, es gibt
keinen PUT, denn Schritte bestaetigt nur der Kunde selbst. Die Schritte bauen auf den Abschnitten der Reiter auf (wochenplanFeld, abwesenheiten,
sonderoeffnungen, betrieb, darstellung, regeln, gastFelder, erinnerung, storno, rechtliches, zahlung); hier steht nur, was der Fragebogen daraus macht.
Nach Schritt 8 kommt die Bestellseite (renderBestellen). Texte der Kunden kommen nur ueber value/textContent in die Seite (h aus termine-dom.js),
nie als HTML. */
import {
BUNDESLAENDER, FRAGEBOGEN_SCHRITTE, LIMITS, MAX, buchungsartFuerAlle, buchungsartLage, ersterOffenerSchritt, preisAnzeige, ressourcenAusNamen, schrittStatus,
} from './termine-view.js'
import { abschnitt, allgemeinerFehler, feld, frage, h, hakenFeld, setzeFehler, zeigeFehler } from './termine-dom.js'
import { neuerSpeicher, stelleVon } from './termine-speichern.js'
import { render as renderLeistungen } from './termine-tab-leistungen.js'
import { wochenplanFeld } from './termine-tab-team.js'
import { abwesenheiten, sonderoeffnungen } from './termine-team-ausnahmen.js'
import { betrieb, darstellung, erinnerung, gastFelder, rechtliches, regeln, storno, zahlung } from './termine-tab-einstellungen.js'
import { renderBestellen } from './funktion-bestellen.js'
const BESTELLEN = 'bestellen'
const TITEL = new Map([...FRAGEBOGEN_SCHRITTE, [BESTELLEN, 'Prüfen und bestellen']])
const AUFBEWAHRUNG = 'Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.'
const KUNDENANSICHT = 'Du siehst den Fragebogen in der Kundenansicht. Angaben kannst du ändern, Schritte bestätigen und bestellen kann nur der Kunde selbst.'
const GESUNDHEIT_TEXT = 'Für den Gesundheitsbereich ist der Terminplaner nicht verfügbar. Schon der Name einer Leistung oder ein Terminwunsch kann etwas über die Gesundheit deiner Gäste verraten, '
+ 'und solche Angaben sind besonders geschützt. Gehört dein Angebot nicht zum Gesundheitsbereich, wähle die Branche, die am besten passt. Fragen dazu beantworten wir dir im Chat.'
const WECHSEL_TEXT = 'Das ersetzt deine Leistungen, Zeiten und Regeln durch die Vorschläge der neuen Branche. Alle Bestätigungen gehen zurück, du bestätigst die Schritte danach noch einmal. '
+ 'Deine Angaben zum Betrieb bleiben erhalten.'
const NACH_VORLAGE_FEHLT = 'Deine Branche ist angelegt, aber nicht alle Angaben konnten gespeichert werden. Bitte prüfe sie und klick auf „Weiter“.'
const NACH_VORLAGE_LADEN = 'Deine Branche ist angelegt. Wir konnten den Stand aber nicht laden. Bitte lade die Seite neu.'
// Betriebsangaben von Schritt 1, ohne Entwurf und mit Entwurf
const BETRIEB_NUR = ['name', 'adresse', 'kleinunternehmer']
// POST vorlage uebernimmt aus betrieb nur diese Felder (server/routes/termine.js) ...
const KONTAKT_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl']
// ... diese bleiben dabei auf der Strecke; der Client traegt sie mit einem PATCH nach (betriebNachtrag)
const NACHTRAG_FELDER = ['kleinunternehmer', 'impressumUrl', 'datenschutzUrl', 'agbUrl']
const WER_ARTEN = [
['einzel', 'Einzelperson', 'Eine Person wird gebucht, zum Beispiel du selbst.'],
['team', 'Team', 'Mehrere Personen können gebucht werden, jede mit eigenen Zeiten.'],
['plaetze', 'Plätze', 'Gebucht werden Plätze oder Räume, zum Beispiel Stühle oder Zimmer.'],
]
// Schritt 4: ohne Wahl des Bundeslands geht es nicht weiter (der Serverstandard RP im Entwurf ist keine Wahl)
const LAND_FEHLT = 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.'
// Schritt 5: Lage der Buchungsarten aller Leistungen (Schluessel wie buchungsartLage) und die zwei Sammelknoepfe
const LAGE_TEXT = {
sofort: 'Alle Leistungen sind sofort buchbar.',
anfrage: 'Alle Leistungen sind nur als Anfrage buchbar.',
gemischt: 'Die Leistungen sind gemischt: je Leistung sofort buchbar oder nur als Anfrage.',
}
const SAMMEL_ARTEN = [['sofort', 'Alle sofort buchbar'], ['anfrage', 'Alle nur als Anfrage']]
// Schritt 8: Einbauwuensche (Werte und Grenzen wie normalisiereFragebogen auf dem Server)
const EINBAU_MODI = [
['inline', 'Direkt auf der Seite', 'Der Terminplaner steht offen an der Stelle, die du uns nennst.'],
['knopf', 'Hinter einem Knopf', 'Gäste klicken auf einen Knopf, dann öffnet sich der Terminplaner.'],
]
const EINBAU_MAX = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
const MODUS_FEHLT = 'Bitte wähle, ob der Terminplaner direkt auf deiner Seite oder hinter einem Knopf erscheint.'
const KNOPFTEXT_FEHLT = 'Bitte gib an, was auf dem Knopf stehen soll, hinter dem sich der Terminplaner öffnet.'
const MEHRWUENSCHE = 'Der Einbau umfasst den Terminplaner an einer Stelle deiner Website. Ein zweites Widget, Knöpfe für einzelne Leistungen und ein Sonderdesign gehen nach Stunden.'
const WEBSITE_NUR = ['telefon', 'impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl']
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/* ---------------------------------------------------------------- Gerüst --- */
export function renderFragebogen(root, ctx) {
const stand = h('p', { class: 'tm-schritt-stand' })
const liste = h('ol', { class: 'tm-schritte' })
const inhalt = h('div', { class: 'tm-fb-inhalt' })
root.replaceChildren(h('div', { class: 'tm-fragebogen' },
ctx.daten.kundenansicht === true ? h('div', { class: 'tm-box tm-box-info', role: 'note' }, h('span', null, KUNDENANSICHT)) : null,
h('nav', { class: 'tm-fortschritt', 'aria-label': 'Fortschritt im Fragebogen' }, stand, liste),
inhalt,
h('p', { class: 'tm-hinweis tm-fb-aufbewahrung' }, AUFBEWAHRUNG)))
let angezeigt = null // Schluessel des Schritts, der gerade steht
// ohne Entwurf gibt es nur die Branchenwahl; alle anderen Schritte brauchen eine Einstellung
const darfHin = (key) => key === 'branche' || Boolean(ctx.daten.einstellung)
function zeichneLeiste() {
const eintraege = [...schrittStatus(ctx.daten.fragebogen), { key: BESTELLEN, titel: TITEL.get(BESTELLEN), erledigt: false }]
liste.replaceChildren(...eintraege.map(({ key, titel, erledigt }, i) => h('li', {
class: 'tm-schritt', 'data-schritt': key, 'data-erledigt': erledigt ? 'true' : null, 'aria-current': key === angezeigt ? 'step' : null,
}, h('button', { type: 'button', class: 'tm-schritt-knopf', disabled: !darfHin(key), onclick: () => { if (key !== angezeigt) zeige(key) } },
h('span', { class: 'tm-schritt-nr', 'aria-hidden': 'true' }, erledigt ? '✓' : String(i + 1)),
h('span', { class: 'tm-schritt-titel' }, titel),
erledigt ? h('span', { class: 'tm-sr' }, ' (erledigt)') : null))))
const nr = FRAGEBOGEN_SCHRITTE.findIndex(([key]) => key === angezeigt)
stand.textContent = nr < 0 ? TITEL.get(BESTELLEN) : `Schritt ${nr + 1} von ${FRAGEBOGEN_SCHRITTE.length}`
}
function bestellseite() {
const seite = h('div', { class: 'tm-fb-bestellen' })
renderBestellen(seite, { ctx, daten: ctx.daten, zumSchritt: (key) => zeige(key) })
return seite
}
/** Schritt zeigen (=> sein Element). fokus: die Ueberschrift bekommt den Fokus, so wie bei jedem Wechsel; der erste Aufruf nimmt keinen. */
function zeige(key, { fokus = true } = {}) {
const ziel = TITEL.has(key) && darfHin(key) ? key : 'branche'
angezeigt = ziel
zeichneLeiste()
const el = ziel === BESTELLEN ? bestellseite() : BAU[ziel](baueT(ziel))
inhalt.replaceChildren(el)
const kopf = fokus ? el.querySelector('h3') : null
if (kopf) {
kopf.setAttribute('tabindex', '-1')
kopf.focus()
}
return el
}
function baueT(key) {
const nr = FRAGEBOGEN_SCHRITTE.findIndex(([k]) => k === key)
return {
ctx,
key,
nr,
titel: TITEL.get(key),
gehe: (ziel) => zeige(ziel),
// nur solange der Schritt noch steht: eine spaete Antwort holt den Kunden nicht aus einem anderen Schritt zurueck
neuZeichnen: () => (angezeigt === key ? zeige(key, { fokus: false }) : null),
weiter: () => zeige(FRAGEBOGEN_SCHRITTE[nr + 1]?.[0] ?? BESTELLEN),
zurueck: nr > 0 ? () => zeige(FRAGEBOGEN_SCHRITTE[nr - 1][0]) : null,
}
}
zeige(ersterOffenerSchritt(ctx.daten.fragebogen), { fokus: false })
}
/* ------------------------------------------------------------ Bausteine --- */
/** Rahmen eines Schritts: Ueberschrift (Fokusziel beim Wechsel) und ein erklaerender Satz; der Schritt haengt seinen Inhalt an. */
function rahmen(t, intro) {
return h('section', { class: 'tm-fb-schritt', 'data-schritt': t.key },
h('h3', { id: `tm-fb-${t.key}`, tabindex: '-1' }, t.titel),
intro ? h('p', { class: 'tm-hinweis tm-abschnitt-text' }, intro) : null)
}
const schrittForm = (t, ...kinder) => h('form', { class: 'tm-fb-form', novalidate: true, 'aria-labelledby': `tm-fb-${t.key}` }, ...kinder)
/** Zurueck (ohne zu speichern) und Weiter (sendet das Formular). */
function knoepfe(t, { mitWeiter = true } = {}) {
return h('div', { class: 'tm-frage-knoepfe' },
t.zurueck ? h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'zurueck', onclick: () => t.zurueck() }, 'Zurück') : null,
mitWeiter ? h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'weiter' }, 'Weiter') : null)
}
/**
* PUT /fragebogen: der Schritt kommt in bestaetigt, aenderung(fragebogen) setzt weitere Angaben (z. B. preiseGeprueft). Gesendet wird der ganze Fragebogen
* (PUT ersetzt ihn) auf dem Stand von ctx.daten; die Antwort gilt danach in ctx.daten (fragebogen und die offenen Punkte der Bestellung).
* In der Kundenansicht passiert nichts: Schritte bestaetigt nur der Kunde selbst.
*/
async function bestaetige(t, aenderung) {
const { ctx } = t
if (ctx.daten.kundenansicht === true) return
const fragebogen = structuredClone(istObjekt(ctx.daten.fragebogen) ? ctx.daten.fragebogen : {})
aenderung?.(fragebogen)
fragebogen.bestaetigt = [...new Set([...(Array.isArray(fragebogen.bestaetigt) ? fragebogen.bestaetigt : []), t.key])]
const antwort = await ctx.api.send('PUT', '/fragebogen', { fragebogen })
if (!ctx.aktuell()) return
if (istObjekt(antwort?.fragebogen)) ctx.daten.fragebogen = antwort.fragebogen
if (antwort?.offen !== undefined) ctx.daten.offen = antwort.offen
}
/**
* „Weiter“ eines Schritts: pruefe() (Fehler ⇒ { feld: text } am Feld, nichts wird gesendet), aendere(entwurf) (ein PATCH ueber den Speicher des
* Schritts; false = der Schritt hat selbst Fehler gezeigt; fehlt aendere, gibt es keinen PATCH), dann PUT mit dem Schritt (fragebogen(f) setzt weitere
* Angaben) und der naechste Schritt. Nur ein Durchlauf zur Zeit; verlaesst der Kunde den Schritt oder die Ansicht, fasst nichts mehr die Seite an.
*/
function verdrahte(t, form, sp, { pruefe, aendere, fragebogen } = {}) {
const weiter = form.querySelector('[data-aktion="weiter"]')
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
if (laeuft) return
laeuft = true
const hatteFokus = document.activeElement === weiter
weiter.disabled = true
try {
setzeFehler(form, {})
const fehler = pruefe?.()
if (fehler) {
zeigeFehler(form, { fehler })
return
}
if (aendere && !(await sp.speichere(aendere, { container: form }))) return
if (!sp.nochHier()) return
try {
await bestaetige(t, fragebogen)
} catch (err) {
if (sp.nochHier()) zeigeFehler(form, err)
return
}
if (sp.nochHier()) t.weiter()
} finally {
laeuft = false
weiter.disabled = false
// ein gesperrter Knopf verliert im Browser den Fokus: bleibt der Schritt stehen und hat ihn niemand sonst (ein Feld mit Fehler), kehrt er zurueck
const jetzt = document.activeElement
if (hatteFokus && weiter.isConnected && (!jetzt || jetzt === document.body || jetzt === weiter)) weiter.focus()
}
})
}
/** Schritt neu zeichnen und im neuen Formular einen allgemeinen Fehler zeigen (nach einem Teilerfolg, den die alte Ansicht nicht kennt). */
function zeigeNeu(t, text) {
const form = t.neuZeichnen()?.querySelector('form')
if (form) setzeFehler(form, { _: text })
}
/**
* aendere() fuer Schritte aus Abschnitten der Einstellungen (jeder ist { el, lese(entwurf, fehler) }): jeder liest seine Felder in den Entwurf.
* Eingaben, die kein Feld versteht, stehen danach am Feld und nichts wird gesendet (false). Das Pruefen im Einzelnen bleibt Sache des Servers.
*/
function leseAbschnitte(form, abschnitte) {
return (entwurf) => {
const fehler = {}
for (const a of abschnitte) a.lese(entwurf, fehler)
if (Object.keys(fehler).length === 0) return true
zeigeFehler(form, { fehler })
return false
}
}
/* ------------------------------------------------- Schritt 1: Branche und Betrieb --- */
/**
* Radiogruppe der Branchen aus daten.branchen. Der Gesundheitsbereich zeigt den Erklaertext und KEINEN Knopf (es geht nie eine Anfrage raus), jede andere
* Wahl den Knopf (type submit, data-aktion branche-starten). => { liste, hinweis, aktion, gewaehlt() }: der Aufrufer setzt die drei Elemente.
*/
function brancheWahl(daten, { name, knopf }) {
let gewaehlt = null
const hinweis = h('div', { class: 'tm-fb-branchenhinweis' })
const aktion = h('div', { class: 'tm-frage-knoepfe' })
const liste = h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' },
h('legend', { class: 'tm-sr' }, 'Branche'),
(daten.branchen || []).map((b) => {
const radio = h('input', { type: 'radio', name, value: b.key })
radio.addEventListener('change', () => { gewaehlt = b; zeichne() })
const beschreibung = (daten.vorlagen || []).find((v) => v.key === b.key)?.beschreibung
return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, b.name), beschreibung ? h('small', null, beschreibung) : null))
}))
function zeichne() {
if (gewaehlt?.gesundheit) {
hinweis.replaceChildren(h('div', { class: 'tm-box tm-box-warn', role: 'note', 'data-hinweis': 'gesundheit' },
h('span', null, GESUNDHEIT_TEXT),
h('button', { type: 'button', class: 'kb-btn-outline', 'data-view': 'chat' }, 'Chat öffnen')))
aktion.replaceChildren()
return
}
hinweis.replaceChildren()
aktion.replaceChildren(h('button', { type: 'submit', class: 'kb-btn-primary', 'data-aktion': 'branche-starten', disabled: gewaehlt === null }, knopf))
}
zeichne()
return { liste, hinweis, aktion, gewaehlt: () => gewaehlt }
}
/** Nur die nichtleeren Texte der Felder, die POST vorlage aus betrieb uebernimmt. */
const kontaktVon = (angaben) => Object.fromEntries(KONTAKT_FELDER
.filter((name) => typeof angaben?.[name] === 'string' && angaben[name].trim() !== '')
.map((name) => [name, angaben[name]]))
/** Was der Kunde angegeben hatte und POST vorlage nicht uebernommen hat (Haken gesetzt, Adresse eingetragen), im Vergleich zum neuen Entwurf. */
function betriebNachtrag(gewuenscht, neu) {
const nachtrag = {}
for (const name of NACHTRAG_FELDER) {
const wunsch = gewuenscht?.[name]
if (wunsch && wunsch !== neu?.[name]) nachtrag[name] = wunsch
}
return nachtrag
}
/**
* Nach einem erfolgreichen POST vorlage: Daten neu laden (Einstellung, Version, Fragebogen) und nachtragen, was der Server aus betrieb nicht uebernimmt
* (ein PATCH, nur wenn etwas fehlt). => true, wenn der Entwurf danach vollstaendig steht.
*/
async function nachVorlage(t, sp, container, gewuenscht) {
const { ctx } = t
try {
await ctx.neuLaden()
} catch {
return false
}
if (!sp.nochHier() || !ctx.daten.einstellung) return false
const nachtrag = betriebNachtrag(gewuenscht, ctx.daten.einstellung.betrieb)
if (Object.keys(nachtrag).length === 0) return true
return sp.speichere((entwurf) => { entwurf.betrieb = { ...entwurf.betrieb, ...nachtrag }; return true }, { container })
}
function schrittBranche(t) {
return t.ctx.daten.einstellung ? brancheMitEntwurf(t) : brancheStart(t)
}
/** Ohne Entwurf: Branche waehlen und die Betriebsangaben pruefen; „Mit dieser Branche starten“ legt den Entwurf an und bestaetigt den Schritt. */
function brancheStart(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Such dir die Branche aus, die am besten zu deinem Betrieb passt. Daraus legen wir Leistungen, Zeiten und Regeln als Vorschlag an. Du kannst danach alles ändern.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const angaben = betrieb(ctx, { betrieb: ctx.daten.betriebVorschlag ?? {} }, { nur: BETRIEB_NUR })
const wahl = brancheWahl(ctx.daten, { name: 'termine-branche', knopf: 'Mit dieser Branche starten' })
const form = schrittForm(t, h('div', { class: 'kb-panel' }, wahl.liste, wahl.hinweis), angaben.el, allgemeinerFehler(), wahl.aktion)
let laeuft = false
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const branche = wahl.gewaehlt()
if (laeuft || branche === null || branche.gesundheit) return // der Gesundheitsbereich sendet nie etwas
laeuft = true
const knopf = form.querySelector('[data-aktion="branche-starten"]')
if (knopf) knopf.disabled = true
try {
setzeFehler(form, {})
const gewuenscht = angaben.lese()
// die Website des Projekts kennt nur der Vorschlag (der Server nimmt Name, Anschrift, E-Mail und Telefon selbst aus dem Konto): sie geht mit
const website = typeof ctx.daten.betriebVorschlag?.websiteUrl === 'string' ? ctx.daten.betriebVorschlag.websiteUrl.trim() : ''
try {
await ctx.api.send('POST', '/einstellungen/vorlage', { branche: branche.key, betrieb: website ? { ...gewuenscht, websiteUrl: website } : gewuenscht })
} catch (err) {
if (sp.nochHier()) zeigeFehler(form, err)
return
}
// ab hier gibt es den Entwurf: was schiefgeht, zeigt den Schritt neu (mit Entwurf) statt noch einmal anzulegen
const vollstaendig = await nachVorlage(t, sp, form, gewuenscht)
if (!sp.nochHier()) return
if (!vollstaendig) {
if (ctx.daten.einstellung) zeigeNeu(t, NACH_VORLAGE_FEHLT)
else zeigeFehler(form, { fehler: { _: NACH_VORLAGE_LADEN } })
return
}
try {
await bestaetige(t)
} catch (err) {
if (sp.nochHier()) zeigeNeu(t, err.message)
return
}
if (!sp.nochHier()) return
ctx.meldung(`Die Branche „${branche.name}“ ist angelegt. Alles darin ist ein Vorschlag, den du anpassen kannst.`)
t.weiter()
} finally {
laeuft = false
if (knopf) knopf.disabled = false
}
})
wurzel.append(sp.konflikt, form)
return wurzel
}
/** Mit Entwurf: gewaehlte Branche (wechseln nur mit Rueckfrage), Betriebsangaben, „Weiter“ speichert sie und bestaetigt den Schritt. */
function brancheMitEntwurf(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const branche = (ctx.daten.branchen || []).find((b) => b.key === ctx.daten.fragebogen?.branche)
const wurzel = rahmen(t, 'Prüfe die Angaben zu deinem Betrieb. Die Branche bestimmt, welche Vorschläge für Leistungen, Zeiten und Regeln du siehst.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const angaben = betrieb(ctx, e, { nur: BETRIEB_NUR })
const wechsel = wechselFrage(t, sp, angaben, branche !== undefined)
const form = schrittForm(t, angaben.el, allgemeinerFehler(), knoepfe(t))
verdrahte(t, form, sp, {
pruefe: () => (branche ? null : { _: 'Bitte wähle zuerst deine Branche.' }),
aendere: (entwurf) => { angaben.lese(entwurf); return true },
})
wurzel.append(sp.konflikt,
h('div', { class: 'tm-fb-branche' }, h('p', null, 'Deine Branche: ', h('strong', null, branche?.name ?? 'noch nicht gewählt')), wechsel.knopf),
wechsel.box,
form)
return wurzel
}
/**
* „Branche wechseln“ mit Rueckfrage in der Seite (kein confirm()): ersetzt Leistungen, Zeiten und Regeln und setzt alle Bestaetigungen zurueck
* (POST vorlage mit ersetzen: true und der gelesenen version). Die Betriebsangaben gehen mit; was der Server dabei nicht uebernimmt, wird nachgetragen.
* Danach steht Schritt 1 neu da, ein PUT gibt es nicht. => { knopf, box }: der Aufrufer setzt beide NEBEN das Formular des Schritts.
*/
function wechselFrage(t, sp, angaben, hatBranche) {
const { ctx } = t
const box = h('div', { class: 'tm-frage' })
const knopf = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'branche-wechseln' }, hatBranche ? 'Branche wechseln' : 'Branche wählen')
const zu = () => { box.replaceChildren(); knopf.focus() }
knopf.addEventListener('click', () => {
const wahl = brancheWahl(ctx.daten, { name: 'termine-branche-neu', knopf: 'Ja, Branche wechseln' })
const abbrechen = h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'abbrechen', onclick: zu }, 'Abbrechen')
const rueckfrage = frage('Branche wechseln?', h('p', null, WECHSEL_TEXT), wahl.liste, wahl.hinweis, allgemeinerFehler(), h('div', { class: 'tm-frage-knoepfe' }, wahl.aktion, abbrechen))
let laeuft = false
rueckfrage.addEventListener('submit', async () => {
const neu = wahl.gewaehlt()
if (laeuft || neu === null || neu.gesundheit) return
laeuft = true
const ok = rueckfrage.querySelector('[data-aktion="branche-starten"]')
if (ok) ok.disabled = true
try {
setzeFehler(rueckfrage, {})
const gewuenscht = { ...ctx.daten.einstellung.betrieb, ...angaben.lese() }
try {
await ctx.api.send('POST', '/einstellungen/vorlage', { branche: neu.key, betrieb: kontaktVon(gewuenscht), ersetzen: true, version: ctx.daten.version })
} catch (err) {
if (!sp.nochHier()) return
if (err.code === 'EINSTELLUNG_VERALTET') {
box.replaceChildren()
sp.konflikt.hidden = false // „Neu laden“ des Speichers
sp.konflikt.scrollIntoView({ block: 'nearest' })
} else zeigeFehler(rueckfrage, err)
return
}
const vollstaendig = await nachVorlage(t, sp, rueckfrage, gewuenscht)
if (!sp.nochHier()) return
if (vollstaendig) t.gehe('branche')
else zeigeNeu(t, NACH_VORLAGE_FEHLT)
} finally {
laeuft = false
if (ok) ok.disabled = false
}
})
box.replaceChildren(rueckfrage)
rueckfrage.focus()
})
return { knopf, box }
}
/* ------------------------------------------------------ Schritt 2: Wer wird gebucht --- */
/** Vorbelegung der Art: nur Plaetze ⇒ Plaetze, mehr als ein Eintrag ⇒ Team, sonst Einzelperson. */
function vorgabeArt(ressourcen) {
if (ressourcen.length > 0 && ressourcen.every((r) => r.typ === 'platz')) return 'plaetze'
return ressourcen.length > 1 ? 'team' : 'einzel'
}
function schrittWer(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Trag ein, wer oder was bei dir gebucht werden kann. Neue Personen oder Plätze können zunächst alle Leistungen anbieten, das änderst du im nächsten Schritt.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const ressourcen = ctx.daten.einstellung.ressourcen || []
const vorgabe = vorgabeArt(ressourcen)
const radios = WER_ARTEN.map(([wert, name, beschreibung]) => h('label', { class: 'tm-vorlage' },
h('input', { type: 'radio', name: 'termine-wer-art', value: wert, checked: wert === vorgabe }),
h('span', null, h('strong', null, name), h('small', null, beschreibung))))
const namen = h('textarea', { rows: '4', maxlength: String(LIMITS.ressourcen * (MAX.ressourcenName + 1)), autocomplete: 'off' }, ressourcen.map((r) => r.name).join('\n'))
const form = schrittForm(t,
h('div', { class: 'kb-panel' },
h('fieldset', { class: 'tm-vorlagen-liste tm-fb-branchen' }, h('legend', { class: 'tm-sr' }, 'Wer wird gebucht'), radios),
feld('ressourcen', 'Namen (einer pro Zeile)', namen, `Höchstens ${LIMITS.ressourcen} Einträge mit je höchstens ${MAX.ressourcenName} Zeichen. Bei einer Einzelperson trägst du nur einen Namen ein.`)),
allgemeinerFehler(),
knoepfe(t))
let gelesen = null
const art = () => form.querySelector('input[name="termine-wer-art"]:checked')?.getAttribute('value') ?? vorgabe
verdrahte(t, form, sp, {
pruefe: () => {
const zeilen = namen.value.split(/\r?\n/)
const gefuellt = zeilen.filter((zeile) => zeile.trim() !== '')
let fehler = null
if (gefuellt.length === 0) fehler = 'Bitte trag mindestens einen Namen ein.'
else if (art() === 'einzel' && gefuellt.length > 1) fehler = 'Für eine Einzelperson trägst du nur einen Namen ein. Wähle „Team“, wenn mehrere Personen gebucht werden können.'
else if (gefuellt.length > LIMITS.ressourcen) fehler = `Es sind höchstens ${LIMITS.ressourcen} Einträge möglich.`
else if (gefuellt.some((zeile) => [...zeile.trim()].length > MAX.ressourcenName)) fehler = `Ein Name darf höchstens ${MAX.ressourcenName} Zeichen lang sein.`
if (fehler) return { ressourcen: fehler }
gelesen = ressourcenAusNamen({ bisher: ctx.daten.einstellung.ressourcen, typ: art() === 'plaetze' ? 'platz' : 'person', namen: zeilen })
return null
},
// neue Eintraege bieten alle Leistungen an (der Validator verlangt eine aktive Ressource), entfernte verschwinden aus Leistungen und Abwesenheiten
aendere: (entwurf) => {
const { ressourcen: neu, entfernt, neu: hinzu } = gelesen
entwurf.ressourcen = neu
for (const leistung of entwurf.leistungen || []) leistung.ressourcen = [...(leistung.ressourcen || []).filter((id) => !entfernt.includes(id)), ...hinzu]
entwurf.abwesenheiten = (entwurf.abwesenheiten || []).filter((a) => !entfernt.includes(a.ressourceId))
return true
},
})
wurzel.append(sp.konflikt, form)
return wurzel
}
/* ---------------------------------------------------------- Schritt 3: Leistungen --- */
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Person oder einem aktiven Platz (wie sichtbareLeistungen auf dem Server). */
function sichtbareLeistungen(einstellung) {
const aktiv = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiv.has(id)))
}
const hatPreis = (leistung) => preisAnzeige(leistung) !== 'ohne Preis'
function schrittLeistungen(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Prüfe die Leistungen, die deine Gäste buchen können, und ändere sie, wie du sie brauchst. Änderungen an einer Leistung werden sofort gespeichert.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
// der Reiter Leistungen speichert selbst; sein Platz ist ein eigenes Element, das Formular des Schritts steht daneben (kein verschachteltes Formular)
const leistungen = h('div', { class: 'tm-fb-leistungen' })
renderLeistungen(leistungen, ctx)
const haken = h('input', { type: 'checkbox', checked: ctx.daten.fragebogen?.preiseGeprueft === true })
const form = schrittForm(t,
hakenFeld('preiseGeprueft', haken, 'Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)'),
h('p', { class: 'tm-fehler', 'data-fehler': 'leistungen', role: 'alert' }),
allgemeinerFehler(),
knoepfe(t))
verdrahte(t, form, sp, {
pruefe: () => {
const sichtbar = sichtbareLeistungen(ctx.daten.einstellung)
if (sichtbar.length === 0) return { leistungen: 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.' }
if (sichtbar.some(hatPreis) && !haken.checked) return { preiseGeprueft: 'Bitte bestätige, dass du die Preise geprüft hast.' }
return null
},
fragebogen: (f) => { f.preiseGeprueft = haken.checked },
})
wurzel.append(sp.konflikt, leistungen, form)
return wurzel
}
/* ---------------------------------------------------------- Schritt 4: Zeiten --- */
/**
* Je Person und Platz ein Wochenplan (wochenplanFeld), dazu Bundesland OHNE Vorauswahl (leere erste Option; gewaehlt ist nur, was der Kunde schon
* ausdruecklich gewaehlt hatte) und „An Feiertagen geschlossen“: alles im PATCH dieses Schritts, danach der PUT mit bundeslandGewaehlt.
* Darunter Abwesenheiten und Sonderoeffnungen aus dem Reiter „Team & Plaetze“: sie speichern selbst und haben deshalb einen eigenen Container mit
* eigenem zeichne, damit das Neuzeichnen nach dem Speichern die ungespeicherten Zeiten im Formular darueber nicht verliert. Der Speicher ist derselbe
* wie im Schritt (ein Speichern zur Zeit, eine Version).
*/
function schrittZeiten(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const wurzel = rahmen(t, 'Wann kann bei dir gebucht werden? Trag für jede Person und jeden Platz die Zeiten ein und wähle dein Bundesland. Urlaub und besondere Öffnungstage trägst du weiter unten ein, sie werden sofort gespeichert.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const plaene = (e.ressourcen || []).map((r, i) => ({ r, plan: wochenplanFeld(r, `ressourcen.${i}`) }))
const vorgabe = ctx.daten.fragebogen?.bundeslandGewaehlt === true ? e.bundesland : ''
const land = h('select', null,
h('option', { value: '' }, 'Bitte wählen'),
BUNDESLAENDER.map(([key, name]) => h('option', { value: key, selected: key === vorgabe }, name)))
const geschlossen = h('input', { type: 'checkbox', checked: e.feiertageGeschlossen !== false })
const form = schrittForm(t,
plaene.length > 0
? plaene.map(({ r, plan }) => h('div', { class: 'kb-panel tm-fb-person', 'data-ressource': r.id }, h('h4', null, `${r.name}${r.aktiv === false ? ' (nicht aktiv)' : ''}`), plan.el))
: h('p', { class: 'tm-leer' }, 'Es gibt noch keine Person und keinen Platz. Lege sie im Schritt „Wer wird gebucht“ an.'),
abschnitt('Bundesland und Feiertage', null,
feld('bundesland', 'Bundesland', land, 'Danach richten sich die gesetzlichen Feiertage. Bitte wähle es ausdrücklich aus.'),
hakenFeld('feiertageGeschlossen', geschlossen, 'An Feiertagen geschlossen', 'Dann kann an gesetzlichen Feiertagen niemand buchen. Eine Sonderöffnung geht vor.')),
allgemeinerFehler(),
knoepfe(t))
let gelesen = []
verdrahte(t, form, sp, {
pruefe: () => {
const fehler = {}
const plaeneGelesen = []
for (const { r, plan } of plaene) {
const ergebnis = plan.lese()
if (ergebnis.fehler) Object.assign(fehler, ergebnis.fehler)
else plaeneGelesen.push([r.id, ergebnis.plan])
}
if (land.value === '') fehler.bundesland = LAND_FEHLT
if (Object.keys(fehler).length > 0) return fehler
gelesen = plaeneGelesen
return null
},
aendere: (entwurf) => {
for (const [id, plan] of gelesen) {
const stelle = stelleVon(entwurf.ressourcen, id, form, 'Diese Person oder dieser Platz')
if (stelle < 0) return false
entwurf.ressourcen[stelle].plan = plan
}
entwurf.bundesland = land.value
entwurf.feiertageGeschlossen = geschlossen.checked
return true
},
fragebogen: (f) => { f.bundeslandGewaehlt = true },
})
const ausnahmen = h('div', { class: 'tm-fb-ausnahmen' })
const at = { ctx, sp, wurzel: ausnahmen, zeichne: () => ausnahmen.replaceChildren(abwesenheiten(at), sonderoeffnungen(at)) }
at.zeichne()
wurzel.append(sp.konflikt, form, ausnahmen)
return wurzel
}
/* --------------------------------------------------------- Schritt 5: Regeln --- */
/**
* Sammelknopf „Alle sofort buchbar“ / „Alle nur als Anfrage“: zeigt die Lage der Leistungen (buchungsartLage) und merkt sich die Wahl; geschrieben wird
* sie mit „Weiter“ in den PATCH des Schritts (buchungsartFuerAlle auf dem frischen Stand), ein Klick allein ruft nichts auf. => { el, lese(entwurf) }.
*/
function buchungsartSammel(e) {
const leistungen = e.leistungen || []
let gewaehlt = null // null: der Kunde hat nichts gewaehlt, die Leistungen bleiben, wie sie sind
const lage = h('p', { class: 'tm-hinweis', role: 'status' })
const tasten = SAMMEL_ARTEN.map(([art, text]) => h('button', {
type: 'button', class: 'kb-btn-outline', 'data-aktion': `alle-${art}`, onclick: () => { gewaehlt = art; zeige() },
}, text))
function zeige() {
const jetzt = buchungsartLage(gewaehlt === null ? leistungen : buchungsartFuerAlle(leistungen, gewaehlt))
lage.setAttribute('data-lage', jetzt)
lage.textContent = `${LAGE_TEXT[jetzt]}${gewaehlt === null ? '' : ' Gespeichert wird mit „Weiter“.'}`
SAMMEL_ARTEN.forEach(([art], i) => tasten[i].setAttribute('aria-pressed', String(jetzt === art)))
}
zeige()
const el = abschnitt('Buchungsart',
'Sollen deine Gäste sofort buchen können oder nur eine Anfrage stellen, die du bestätigst? Das gilt für alle Leistungen zugleich. Einzelne Leistungen änderst du im Schritt „Leistungen“.',
lage,
h('div', { class: 'tm-fb-sammel', role: 'group', 'aria-label': 'Buchungsart für alle Leistungen' }, tasten))
return { el, lese(entwurf) { if (gewaehlt !== null) entwurf.leistungen = buchungsartFuerAlle(entwurf.leistungen, gewaehlt) } }
}
function schrittRegeln(t) {
const { ctx } = t
const e = ctx.daten.einstellung
const wurzel = rahmen(t, 'Lege fest, wie deine Gäste buchen, was bei Absagen gilt und wie sie bezahlen.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const sammel = buchungsartSammel(e)
const regelnAbschnitt = regeln(e)
// das Muster der Stornobedingungen folgt der Frist, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
const abschnitte = [regelnAbschnitt, storno(ctx, e, regelnAbschnitt.absage), rechtliches(e), zahlung(e)]
const form = schrittForm(t, sammel.el, abschnitte.map((a) => a.el), allgemeinerFehler(), knoepfe(t))
verdrahte(t, form, sp, { aendere: leseAbschnitte(form, [sammel, ...abschnitte]) })
wurzel.append(sp.konflikt, form)
return wurzel
}
/* ----------------------------------------------------- Schritt 6: Gastfelder --- */
function schrittGastfelder(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Was möchtest du von deinen Gästen wissen? Name und E-Mail-Adresse brauchen wir immer.')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const gast = gastFelder(ctx, ctx.daten.einstellung)
const form = schrittForm(t, gast.el, allgemeinerFehler(), knoepfe(t))
verdrahte(t, form, sp, { aendere: leseAbschnitte(form, [gast]) })
wurzel.append(sp.konflikt, form)
return wurzel
}
/* ------------------------------------------------- Schritt 7: Benachrichtigung --- */
function schrittBenachrichtigung(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Wohin sollen die Nachrichten zu Terminen gehen, wie sprichst du deine Gäste an und bekommen Gäste eine Erinnerung?')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
const e = ctx.daten.einstellung
// aus dem Betrieb nur die E-Mail-Adresse: sie ist die einzige Adresse fuer Nachrichten und auch Reply-To
const abschnitte = [betrieb(ctx, e, { nur: ['email'], titel: 'Deine E-Mail-Adresse', hinweis: null }), darstellung(e), erinnerung(e)]
const form = schrittForm(t, abschnitte.map((a) => a.el), allgemeinerFehler(), knoepfe(t))
verdrahte(t, form, sp, { aendere: leseAbschnitte(form, abschnitte) })
wurzel.append(sp.konflikt, form)
return wurzel
}
/* ---------------------------------------------------------- Schritt 8: Website --- */
/**
* Einbauwuensche: Modus (direkt auf der Seite / hinter einem Knopf, ohne Vorauswahl), Knopftext (nur bei Knopf), Stelle, Gestaltung, Bemerkung und der
* Hinweis, was nach Stunden geht. einbau = fragebogen.einbau (darf fehlen). => { el, pruefe(), wert() }: pruefe() => null oder { feld: text }, wert() =>
* das Objekt fuer fragebogen.einbau (bei „direkt auf der Seite“ ohne Knopftext). Die Texte kuerzt und bereinigt der Server.
*/
function einbauWuensche(einbau) {
const alt = istObjekt(einbau) ? einbau : {}
const textVon = (name) => (typeof alt[name] === 'string' ? alt[name] : '')
const eingaben = []
const radios = EINBAU_MODI.map(([wert, name, beschreibung]) => {
const radio = h('input', { type: 'radio', name: 'termine-einbau-modus', value: wert, checked: alt.modus === wert })
radio.addEventListener('change', zeigeKnopfFeld)
eingaben.push(radio)
return h('label', { class: 'tm-vorlage' }, radio, h('span', null, h('strong', null, name), h('small', null, beschreibung)))
})
const gewaehlt = () => eingaben.find((radio) => radio.checked)?.value ?? null
const knopfText = h('input', { type: 'text', maxlength: String(EINBAU_MAX.knopfText), autocomplete: 'off', value: textVon('knopfText') })
const knopfFeld = feld('einbau.knopfText', 'Text auf dem Knopf', knopfText, 'Zum Beispiel „Termin buchen“.')
const mehrzeilig = (name) => h('textarea', { rows: '3', maxlength: String(EINBAU_MAX[name]), autocomplete: 'off' }, textVon(name))
const stelle = mehrzeilig('stelle')
const gestaltung = mehrzeilig('gestaltung')
const bemerkung = mehrzeilig('bemerkung')
function zeigeKnopfFeld() { knopfFeld.hidden = gewaehlt() !== 'knopf' }
zeigeKnopfFeld()
const el = abschnitt('Einbau auf deiner Website', 'Wir bauen den Terminplaner für dich in deine Website ein. Sag uns, wie und wo er erscheinen soll.',
h('fieldset', { class: 'tm-gruppe-felder', 'data-feld': 'einbau.modus', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'So erscheint der Terminplaner'),
h('div', { class: 'tm-vorlagen-liste' }, radios),
h('p', { class: 'tm-fehler', 'data-fehler': 'einbau.modus', role: 'alert' })),
knopfFeld,
feld('einbau.stelle', 'Wo auf deiner Website soll er stehen?', stelle, 'Beschreibe die Stelle in Worten, zum Beispiel „auf der Seite Kontakt unter dem Text“.'),
feld('einbau.gestaltung', 'Wünsche zur Gestaltung (optional)', gestaltung, 'Farben, Schrift, Stil: Wir passen den Terminplaner an deine Website an.'),
feld('einbau.bemerkung', 'Bemerkung (optional)', bemerkung),
h('p', { class: 'tm-hinweis', 'data-hinweis': 'einbau-mehr' }, MEHRWUENSCHE))
el.classList.add('tm-fb-einbau')
return {
el,
pruefe() {
const modus = gewaehlt()
if (modus === null) return { 'einbau.modus': MODUS_FEHLT }
if (modus === 'knopf' && knopfText.value.trim() === '') return { 'einbau.knopfText': KNOPFTEXT_FEHLT }
return null
},
wert() {
const modus = gewaehlt()
return { modus, knopfText: modus === 'knopf' ? knopfText.value.trim() : '', stelle: stelle.value, gestaltung: gestaltung.value, bemerkung: bemerkung.value }
},
}
}
function schrittWebsite(t) {
const { ctx } = t
const wurzel = rahmen(t, 'Zum Schluss: Wie erreichen dich Gäste, und wie soll der Terminplaner auf deiner Website erscheinen?')
const sp = neuerSpeicher(ctx, wurzel, t.neuZeichnen)
// aus dem Betrieb Telefon (Ausweichweg fuer Gaeste) und die Links; Name, Anschrift und E-Mail stehen in den Schritten 1 und 7
const angaben = betrieb(ctx, ctx.daten.einstellung, {
nur: WEBSITE_NUR,
titel: 'Telefon und Links',
hinweis: 'Das Telefon ist der Ausweichweg für Gäste. Impressum und Datenschutzerklärung brauchen wir, weil der Terminplaner Gäste darauf verweist.',
})
const einbau = einbauWuensche(ctx.daten.fragebogen?.einbau)
const form = schrittForm(t, angaben.el, einbau.el, allgemeinerFehler(), knoepfe(t))
let gewuenscht = null
verdrahte(t, form, sp, {
pruefe: () => {
const fehler = einbau.pruefe()
if (fehler === null) gewuenscht = einbau.wert()
return fehler
},
aendere: (entwurf) => { angaben.lese(entwurf); return true },
fragebogen: (f) => { f.einbau = gewuenscht },
})
wurzel.append(sp.konflikt, form)
return wurzel
}
const BAU = {
branche: schrittBranche, wer: schrittWer, leistungen: schrittLeistungen, zeiten: schrittZeiten, regeln: schrittRegeln,
gastfelder: schrittGastfelder, benachrichtigung: schrittBenachrichtigung, website: schrittWebsite,
}
+3 -2
View File
@@ -112,8 +112,9 @@ export function neuerSpeicher(ctx, wurzel, neuZeichnen) {
try {
const antwort = await ctx.api.send('PATCH', '/einstellungen', { version: ctx.daten.version, einstellung: entwurf })
if (!ctx.aktuell()) return false
// Der Server hat streng geprueft: Telefon und Anschrift fehlen nach einem Erfolg nie mehr
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: [] })
// fehlt (Pflichtangaben fuer Gaeste) nennt der Server in der Antwort: in aktiver Funktion ist es nach einem Erfolg leer, im Entwurf
// des Fragebogens koennen Telefon und Anschrift noch fehlen
Object.assign(ctx.daten, { einstellung: antwort.einstellung, version: antwort.version, fehlt: antwort.fehlt ?? [] })
ctx.hinweise()
try {
await ctx.neuLaden() // neue Personen/Plaetze bekommen vom Server ihre Kalender-Links
+83 -43
View File
@@ -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 }) => {
@@ -69,48 +72,79 @@ export function render(panel, ctx) {
}
/* ----------------------------------------------------------------- Betrieb --- */
function betrieb(ctx, e) {
const textFeld = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const urlFeld = (wert) => textFeld(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
/** Die Angaben zum Betrieb in der Reihenfolge der Seite: Name, Mailadresse des Betriebs, Telefon, Anschrift, Kleinunternehmer, vier Links. */
const BETRIEB_FELDER = {
name: { eingabe: (b) => textFeld(b.name, MAX.betriebName), zeige: (i) => feld('betrieb.name', 'Name des Betriebs', i) },
email: {
eingabe: (b) => h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }),
zeige: (i) => feld('betrieb.email', 'E-Mail des Betriebs', i, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.'),
},
telefon: {
eingabe: (b) => textFeld(b.telefon, MAX.telefon),
zeige: (i) => feld('betrieb.telefon', 'Telefon (Pflicht)', i, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
},
adresse: {
eingabe: (b) => textFeld(b.adresse, MAX.adresse),
zeige: (i) => feld('betrieb.adresse', 'Anschrift', i, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.'),
},
kleinunternehmer: {
eingabe: (b) => h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
zeige: (i) => hakenFeld('betrieb.kleinunternehmer', i, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
},
impressumUrl: { eingabe: (b) => urlFeld(b.impressumUrl), zeige: (i) => feld('betrieb.impressumUrl', 'Link zum Impressum', i, 'Vollständige Adresse mit https://') },
datenschutzUrl: { eingabe: (b) => urlFeld(b.datenschutzUrl), zeige: (i) => feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', i, 'Vollständige Adresse mit https://') },
agbUrl: { eingabe: (b) => urlFeld(b.agbUrl), zeige: (i) => feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', i, 'Vollständige Adresse mit https://') },
websiteUrl: { eingabe: (b) => urlFeld(b.websiteUrl), zeige: (i) => feld('betrieb.websiteUrl', 'Link zu deiner Website', i, 'Vollständige Adresse mit https://') },
}
/** Wie die Felder auf der Seite zusammenstehen: Zeilen im Raster, das Ankreuzfeld steht allein. */
const BETRIEB_ZEILEN = [
{ felder: ['name', 'email'], raster: true },
{ felder: ['telefon', 'adresse'], raster: true },
{ felder: ['kleinunternehmer'], raster: false },
{ felder: ['impressumUrl', 'datenschutzUrl', 'agbUrl', 'websiteUrl'], raster: true },
]
const BETRIEB_TITEL = 'Dein Betrieb'
const BETRIEB_HINWEIS = 'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.'
/**
* Abschnitt „Dein Betrieb“. Ohne Optionen alle neun Felder wie bisher. Der Fragebogen zeigt in einem Schritt nur einen Teil:
* nur = Teilmenge der Felder (name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl); es entstehen nur
* diese Felder (in der Reihenfolge der Seite) und lese schreibt nur diese in den Entwurf. titel und hinweis ersetzen Ueberschrift und Erklaersatz
* (hinweis: null laesst ihn weg). lese(entwurf) schreibt in entwurf.betrieb und gibt die gelesenen Felder zurueck (nur die gezeigten).
* Der Hinweis auf fehlende Pflichtangaben nennt mit nur nur Angaben, die dabei zu sehen sind.
*/
export function betrieb(ctx, e, { nur, titel = BETRIEB_TITEL, hinweis = BETRIEB_HINWEIS } = {}) {
const b = e.betrieb || {}
const text = (wert, max, extra) => h('input', { type: 'text', maxlength: max, autocomplete: 'off', value: wert ?? '', ...extra })
const url = (wert) => text(wert, MAX.url, { inputmode: 'url', placeholder: 'https://' })
const f = {
name: text(b.name, MAX.betriebName), email: h('input', { type: 'email', maxlength: '254', autocomplete: 'off', value: b.email ?? '' }), telefon: text(b.telefon, MAX.telefon),
adresse: text(b.adresse, MAX.adresse), impressumUrl: url(b.impressumUrl), datenschutzUrl: url(b.datenschutzUrl), agbUrl: url(b.agbUrl), websiteUrl: url(b.websiteUrl),
kleinunternehmer: h('input', { type: 'checkbox', checked: b.kleinunternehmer === true }),
}
const fehlt = ctx.daten.fehlt || []
for (const name of nur || []) if (!Object.hasOwn(BETRIEB_FELDER, name)) throw new Error(`Unbekanntes Feld im Betrieb: ${name}`)
const gezeigt = Object.keys(BETRIEB_FELDER).filter((name) => !nur || nur.includes(name))
const f = Object.fromEntries(gezeigt.map((name) => [name, BETRIEB_FELDER[name].eingabe(b)]))
const fehlt = (ctx.daten.fehlt || []).filter((pfad) => !nur || gezeigt.includes(pfad.replace(/^betrieb\./, '')))
const warnung = fehlt.length
? h('div', { class: 'tm-box tm-box-warn', role: 'note' }, `Ohne Telefon/Anschrift ist der Terminplaner auf deiner Website nicht buchbar. Es fehlt noch: ${fehltText(fehlt) || 'eine Angabe zu deinem Betrieb'}.`)
: null
const el = abschnitt('Dein Betrieb',
'Diese Angaben sehen deine Gäste beim Buchen und in ihren E-Mails. Die meisten haben wir aus deinem Kundenkonto übernommen.',
warnung,
h('div', { class: 'tm-raster' },
feld('betrieb.name', 'Name des Betriebs', f.name),
feld('betrieb.email', 'E-Mail des Betriebs', f.email, 'Hierhin schicken wir dir Nachrichten zu Terminen. Gäste können auf ihre Mails hier antworten.')),
h('div', { class: 'tm-raster' },
feld('betrieb.telefon', 'Telefon (Pflicht)', f.telefon, 'Gäste sehen die Nummer als Ausweichweg und in ihren Mails.'),
feld('betrieb.adresse', 'Anschrift', f.adresse, 'Pflicht, wenn Privatpersonen bei dir buchen oder anfragen: Die Anschrift steht in der Widerrufsbelehrung.')),
hakenFeld('betrieb.kleinunternehmer', f.kleinunternehmer, 'Ich bin Kleinunternehmer nach § 19 UStG', 'Dann steht bei den Preisen „kein Ausweis der USt nach § 19 UStG“ statt „inkl. USt“.'),
h('div', { class: 'tm-raster' },
feld('betrieb.impressumUrl', 'Link zum Impressum', f.impressumUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.datenschutzUrl', 'Link zur Datenschutzerklärung', f.datenschutzUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.agbUrl', 'Link zu deinen AGB (optional)', f.agbUrl, 'Vollständige Adresse mit https://'),
feld('betrieb.websiteUrl', 'Link zu deiner Website', f.websiteUrl, 'Vollständige Adresse mit https://')))
const zeilen = BETRIEB_ZEILEN
.map(({ felder, raster }) => {
const els = felder.filter((name) => f[name]).map((name) => BETRIEB_FELDER[name].zeige(f[name]))
if (!els.length) return null
return raster ? h('div', { class: 'tm-raster' }, els) : els
})
.filter(Boolean)
const el = abschnitt(titel, hinweis, warnung, ...zeilen)
return {
el,
lese(entwurf) {
entwurf.betrieb = {
...entwurf.betrieb,
name: f.name.value, email: f.email.value, telefon: f.telefon.value, adresse: f.adresse.value, kleinunternehmer: f.kleinunternehmer.checked,
impressumUrl: f.impressumUrl.value, datenschutzUrl: f.datenschutzUrl.value, agbUrl: f.agbUrl.value, websiteUrl: f.websiteUrl.value,
}
lese(entwurf = {}) {
const gelesen = Object.fromEntries(gezeigt.map((name) => [name, name === 'kleinunternehmer' ? f[name].checked : f[name].value]))
entwurf.betrieb = { ...entwurf.betrieb, ...gelesen }
return gelesen
},
}
}
/* -------------------------------------------------------- Anrede und Farbe --- */
function darstellung(e) {
export function darstellung(e) {
const anrede = h('select', null, wahl(ANREDE_WAHL, e.anrede ?? 'sie'))
const farbe = h('input', { type: 'color', value: /^#[0-9a-f]{6}$/.test(e.farbe) ? e.farbe : '#7c3aed' })
const el = abschnitt('Anrede und Farbe', 'So treten die Buchungsseite und die E-Mails an deine Gäste auf.',
@@ -121,7 +155,7 @@ function darstellung(e) {
}
/* ------------------------------------------------------------------ Regeln --- */
function regeln(e) {
export function regeln(e) {
const r = e.regeln || {}
const raster = h('select', null, wahl(RASTER_MIN.map((n) => [n, `${n} Minuten`]), r.raster ?? 15))
const vorlauf = zahlenFeld('regeln.vorlaufMin', 'Mindestvorlauf (Minuten)', r.vorlaufMin, { min: 0, max: 20160, hinweis: 'Wie kurzfristig darf gebucht werden? 120 sind 2 Stunden, höchstens 20160 (14 Tage).' })
@@ -146,7 +180,7 @@ function regeln(e) {
}
/* ------------------------------------------------- Angaben von Gaesten --- */
function gastFelder(ctx, e) {
export function gastFelder(ctx, e) {
const g = e.gastFelder || {}
const telefon = h('select', null, wahl(GAST_FELD_WAHL, g.telefon ?? 'optional'))
const nachricht = h('select', null, wahl(GAST_FELD_WAHL, g.nachricht ?? 'aus'))
@@ -210,7 +244,7 @@ function gastFelder(ctx, e) {
}
/* -------------------------------------------------------------- Erinnerung --- */
function erinnerung(e) {
export function erinnerung(e) {
const r = e.erinnerung || {}
const anbieten = h('input', { type: 'checkbox', checked: r.anbieten !== false })
const stunden = h('select', null, wahl(ERINNERUNG_STUNDEN.map((n) => [n, `${n} Stunden vorher`]), r.stundenVorher ?? 24))
@@ -221,7 +255,7 @@ function erinnerung(e) {
}
/* --------------------------------------------------------- Stornobedingungen --- */
function storno(ctx, e, fristFeld) {
export function storno(ctx, e, fristFeld) {
const s = e.storno || {}
const textfeld = h('textarea', { rows: '6', maxlength: String(MAX.storno) }, s.text ?? '')
// das Muster setzt die Frist ein, die im Feld „Selbst absagen bis“ steht (auch ungespeichert)
@@ -231,9 +265,13 @@ 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,
@@ -243,7 +281,7 @@ function storno(ctx, e, fristFeld) {
}
/* ------------------------------------------------------------- Rechtliches --- */
function rechtliches(e) {
export function rechtliches(e) {
const r = e.recht || {}
const gaeste = h('select', null, wahl([['verbraucher', 'Privatpersonen'], ['unternehmer', 'Unternehmen'], ['gemischt', 'Beides']], r.gaesteSind ?? 'verbraucher'))
const freizeit = h('input', { type: 'checkbox', checked: r.freizeit === true })
@@ -255,7 +293,7 @@ function rechtliches(e) {
}
/* ----------------------------------------------------------------- Bezahlung --- */
function zahlung(e) {
export function zahlung(e) {
const z = e.zahlung || {}
const arten = ZAHLARTEN.map(([key, name]) => ({ key, name, input: h('input', { type: 'checkbox', checked: (z.vorOrtArten || []).includes(key) }) }))
const el = abschnitt('Bezahlung', 'Bezahlt wird bei dir vor Ort. Welche Zahlarten nimmst du an? Gäste sehen das vor der Buchung.',
@@ -310,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,
+4 -2
View File
@@ -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()
}
+32 -12
View File
@@ -60,6 +60,34 @@ function personen(t) {
: h('p', { class: 'tm-leer' }, 'Noch keine Person und kein Platz. Ohne sie kann niemand buchen.'))
}
/**
* Wochenplan einer Person/eines Platzes: je Wochentag ein Textfeld mit den Zeitfenstern (Text wie „09:00–12:30, 13:30–18:00“, leer = geschlossen),
* vorbelegt aus r.plan (r darf fehlen). pfad ist der Pfad der Person in der Einstellung ('ressourcen.1'); die Felder heissen `${pfad}.plan.${nr}`,
* so finden die Fehler des Servers (ressourcen.1.plan.3) ihr Feld. Der Editor einer Person und der Fragebogen (Schritt „Zeiten“) bauen ihn hiermit.
* => { el, lese }: el ist die Gruppe (Beschriftung, Hinweis, sieben Felder), lese() liefert { plan } (geschlossene Tage fehlen im Plan) oder, bei
* einem Fenster, das nicht zu lesen ist, { fehler } (Feldname ⇒ Text; dann gibt es keinen Teilplan). Pruefen im Einzelnen bleibt Sache des Servers.
*/
export function wochenplanFeld(r, pfad) {
const texte = planTexte(r?.plan)
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
nr, name: `${pfad}.plan.${nr}`,
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
tag,
}))
const el = h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
h('legend', { class: 'tm-label' }, 'Wochenplan'),
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
h('div', { class: 'tm-raster' }, tage.map(({ name, input, tag }) => feld(name, tag, input))))
return {
el,
lese() {
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
if (gelesen.fehler) return { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${pfad}.plan.${nr}`, text])) }
return { plan: gelesen.plan }
},
}
}
/**
* Editor einer neuen (r null) oder bestehenden Person/eines Platzes: Formular, darunter (nur bestehend) Kalender-Link und die Rueckfrage zum
* Entfernen. Die Rueckfragen stehen neben dem Formular, nicht darin: ein submit der Rueckfrage steigt sonst ins aeussere Formular auf.
@@ -78,12 +106,7 @@ function formular(t, { r, schliessen }) {
const name = h('input', { type: 'text', maxlength: MAX.ressourcenName, autocomplete: 'off', value: alt.name })
const typ = h('select', null, TYPEN.map(([wert, text]) => h('option', { value: wert, selected: alt.typ === wert }, text)))
const aktiv = h('input', { type: 'checkbox', checked: alt.aktiv })
const texte = planTexte(alt.plan)
const tage = WOCHENTAGE_PLAN.map(([nr, tag]) => ({
nr, name: `${p}.plan.${nr}`,
input: h('input', { type: 'text', autocomplete: 'off', placeholder: 'geschlossen', value: texte[nr] }),
tag,
}))
const wochenplan = wochenplanFeld(alt, p)
const zuordnung = leistungen.map((l, k) => ({ id: l.id, input: h('input', { type: 'checkbox', checked: (l.ressourcen || []).includes(alt.id) }), name: `leistungen.${k}.ressourcen`, label: `${l.name}${l.aktiv ? '' : ' (inaktiv)'}` }))
const speichern = h('button', { type: 'submit', class: 'kb-btn-primary' }, neu ? 'Hinzufügen' : 'Speichern')
@@ -105,10 +128,7 @@ function formular(t, { r, schliessen }) {
feld(`${p}.name`, 'Name', name, 'So steht es beim Buchen, wenn Gäste eine Person wählen dürfen.'),
feld(`${p}.typ`, 'Art', typ)),
hakenFeld(`${p}.aktiv`, aktiv, 'Aktiv', 'Nur aktive Personen und Plätze können gebucht werden. Termine, die schon gebucht sind, bleiben bei „Inaktiv“ bestehen.'),
h('fieldset', { class: 'tm-gruppe-felder tm-wochenplan' },
h('legend', { class: 'tm-label' }, 'Wochenplan'),
h('p', { class: 'tm-hinweis' }, 'Wann kann gebucht werden? Mehrere Zeitfenster trennst du mit Komma, zum Beispiel 09:00–12:30, 13:30–18:00. Leer lassen: an diesem Tag geschlossen. Uhrzeiten in 5-Minuten-Schritten, das Ende bis 24:00.'),
h('div', { class: 'tm-raster' }, tage.map(({ name: feldName, input, tag }) => feld(feldName, tag, input)))),
wochenplan.el,
h('fieldset', { class: 'tm-gruppe-felder', tabindex: '-1' },
h('legend', { class: 'tm-label' }, 'Diese Leistungen bietet sie an'),
zuordnung.length
@@ -124,9 +144,9 @@ function formular(t, { r, schliessen }) {
form.addEventListener('submit', async (ev) => {
ev.preventDefault()
const ok = await sp.speichere((entwurf) => {
const gelesen = planAusTexten(Object.fromEntries(tage.map(({ nr, input }) => [nr, input.value])))
const gelesen = wochenplan.lese()
if (gelesen.fehler) {
zeigeFehler(form, { fehler: Object.fromEntries(Object.entries(gelesen.fehler).map(([nr, text]) => [`${p}.plan.${nr}`, text])) })
zeigeFehler(form, { fehler: gelesen.fehler })
return false
}
const ressource = { ...alt, name: name.value, typ: typ.value, aktiv: aktiv.checked, plan: gelesen.plan }
+268
View File
@@ -2,6 +2,8 @@
Tage sind Berliner Kalendertage 'JJJJ-MM-TT' (so liefert und erwartet sie /api/termine). Gerechnet wird nur mit dem Kalender
(UTC-Mitternacht), nie mit der Zeitzone des Browsers; nur heuteBerlin() und endeZeit() fragen die Uhr bzw. Zeitzone Berlin. */
import { ANREDE_WAHL } from './termine-gaestetexte.js'
const TAG_RE = /^(\d{4})-(\d{2})-(\d{2})$/
const MONATE = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember']
const WOCHENTAGE = ['Sonntag', 'Montag', 'Dienstag', 'Mittwoch', 'Donnerstag', 'Freitag', 'Samstag']
@@ -456,3 +458,269 @@ export function aktivSeitText(iso) {
if (!zeit || Number.isNaN(zeit.getTime())) return ''
return `Aktiv seit ${datumKurz(heuteBerlin(zeit))}`
}
/**
* Text des Banners einer abgeschalteten Funktion nach Abschaltgrund. abgeschaltet = { grund, seit, loeschungAm } aus GET /api/termine/einstellungen
* (grund: 'abo_ende' | 'abo_ende_vor_einrichtung' | 'team' | 'kunde_geloescht' | null, loeschungAm: 'JJJJ-MM-TT' oder null; null/fehlend = bisheriger Satz).
* Liefert { davor, danach, mitExport }: mit Export steht zwischen den Teilen der Export-Link ([davor, Link 'exportieren', danach]), ohne
* Export (nur wenn das Abo vor der Einrichtung endete: es gibt nichts zu exportieren) folgen die Teile direkt aufeinander.
* Das Loeschdatum steht nur da, wenn loeschungAm ein gueltiger Kalendertag ist. Du-Form; der Wortlaut ist ein Vorschlag fuer die Rechtsrunde.
*/
export function abschaltText(abgeschaltet) {
const { grund, loeschungAm } = abgeschaltet || {}
const loeschen = istTag(loeschungAm) ? ` Ab dem ${datumKurz(loeschungAm)} löschen wir die Daten.` : ''
if (grund === 'abo_ende_vor_einrichtung') {
return { davor: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.', danach: loeschen, mitExport: false }
}
const davor = grund === 'abo_ende'
? 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und '
: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und '
return { davor, danach: `.${loeschen}`, mitExport: true }
}
/* --------------------------------------------------------- Fragebogen (TP2) --- */
/**
* Die acht Schritte des Fragebogens als [Schluessel, Titel], in der Reihenfolge des Servers. Spiegel von SCHRITTE und SCHRITT_TITEL in
* server/services/termine/fragebogen.js (test/termineView.test.js vergleicht beide; der Titel steht auch in „Bitte bestaetige den Schritt …“).
* Der neunte Schritt „Pruefen und bestellen“ ist kein Fragebogen-Schritt und wird nie bestaetigt.
*/
export const FRAGEBOGEN_SCHRITTE = [
['branche', 'Branche und Betrieb'],
['wer', 'Wer wird gebucht'],
['leistungen', 'Leistungen'],
['zeiten', 'Zeiten'],
['regeln', 'Regeln'],
['gastfelder', 'Gastfelder'],
['benachrichtigung', 'Benachrichtigung'],
['website', 'Website'],
]
/** Stand der Schritte aus fragebogen.bestaetigt: [{ key, titel, erledigt }] in Schrittfolge. Ohne Fragebogen ist nichts erledigt. */
export function schrittStatus(fragebogen) {
const bestaetigt = Array.isArray(fragebogen?.bestaetigt) ? fragebogen.bestaetigt : []
return FRAGEBOGEN_SCHRITTE.map(([key, titel]) => ({ key, titel, erledigt: bestaetigt.includes(key) }))
}
/** Schluessel des ersten Schritts ohne Haken (die erste Luecke, nicht die Zahl der Haken); sind alle bestaetigt: 'bestellen'. */
export function ersterOffenerSchritt(fragebogen) {
return schrittStatus(fragebogen).find((schritt) => !schritt.erledigt)?.key ?? 'bestellen'
}
const leerzeichenEins = (wert) => wert.replace(/\s+/g, ' ').trim()
const nameSchluessel = (name) => leerzeichenEins(name).normalize('NFC').toLowerCase()
/**
* Personen oder Plaetze aus den Namen im Schritt „Wer wird gebucht“. bisher = ressourcen der Einstellung, namen = Liste von Texten,
* typ = 'person' | 'platz' (gilt fuer alle, auch fuer behaltene Eintraege; sonst Person), neueId(belegt) = Erzeuger einer freien ID
* (belegt: Set aller schon vergebenen IDs inklusive der alten, auch der wegfallenden; Vorgabe eindeutigeId('r', belegt); eine belegte ID wirft).
* Namen: getrimmt, Leerraum zusammengefasst, auf MAX.ressourcenName gekuerzt, ohne Leere und ohne Doppelte (Gross-/Kleinschreibung zaehlt
* nicht, die erste Schreibweise gilt), hoechstens LIMITS.ressourcen. Zuordnung: gleiche Namen behalten ID und Plan; uebrige alte Eintraege
* werden in ihrer Reihenfolge den uebrigen Namen zugeteilt (umbenannt, ID und Plan bleiben); weitere Namen bekommen eine neue ID und eine
* Kopie des Plans des ersten behaltenen oder umbenannten Eintrags, gleich wo der neue Name steht (ohne solchen Eintrag: leerer Plan). Alle Eintraege sind aktiv. Ergebnis in der Reihenfolge der Namen:
* { ressourcen, entfernt: IDs der weggefallenen Eintraege, neu: IDs der neuen }. Die Eintraege tragen nur Felder, die der Server aus dem
* Client uebernimmt (kein feedToken, den vergibt und behaelt der Server). Die Zuordnung der Leistungen setzt der Aufrufer.
*/
export function ressourcenAusNamen({ bisher, typ, namen, neueId: erzeugeId = (belegt) => eindeutigeId('r', belegt) } = {}) {
const art = typ === 'platz' ? 'platz' : 'person'
const alt = (Array.isArray(bisher) ? bisher : []).filter((r) => r !== null && typeof r === 'object' && typeof r.id === 'string')
const sauber = []
const gesehen = new Set()
for (const roh of Array.isArray(namen) ? namen : []) {
if (typeof roh !== 'string') continue
const name = leerzeichenEins([...leerzeichenEins(roh)].slice(0, MAX.ressourcenName).join(''))
if (name === '' || gesehen.has(nameSchluessel(name))) continue
gesehen.add(nameSchluessel(name))
sauber.push(name)
if (sauber.length === LIMITS.ressourcen) break
}
// 1. gleiche Namen behalten ihren alten Eintrag, 2. die uebrigen alten Eintraege gehen der Reihe nach an die uebrigen Namen
const frei = new Set(alt)
const zugeteilt = sauber.map((name) => {
const treffer = alt.find((r) => frei.has(r) && typeof r.name === 'string' && nameSchluessel(r.name) === nameSchluessel(name))
if (treffer) frei.delete(treffer)
return treffer ?? null
})
const uebrige = alt.filter((r) => frei.has(r))
sauber.forEach((name, i) => { if (zugeteilt[i] === null && uebrige.length > 0) zugeteilt[i] = uebrige.shift() })
// Vorlage fuer neue Namen: Plan des ersten behaltenen oder umbenannten Eintrags (unabhaengig davon, wo ein neuer Name in der Liste steht)
const vorlagePlan = zugeteilt.find(Boolean)?.plan
const belegt = new Set(alt.map((r) => r.id))
const ressourcen = []
const neu = []
sauber.forEach((name, i) => {
const alter = zugeteilt[i]
let id
let plan
if (alter) {
id = alter.id
plan = alter.plan
} else {
id = erzeugeId(new Set(belegt))
if (typeof id !== 'string' || belegt.has(id)) throw new Error(`neueId lieferte keine freie ID: ${String(id).slice(0, 40)}`)
belegt.add(id)
neu.push(id)
plan = vorlagePlan
}
ressourcen.push({ id, typ: art, name, aktiv: true, plan: structuredClone(plan !== null && typeof plan === 'object' ? plan : {}) })
})
return { ressourcen, entfernt: uebrige.map((r) => r.id), neu }
}
const BUCHUNGSARTEN = ['sofort', 'anfrage']
/** Kopie der Leistungen, in der jede (auch eine inaktive) die Buchungsart art hat; sonst aendert sich nichts. Unbekannte Art: unveraenderte Kopie. */
export function buchungsartFuerAlle(leistungen, art) {
return (Array.isArray(leistungen) ? leistungen : []).map((leistung) => (BUCHUNGSARTEN.includes(art) ? { ...leistung, buchungsart: art } : { ...leistung }))
}
/** 'sofort' | 'anfrage', wenn alle Leistungen gleich sind (auch ohne Leistungen und bei fehlender Angabe: sofort, wie beim Server), sonst 'gemischt'. */
export function buchungsartLage(leistungen) {
const arten = new Set((Array.isArray(leistungen) ? leistungen : []).map((leistung) => (leistung?.buchungsart === 'anfrage' ? 'anfrage' : 'sofort')))
if (arten.size > 1) return 'gemischt'
return arten.has('anfrage') ? 'anfrage' : 'sofort'
}
// ---- Zusammenfassung auf der Bestellseite
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
const einheit = (n, einzahl, mehrzahl) => `${n} ${n === 1 ? einzahl : mehrzahl}`
const wahlName = (wahl, key) => wahl.find(([k]) => k === key)?.[1]
/** Kundentext als eine Zeile: Umbrueche werden Leerzeichen, ueber max Zeichen gekuerzt (mit „…“). */
function kurzText(wert, max) {
const zeichen = [...leerzeichenEins(wert)]
return zeichen.length > max ? `${zeichen.slice(0, max - 1).join('').trimEnd()}…` : zeichen.join('')
}
/** 0 ⇒ 'keine', ganze Tage, ganze Stunden, sonst Minuten. */
function zeitspanne(minuten) {
if (minuten === 0) return 'keine'
if (minuten % 1440 === 0) return einheit(minuten / 1440, 'Tag', 'Tage')
if (minuten % 60 === 0) return einheit(minuten / 60, 'Stunde', 'Stunden')
return einheit(minuten, 'Minute', 'Minuten')
}
/** Wochenplan einer Ressource als Zeilen: aufeinanderfolgende Tage mit gleichen Zeiten zusammengefasst, danach 'Geschlossen: …'. */
function wochenplanZeilen(plan, vorsatz) {
const texte = planTexte(plan)
const tage = WOCHENTAGE_PLAN
const bereich = (von, bis) => (von === bis ? tage[von][1] : `${tage[von][1]}–${tage[bis][1]}`)
const offen = []
const zu = []
for (let i = 0; i < tage.length;) {
const text = texte[tage[i][0]]
let bis = i
while (bis + 1 < tage.length && texte[tage[bis + 1][0]] === text) bis += 1
if (text === '') zu.push(bereich(i, bis))
else offen.push(`${bereich(i, bis)}: ${text}`)
i = bis + 1
}
if (zu.length) offen.push(`Geschlossen: ${zu.join(', ')}`)
return offen.map((zeile) => `${vorsatz}${zeile}`)
}
const GAESTE_SIND = { verbraucher: 'Privatpersonen', unternehmer: 'Unternehmen', gemischt: 'Privatpersonen und Unternehmen' }
const BUCHUNGSART_TEXT = { sofort: 'sofort buchbar', anfrage: 'auf Anfrage', gemischt: 'gemischt, je Leistung' }
/**
* Zusammenfassung der Angaben fuer „Pruefen und bestellen“: acht Gruppen in der Reihenfolge der Schritte, jede { schritt, titel, zeilen },
* die Zeilen nur Text (der Aufrufer setzt sie per textContent; Kundentext bleibt unveraendert, lange Wuensche sind gekuerzt). Es kommt nur vor,
* was angegeben ist; Fehlendes nennt bestellpruefung des Servers. Das Bundesland steht nur, wenn der Kunde es ausdruecklich gewaehlt hat
* (fragebogen.bundeslandGewaehlt: der Serverstandard RP ist keine Wahl). Nie feedToken oder IDs. branchen = Liste { key, name } aus GET /einstellungen.
*/
export function zusammenfassung(einstellung, fragebogen, branchen) {
const e = istObjekt(einstellung) ? einstellung : {}
const f = istObjekt(fragebogen) ? fragebogen : {}
const betrieb = istObjekt(e.betrieb) ? e.betrieb : {}
const ressourcen = (Array.isArray(e.ressourcen) ? e.ressourcen : []).filter(istObjekt)
const leistungen = (Array.isArray(e.leistungen) ? e.leistungen : []).filter(istObjekt)
const regeln = istObjekt(e.regeln) ? e.regeln : {}
const gastFelder = istObjekt(e.gastFelder) ? e.gastFelder : {}
const erinnerung = istObjekt(e.erinnerung) ? e.erinnerung : {}
const recht = istObjekt(e.recht) ? e.recht : {}
const einbau = istObjekt(f.einbau) ? f.einbau : {}
const gruppen = Object.fromEntries(FRAGEBOGEN_SCHRITTE.map(([key]) => [key, []]))
// zeile(schritt, text): nur ein nicht-leerer Text kommt in die Gruppe (so fehlt eine Zeile, wenn `angabe && …` falsch ist)
const zeile = (schritt, text) => { if (typeof text === 'string' && text !== '') gruppen[schritt].push(text) }
const bundesland = f.bundeslandGewaehlt === true ? wahlName(BUNDESLAENDER, e.bundesland) : undefined
const guteWahl = (wahl, key) => wahlName(wahl, key) !== undefined
// 1. Branche und Betrieb
const branche = (Array.isArray(branchen) ? branchen : []).find((b) => istObjekt(b) && b.key === f.branche)
zeile('branche', istText(branche?.name) && `Branche: ${branche.name}`)
zeile('branche', istText(betrieb.name) && `Betrieb: ${betrieb.name}`)
zeile('branche', istText(betrieb.adresse) && `Anschrift: ${betrieb.adresse}`)
zeile('branche', typeof betrieb.kleinunternehmer === 'boolean' && `Kleinunternehmer: ${betrieb.kleinunternehmer ? 'ja' : 'nein'}`)
// 2. Wer wird gebucht: Personen und Plaetze getrennt, in der Reihenfolge ihres ersten Auftretens
const nachTyp = new Map()
for (const r of ressourcen.filter((x) => istText(x.name))) {
const wort = r.typ === 'platz' ? 'Plätze' : 'Personen'
if (!nachTyp.has(wort)) nachTyp.set(wort, [])
nachTyp.get(wort).push(`${r.name}${r.aktiv === false ? ' (nicht aktiv)' : ''}`)
}
for (const [wort, namen] of nachTyp) zeile('wer', `${wort}: ${namen.join(', ')}`)
// 3. Leistungen
for (const l of leistungen.filter((x) => istText(x.name))) {
zeile('leistungen', `${l.name}: ${Number.isInteger(l.dauer) ? `${l.dauer} Min., ` : ''}${preisAnzeige(l)}${l.aktiv === false ? ' (nicht aktiv)' : ''}`)
}
zeile('leistungen', leistungen.some((l) => preisAnzeige(l) !== 'ohne Preis') && `Preise geprüft: ${f.preiseGeprueft === true ? 'ja' : 'nein'}`)
// 4. Zeiten
const aktive = ressourcen.filter((r) => r.aktiv !== false)
for (const r of aktive) for (const text of wochenplanZeilen(r.plan, aktive.length > 1 && istText(r.name) ? `${r.name}: ` : '')) zeile('zeiten', text)
zeile('zeiten', typeof e.feiertageGeschlossen === 'boolean' && `Feiertage: ${e.feiertageGeschlossen ? 'geschlossen' : 'geöffnet'}`)
zeile('zeiten', bundesland !== undefined && `Bundesland: ${bundesland}`)
const abwesend = Array.isArray(e.abwesenheiten) ? e.abwesenheiten.length : 0
zeile('zeiten', abwesend > 0 && `Abwesenheiten: ${abwesend} eingetragen`)
const sonder = istObjekt(e.sonderoeffnungen) ? Object.keys(e.sonderoeffnungen).length : 0
zeile('zeiten', sonder > 0 && `Besondere Öffnungstage: ${sonder}`)
// 5. Regeln
zeile('regeln', leistungen.length > 0 && `Buchungsart: ${BUCHUNGSART_TEXT[buchungsartLage(leistungen)]}`)
zeile('regeln', Number.isInteger(regeln.raster) && `Raster: ${regeln.raster} Minuten`)
zeile('regeln', Number.isInteger(regeln.vorlaufMin) && regeln.vorlaufMin >= 0 && `Vorlaufzeit: ${zeitspanne(regeln.vorlaufMin)}`)
zeile('regeln', Number.isInteger(regeln.horizontTage) && `Buchungszeitraum: ${einheit(regeln.horizontTage, 'Tag', 'Tage')} im Voraus`)
zeile('regeln', Number.isInteger(regeln.absageBisStd) && `Absage durch Gäste: bis ${einheit(regeln.absageBisStd, 'Stunde', 'Stunden')} vorher`)
zeile('regeln', Number.isInteger(regeln.maxOffeneJeEmail) && `Offene Termine je E-Mail-Adresse: höchstens ${regeln.maxOffeneJeEmail}`)
zeile('regeln', typeof regeln.personWaehlbar === 'boolean' && `${ressourcenWort(ressourcen)} wählbar: ${regeln.personWaehlbar ? 'ja' : 'nein'}`)
zeile('regeln', Object.hasOwn(GAESTE_SIND, recht.gaesteSind) && `Deine Gäste sind: ${GAESTE_SIND[recht.gaesteSind]}`)
zeile('regeln', recht.freizeit === true && 'Leistungen sind Freizeitangebote mit festem Termin')
const arten = (istObjekt(e.zahlung) && Array.isArray(e.zahlung.vorOrtArten) ? e.zahlung.vorOrtArten : []).map((a) => wahlName(ZAHLARTEN, a)).filter(Boolean)
zeile('regeln', arten.length > 0 && `Zahlung: vor Ort (${arten.join(', ')})`)
zeile('regeln', istObjekt(e.storno) && `Stornobedingungen: ${istText(e.storno.text) ? 'Text hinterlegt' : 'kein Text'}`)
// 6. Gastfelder
zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.telefon) && `Telefonnummer des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.telefon)}`)
zeile('gastfelder', guteWahl(GAST_FELD_WAHL, gastFelder.nachricht) && `Nachricht des Gastes: ${wahlName(GAST_FELD_WAHL, gastFelder.nachricht)}`)
for (const z of (Array.isArray(gastFelder.zusatz) ? gastFelder.zusatz : []).filter((x) => istObjekt(x) && istText(x.label))) {
zeile('gastfelder', `Zusatzfeld „${z.label}“: ${z.pflicht === true ? 'Pflicht' : 'Optional'}${z.typ === 'auswahl' ? ' (Auswahl)' : ''}`)
}
// 7. Benachrichtigung
zeile('benachrichtigung', istText(betrieb.email) && `Benachrichtigungen an: ${betrieb.email}`)
zeile('benachrichtigung', guteWahl(ANREDE_WAHL, e.anrede) && `Anrede der Gäste: ${wahlName(ANREDE_WAHL, e.anrede)}`)
if (typeof erinnerung.anbieten === 'boolean') {
zeile('benachrichtigung', erinnerung.anbieten ? `E-Mail-Erinnerung für Gäste: angeboten, ${einheit(erinnerung.stundenVorher, 'Stunde', 'Stunden')} vorher` : 'E-Mail-Erinnerung für Gäste: aus')
}
// 8. Website und Einbau
zeile('website', istText(betrieb.telefon) && `Telefon: ${betrieb.telefon}`)
zeile('website', istText(betrieb.impressumUrl) && `Impressum: ${betrieb.impressumUrl}`)
zeile('website', istText(betrieb.datenschutzUrl) && `Datenschutzerklärung: ${betrieb.datenschutzUrl}`)
zeile('website', istText(betrieb.agbUrl) && `AGB: ${betrieb.agbUrl}`)
zeile('website', istText(betrieb.websiteUrl) && `Website: ${betrieb.websiteUrl}`)
zeile('website', einbau.modus === 'inline' && 'Darstellung: direkt auf deiner Seite')
zeile('website', einbau.modus === 'knopf' && `Darstellung: hinter einem Knopf${istText(einbau.knopfText) ? ` „${einbau.knopfText}“` : ''}`)
for (const [label, wert] of [['Stelle', einbau.stelle], ['Gestaltungswunsch', einbau.gestaltung], ['Bemerkung', einbau.bemerkung]]) {
zeile('website', istText(wert) && `${label}: ${kurzText(wert, 200)}`)
}
return FRAGEBOGEN_SCHRITTE.map(([schritt, titel]) => ({ schritt, titel, zeilen: gruppen[schritt] }))
}
+89
View File
@@ -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; }
@@ -188,6 +189,86 @@
.tm-status { margin: 0; }
.tm-abschnitt details.tm-details { margin-top: 12px; }
/* ---------------------------------------- Bestellseite und Statuskarte (funktion-bestellen.js) --- */
.tm-bestellen > * + * { margin-top: 18px; }
.tm-bestellen h3 { margin-bottom: 0; }
.tm-bestellen h4 { margin: 0 0 6px; font-size: 14.5px; }
.tm-bestellen .tm-abschnitt-text { margin: 6px 0 0; font-size: 14px; line-height: 1.55; }
.tm-bestellen-zusammenfassung { display: grid; gap: 10px; }
.tm-bestellen-gruppe { padding: 12px 14px; border-radius: 12px; background: var(--kb-surface); border: 1px solid var(--kb-border); }
.tm-bestellen-gruppe-kopf { display: flex; align-items: center; justify-content: space-between; gap: 8px 14px; flex-wrap: wrap; }
.tm-bestellen-gruppe-kopf h4 { margin: 0; }
.tm-bestellen-gruppe ul { margin: 8px 0 0; padding-left: 18px; font-size: 14px; line-height: 1.55; overflow-wrap: anywhere; }
.tm-bestellen-gruppe .tm-hinweis { margin-top: 8px; }
.tm-bestellen-offen { padding: 12px 16px; border-radius: 12px; background: rgba(251, 191, 36, 0.12); border: 1px solid rgba(251, 191, 36, 0.45); }
.tm-bestellen-offen ul { display: grid; gap: 8px; margin: 10px 0 0; padding: 0; list-style: none; }
.tm-bestellen-offen li { display: flex; align-items: center; justify-content: space-between; gap: 6px 14px; flex-wrap: wrap; font-size: 14px; line-height: 1.5; }
.tm-bestellen-offen li > span { min-width: 0; overflow-wrap: anywhere; }
.tm-bestellen-betrag { margin: 0 0 4px; font-size: 14px; }
.tm-bestellen-betrag strong { font-size: 22px; }
.tm-bestellen-preis p { margin: 0 0 4px; line-height: 1.5; }
.tm-bestellen-belehrung .tm-hinweis { margin-bottom: 8px; }
.tm-rechtstext {
max-height: 320px; overflow-y: auto; padding: 14px 16px; border-radius: 12px; border: 1px solid var(--kb-border-strong); background: rgba(255, 255, 255, 0.03);
font-size: 13.5px; line-height: 1.6; white-space: pre-wrap; overflow-wrap: anywhere;
}
.tm-rechtstext:focus-visible { outline: 3px solid #d4b4ff; outline-offset: 2px; }
.tm-bestellen-dokumente { display: grid; gap: 6px; margin: 0; padding-left: 18px; font-size: 14px; }
.tm-bestellen-dokumente a { color: var(--kb-lilac); overflow-wrap: anywhere; }
.tm-bestellen-text { margin: 10px 0 0; font-size: 14px; line-height: 1.55; color: var(--kb-muted); }
.tm-bestellen-aktion { display: grid; justify-items: start; gap: 8px; }
.tm-bestellen-aktion .tm-fehler { font-size: 14px; line-height: 1.5; }
.tm-bestellen-knopf { padding: 13px 22px; font-size: 15px; }
.tm-statuskarte > * + * { margin-top: 12px; }
.tm-statuskarte h3 { margin: 10px 0 0; }
.tm-statuskarte p { margin: 0; line-height: 1.55; }
.tm-statuskarte .tm-hinweis { font-size: 13.5px; }
.tm-statuskarte .tm-fakten { margin: 0; }
.tm-statuskarte .tm-aktionen { margin-top: 16px; }
.tm-statuskarte a.kb-btn-outline { display: inline-flex; align-items: center; text-decoration: none; }
.tm-danke { padding: 12px 16px; border-radius: 12px; background: rgba(74, 222, 128, 0.1); border: 1px solid rgba(74, 222, 128, 0.4); font-weight: 600; }
/* ------------------------------------------ Fragebogen (termine-fragebogen.js) --- */
.tm-fragebogen > * + * { margin-top: 16px; }
.tm-fragebogen > .tm-box { margin-bottom: 0; }
.tm-fortschritt { padding-bottom: 14px; border-bottom: 1px solid var(--kb-border); }
.tm-schritt-stand { display: none; margin: 0 0 10px; font-size: 14px; font-weight: 700; }
.tm-schritte { display: flex; flex-wrap: wrap; gap: 6px; margin: 0; padding: 0; list-style: none; }
.tm-schritt { margin: 0; }
.tm-schritt-knopf {
display: inline-flex; align-items: center; gap: 8px; padding: 8px 14px 8px 8px; border-radius: 999px; border: 1px solid transparent;
background: transparent; color: var(--kb-muted); font: inherit; font-size: 14px; font-weight: 700; text-align: left; cursor: pointer;
}
.tm-schritt-knopf:hover:not(:disabled) { color: #fff; background: rgba(255, 255, 255, 0.06); }
.tm-schritt-knopf:disabled { opacity: 0.5; cursor: not-allowed; }
.tm-schritt[aria-current='step'] .tm-schritt-knopf { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.tm-schritt-nr {
display: inline-grid; place-items: center; flex: none; width: 26px; height: 26px; border-radius: 50%; border: 1px solid var(--kb-border-strong);
font-size: 12px; font-variant-numeric: tabular-nums;
}
.tm-schritt[data-erledigt] .tm-schritt-nr { color: #86efac; background: rgba(74, 222, 128, 0.1); border-color: rgba(74, 222, 128, 0.4); }
.tm-schritt[aria-current='step'] .tm-schritt-nr { color: #fff; background: var(--kb-purple); border-color: var(--kb-purple); }
.tm-fb-schritt > * + * { margin-top: 16px; }
.tm-fb-schritt > h3 { margin: 0; }
.tm-fb-schritt > h3:focus { outline: none; }
.tm-fb-schritt > .tm-abschnitt-text { margin-bottom: 0; line-height: 1.55; }
.tm-fb-form > * + * { margin-top: 16px; }
.tm-fb-form > .tm-box, .tm-fb-form .tm-fb-branchenhinweis .tm-box { margin-bottom: 0; }
.tm-fb-branchenhinweis:empty { display: none; }
.tm-fb-branchen { margin-bottom: 0; }
.tm-fb-branche { display: flex; align-items: center; justify-content: space-between; gap: 8px 16px; flex-wrap: wrap; }
.tm-fb-branche p { margin: 0; }
.tm-fb-aufbewahrung { margin-top: 18px; }
/* Schritte 4 bis 8: ein Wochenplan je Person, Urlaub und Sonderoeffnungen unter dem Formular, Sammelknopf, Einbauwuensche */
.tm-fb-person > h4 { margin: 0 0 4px; font-size: 14.5px; overflow-wrap: anywhere; }
.tm-fb-person .tm-wochenplan { margin-top: 0; }
.tm-fb-ausnahmen { margin-top: 28px; }
.tm-fb-ausnahmen > * + * { margin-top: 16px; }
.tm-fb-sammel { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 12px; }
.tm-fb-sammel [aria-pressed='true'] { color: #fff; background: var(--kb-purple-soft); border-color: var(--kb-purple-line); }
.tm-fb-einbau .tm-vorlagen-liste { margin-bottom: 4px; }
.tm-fb-einbau .tm-feld textarea { min-height: 76px; }
/* ----------------------------------------------------------------- Handy --- */
@media (max-width: 880px), (pointer: coarse) {
#view-termine button:not(.tm-link-knopf), #view-termine .kb-btn-outline, #view-termine .kb-btn-primary { min-height: 44px; }
@@ -197,6 +278,11 @@
.tm-haken { align-items: center; min-height: 44px; margin-top: 0; margin-bottom: 0; }
.tm-haken input { margin-top: 0; }
.tm-link-feld, .tm-code-feld { min-height: 44px; font-size: 16px; } /* 16 px: iOS zoomt beim Antippen kleinerer Felder */
/* Fragebogen: „Schritt n von 8“ und eine Reihe nummerierter Punkte statt der Titel (der Titel steht als Ueberschrift darunter) */
.tm-schritt-stand { display: block; }
.tm-schritte { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px, 1fr)); gap: 6px; }
.tm-schritt-knopf { width: 100%; justify-content: center; padding: 0; }
.tm-schritt-titel { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
}
@media (max-width: 600px) {
.tm-reiterleiste { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -218,5 +304,8 @@
.tm-kopf { flex-wrap: wrap; }
.tm-feed { padding: 12px; }
.tm-frage-knoepfe > * { flex: 1 1 auto; }
.tm-bestellen-aktion { justify-items: stretch; }
.tm-fb-sammel > * { flex: 1 1 auto; }
.tm-rechtstext { max-height: 260px; }
}
@media (prefers-reduced-motion: reduce) { #view-termine * { transition: none !important; animation: none !important; } }
+225 -43
View File
@@ -1,32 +1,59 @@
/* 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=<id> (Zahlung) und #termine?projekt=<id> (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 }: Zustand und Pruefung "bin ich noch
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. */
import { fehltText, heuteBerlin } from './termine-view.js'
import { abschaltText, fehltText, heuteBerlin } from './termine-view.js'
import { exportLink, fehlerKarte, h, infoKarte, request } from './termine-dom.js'
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=<id> nach der Zahlung
* (success_url) und #termine?projekt=<id> 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,26 +93,36 @@ function projektwahl(s) {
const ladeDaten = (projectId) => request(`/api/termine/einstellungen?projectId=${encodeURIComponent(projectId)}`)
/** Hinweis fuer abgeschaltete Funktionen: nur noch ansehen und exportieren. */
const hinweisAbgeschaltet = (projectId) => h('div', { class: 'tm-box tm-box-warn', role: 'note' },
'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und ',
exportLink(projectId, 'exportieren'),
'.')
/**
* 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
}
/**
* Je nach Zustand der Funktion: nicht freigeschaltet, Vorlage waehlen oder die Reiter. Jeder Aufruf zaehlt s.lauf hoch: die Antwort
* 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).
*/
function hinweisAbgeschaltet(projectId, abgeschaltet) {
const { davor, danach, mitExport } = abschaltText(abgeschaltet)
// ein einziger Textblock in der Flex-Zeile der Box, sonst zerfaellt der Satz in drei Spalten (Text, Link, Punkt)
return h('div', { class: 'tm-box tm-box-warn', role: 'note' }, h('span', null, davor, mitExport && exportLink(projectId, 'exportieren'), danach))
}
/**
* 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 {
@@ -91,15 +132,128 @@ 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),
hinweisAbgeschaltet(projekt.projectId, daten.abgeschaltet),
infoKarte('Keine Termine eingerichtet', 'Für diese Website wurden keine Einstellungen und Termine angelegt.', { chat: true }))
} 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. Der Text kommt ausschliesslich vom Server, nie aus einer Kopie im
* Browser: aboHinweis aus GET /api/termine/einstellungen (derselbe Aufruf, der den Zustand liefert, also auch dann gefuellt, wenn das Abo nicht
* lesbar war oder sich seit dem Laden der Liste geaendert hat), ersatzweise termine.hinweis aus GET /api/funktionen (beide: aboMeldung in
* server/services/featureOrder.js). Fehlt der Text auch dort, wird die Liste noch einmal geholt. Bei kein_abo kommt der Link zu den
* Vertraegen dazu (Quelle: vertraege-link.js). „Neu laden“ holt Daten und Ansicht neu, damit der Kunde nach einer Aenderung des Abos nicht
* an der Karte haengen bleibt.
*/
async function zeigeAboHinweis(s, { aktuell }) {
const textVon = (wert) => (typeof wert === 'string' ? wert : '')
if (textVon(s.daten?.aboHinweis) === '' && textVon(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 = textVon(s.daten?.aboHinweis) || textVon(s.termine?.hinweis)
const keinAbo = s.daten?.abo?.grund === 'kein_abo' || (s.termine?.bestellbar === false && s.termine.grund === 'kein_abo')
const link = keinAbo
? 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,
h('div', { class: 'tm-aktionen' },
h('button', { type: 'button', class: 'kb-btn-outline', 'data-aktion': 'neu-laden', onclick: () => zeigeProjekt(s) }, 'Neu laden'))))
}
/** 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' })
@@ -160,15 +314,18 @@ function zeigeMeldung(s, text, art = 'ok') {
el.scrollIntoView({ block: 'nearest' })
}
function zeigeReiter(s, { projekt, aktuell }) {
/**
* Kontext fuer die Module, die in der Huelle laufen (die Reiter, der Fragebogen): Zustand und die Pruefung "bin ich noch aktuell?" kommen nur hierher.
* hinweise() zeichnet die Hinweisleiste ueber den Reitern neu (der Fragebogen hat keine), zumReiter(key) wechselt den Reiter; beide duerfen fehlen.
* nurLesen und daten sind die der Huelle im Moment des Aufrufs; neuLaden() holt die Daten neu und ersetzt sie in s und im Kontext.
*/
export function baueCtx(s, { projekt, aktuell, hinweise = () => {}, zumReiter = () => {} }) {
const projectId = projekt.projectId
const nurLesen = s.nurLesen
s.meldungEl = h('div', { class: 'tm-meldung', role: 'status', 'aria-live': 'polite', tabindex: '-1' })
const ctx = {
s,
projectId,
daten: s.daten,
nurLesen,
nurLesen: s.nurLesen,
auswahl,
aktuell,
api: {
@@ -179,26 +336,42 @@ function zeigeReiter(s, { projekt, aktuell }) {
},
// eine Meldung, die erst nach dem Wechsel der Website oder der Ansicht eintrifft, gehoert nicht mehr auf diese Seite
meldung: (text, art) => { if (aktuell()) zeigeMeldung(s, text, art) },
hinweise: () => { if (aktuell()) zeichneHinweise() },
zumReiter: (key) => waehleReiter(key),
hinweise: () => { if (aktuell()) hinweise() },
zumReiter: (key) => zumReiter(key),
neuLaden: async () => {
const daten = await ladeDaten(projectId)
if (aktuell()) {
s.daten = daten
ctx.daten = daten
zeichneHinweise()
hinweise()
}
return daten
},
}
return ctx
}
/**
* 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 = 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) {
hinweise.push(hinweisAbgeschaltet(projectId))
} else if ((s.daten.fehlt || []).length) {
if (zustand === 'disabled') {
hinweise.push(hinweisAbgeschaltet(projectId, s.daten.abgeschaltet))
} 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')))
@@ -207,24 +380,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))
@@ -238,7 +420,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)
}
+5
View File
@@ -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, Kachel Terminplaner; baut daraus HTML), analyse.js (Analyse-Seite; baut daraus HTML) und termine.js (Hinweis
in der Ansicht #termine; baut daraus ein Element). Reine Daten ohne Import: alle laden das Modul relativ.
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' })
@@ -0,0 +1,78 @@
<!DOCTYPE html>
<html lang="de">
<!-- ENTWURF, Freigabe Nutzer ausstehend -->
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Leistungsbeschreibung Funktionen – News-Pinnwand – WEBklar</title>
<style>
: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; }
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; }
table { border-collapse: collapse; width: 100%; font-size: 15px; }
td { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
td:first-child { font-weight: 600; width: 30%; }
a { color: #6d1fd1; }
nav.top { font-size: 14px; margin-bottom: 22px; }
@media print {
@page { size: A4; margin: 18mm 17mm 18mm; }
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 { break-inside: avoid; }
}
</style>
</head>
<body>
<main>
<nav class="top"><a href="/dashboard.html">← Zurück zum Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/datenschutz.html#news">Datenschutz</a></nav>
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung: Funktionen – News-Pinnwand</h1>
<p class="meta">Stand 30.09.2026</p>
<div class="box">
<strong>Das Wichtigste auf einen Blick</strong>
<table>
<tr><td>Leistung</td><td>Einbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal</td></tr>
<tr><td>Preis</td><td>30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten</td></tr>
<tr><td>Dauer des Einbaus</td><td>meist innerhalb von 1–2 Wochen (keine verbindliche Zusage)</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)</td></tr>
</table>
</div>
<h2 id="leistung">1. Leistung</h2>
<p>Wir bauen die News-Pinnwand in die Website ein, die Sie für das gewählte Projekt bei uns im Kundenportal führen. Die Pinnwand zeigt Ihre Beiträge als Zettel mit Titel, Text und Foto; pro Seite erscheinen sechs Beiträge, die neuesten zuerst. Die Beiträge schreiben, ändern und löschen Sie selbst im Kundenportal im Bereich „Funktionen“. Sie wählen je Beitrag Zettelfarbe und Anzeigedauer; danach verschwindet der Beitrag von Ihrer Website. Hochgeladene Fotos werden verkleinert, alle Metadaten einschließlich Aufnahmeort (GPS) werden entfernt.</p>
<p>Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.</p>
<h2 id="preis">2. Preis</h2>
<p>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.</p>
<h2 id="einbau">3. Einbau und Freischaltung</h2>
<p>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.</p>
<h2 id="ende">4. Ende der Zusammenarbeit</h2>
<p>Auf Ihren Wunsch schalten wir die Pinnwand ab; das tun wir auch, wenn die Zusammenarbeit endet und wir die Funktion bei Ihnen abschalten. Ihre Beiträge und Fotos werden gelöscht, wenn Sie sie selbst löschen, andernfalls nach den Fristen der Datenschutzerklärung (<a href="/datenschutz.html#news">Abschnitt 6a</a>, dort Abschnitt 7).</p>
<h2 id="inhalte">5. Ihre Inhalte</h2>
<p>Sie sind für die Inhalte Ihrer Beiträge und Fotos verantwortlich. Sie versichern, dass Sie die nötigen Rechte an Texten und Fotos haben und dass die Inhalte keine Rechte Dritter verletzen, etwa Urheberrechte, Persönlichkeitsrechte oder das Recht am eigenen Bild abgebildeter Personen.</p>
<h2 id="widerruf">6. Widerrufsrecht</h2>
<p>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 <a href="/widerrufen.html">project.webklar.com/widerrufen.html</a>.</p>
<p>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.</p>
<p class="meta">Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die <a href="/abo-bedingungen.html">Vertragsbedingungen</a> und die <a href="/datenschutz.html">Datenschutzerklärung</a>.</p>
</main>
</body>
</html>
@@ -40,15 +40,16 @@
<div class="brand">WEBKLAR</div>
<h1>Leistungsbeschreibung: Funktionen – News-Pinnwand</h1>
<p class="meta">Stand 30.09.2026</p>
<p class="meta">Stand 05.10.2026</p>
<p class="meta">Frühere Fassung vom 30.09.2026: <a href="/vertragsunterlagen/funktionen-leistungsbeschreibung-2026-09-30.html">Leistungsbeschreibung (Stand 30.09.2026)</a></p>
<div class="box">
<strong>Das Wichtigste auf einen Blick</strong>
<table>
<tr><td>Leistung</td><td>Einbau der News-Pinnwand in Ihre Website durch WEBklar; Pflege der Beiträge durch Sie im Kundenportal</td></tr>
<tr><td>Preis</td><td>30,00 € einmalig, inklusive Umsatzsteuer, keine laufenden Kosten</td></tr>
<tr><td>Preis</td><td>30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG), keine laufenden Kosten</td></tr>
<tr><td>Dauer des Einbaus</td><td>meist innerhalb von 1–2 Wochen (keine verbindliche Zusage)</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 4 BGB erlöschen (Abschnitt 6)</td></tr>
<tr><td>Widerruf</td><td>Verbraucher können innerhalb von 14 Tagen widerrufen; das Recht kann nach § 356 Abs. 5 Nr. 2 BGB erlöschen (Abschnitt 6)</td></tr>
</table>
</div>
@@ -57,7 +58,7 @@
<p>Änderungen an der Website, die über den Einbau der Pinnwand hinausgehen, sind nicht Teil dieser Leistung.</p>
<h2 id="preis">2. Preis</h2>
<p>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.</p>
<p>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.</p>
<h2 id="einbau">3. Einbau und Freischaltung</h2>
<p>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.</p>
@@ -70,7 +71,7 @@
<h2 id="widerruf">6. Widerrufsrecht</h2>
<p>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 <a href="/widerrufen.html">project.webklar.com/widerrufen.html</a>.</p>
<p>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.</p>
<p>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.</p>
<p class="meta">Anbieter: WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler. Es gelten ergänzend die <a href="/abo-bedingungen.html">Vertragsbedingungen</a> und die <a href="/datenschutz.html">Datenschutzerklärung</a>.</p>
</main>
+1
View File
@@ -21,6 +21,7 @@
<label class="radio"><input type="radio" name="order" value="wechsel" data-text="Wechsel ins Wartungs-Abo"> Wechsel ins Wartungs-Abo</label>
<label class="radio"><input type="radio" name="order" value="zusatzspeicher" data-text="E-Mail-Zusatzspeicher"> E-Mail-Zusatzspeicher</label>
<label class="radio"><input type="radio" name="order" value="funktion" data-text="Funktion (z. B. News-Pinnwand)"> Funktion (z. B. News-Pinnwand)</label>
<label class="radio"><input type="radio" name="order" value="termine" data-text="Terminplaner (Einrichtung)"> Terminplaner (Einrichtung)</label>
</fieldset>
<label for="name" data-label-for="name" data-label="Name">Name</label>
+325
View File
@@ -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/<slug>-<Fassung>.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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }
/** 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 += `<strong>${klartext(innen, zeile)}</strong>`
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 += `<code>${esc(text.slice(i + 1, ende))}</code>`
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; kein Rückwärtsschrägstrich (Browser lesen ihn wie "/"). */
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(`<div class="brand">WEBKLAR</div>\n <h1>${b.html}</h1>`)
} else {
aus.push(`<h${b.ebene}>${b.html}</h${b.ebene}>`)
}
} else if (b.art === 'p') {
aus.push(`<p${i === 1 ? ' class="meta"' : ''}>${b.html}</p>`)
} else if (b.art === 'ul') {
aus.push(`<ul>\n${b.punkte.map((p) => ` <li>${p}</li>`).join('\n')}\n </ul>`)
} else {
aus.push(
[
'<table>',
`<thead><tr>${b.kopf.map((z) => `<th>${z}</th>`).join('')}</tr></thead>`,
'<tbody>',
...b.zeilen.map((zeile) => `<tr>${zeile.map((z) => `<td>${z}</td>`).join('')}</tr>`),
'</tbody>',
'</table>',
].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(`<strong>Fassung ${meta.fassung}</strong>`)) {
throw new MarkdownFehler(3, `der Absatz nach der Überschrift muss mit **Fassung ${meta.fassung}** beginnen`)
}
const nav = meta.nav.map((l) => `<a href="${esc(pruefeHref(l.href))}">${esc(l.text)}</a>`).join(' · ')
return `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="webklar-fassung" content="${esc(meta.fassung)}">
<title>${esc(meta.titel)} – WEBklar</title>
<style>
${STYLE(meta.seitenkopf)} </style>
</head>
<body>
<main>
<nav class="top">${nav}</nav>
${bloeckeHtml(bloecke)}
</main>
</body>
</html>
`
}
// ---------------------------------------------------------------------------------------------------------- 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/<slug>.md, Ziel public/rechtliches/<slug>-<Fassung>.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
}
}
+46 -3
View File
@@ -1,15 +1,22 @@
#!/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 <PDF>.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); daneben liegt <PDF>.sha256 mit der SHA-256 von
# public/datenschutz.html (test/legalTexts.test.js prueft sie); ein PDF mit passender Pruefsumme wird nicht neu gedruckt (FORCE=1 druckt neu)
# 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 +24,47 @@ 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)
quelle="public/datenschutz.html"
ziel="docs/WEBklar-Datenschutzerklaerung.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 public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf
echo "$summe" > "$ziel.sha256"
fi
;;
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 +72,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
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env node
/**
* Einmalig (TP2 Abschlusspruefung): IP und Browser der Bestellung von den Funktionsdokumenten (portalFeatures.consentIp,
* consentUserAgent) leeren. Neue Bestellungen speichern sie dort nicht mehr; der Nachweis (featureConsents) traegt sie und hat die
* Loeschfrist der Datenschutzerklaerung Abschnitt 7 (drei Jahre nach Ablauf des Jahres, in dem das letzte Abo endet). Altbestand am
* Funktionsdokument hatte keine Loeschung.
*
* Geleert wird nur dort, wo der Nachweis die Angabe wirklich traegt: ein featureConsents-Eintrag zur Stripe-Sitzung des Dokuments, dessen
* ip bzw. userAgent gesetzt ist, wo das Dokument sie hat. Ohne solchen Nachweis (Bestellung vor dem Nachweis, keine Sitzung) ist die Angabe
* am Dokument der einzige Beleg; sie bleibt stehen und wird im Bericht genannt (die Loeschfrist im Aufbewahrungslauf, services/retention.js,
* leert sie spaeter). consentText und consentAt bleiben: sie sind kein Personenbezug, der Aufbewahrungslauf leert sie mit.
*
* Standard ist der Probelauf: zaehlt und listet, schreibt nichts.
* node scripts/feature-consent-felder-leeren.mjs (= --dry-run)
* node scripts/feature-consent-felder-leeren.mjs --dry-run
* node scripts/feature-consent-felder-leeren.mjs --apply (leert consentIp und consentUserAgent; danach Bericht)
* Im Container als Datei: docker exec -w /app <container> node scripts/feature-consent-felder-leeren.mjs
* NICHT gegen Produktion ausfuehren, ohne dass der Probelauf geprueft und die Freigabe da ist.
*/
import 'dotenv/config'
import { pathToFileURL } from 'node:url'
import { config } from '../server/config.js'
import { listAllDocuments, updateDocument } from '../server/services/appwriteAdmin.js'
import { zustimmungFuerSitzung } from '../server/services/featureConsents.js'
/** Aufrufargumente: keine oder --dry-run => Probelauf, --apply => schreiben; alles andere (auch beide zusammen) ist ein Fehler. */
export function parseArgs(argv = []) {
const flags = new Set(argv)
const unknown = argv.filter((a) => a !== '--apply' && a !== '--dry-run')
if (unknown.length) throw new Error(`Unbekanntes Argument: ${unknown.join(' ')} (erlaubt: --dry-run, --apply)`)
if (flags.has('--apply') && flags.has('--dry-run')) throw new Error('--apply und --dry-run schliessen sich aus')
return { apply: flags.has('--apply') }
}
/** Traegt der Nachweis jede Angabe, die am Funktionsdokument steht? */
const nachweisTraegt = (doc, nachweis) => (!doc.consentIp || Boolean(nachweis.ip)) && (!doc.consentUserAgent || Boolean(nachweis.userAgent))
/**
* @param {{ apply?: boolean, log?: (line: string) => void }} [options]
* @returns {Promise<{ applied: boolean, total: number, withData: number, clearable: string[], kept: string[], unreadable: string[],
* cleared: number, failed: { id: string, reason: string }[] }>}
*/
export async function run({ apply = false, log = console.log } = {}) {
const docs = await listAllDocuments(config.collections.portalFeatures)
const withData = docs.filter((d) => d.consentIp || d.consentUserAgent)
const clearable = []
const kept = []
const unreadable = []
for (const doc of withData) {
try {
const nachweis = await zustimmungFuerSitzung(doc.stripeSessionId)
if (nachweis && nachweisTraegt(doc, nachweis)) clearable.push(doc.$id)
else kept.push(doc.$id)
} catch (err) {
unreadable.push(doc.$id)
log(`Nachweis zu Dokument ${doc.$id} nicht lesbar: ${err.message}`)
}
}
log(`Funktionsdokumente: ${docs.length}, davon mit IP/Browser der Bestellung: ${withData.length}`)
log(` zu leeren (der Nachweis in featureConsents traegt die Angabe): ${clearable.length}`)
log(` bleiben (kein tragender Nachweis, die Angabe am Dokument ist der einzige Beleg): ${kept.length}${kept.length ? ` (${kept.join(', ')})` : ''}`)
if (unreadable.length) log(` nicht geprueft (Nachweis nicht lesbar, bleiben): ${unreadable.length} (${unreadable.join(', ')})`)
const failed = []
let cleared = 0
if (!apply) {
log('Probelauf (--dry-run): es wird nichts geschrieben. Mit --apply werden die oben genannten Dokumente geleert.')
return { applied: false, total: docs.length, withData: withData.length, clearable, kept, unreadable, cleared, failed }
}
for (const id of clearable) {
try {
await updateDocument(config.collections.portalFeatures, id, { consentIp: '', consentUserAgent: '' })
cleared += 1
} catch (err) {
failed.push({ id, reason: err.message })
log(`Dokument ${id} nicht geleert: ${err.message}`)
}
}
log(`Geleert: ${cleared} von ${clearable.length}${failed.length ? `, fehlgeschlagen: ${failed.length} (Skript erneut ausfuehren)` : ''}`)
return { applied: true, total: docs.length, withData: withData.length, clearable, kept, unreadable, cleared, failed }
}
if (import.meta.url === pathToFileURL(process.argv[1] || '').href) {
try {
const { apply } = parseArgs(process.argv.slice(2))
const result = await run({ apply })
if (result.failed.length || result.unreadable.length) process.exitCode = 1
} catch (err) {
console.error('Fehler:', err.message)
process.exit(1)
}
}
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env node
/**
* Portal-Funktionen (News, Terminplaner): legt die Attribute an der Collection portalFeatures an, die der
* Verkauf des Terminplaners braucht (Abschaltgrund, Marker der Abo-Ende-Mail, Marker der Zahlungsmeldung),
* und die Collection featureConsents (Zustimmungsnachweis zur Bestellung, nur Server-Key) samt Attributen und
* Indizes. Idempotent: Vorhandenes wird uebersprungen. Vor dem Code-Deploy ausfuehren (activate() schreibt die
* neuen Felder; ohne Schema antwortet Appwrite mit "Unknown attribute").
* scripts/setup-abo-collections.mjs bleibt unveraendert (Top-Level-Skript ohne Pruefmodus).
* Usage:
* node scripts/feature-setup.mjs --check (nur melden, nichts anlegen; Exit-Code 1 bei Luecken)
* node scripts/feature-setup.mjs (Fehlendes anlegen)
*
* config.js und der Appwrite-Client werden erst in main() geladen: test/support/schema.js importiert die
* Attributlisten, bevor die Tests ihre Umgebung (useTestEnv) setzen koennen.
*/
import { pathToFileURL } from 'node:url'
/**
* Neue Attribute von portalFeatures (alle optional):
* - disabledReason: 'team' | 'abo_ende' | 'abo_ende_vor_einrichtung' | 'kunde_geloescht' (laengster Wert 24 Zeichen)
* - aboEndeMailAt: Zeitpunkt der versandten Mail "Terminplaner beendet" (Marker, nach dem Versand gesetzt)
* - zahlungMeldung: '' | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' | 'gemeldet' (Marker einer Zahlung ohne Ticket)
*/
export const PORTAL_FEATURE_ATTRIBUTES = [
{ type: 'string', key: 'disabledReason', size: 32, required: false },
{ type: 'datetime', key: 'aboEndeMailAt', required: false },
{ type: 'string', key: 'zahlungMeldung', size: 24, required: false },
]
const str = (key, size) => ({ key, type: 'string', size, required: false })
/**
* Zustimmungsnachweis zur Bestellung einer Funktion (Collection featureConsents, nur Server-Key, wie analyseConsents):
* - fassungen: JSON mit den Fassungen der gezeigten Texte; texte: JSON mit den gezeigten Texten (Appwrite macht aus
* dieser Groesse eine TEXT-Spalte; 20000 Zeichen genuegen fuer Zustimmung, Haken und Belehrung)
* - textHash: SHA-256 (hex) ueber die gespeicherten Texte; stripeSessionId: Stripe-Sitzung, zu der der Nachweis gehoert
* - ip, userAgent, konto: Herkunft der Zustimmung; acceptedAt: Zeitpunkt des Klicks (Anker der Widerrufsfrist)
*/
export const CONSENT_COLLECTION_NAME = 'featureConsents'
export const FEATURE_CONSENT_ATTRIBUTES = [
str('customerId', 36),
str('projectId', 36),
str('featureKey', 16),
str('stripeSessionId', 128),
{ key: 'acceptedAt', type: 'datetime', required: false },
str('fassungen', 1000),
str('texte', 20000),
str('textHash', 64),
str('ip', 64),
str('userAgent', 300),
str('konto', 255),
]
export const FEATURE_CONSENT_INDEXES = [
{ key: 'idx_session', type: 'key', attributes: ['stripeSessionId'] },
{ key: 'idx_project_feature', type: 'key', attributes: ['projectId', 'featureKey', 'acceptedAt'] },
// Loeschfristen (services/retention.js): alle Nachweise eines Kunden, drei Jahre nach Ablauf des Jahres, in dem sein letztes Abo endet
{ key: 'idx_customer', type: 'key', attributes: ['customerId'] },
]
/** true, wenn das Ergebnis von main() Luecken meldet (Exit-Code 1 im Pruefmodus) */
export function hatLuecken({ missing = [], missingCollection = false, missingIndexes = [] } = {}) {
return missing.length > 0 || missingCollection || missingIndexes.length > 0
}
export async function main({ check = false, client, log = console.log } = {}) {
const { config } = await import('../server/config.js')
let api = client
if (!api) {
const { createAppwriteClient } = await import('./analyse-setup.mjs')
api = createAppwriteClient()
}
const featuresId = config.collections.portalFeatures
const consentsId = config.collections.featureConsents
const missing = []
// Attribute einer Collection abgleichen; `exists` false = Collection fehlt (alle Attribute fehlen)
async function syncAttributes(collectionId, attributes, exists = true) {
const have = exists ? new Set((await api.listAttributes(collectionId)).map((a) => a.key)) : new Set()
for (const attr of attributes) {
if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`)
else if (check) log(`- ${collectionId}.${attr.key} FEHLT`)
}
return attributes.filter((a) => !have.has(a.key))
}
const featureAbsent = await syncAttributes(featuresId, PORTAL_FEATURE_ATTRIBUTES)
featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId }))
const consentsExist = await api.collectionExists(consentsId)
if (!consentsExist && check) log(`- ${consentsId} FEHLT`)
const consentAbsent = await syncAttributes(consentsId, FEATURE_CONSENT_ATTRIBUTES, consentsExist)
consentAbsent.forEach((a) => missing.push({ ...a, collection: consentsId }))
const haveIndexes = consentsExist ? new Set((await api.listIndexes(consentsId)).map((i) => i.key)) : new Set()
const indexAbsent = FEATURE_CONSENT_INDEXES.filter((i) => !haveIndexes.has(i.key))
if (check) indexAbsent.forEach((i) => log(`- ${consentsId}.${i.key} FEHLT`))
const result = { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent }
if (check) {
const open = missing.length + indexAbsent.length + (consentsExist ? 0 : 1)
log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
return result
}
for (const attr of featureAbsent) {
await api.createAttribute(featuresId, attr)
log(`+ Attribut ${featuresId}.${attr.key}`)
}
if (!consentsExist) {
await api.ensureCollection(consentsId, CONSENT_COLLECTION_NAME)
log(`+ Collection ${consentsId}`)
}
for (const attr of consentAbsent) {
await api.createAttribute(consentsId, attr)
log(`+ Attribut ${consentsId}.${attr.key}`)
}
for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key)
for (const attr of consentAbsent) await api.waitForAttribute(consentsId, attr.key)
// Indizes erst, wenn ihre Attribute verfuegbar sind
for (const index of indexAbsent) {
await api.createIndex(consentsId, index)
log(`+ Index ${consentsId}.${index.key}`)
}
log('Fertig.')
return result
}
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
if (invokedDirectly) {
const check = process.argv.includes('--check')
main({ check })
.then((result) => process.exit(check && hatLuecken(result) ? 1 : 0))
.catch((e) => {
console.error(e.message)
process.exit(1)
})
}
+4 -3
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/**
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News-Einrichtung als Einmalpreis): legt fehlende
* Stripe-Preise je Produkt (Wartungs-Abo, Hosting-Abo, E-Mail-Zusatzspeicher, News- und Terminplaner-Einrichtung als Einmalpreise): legt fehlende
* Preise an (ensurePrice), haelt die Wartungs-Produktbeschreibung bei Stripe auf dem Katalogtext
* (server/config.js) und passt die Kundenportal-Konfiguration an ("WEBklar – Abo verwalten").
* Idempotent: vorhandene Preise werden ueber lookup_key gefunden, nicht neu angelegt.
@@ -17,7 +17,7 @@ import { config } from '../server/config.js'
import { ensurePortalConfiguration, ensurePrice, getStripe, PORTAL_HEADLINE } from '../server/services/stripeBilling.js'
import { PRODUCT_KEYS } from '../server/services/products.js'
const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup']
export const KEYS = [...PRODUCT_KEYS, 'mailStorage', 'newsSetup', 'termineSetup'] // ['wartung', 'hosting', 'mailStorage', 'newsSetup', 'termineSetup']
const USAGE = 'Usage: node scripts/stripe-products.mjs --pruefen | --anlegen'
export function parseArgs(argv) {
@@ -27,9 +27,10 @@ export function parseArgs(argv) {
return null
}
function catalogEntry(key) {
export function catalogEntry(key) {
if (key === 'mailStorage') return config.mailStorage
if (key === 'newsSetup') return config.newsSetup
if (key === 'termineSetup') return config.termineSetup
return config.products[key]
}
+162
View File
@@ -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-<Fassung>.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 bei einer Leistung mit Preis, wenn der Betrieb Stornobedingungen hat; bei kostenlosen Leistungen fragt der Terminplaner nicht danach; 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
}
}
+253 -5
View File
@@ -106,6 +106,16 @@ const newsSetup = {
priceNickname: 'News-Einrichtung 30 €',
}
// Terminplaner: einmalige Einrichtung (Einmalpreis, kein Abo); der Terminplaner selbst laeuft, solange das Wartungs-Abo laeuft
const termineSetup = {
key: 'termineSetup',
name: 'Terminplaner – Einrichtung',
amountCents: 14900,
lookupKey: 'webklar_termine_einrichtung_14900',
oneTime: true,
priceNickname: 'Terminplaner-Einrichtung 149 €',
}
// Ticketsystem (WOMS); eigene Konstante, weil auch die Enable-Banking-Rueckkehradresse darauf aufbaut
const womsBaseUrl = (process.env.WOMS_PUBLIC_URL || 'https://ticket.webklar.com').replace(/\/$/, '')
@@ -130,6 +140,217 @@ export function resolveEnableBankingApiBase(value) {
return ENABLE_BANKING_API_BASE
}
// Pflichthinweis zum Preis (Kleinunternehmer); die Funktionen-Register-Texte und die Preisangaben nutzen ihn
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,
* featureHaken, oeffentlicheAnsicht) stehen in server/services/featureRegistry.js. Haken (verfuegbar,
* bestellpruefung, unterlagenBereit, belehrung, freischaltbar) gehoeren nicht hierher: wer sie in Tests auf dem
* Eintrag setzt, ueberschreibt den Standard aus featureRegistry.js.
* ticket.topic(project) liefert den Ticket-Betreff, ticket.details(ctx) den Text des Einrichtungs-Tickets
* (ctx = { feature, doc, project, customer }: Register-Eintrag, portalFeatures-, websiteProjects- und Kunden-Dokument).
*/
const features = {
news: {
key: 'news',
product: 'newsSetup',
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. 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 (<F>); bestehende
// Bestellungen behalten Text und Fassung, die am Dokument und im Nachweis gespeichert sind.
consent: {
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 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).',
},
],
},
// Stand der Leistungsbeschreibung (public/vertragsunterlagen/funktionen-leistungsbeschreibung.html) = <F>
leistungsbeschreibung: {
get version() {
return config.termine.avvVersion
},
url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html',
},
avv: null,
widerruf: null,
returnHash: 'news',
returnProjekt: false,
ticket: {
topic: (project) => `News-Pinnwand einbauen – ${ticketProjektName(project)}`.trim(),
details: async (ctx) => {
const { feature, doc, project } = ctx || {}
const siteKey = doc?.siteKey || ''
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
`Funktion: ${feature?.key || 'news'}`,
`Projekt: ${ticketProjektName(project)}`,
`Domain: ${domain}`,
`siteKey: ${siteKey}`,
`Einbau: <div data-webklar-news="${siteKey}"></div> + https://project.webklar.com/embed/news.js`,
'Anleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)',
].join('\n')
},
},
mails: {
funktion: 'News-Pinnwand',
formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' },
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: `einmalig, ${ustHinweis}; die Nutzung ist kostenlos, es läuft kein Abo`,
anleitung: false,
},
requiresWartungsAbo: false,
ticketFreischaltung: true,
disabledMessage:
'Die News-Pinnwand wurde für diese Website abgeschaltet. Schreib uns im Chat, wenn du sie wieder möchtest.',
orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.',
},
termine: {
key: 'termine',
product: 'termineSetup',
label: { name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' },
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)
},
returnHash: 'termine',
returnProjekt: true,
ticket: {
topic: (project) => `Terminplaner einbauen – ${ticketProjektName(project)}`,
// Dienst erst beim Aufruf laden: config.js importiert keine Dienste statisch
details: async (ctx) => {
const { termineTicketDetails } = await import('./services/termine/ticketDetails.js')
return termineTicketDetails(ctx)
},
},
mails: {
funktion: 'Terminplaner',
formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' },
knopf: 'Termine öffnen',
// Platzhalter: den Wortlaut schreibt der Inhaber in seiner Mailrunde
danach: 'ENTWURF (Mailrunde): Danach kannst Du im Portal Termine und Einstellungen verwalten.',
bereit: 'ENTWURF (Mailrunde): Du kannst ab sofort Termine online buchen lassen.',
preisHinweis: `einmalig, ${ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`,
anleitung: true,
},
requiresWartungsAbo: true,
ticketFreischaltung: true,
disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.',
orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.',
unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.',
},
}
export const config = {
port: Number(process.env.PORT) || 3000,
sessionSecret: process.env.SESSION_SECRET || '',
@@ -152,6 +373,8 @@ export const config = {
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
analyseConsents: process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents',
// Zustimmungsnachweis zur Bestellung einer Funktion (nur Server-Key; scripts/feature-setup.mjs)
featureConsents: process.env.APPWRITE_COLLECTION_FEATURE_CONSENTS || 'featureConsents',
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
@@ -188,12 +411,19 @@ export const config = {
products,
mailStorage,
newsSetup,
// Funktionen-Tab (News-Pinnwand): Zeiteinschaetzung auf der Buchungsseite und Wortlaut der Zustimmung,
// der mit Zeitstempel am Feature-Dokument gespeichert wird (Widerruf, § 356 Abs. 4 BGB)
termineSetup,
ustHinweis,
// Register der kaufbaren Funktionen (siehe oben)
features,
// Funktionen-Tab (News-Pinnwand): Alias auf den News-Eintrag des Registers, Zeiteinschaetzung auf der
// Buchungsseite und Wortlaut der Zustimmung (die Getter folgen config.features.news zur Laufzeit)
funktionen: {
setupEstimate: 'meist innerhalb von 1–2 Wochen',
consentText:
'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).',
get setupEstimate() {
return config.features.news.setupEstimate
},
get consentText() {
return config.features.news.consent.items[0].text
},
},
// Wartungs-Abo (Stripe Billing). Preis/Produkt legt der Server per lookup_key selbst an.
@@ -383,9 +613,27 @@ 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,
// Mail-Fairness (outbox.js): hoechstens so viele Mails je Funktion und Stunde (gleitendes Fenster, unter mailsProStunde)
mailsProFunktion: 40,
// Anteil der Stunde, der Vorrang- und Normal-Mails vorbehalten bleibt: nachrangige Mails (G05, K20, K21) nehmen hoechstens
// max(1, floor(mailsProStunde x (1 - mailsReserve))) Plaetze
mailsReserve: 0.25,
// Eintraege je Abfrage der Mail-Warteschlange (verarbeite, Minutenjob): hoechstens so viele fuer eine Klasse und Runde
mailListe: 100,
// Hoechstens so viele Buchungen loescht ein Schritt der Aufbewahrungs-Loeschung je Lauf (Tagesjob): je Status, bei erledigt und
// nichterschienen zusammen je Funktion; der Rest folgt am naechsten Tag
loeschDeckel: 2000,
// Alle Schritte der Aufbewahrungs-Loeschung eines Laufs zusammen loeschen hoechstens so viele Buchungen (Gesamtdeckel ueber alle Funktionen)
loeschDeckelGesamt: 20000,
// Hoechstens so viele alte Belegungszellen (bis mehr als einen Tag vorbei) loescht der Zellen-Sweep des Tagesjobs je Lauf; der Rest folgt am naechsten Tag
zellenDeckel: 5000,
// Mindestzeit zwischen Laden und Absenden des Buchungsformulars (schneller = Bot)
formMinSekunden: nonNegativeNumber(process.env.TERMINE_FORM_MIN_SECONDS, 3),
// Kuerzeste Antwortzeit (ms) von POST /buchungen und den Widerrufs-POSTs der oeffentlichen Schnittstelle: verwischt, ob
+3 -3
View File
@@ -38,7 +38,7 @@ import newsRoutes from './routes/news.js'
import publicNewsRoutes from './routes/publicNews.js'
import publicTermineRoutes from './routes/publicTermine.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'
@@ -110,8 +110,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)
@@ -8,8 +8,8 @@
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail am Abo) · BCC intern
Platzhalter: {{name}}, {{produkt}}, {{eingang_zeitpunkt}}, {{vertragsende}}, {{vertragsende_paragraph}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): wartung
produkt = Katalogname ohne „WEBklar “; vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11); wartung = Hinweis zum Stundenkontingent.
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): wartung, funktionen
produkt = Katalogname ohne „WEBklar “; vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11); wartung = Hinweis zum Stundenkontingent; funktionen = Hinweis, dass Website-Analyse und Terminplaner mit dem Wartungs-Abo enden (nur Wartungs-Abo; Sie-Form wie die Vorlage).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -39,6 +39,9 @@ a{color:#111111;}
<tr><td class="px" style="padding:32px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="mut" style="font-size:12px;text-transform:uppercase;letter-spacing:1px;color:#6b6964;">Ihr Abo endet zum</div><div class="ink" style="font-size:30px;line-height:38px;font-weight:bold;letter-spacing:-0.5px;color:#111111;padding-top:4px;">{{vertragsende}}</div></td></tr></table></td></tr>
<tr><td class="px mut" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Bis dahin bleiben alle Leistungen verfügbar, danach erfolgen keine weiteren Abbuchungen. Gebuchter E-Mail-Zusatzspeicher endet zusammen mit dem Abo.<!-- WENN wartung --> Nicht genutzte Zeit aus dem Stundenkontingent entfällt mit Vertragsende.<!-- ENDE wartung --></td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Ihre Daten, den Domain-AuthCode und die Inhalte Ihrer Postfächer stellen wir Ihnen auf Wunsch gemäß {{vertragsende_paragraph}} der Vertragsbedingungen bereit.</td></tr>
<!-- WENN funktionen -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.</td></tr>
<!-- ENDE funktionen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf6e7;border:1px solid #f0dcb0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Sie haben das nicht veranlasst?</div><div class="mut" style="color:#4a4843;">Dann melden Sie sich bitte umgehend bei uns: <a class="mut" href="mailto:support@webklar.com" style="color:#4a4843;">support@webklar.com</a> oder 0176 23726355.</div></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
@@ -8,8 +8,8 @@
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: E-Mail aus dem Formular · BCC intern
Platzhalter: {{name}}, {{produkt}}, {{kuendigungsart}}, {{eingang_zeitpunkt}}, {{email}}, {{kundennummer}}, {{vertrag}}, {{wunschtermin}}, {{grund}}, {{vertragsende}}, {{vertragsende_paragraph}}, {{pakete}}, {{pakete_neu}}, {{pakete_angabe}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, pruefen, abo, wartung, speicher_angaben, speicher_kind_pruefung
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11); wartung (innerhalb von abo) = Hinweis auf nicht genutzte Arbeitszeit aus dem Stundenkontingent (kein Geldguthaben, v4 § 4 Abs. 1).
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): kundennummer, grund, automatisch_gekuendigt, sonderkuendigung, ausserordentlich, kein_abo_gefunden, zusatzspeicher, pruefen, abo, wartung, speicher_angaben, speicher_kind_pruefung, funktionen
produkt = Katalogname ohne „WEBklar “ („Abo“ ohne gefundenes Abo); vertragsende_paragraph = Paragraph zum Vertragsende der Fassung des Kunden (v3 § 9, v4 § 11); wartung (innerhalb von abo) = Hinweis auf nicht genutzte Arbeitszeit aus dem Stundenkontingent (kein Geldguthaben, v4 § 4 Abs. 1); funktionen (innerhalb von abo, nur Wartungs-Abo) = Hinweis, dass Website-Analyse und Terminplaner mit dem Wartungs-Abo enden (Sie-Form wie die Vorlage).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -79,6 +79,9 @@ a{color:#111111;}
<!-- WENN abo -->
<tr><td class="px mut" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;"><!-- WENN wartung -->Nicht genutzte Arbeitszeit aus dem Stundenkontingent verfällt mit Vertragsende. <!-- ENDE wartung -->Ihre Daten, den Domain-AuthCode und die Inhalte Ihrer Postfächer stellen wir Ihnen auf Wunsch gemäß {{vertragsende_paragraph}} der Vertragsbedingungen bereit. Gebuchter E-Mail-Zusatzspeicher endet zusammen mit dem Abo.</td></tr>
<!-- WENN funktionen -->
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.</td></tr>
<!-- ENDE funktionen -->
<!-- ENDE abo -->
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Ihr WEBklar-Team</strong></td></tr>
</table>
@@ -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).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
@@ -51,7 +51,7 @@ a{color:#111111;}
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir setzen Ihren E-Mail-Zusatzspeicher auf die Menge vor der Bestellung zurück. Was Sie dafür gezahlt haben, erstatten wir vollständig innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel; Wertersatz verlangen wir nicht. Passen die gespeicherten E-Mails danach nicht mehr in den verbleibenden Speicher, nimmt das betroffene Postfach keine neuen E-Mails mehr an, bis wieder Platz ist.</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; war die Einrichtung auf Ihren Wunsch schon vor Ablauf der Widerrufsfrist abgeschlossen, verrechnen wir gegebenenfalls einen Wertersatz (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen Ihren Widerruf und melden uns umgehend bei Ihnen. Ist er wirksam, erstatten wir bereits gezahlte Beträge innerhalb von 14 Tagen über das ursprüngliche Zahlungsmittel und schalten die Funktion ab; hat die Einrichtung auf Ihren ausdrücklichen Wunsch schon vor Ablauf der Widerrufsfrist begonnen, verrechnen wir gegebenenfalls einen Wertersatz für die bis zu Ihrem Widerruf erbrachte Leistung (§ 357a Abs. 2 BGB). Sie erhalten dazu eine gesonderte Abrechnung.</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN pruefen -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie es weitergeht</div><div class="mut" style="color:#4a4843;">Wir prüfen, ob für diese Bestellung eine Widerrufsfrist läuft, und melden uns umgehend bei Ihnen.</div></td></tr></table></td></tr>
@@ -3,18 +3,23 @@
<head>
<meta charset="utf-8">
<!--
K15 · Bestellung Funktion (News-Pinnwand)
Betreff: Deine Bestellung: News-Pinnwand
K15 · Bestellung Funktion (je Funktion: News-Pinnwand, Terminplaner)
Betreff: Deine Bestellung: {{funktion}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{preis}}, {{zeit}}, {{rechnung_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): rechnung
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, 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).
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.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine Bestellung: News-Pinnwand</title>
<title>Deine Bestellung: {{funktion}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -23,7 +28,7 @@ a{color:#111111;}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Bestellung der News-Pinnwand ist bei uns eingegangen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine Bestellung {{funktion_gen}} ist bei uns eingegangen.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
@@ -34,14 +39,20 @@ a{color:#111111;}
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Bestellung eingegangen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Bestellung: News-Pinnwand</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten die News-Pinnwand jetzt für Dein Projekt ein.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong> – Einrichtung</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald die News-Pinnwand auf Deiner Website eingebaut ist, schalten wir sie im Kundenportal frei und Du bekommst eine weitere E-Mail. Danach kannst Du Beiträge im Portal schreiben.</div></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Bestellung: {{funktion}}</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>vielen Dank für Deine Bestellung und Deine Zahlung. Wir richten {{funktion_akk}} jetzt für Dein Projekt ein.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong> – Einrichtung</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Einmalig bezahlt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{preis}}</strong> <span class="mut" style="color:#6b6964;">({{preis_hinweis}})</span></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Einrichtung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeit}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Wie geht es weiter?</div><div class="mut" style="color:#4a4843;">Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei und Du bekommst eine weitere E-Mail. {{danach}}</div></td></tr></table></td></tr>
<!-- WENN anlagen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Deine Vertragsunterlagen</div><div class="mut" style="color:#4a4843;">Fassung {{fassung}}: <a href="{{lb_url}}" style="color:#111111;">Leistungsbeschreibung</a>, <a href="{{avv_url}}" style="color:#111111;">Auftragsverarbeitungsvertrag</a> und <a href="{{widerruf_url}}" style="color:#111111;">Widerrufsbelehrung mit Muster-Widerrufsformular</a>.<!-- WENN anhang --> Alle drei hängen als PDF an dieser E-Mail. Bitte bewahre sie auf.<!-- ENDE anhang --></div></td></tr></table></td></tr>
<!-- ENDE anlagen -->
<!-- WENN zustimmungen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Deine Zustimmungen</div><div class="mut" style="color:#4a4843;">{{zustimmungen_text}}</div></td></tr></table></td></tr>
<!-- ENDE zustimmungen -->
<!-- WENN rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{rechnung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnung ansehen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="https://project.webklar.com/dashboard.html#news" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
+12 -10
View File
@@ -3,16 +3,18 @@
<head>
<meta charset="utf-8">
<!--
K16 · Funktion bereit (News-Pinnwand)
Betreff: Deine News-Pinnwand ist bereit
K16 · Funktion bereit (je Funktion: News-Pinnwand, Terminplaner)
Betreff: {{funktion_dein}} ist bereit
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
Platzhalter: {{kunde_name}}, {{projekt}}, {{funktion}}, {{funktion_dein}}, {{funktion_nom}}, {{bereit}}, {{knopf}}, {{portal_url}}
funktion = Name der Funktion; funktion_dein/_nom = ihre Formen im Satz („Deine News-Pinnwand“, „die News-Pinnwand“), aus dem Register (mails.formen).
bereit = Satz, was der Kunde jetzt tun kann (mails.bereit). knopf = Beschriftung des Knopfs (mails.knopf). portal_url = Adresse der Funktion im Kundenportal.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Deine News-Pinnwand ist bereit</title>
<title>{{funktion_dein}} ist bereit</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -21,7 +23,7 @@ a{color:#111111;}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Deine News-Pinnwand ist freigeschaltet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{funktion_dein}} ist freigeschaltet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
@@ -31,11 +33,11 @@ a{color:#111111;}
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; News-Pinnwand</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine News-Pinnwand ist bereit</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die News-Pinnwand ist auf Deiner Website eingebaut und freigeschaltet. Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>News-Pinnwand</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Beitrag schreiben &rarr;</a></td></tr></table></td></tr>
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; {{funktion}}</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">{{funktion_dein}} ist bereit</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>{{funktion_nom}} ist auf Deiner Website eingebaut und freigeschaltet. {{bereit}}</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>{{funktion}}</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">{{knopf}} &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K22 · Terminplaner beendet
Betreff: Dein Terminplaner wurde beendet – {{projekt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{enddatum}}, {{loeschdatum}}, {{preis}}, {{portal_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): abo_ende, hosting, vor_einrichtung, daten
Genau einer der Gründe abo_ende (Wartungs-Abo beendet), hosting (Hosting-Abo reicht nicht) oder vor_einrichtung (Abo endete vor der Einrichtung, die Einrichtung wird voll erstattet) ist an; daten (Kasten „Was jetzt gilt“) gehört nur zu den ersten beiden.
enddatum = Tag des Abo-Endes; loeschdatum = Tag, ab dem wir die Termindaten löschen (30 Tage nach dem Abschalten); preis = Preis der Einrichtung, z. B. „149 €“.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Dein Terminplaner wurde beendet – {{projekt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Dein Terminplaner wurde beendet.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Beendet</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Terminplaner</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Dein Terminplaner wurde beendet</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br><!-- WENN abo_ende -->Dein Terminplaner wurde beendet, weil Dein Wartungs-Abo am {{enddatum}} beendet wurde. Der Terminplaner gehört zum Wartungs-Abo und endet mit ihm.<!-- ENDE abo_ende --><!-- WENN hosting -->Dein Terminplaner wurde beendet, weil Dein Abo ein Hosting-Abo ist. Der Terminplaner gehört nur zum Wartungs-Abo; ein Hosting-Abo reicht dafür nicht.<!-- ENDE hosting --><!-- WENN vor_einrichtung -->Dein Wartungs-Abo wurde am {{enddatum}} beendet, noch vor der Einrichtung Deines Terminplaners. Deshalb richten wir ihn nicht mehr ein und erstatten Dir die Einrichtung ({{preis}}) vollständig.<!-- ENDE vor_einrichtung --></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Terminplaner</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Beendet am</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{enddatum}}</td></tr></table></td></tr>
<!-- WENN daten -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Was jetzt gilt</div><div class="mut" style="color:#4a4843;">Bis {{loeschdatum}} kannst Du Deine Termine im Kundenportal ansehen und als CSV exportieren.</div><div class="mut" style="color:#4a4843;padding-top:8px;">Gäste können ihre Termine weiter absagen. Bestehende Termine bleiben eingetragen, und die Erinnerungen an Deine Gäste laufen bis zur Löschung weiter. <strong>Bitte informiere Deine Gäste selbst.</strong></div><div class="mut" style="color:#4a4843;padding-top:8px;">Ab {{loeschdatum}} löschen wir die Termindaten, 30 Tage nach dem Abschalten.</div><div class="mut" style="color:#4a4843;padding-top:8px;"><strong>Rückkehr:</strong> Läuft bis dahin wieder ein Wartungs-Abo, schalten wir den Terminplaner automatisch und ohne Gebühr wieder ein. Nach der Löschung braucht es einen neuen Fragebogen und die Einrichtung für {{preis}} noch einmal.</div></td></tr></table></td></tr>
<!-- ENDE daten -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
K23 · Terminplaner läuft wieder
Betreff: Dein Terminplaner läuft wieder – {{projekt}}
Absender: „WEBklar“ <Portal-Postfach>
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
Geht nur an Kunden, die K22 bekommen haben, wenn ihr Wartungs-Abo innerhalb der Löschfrist zurückkehrt und wir den Terminplaner automatisch und ohne Gebühr wieder einschalten.
Texte sind Entwurf für die Mail-Überarbeitung des Inhabers; verbindlich sind nur Platzhalter und Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Dein Terminplaner läuft wieder – {{projekt}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">Dein Terminplaner läuft wieder.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Wieder aktiv</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">&#10003;&nbsp; Terminplaner</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Dein Terminplaner läuft wieder</td></tr>
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>Dein Wartungs-Abo läuft wieder. Deshalb haben wir Deinen Terminplaner automatisch und ohne Gebühr wieder eingeschaltet. Deine Termine und Einstellungen sind unverändert da, und Deine Gäste können wieder buchen.</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Terminplaner</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal &rarr;</a></td></tr></table></td></tr>
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
</table>
</td></tr>
<tr><td class="px" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;"><a class="mut" href="https://project.webklar.com/dashboard.html" style="color:#4a4843;text-decoration:underline;">Kundenportal</a></td></tr>
<tr><td class="px mut" align="center" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">WEBklar GbR · Am Schwimmbad 10 · 67722 Winnweiler<br>Telefon <a class="mut" href="tel:+4917623726355" style="color:#6b6964;text-decoration:none;">0176 23726355</a> · <a class="mut" href="mailto:support@webklar.com" style="color:#6b6964;text-decoration:none;">support@webklar.com</a><br>Vertreten durch die Gesellschafter Kenso Grimm und Justin Klein</td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -7,7 +7,8 @@
Betreff: [Abo] WIDERRUF eingegangen: {{email}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
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
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -61,7 +62,7 @@ a{color:#111111;}
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Zusatzspeicher-Bestellung widerrufen: in Stripe die Menge zurück auf den Stand vor der Bestellung setzen, konsoleH anpassen und den Betrag innerhalb von 14 Tagen vollständig erstatten (kein Wertersatz, Hinweise zum Widerruf Nr. 4). Widerrufen:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE zusatzspeicher -->
<!-- WENN funktion -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion (z. B. News-Pinnwand, 30 € einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten (gegebenenfalls abzüglich Wertersatz, wenn der Kunde den Einbau vor Fristende ausdrücklich verlangt hat) und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Der Kunde hat „wir prüfen“ bekommen. Bezahlte Funktionen des Kunden:<br>{{ereignisse}}</div></td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zu tun</div><div class="mut" style="color:#4a4843;">Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig). Widerrufsfrist bis {{fristende}}, Eingang: <strong>{{rechtzeitig}}</strong>. Es wurde NICHTS automatisch geändert: kein Abo, kein Vertragsereignis. Bitte von Hand prüfen, die Zahlung in Stripe innerhalb von 14 Tagen erstatten, gegebenenfalls abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), sofern der Kunde den Beginn vor Fristende ausdrücklich verlangt hat, und die Funktion im Ticketsystem abschalten (Kunde → Reiter „Funktionen“). Beim Terminplaner bleibt das Wartungs-Abo unberührt. Der Kunde hat „wir prüfen“ bekommen. Bezahlte Bestellungen dieser Funktion (je Projekt mit eigenem Fristende):<br>{{ereignisse}}</div></td></tr></table></td></tr>
<!-- ENDE funktion -->
<!-- WENN keine_frist -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Achtung</div><div class="mut" style="color:#4a4843;">Nichts automatisch geändert – keine offene Frist gefunden: zu dieser Bestellung läuft keine Widerrufsfrist (mehr) oder es gibt keine Bestellung dieser Art. Bitte prüfen, bevor der Widerruf abgelehnt wird; der Kunde hat „wir prüfen“ bekommen.</div></td></tr></table></td></tr>
@@ -3,19 +3,25 @@
<head>
<meta charset="utf-8">
<!--
T16 · Funktion bestellt (News-Pinnwand)
Betreff: Neue Funktion bestellt: News-Pinnwand
T16 · Funktion bestellt (je Funktion)
Betreff: Neue Funktion bestellt: {{funktion}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{woid}}, {{sitekey}}, {{sitzung}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_fehlgeschlagen, ungeklaert
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, 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.
kein_abo = die Zahlung ist da und das Ticket angelegt, aber der Kunde hat kein laufendes Wartungs-Abo; die Freischaltung bleibt gesperrt, endet das Abo vor der Fertigstellung, ist voll zu erstatten (Mail „Erstattung fällig“, T19).
bereits_aktiv = die Zahlung ist da, aber die Funktion war schon in dem Zustand {{zustand}}; es wurde kein Ticket angelegt und nichts eingerichtet (Einbau nachholen oder erstatten); sitzung nennt die Stripe-Sitzung.
anleitung = Knopf „Im Ticketsystem öffnen“ zur Einbauanleitung des Projekts (anleitung_url); nur bei Funktionen mit Anleitung und angelegtem Ticket.
gesundheit = Warnzeile „Gesundheitsbereich prüfen“ mit gesundheit_text (Hinweise zur Branche des Betriebs).
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.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>Neue Funktion bestellt: News-Pinnwand</title>
<title>Neue Funktion bestellt: {{funktion}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@@ -24,7 +30,7 @@ a{color:#111111;}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde_name}}: News-Pinnwand, Projekt {{projekt}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde_name}}: {{funktion}}, Projekt {{projekt}}.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
@@ -35,7 +41,7 @@ a{color:#111111;}
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Funktionen</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Funktion bestellt: News-Pinnwand</td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Neue Funktion bestellt: {{funktion}}</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde_name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Ticket</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{woid}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Site-Key</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{sitekey}}</td></tr></table></td></tr>
<!-- WENN zahlung_fehlgeschlagen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zahlung fehlgeschlagen</div><div class="mut" style="color:#4a4843;">Die Zahlung ist nicht eingegangen (zum Beispiel SEPA-Lastschrift abgelehnt). Es wurde nichts eingerichtet und kein Ticket angelegt; die Bestellung steht im Portal wieder auf „nicht bestellt“.</div></td></tr></table></td></tr>
@@ -43,6 +49,21 @@ a{color:#111111;}
<!-- WENN ungeklaert -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bezahlt, aber nicht zuzuordnen</div><div class="mut" style="color:#4a4843;">Der Kunde hat bezahlt, die Zahlung passt aber zu keiner offenen Bestellung. Es wurde nichts eingerichtet und kein Ticket angelegt. Bitte von Hand prüfen: {{sitzung}}</div></td></tr></table></td></tr>
<!-- ENDE ungeklaert -->
<!-- WENN kein_abo -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Kein laufendes Wartungs-Abo</div><div class="mut" style="color:#4a4843;">Die Zahlung ist eingegangen und das Ticket {{woid}} ist angelegt, aber der Kunde hat kein laufendes Wartungs-Abo. Die Freischaltung bleibt gesperrt. Endet das Abo vor der Fertigstellung, wird die Einrichtung voll erstattet (Mail „Erstattung fällig“).</div></td></tr></table></td></tr>
<!-- ENDE kein_abo -->
<!-- WENN bereits_aktiv -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Bezahlt, aber schon vorhanden</div><div class="mut" style="color:#4a4843;">Zahlung eingegangen, aber die Funktion war schon {{zustand}}. Es wurde kein Ticket angelegt und nichts eingerichtet. Bitte prüfen: Einbau nachholen oder erstatten. Sitzung: {{sitzung}}</div></td></tr></table></td></tr>
<!-- ENDE bereits_aktiv -->
<!-- WENN gesundheit -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:16px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;">Gesundheitsbereich prüfen: {{gesundheit_text}}</div></td></tr></table></td></tr>
<!-- ENDE gesundheit -->
<!-- WENN anhang_fehlt -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Unterlagen von Hand nachsenden</div><div class="mut" style="color:#4a4843;">Die Vertragsunterlagen (Leistungsbeschreibung, Auftragsverarbeitungsvertrag und Widerrufsbelehrung mit Muster-Widerrufsformular, jeweils als PDF) sind nicht mit der Bestätigung beim Kunden angekommen. Bitte dem Kunden die Unterlagen in der bestellten Fassung per E-Mail nachsenden. Der Grund steht im Log des Portals.</div></td></tr></table></td></tr>
<!-- ENDE anhang_fehlt -->
<!-- WENN anleitung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{anleitung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Im Ticketsystem öffnen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE anleitung -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
@@ -0,0 +1,60 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T19 · Erstattung fällig
Betreff: [Abo] Erstattung fällig: {{funktion}}-Einrichtung
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: kenso@webklar.com, andrej@webklar.com
Platzhalter: {{kunde_name}}, {{email}}, {{projekt}}, {{funktion}}, {{betrag}}, {{sitzung}}, {{woid}}, {{rechnung_url}}
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): zahlung_nach_ende, ticket_offen, rechnung
Der Kunde hat die Einrichtung einer Funktion bezahlt (betrag, sitzung), sein Wartungs-Abo lief aber nicht mehr bzw. endete vor der Freischaltung: volle Erstattung von Hand in Stripe, keine automatische Rückzahlung.
zahlung_nach_ende = die Zahlung kam erst nach dem Abo-Ende an; es wurde kein Ticket angelegt (woid ist leer).
ticket_offen = das Ticket woid ist noch offen: mit Vermerk schließen (die Freischaltung beim Schließen schaltet dann nicht frei, weil die Funktion abgeschaltet ist).
rechnung = Knopf „Rechnung ansehen“ zur Stripe-Rechnung (rechnung_url).
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Abo] Erstattung fällig: {{funktion}}-Einrichtung</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{kunde_name}}: {{betrag}} für die {{funktion}}-Einrichtung erstatten.&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Kundenportal</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#ecebe6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#4a4843;">Erstattung</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Erstattung fällig: {{funktion}}-Einrichtung</td></tr>
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Kunde</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{kunde_name}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">E-Mail</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{email}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Betrag</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{betrag}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Sitzung</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{sitzung}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Volle Erstattung von Hand</div><div class="mut" style="color:#4a4843;">Der Kunde hat die Einrichtung bezahlt, sein Wartungs-Abo lief aber nicht mehr bzw. endete vor der Freischaltung. Die Einrichtung wird deshalb voll erstattet: bitte von Hand in Stripe zurückzahlen. Eine automatische Rückzahlung gibt es nicht.</div></td></tr></table></td></tr>
<!-- WENN zahlung_nach_ende -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Zahlung nach dem Abo-Ende</div><div class="mut" style="color:#4a4843;">Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt.</div></td></tr></table></td></tr>
<!-- ENDE zahlung_nach_ende -->
<!-- WENN ticket_offen -->
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#fdf0ef;border:1px solid #f1c9c5;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Ticket noch offen</div><div class="mut" style="color:#4a4843;">Das Ticket {{woid}} ist noch offen. Bitte das Ticket mit einem Vermerk schließen; die Freischaltung beim Schließen schaltet dann nicht frei, weil die Funktion abgeschaltet ist.</div></td></tr></table></td></tr>
<!-- ENDE ticket_offen -->
<!-- WENN rechnung -->
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{rechnung_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Rechnung ansehen &rarr;</a></td></tr></table></td></tr>
<!-- ENDE rechnung -->
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
@@ -0,0 +1,48 @@
<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<!--
T20 · Terminplaner Betriebsmeldung
Betreff: [Terminplaner] {{vorfall}}
Absender: „WEBklar Kundenportal“ <Portal-Postfach>
Empfänger: das Team (kenso@webklar.com, andrej@webklar.com)
Platzhalter: {{vorfall}}, {{details}}, {{zeit}}
vorfall ist die kurze Überschrift der Meldung; details ist Text (Zeilenumbrüche bleiben); zeit steht als TT.MM.JJJJ, HH:MM (Berliner Zeit). Die Vorlage hat keine optionalen Blöcke.
-->
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<meta name="supported-color-schemes" content="light dark">
<title>[Terminplaner] {{vorfall}}</title>
<!--[if mso]><style>table,td{font-family:Arial,Helvetica,sans-serif!important;}</style><![endif]-->
<style>
a{color:#111111;}
@media (max-width:620px){.w{width:100%!important;}.px{padding-left:24px!important;padding-right:24px!important;}.lbl{display:block!important;width:100%!important;padding-bottom:2px!important;border-bottom:0!important;}.val{display:block!important;width:100%!important;padding-top:0!important;}}
@media (prefers-color-scheme:dark){.bg{background:#161615!important;}.card{background:#1f1f1e!important;}.ink{color:#f2f1ee!important;}.mut{color:#a8a6a0!important;}.box{background:#2a2a28!important;border-color:#3a3a37!important;}.btn{background:#f2f1ee!important;}.btn a{color:#161615!important;}.rowline{border-color:#3a3a37!important;}}
</style>
</head>
<body style="margin:0;padding:0;background:#f1f0ec;">
<span style="display:none;max-height:0;overflow:hidden;opacity:0;mso-hide:all;font-size:1px;line-height:1px;color:#f1f0ec;">{{vorfall}} · {{zeit}}&#8199;&#847;&#8199;&#847;&#8199;&#847;</span>
<table role="presentation" class="bg" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f1f0ec;">
<tr><td align="center" style="padding:32px 12px;">
<table role="presentation" class="w" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;">
<tr><td class="px" style="padding:0 40px 20px 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0"><tr>
<td><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td valign="middle" style="padding-right:10px;"><img src="https://project.webklar.com/webklar-logo.png" width="40" height="40" alt="WEBklar" style="display:block;width:40px;height:40px;border:0;outline:none;text-decoration:none;border-radius:10px;"></td><td valign="middle" class="ink" style="font-family:Arial,Helvetica,sans-serif;font-size:26px;font-weight:bold;letter-spacing:-1px;color:#111111;">WEBklar</td></tr></table></td>
<td align="right" class="mut" style="font-family:Arial,Helvetica,sans-serif;font-size:12px;color:#6b6964;">Intern · Terminplaner</td>
</tr></table></td></tr>
<tr><td class="card" style="background:#ffffff;border-radius:16px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#fdebd3;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#8a4b00;">Betriebsmeldung</td></tr></table></td></tr>
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">{{vorfall}}</td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Zeit</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{zeit}}</td></tr></table></td></tr>
<tr><td class="px" style="padding:24px 40px 0 40px;"><table role="presentation" class="box" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:#f7f6f3;border:1px solid #e8e6e0;border-radius:12px;"><tr><td class="ink" style="padding:20px 22px;font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;color:#111111;"><div class="ink" style="font-weight:bold;color:#111111;padding-bottom:6px;">Details</div><div class="mut" style="color:#4a4843;">{{details}}</div></td></tr></table></td></tr>
<tr><td style="padding:0 0 44px 0;font-size:0;line-height:0;">&nbsp;</td></tr>
</table>
</td></tr>
<tr><td class="px mut" align="center" style="padding:24px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:12px;line-height:19px;color:#6b6964;">Automatische Benachrichtigung aus dem WEBklar Kundenportal · <a class="mut" href="https://ticket.webklar.com/customers" style="color:#4a4843;text-decoration:underline;">Ticketsystem</a></td></tr>
</table>
</td></tr>
</table>
</body>
</html>
+7 -2
View File
@@ -15,8 +15,13 @@ export const KUNDENANSICHT_ALLOWED = [
['PATCH', /^\/api\/news\/[^/]+\/?$/],
['DELETE', /^\/api\/news\/[^/]+\/?$/],
['POST', /^\/api\/funktionen\/analyse\/heatmap-link\/?$/],
// Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02, Abschnitt 9)
['POST', /^\/api\/termine\/.+$/],
// Terminplaner: Einstellungen und Termine kosten nichts und sind keine Erklaerungen im Namen des Kunden (Spec 2026-10-02,
// Abschnitt 9). Genau diese sechs Pfade, jeder einzeln (keine Wildcard, Spec 2026-10-03): alles andere unter /api/termine,
// auch der Fragebogen (PUT /fragebogen, Bestellung), ist in der Kundenansicht gesperrt, ohne dass es einen Eintrag braucht.
['POST', /^\/api\/termine\/einstellungen\/vorlage\/?$/],
['POST', /^\/api\/termine\/ressourcen\/[^/]+\/feed\/?$/],
['POST', /^\/api\/termine\/buchungen\/?$/],
['POST', /^\/api\/termine\/buchungen\/[^/]+\/aktion\/?$/],
['PATCH', /^\/api\/termine\/einstellungen\/?$/],
['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/],
['POST', /^\/api\/auth\/kundenansicht\/ende\/?$/],
+69 -17
View File
@@ -18,7 +18,8 @@ import {
reconcilePendingSwitch,
switchToWartung,
} from '../services/planSwitch.js'
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, paidFeatureOrders } from '../services/siteFeatures.js'
import { handleFeatureCheckoutEvent, isFeatureCheckoutEvent, listCustomerFeatureDocs, 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,
@@ -67,6 +68,8 @@ function minTermEndOf(sub) {
// Alte Dokumente ohne productKey sind Wartungs-Abos (Spec 6)
const productKeyOf = (sub) => sub?.productKey || 'wartung'
const isWartung = (sub) => Boolean(sub) && productKeyOf(sub) === 'wartung'
// Funktionen, die zum Wartungs-Abo gehoeren und mit ihm enden (portalFeatures.featureKey): Website-Analyse und Terminplaner
const ABO_FUNKTIONEN = ['analyse', 'termine']
// {{produkt}} in den Mails: Katalogname ohne "WEBklar " (F33), ohne gefundenes Abo neutral "Abo"
const productLabelOf = (sub) => (sub ? productLabel(productKeyOf(sub)) : 'Abo')
// {{vertragsende_paragraph}}: Paragraph zum Vertragsende in der Fassung des Kunden (v3 § 9, v4 § 11); ohne
@@ -407,6 +410,24 @@ router.post('/switch', requireSession, async (req, res) => {
// Stripe-Webhook
// ---------------------------------------------------------------------------
/**
* Hat der Kunde dieses Abos die Website-Analyse oder den Terminplaner (Funktionsdokument in portalFeatures mit featureKey analyse
* oder termine, Zustand ausser off; ein Dokument ohne Zustand gilt wie off, wie in der Ansicht)? Nur dann gilt der Hinweis in K02 und
* K03 (Block funktionen), dass beide mit dem Wartungs-Abo enden (Entscheidung 2026-10-05). Der Kunde ist der des Abos (sub.customerId,
* wie in customerNameOf); gelesen wird wie sonst auch ueber listCustomerFeatureDocs. Wirft nie: ein Lesefehler zaehlt als ja (der Kunde
* wird eher informiert als nicht), eine Logzeile, die Mail geht in jedem Fall raus.
*/
async function hatAboFunktionen(sub) {
try {
if (!sub?.customerId) throw new Error('Abo ohne Kunden-ID')
const docs = await listCustomerFeatureDocs(sub.customerId)
return docs.some((doc) => ABO_FUNKTIONEN.includes(doc.featureKey) && (doc.state || 'off') !== 'off')
} catch (err) {
console.error(`[abo] Funktionen des Kunden nicht lesbar, der Hinweis auf das Ende von Analyse und Terminplaner bleibt in der Kündigungsbestätigung (Abo ${sub?.$id || '-'}): ${err.message}`)
return true
}
}
/** Anrede in Kundenmails: Name aus den Kundendaten des Portals, sonst der übergebene Ersatz. */
async function customerNameOf(sub, fallback = '') {
const customer = sub?.customerId
@@ -474,7 +495,9 @@ async function sendPortalCancellationConfirmation(sub, createdUnix) {
vertragsende: fmtDate(endsAt),
vertragsende_paragraph: endParagraphOf(sub),
},
blocks: { wartung: isWartung(sub) },
// Hinweis auf nicht genutzte Arbeitszeit nur beim Wartungs-Abo; auf das Ende von Website-Analyse und Terminplaner nur beim
// Wartungs-Abo eines Kunden, der eine von beiden hat (hatAboFunktionen)
blocks: { wartung: isWartung(sub), funktionen: isWartung(sub) && (await hatAboFunktionen(sub)) },
})
await updateDocument(config.collections.subscriptions, sub.$id, {
cancelRequestedAt: receivedAt.toISOString(),
@@ -704,13 +727,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) {
@@ -783,6 +808,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})` : ''}`
}
@@ -942,6 +968,9 @@ router.post('/public/cancel', async (req, res) => {
abo: !storage,
// Hinweis auf nicht genutzte Arbeitszeit nur im Wartungs-Abo (Block innerhalb von abo)
wartung: !storage && isWartung(sub),
// Website-Analyse und Terminplaner enden mit dem Wartungs-Abo (Block innerhalb von abo, nie bei der Speicher-Kündigung),
// nur fuer einen Kunden, der eine von beiden hat (hatAboFunktionen)
funktionen: !storage && isWartung(sub) && (await hatAboFunktionen(sub)),
automatisch_gekuendigt: !storage && Boolean(effectiveAt) && d.kind !== 'geschaeftsaufgabe',
sonderkuendigung: !storage && Boolean(effectiveAt) && d.kind === 'geschaeftsaufgabe',
ausserordentlich: !storage && !effectiveAt && d.kind === 'ausserordentlich',
@@ -1056,6 +1085,15 @@ async function withdrawOrder(sub, d, receivedAt) {
} catch (err) {
console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
}
// Ebenso der Terminplaner (gehoert zum Wartungs-Abo): sofort abschalten und K22 (bzw. T19 bei bezahlter Einrichtung) melden, ohne
// Abo-Pruefung - der Spiegel steht bis zur Handarbeit des Teams noch auf Wartung. Der Widerruf scheitert nie daran; was hier
// misslingt, holt der Tagesabgleich nach (aboLage kennt den widerrufenen Wechsel).
try {
const { endeTermineFuerKunde } = await import('../services/termine/aboAbgleich.js')
await endeTermineFuerKunde(sub.customerId, { now: receivedAt })
} catch (err) {
console.error(`[abo] Terminplaner nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
}
}
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
return {
@@ -1080,18 +1118,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) : []
const ends = orders.filter((o) => o.orderedAt).map((o) => withdrawalDeadline(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.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 }
}
@@ -1114,12 +1163,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)
@@ -1140,7 +1191,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,
@@ -1163,7 +1214,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',
+174 -12
View File
@@ -1,17 +1,28 @@
import { Router } from 'express'
import { config } from '../../config.js'
import { getDocument } from '../../services/appwriteAdmin.js'
import { aboMemo } from '../../services/aboStatus.js'
import { validateTextform } from '../../services/analyse.js'
import { getDocument, updateDocument } from '../../services/appwriteAdmin.js'
import { featureConfig } from '../../services/featureRegistry.js'
import { withLocks } from '../../services/keyedLock.js'
import { featureLegalAttachments, featureLegalPaths } from '../../services/legalDocs.js'
import {
FEATURE_KEYS,
FEATURE_LABELS,
adminActivate,
assertOwnProject,
customerOf,
disable,
freischaltenNachAbnahme,
getFeatureDoc,
listCustomerFeatureDocs,
listCustomerFeatures,
ticketNachlegen,
} from '../../services/siteFeatures.js'
import { driftWarnung, einbauanleitung, ticketStand } from '../../services/termine/einbauanleitung.js'
import { freischaltHindernis, meldeFreigeschaltet } from '../../services/termine/freischaltung.js'
import { ladeStand } from '../../services/termine/kontext.js'
import { termineTextformNoetig } from '../../services/termine/schalter.js'
/**
* Funktionen (News-Pinnwand, Terminplaner) je Kundenprojekt im Ticketsystem (Kunden-Reiter "Funktionen").
@@ -35,60 +46,204 @@ 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
}
}
// 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 K16_FEHLT = 'K16 mit Unterlagen NICHT gesendet – von Hand nachsenden'
/**
* Vermerkt in der Notiz des Funktionsdokuments, dass die K16 mit den Unterlagen nicht rausging (Staff-Aktivierung in Textform, die
* Unterlagen sind der dauerhafte Datentraeger). Best effort unter der Sperre der Verwaltung: scheitert das Schreiben, steht der Fehler im
* Log, die Antwort traegt die Warnung trotzdem.
*/
async function notiereK16Fehlt(projectId, doc) {
try {
await withLocks([`feature-admin:${projectId}`], async () => {
const frisch = await getDocument(config.collections.portalFeatures, doc.$id)
await updateDocument(config.collections.portalFeatures, doc.$id, {
adminNote: composeAdminNote(frisch.adminNote, { by: 'System', action: K16_FEHLT }),
})
})
} catch (err) {
console.error('[funktionen-admin] Notiz zur nicht gesendeten K16 nicht geschrieben:', err.message)
}
}
const ticketExists = async (ticketId) => (await readTicket(ticketId)) !== null
/**
* Zustimmung des Kunden in Textform fuer die Aktivierung des Terminplaners durch das Team (F13a). null, wenn sie entfaellt: andere
* Funktion, Modus off oder pilot, Pilotadresse (termineTextformNoetig) oder ein Dokument, das nichts mehr aktiviert und dem nichts
* fehlt (schon aktiv, oder durch Kauf freigeschaltet, dann liegt die Zustimmung des Checkouts vor). Sonst { date, channel }; fehlt
* oder ist eine Angabe ungueltig (Datum vor der Fassung oder in der Zukunft, kein Weg), 400 - ohne dass etwas angelegt wird.
*/
async function textformFuerAktivierung({ body, customerId, key, doc }) {
if (key !== 'termine') return null
if (doc?.state === 'active' || doc?.unlockedByPurchase === true) return null
const kunde = await customerOf(customerId)
if (!termineTextformNoetig(kunde?.email)) return null
// nur Texte: validateTextform macht aus jedem Wert einen Text (aus ['chat'] wuerde 'chat')
const text = (wert) => (typeof wert === 'string' ? wert : '')
return validateTextform({ date: text(body?.textformDate), channel: text(body?.textformChannel) }, { minDate: config.termine.avvVersion })
}
/** Leistungsbeschreibung und AVV als PDF fuer die K16 der Textform-Aktivierung; nicht lesbar => 503, bevor etwas geaendert wird. */
async function unterlagenAlsAnhang() {
try {
return await featureLegalAttachments('termine', { ohne: ['widerruf'] })
} catch (err) {
console.error('[funktionen-admin] Rechtsunterlagen (PDF) fuer die Freischaltung nicht lesbar:', err.message)
throw Object.assign(new Error('Die Rechtsunterlagen (PDF) sind nicht lesbar. Der Terminplaner wurde nicht freigeschaltet. Bitte später erneut versuchen.'), { status: 503 })
}
}
// 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, includeArchived: true })
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
}
}
return projects
}
/**
* Angaben des Terminplaners fuer die Karte im WOMS (F13a): textformNoetig = verlangt die Aktivierung dieses Kunden die Zustimmung in
* Textform (Modus on und Adresse nicht in der Pilotliste), avvVersion = Fassung der Unterlagen, auf die sich die Zustimmung bezieht,
* links = absolute, versionierte Adressen von Leistungsbeschreibung und AVV fuer die Chat-Vorlage (nie Adressen ohne Fassung).
*/
async function termineFeld(customerId) {
const kunde = await customerOf(customerId)
const { lb, avv } = featureLegalPaths('termine')
return {
textformNoetig: termineTextformNoetig(kunde?.email),
avvVersion: config.termine.avvVersion,
links: { lb: `${config.publicBaseUrl}${lb}`, avv: `${config.publicBaseUrl}${avv}` },
}
}
funktionenAdminRouter.get('/:customerId', async (req, res) => {
try {
res.json({ projects: await adminView(req.params.customerId) })
const projects = await adminView(req.params.customerId)
res.json({ projects, termine: await termineFeld(req.params.customerId) })
} catch (err) {
sendError(res, err)
}
})
// Aktivieren (ohne Zahlung) und Abschalten je Funktion: /news wie bisher, /termine neu; jeder andere Schluessel -> 404
/**
* 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)
}
})
// Je Funktion (/news, /termine; jeder andere Schluessel -> 404): activate (ohne Zahlung), 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 freigeschaltet = null // Terminplaner mit Zustimmung in Textform: K16 mit den Unterlagen nach der Sperre
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 })
const textform = await textformFuerAktivierung({ body: req.body, customerId, key, doc })
const attachments = textform ? await unterlagenAlsAnhang() : []
const aktiviert = await adminActivate(customerId, projectId, key, { by, adminNote, textform })
if (textform) freigeschaltet = { doc: aktiviert, attachments }
} 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 })
@@ -96,7 +251,14 @@ funktionenAdminRouter.post('/:customerId/:key', async (req, res) => {
await disable(doc.$id, { by, adminNote })
}
})
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId) })
// Der Kunde bekommt die Unterlagen auf einem dauerhaften Datentraeger (Anhang); ein Versandfehler wird dort geloggt, nie geworfen.
// Ging K16 nicht raus (Versand, Kunde ohne Adresse), steht es in der Notiz und die Antwort traegt warnung: true - das Team sendet von Hand nach
let warnung = false
if (freigeschaltet && !(await meldeFreigeschaltet(freigeschaltet.doc, { attachments: freigeschaltet.attachments }))) {
warnung = true
await notiereK16Fehlt(projectId, freigeschaltet.doc)
}
res.json({ projects: (await adminView(customerId)).filter((p) => p.projectId === projectId), ...(warnung ? { warnung } : {}) })
} catch (err) {
sendError(res, err)
}
+179 -25
View File
@@ -6,15 +6,20 @@ import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { withLocks } from '../services/keyedLock.js'
import { createFeatureCheckout, retrieveCheckoutSession } from '../services/stripeBilling.js'
import {
DISABLED_MESSAGE,
assertOwnProject,
backfillInvoiceLinks,
getFeatureDoc,
hakenTexte,
listCustomerFeatures,
markOrdered,
runActivation,
unterlagenAdressen,
} from '../services/siteFeatures.js'
import { nachweisTexte, speichereZustimmung, zustimmungFuerSitzung, zustimmungsfassungen } from '../services/featureConsents.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import { aboMemo } from '../services/aboStatus.js'
import { featureConfig, featureHaken, featureKeys, featureSperre, oeffentlicheAnsicht } from '../services/featureRegistry.js'
import { aboMeldung, bestellFehler, istFortsetzen, pruefeAbo, pruefeZustimmung } from '../services/featureOrder.js'
import {
ELIGIBILITY_MESSAGES,
@@ -32,9 +37,10 @@ const DAY_MS = 24 * 60 * 60 * 1000
// Eigene Fehler (httpError) tragen einen Text fuer den Kunden. Fehler von Appwrite (err.type) oder
// unerwartete Fehler werden nur geloggt; der Kunde sieht einen verstaendlichen Hinweis statt der Rohmeldung.
// err.body (Checkout-Vorpruefungen: code, consent, offen, grund) geht mit in die Antwort, error bleibt der Text.
function fail(res, err, fallback) {
const own = ((err.status >= 400 && err.status < 500) || err.status === 503) && !err.type
if (own) return res.status(err.status).json({ error: err.message })
if (own) return res.status(err.status).json({ ...err.body, error: err.message })
console.error('[funktionen]', err.status || '', err.type || '', err.message)
return res.status(500).json({ error: fallback })
}
@@ -85,6 +91,57 @@ function analyseProjectId(value) {
return value
}
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 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) {
if (!termineFuerKunde(email)) return { verfuegbar: false }
const register = featureConfig('termine')
let bestellbar = null
let grund = null
try {
const abo = await aboMemo().bestellbar(customerId)
bestellbar = abo?.ok === true
grund = bestellbar || !ABO_GRUENDE.includes(abo?.grund) ? null : abo.grund
} catch (err) {
console.error(`[funktionen] Abo für den Terminplaner nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
}
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
}
// Zustaende der Funktionen je Projekt; geschlossene Einrichtungs-Tickets dieses Kunden werden vorher freigeschaltet
router.get('/', requireSession, async (req, res) => {
try {
@@ -104,10 +161,18 @@ router.get('/', requireSession, async (req, res) => {
)
res.json({
projects,
// Preise und Bestelltexte je Funktion aus dem Register (nur die oeffentliche Ansicht, auch wenn der Terminplaner
// dem Kunden noch nicht freigegeben ist); setupEstimate und consentText bleiben als News-Alias fuer aeltere Clients
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
termine: { verfuegbar: termineFuerKunde(req.session.email) },
// Terminplaner: Schalter TERMINE_ENABLED (off | pilot | on); nur wenn verfuegbar zeigt das Portal die Kachel, dann auch
// mit dem Abo des Kunden (bestellbar, grund) und dem Preis des Registers
termine: await termineAngebot(req.session.email, customerId),
})
} catch (err) {
fail(res, err, 'Die Funktionen konnten gerade nicht geladen werden. Bitte lade die Seite gleich noch einmal.')
@@ -118,9 +183,10 @@ router.get('/', requireSession, async (req, res) => {
* Offene Bestellung: die gespeicherte Stripe-Sitzung entscheidet. open -> dieselbe Zahlungsseite nochmal
* (z. B. nach "Zurueck" im Checkout), expired -> null (neue Sitzung), complete -> 409 (Zahlung laeuft
* oder ist da, der Webhook richtet ein). Ist Stripe nicht erreichbar, gilt wie frueher: frisch (< 24 h) -> 409.
* Die Meldung bei 409 steht im Register der Funktion (orderedConflictMessage).
*/
async function openOrderSession(doc) {
const conflict = () => Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
async function openOrderSession(doc, feature) {
const conflict = () => Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
if (!doc.stripeSessionId) return null
let session
try {
@@ -135,46 +201,134 @@ async function openOrderSession(doc) {
throw conflict()
}
router.post('/news/checkout', requireSession, async (req, res) => {
const ZUSTIMMUNG_NOETIG = 'Bitte stimme der Erklärung zu, um zu bestellen.'
/** "Name <E-Mail>" der handelnden Person (oder was davon da ist); das Speichern kuerzt auf die Feldgroesse. */
const kontoLabel = (person) => (person?.name && person?.email ? `${person.name} <${person.email}>` : person?.name || person?.email || '')
/**
* Zustimmungsnachweis zu einer Stripe-Sitzung speichern: die Serverfassungen und -texte der Funktion (nie Texte aus dem
* Client), dazu die Belehrung aus dem Haken und die Zeit des Klicks. Jeder Fehler hier ist ein Serverfehler: die Route
* antwortet mit dem allgemeinen Text (auch bei Pruefungen des Nachweises wie "Texte zu lang"), der Rohtext steht nur im Log.
*/
async function speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt }) {
const belehrung = (await haken.belehrung?.()) ?? null
const texte = nachweisTexte(feature, { belehrung })
try {
await speichereZustimmung({
customerId,
projectId,
featureKey: feature.key,
stripeSessionId: sessionId,
fassungen: zustimmungsfassungen(feature),
texte,
ip: req.ip,
userAgent: req.get('user-agent'),
konto: kontoLabel(actingPerson(req.session)),
at: klickAt,
})
} catch (err) {
throw Object.assign(new Error(`Zustimmungsnachweis nicht gespeichert: ${err?.message}`), { status: 500, cause: err })
}
}
// Checkout je Funktion (Register config.features); /news/checkout ist derselbe Pfad mit key = news.
// Vorpruefungen in der Reihenfolge der Spezifikation, jede Stufe ohne Seiteneffekt (keine Sperre, keine Stripe-Sitzung,
// kein Dokument, kein Nachweis): 1 Kundenansicht, 2 Funktionsschluessel, 3 Zustimmung, 4 Projekt, 5 Schalter,
// 5b Unterlagen, 6 Bestellpruefung, 7 Wartungs-Abo.
router.post('/:key/checkout', requireSession, async (req, res) => {
// Zeit des Klicks: Eingang der Anfrage, vor jeder Sperre und jedem Stripe-Aufruf. Sie ist der eine Zeitpunkt von Nachweis,
// Stripe-Metadaten (consentAt) und Dokument und damit der Anker der Widerrufsfrist (UTC mit Z)
const klickAt = new Date().toISOString()
try {
// 1 Kundenansicht
if (req.session.viewAs) {
return res.status(403).json({ error: 'In der Kundenansicht kann nicht bestellt werden.' })
}
const { projectId, consent } = req.body || {}
if (consent !== true) {
return res.status(400).json({ error: 'Bitte stimme der Erklärung zu, um zu bestellen.' })
}
// 2 Funktionsschluessel (analyse hat einen eigenen Weg und steht nicht im Register)
const key = req.params.key
const feature = featureConfig(key)
if (!feature) return res.status(404).json({ error: 'Unbekannte Funktion' })
const body = req.body || {}
// 3 Zustimmung: alle Pflicht-Haken und die aktuelle Fassung; bei "Zahlung fortsetzen" entfaellt sie (Stufe 4 prueft dafuer ordered)
const fortsetzen = istFortsetzen(body)
if (!fortsetzen) pruefeZustimmung(feature, body)
// 4 Projekt
const { projectId } = body
if (typeof projectId !== 'string' || !projectId) {
return res.status(400).json({ error: 'Bitte wähle ein Projekt.' })
}
const customerId = getSessionCustomerId(req)
await assertOwnProject(customerId, projectId, { forNewOrder: true })
// Pruefen, Sitzung anlegen und eintragen als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine
// zwei Sitzungen erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
if (fortsetzen && (await getFeatureDoc(customerId, projectId, key))?.state !== 'ordered') {
throw bestellFehler(400, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
}
// 5 Schalter (Terminplaner: TERMINE_ENABLED und Pilotliste)
const sperre = featureSperre(key, req.session.email)
if (sperre) throw bestellFehler(409, sperre, 'nicht_verfuegbar')
// 5b Vertragsunterlagen abrufbar (vor jeder Stripe-Sitzung)
const haken = featureHaken(key)
if (haken.unterlagenBereit && !(await haken.unterlagenBereit())) {
throw bestellFehler(
503,
'Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.',
'unterlagen_fehlen'
)
}
// 6 Bestellpruefung: offene Punkte des Entwurfs. Ohne abo: den Abo-Punkt (h) wendet der Checkout nicht an, das tut Stufe 7
if (haken.bestellpruefung) {
const offen = await haken.bestellpruefung({ customerId, projectId, feature })
if (!Array.isArray(offen)) throw new TypeError('bestellpruefung muss eine Liste liefern')
if (offen.length > 0) {
throw bestellFehler(409, 'Die Bestellung ist noch nicht vollständig. Bitte erledige zuerst die offenen Punkte.', 'bestellung_offen', { offen })
}
}
// 7 Wartungs-Abo (nur Funktionen mit requiresWartungsAbo); die einzige Quelle von kein_abo | gekuendigt | nicht_aktiv
await pruefeAbo(feature, customerId)
// Zustand, Sitzung, Nachweis und Eintrag als Ganzes je (Kunde, Projekt): zwei schnelle Klicks duerfen keine zwei Sitzungen
// erzeugen, sonst ueberschreibt die zweite die ID der ersten und deren Zahlung findet kein Dokument
const session = await withLocks([`feature-order:${customerId}:${projectId}`], async () => {
// Zustand vor dem Stripe-Aufruf pruefen: bezahlt/aktiv/abgeschaltet -> 409, eine offene Bestellung
// richtet sich nach ihrer gespeicherten Stripe-Sitzung
const existing = await getFeatureDoc(customerId, projectId, 'news')
// a Zustand vor jedem Stripe-Aufruf: bezahlt/aktiv und abgeschaltet sind 409 (Meldungen aus dem Register)
const existing = await getFeatureDoc(customerId, projectId, key)
if (existing?.state === 'setup' || existing?.state === 'active') {
throw Object.assign(new Error('Die News-Pinnwand ist für dieses Projekt schon bestellt.'), { status: 409 })
throw Object.assign(new Error(feature.orderedConflictMessage), { status: 409 })
}
if (existing?.state === 'disabled') throw Object.assign(new Error(DISABLED_MESSAGE), { status: 409 })
if (existing?.state === 'ordered') {
const open = await openOrderSession(existing)
if (open) return open
if (existing?.state === 'disabled') throw Object.assign(new Error(feature.disabledMessage), { status: 409 })
const nachweis = (sessionId) => speichereNachweis({ feature, haken, customerId, projectId, sessionId, req, klickAt })
// b eine offene Bestellung richtet sich nach ihrer gespeicherten Stripe-Sitzung: offen -> dieselbe Zahlungsseite
const open = existing?.state === 'ordered' ? await openOrderSession(existing, feature) : null
if (open) {
// d Liegt zur Sitzung noch kein Nachweis, wird er vor der URL nachgetragen. "Zahlung fortsetzen" (ohne Zustimmung)
// gibt die URL nur her, wenn er schon liegt, sonst stimmt der Kunde im Formular neu zu
if (!(await zustimmungFuerSitzung(open.id))) {
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
await nachweis(open.id)
}
return open
}
// keine offene Sitzung (abgelaufen, nie angelegt): "Zahlung fortsetzen" legt nie eine neue an, der Kunde stimmt im Formular neu zu
if (fortsetzen) throw bestellFehler(409, ZUSTIMMUNG_NOETIG, 'zustimmung_fehlt')
// c neue Sitzung: die Stripe-Sitzung entsteht zuerst, weil der Nachweis ihre ID traegt
const customer = await getDocument(config.collections.customers, customerId)
const now = new Date().toISOString()
const created = await createFeatureCheckout(customer, { projectId, key: 'news', consentAt: now })
const created = await createFeatureCheckout(customer, { projectId, key, consentAt: klickAt })
// d Nachweis vor allem anderen: scheitert er, gibt es keine URL und kein ordered (die unerreichbare Stripe-Sitzung laeuft
// nach 24 h ab, ein Storno ist nicht noetig)
await nachweis(created.id)
// e erst nach dem Nachweis wird die Bestellung eingetragen
await markOrdered({
customerId,
projectId,
key: 'news',
key,
sessionId: created.id,
consent: { text: config.funktionen.consentText, at: now, ip: req.ip, userAgent: req.get('user-agent') },
consent: { text: hakenTexte(feature), at: klickAt },
})
return created
})
// f die URL geht erst jetzt an den Browser
res.json({ url: session.url })
} catch (err) {
fail(res, err, 'Die Bestellung konnte gerade nicht gestartet werden. Bitte versuch es später noch einmal oder schreib uns im Chat.')
+3
View File
@@ -8,6 +8,7 @@ import { takeoverAdminRouter } from '../takeoverCredits.js'
import customersRoutes from './customers.js'
import filesRoutes from './files.js'
import previewRoutes from './preview.js'
import termineTeamRoutes from './termine.js'
// Team-Eingang fuer das Ticketsystem: alle Mitarbeiter, Appwrite-JWT (Spec 2026-10-01 Admin-Panel ins Ticketsystem)
const router = Router()
@@ -22,5 +23,7 @@ router.use('/takeover-credits', takeoverAdminRouter)
router.use('/offers', offersAdminRouter)
router.use('/storage-tasks', storageTasksAdminRouter)
router.use('/funktionen', funktionenAdminRouter)
// Terminplaner-Betrieb (Status fuer Budget, Warteschlange, Anmeldepause, Tagesjob)
router.use('/termine', termineTeamRoutes)
export default router
+24
View File
@@ -0,0 +1,24 @@
import { Router } from 'express'
import { statusDaten } from '../../services/termine/betrieb.js'
/**
* Terminplaner fuers Team (Ticketsystem). Eingehaengt in routes/team/index.js unter /api/team/termine, dort hinter requireStaffJwt:
* ohne Mitarbeiter-JWT kommt niemand hierher, ein Kunden-Cookie des Portals zaehlt nicht.
*/
const router = Router()
/**
* Betriebsstand: Mailbudget, Warteschlange, Anmeldepause, letzter Tagesjob, Zahl der aktiven Betriebe (nur Zaehlungen, nie Gast-Daten).
* Lesefehler ergeben null-Werte und ein Log (statusDaten), nie einen 500; nie zwischenspeichern.
*/
router.get('/status', async (_req, res, next) => {
try {
const daten = await statusDaten(new Date())
res.set('Cache-Control', 'no-store')
res.json(daten)
} catch (err) {
next(err)
}
})
export default router
+366 -72
View File
@@ -1,16 +1,22 @@
import { Router } from 'express'
import { config } from '../config.js'
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
import { aboMemo, laeuft } from '../services/aboStatus.js'
import { getDocument } from '../services/appwriteAdmin.js'
import { analysePrivacyData } from '../services/analysePrivacy.js'
import { featureConfig } from '../services/featureRegistry.js'
import { aboMeldung } from '../services/featureOrder.js'
import { isKundenansicht, logIfKundenansicht } from '../services/kundenansicht.js'
import { assertOwnProject, getFeatureDoc } from '../services/siteFeatures.js'
import { assertOwnProject, ensureFeatureDoc, getFeatureDoc } from '../services/siteFeatures.js'
import { createWriteLimiter } from '../services/writeLimiter.js'
import { verschiebeZeiten } from '../services/termine/ablauf.js'
import { aktion, entsperre, manuell, sperre } from '../services/termine/ablaufBetrieb.js'
import { hole, imZeitraum, zaehleAnfragen, zaehleKuenftige } from '../services/termine/buchungen.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 { kontextPerFeature, speichereEinstellung } from '../services/termine/kontext.js'
import { loeschTag, loeschzeit } from '../services/termine/frist.js'
import { BRANCHEN, SCHRITTE, bestellpruefung, bestellungAnsicht, leerFragebogen, normalisiereFragebogen } from '../services/termine/fragebogen.js'
import { kontextFuerPortal, kontextPerFeature, liveHost, speichereEinstellung, speichereFragebogen, veraltet } from '../services/termine/kontext.js'
import { zustellFehler } from '../services/termine/outbox.js'
import { termineFuerKunde } from '../services/termine/schalter.js'
import { belegungAus, freieZeiten } from '../services/termine/slots.js'
@@ -23,6 +29,12 @@ import { ausUtc, datumKurz, plusTage, uhrzeit, zuUtc } from '../services/termine
*
* Jede Route: Session, projectId (Query oder Body), eigenes Projekt, Pilot-Schalter des Kunden, Funktion `active` oder
* `disabled`. Abgeschaltete Funktionen (`disabled`) dürfen nur gelesen werden (GET, auch der Export); jedes Schreiben ist 409.
* Ausnahme Entwurf (TP2): GET /einstellungen liest in jedem Zustand (off, ordered, setup, active, disabled) und auch ohne
* Funktionsdokument (virtueller Kontext `off`, nichts wird angelegt), siehe withTermine({ entwurf: true }). PATCH /einstellungen
* schreibt den Entwurf nach eigenen Regeln (off und active ja; ordered und setup nur in der Kundenansicht; disabled nie; in off
* nur mit laufendem Wartungs-Abo), speichert ihn ohne Pflichtangaben für Gäste und meldet sie in `fehlt`. POST /einstellungen/vorlage
* legt den Entwurf aus einer Branche an (oder ersetzt ihn, solange nichts bestellt ist) und schreibt dabei den Fragebogen mit;
* PUT /fragebogen speichert nur den Fragebogen (nie die Version der Einstellung) und nie in der Kundenansicht.
* Schreib-Routen zählen gegen 120 Änderungen pro Kunde und Stunde (im Speicher). Die Geschäftsregeln stehen in
* services/termine/*; hier wird geprüft, übersetzt und protokolliert (Kundenansicht: termin_einstellungen, termin_aktion,
* termin_manuell, nur nach Erfolg und ohne Gastdaten im Detail).
@@ -57,10 +69,18 @@ const MELDUNG = {
nichtVerfuegbar: 'Der Terminplaner ist noch nicht verfügbar.',
nichtAktiv: 'Der Terminplaner ist für diese Website nicht aktiv.',
abgeschaltet: 'Der Terminplaner ist für diese Website abgeschaltet. Du kannst Termine nur noch ansehen und exportieren.',
abgeschaltetAboEnde: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren.',
abgeschaltetVorEinrichtung: 'Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo endete, bevor der Terminplaner eingerichtet war.',
gesperrt: 'Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.',
keineEinstellung: 'Bitte richte den Terminplaner zuerst ein.',
zuViele: 'Zu viele Änderungen in kurzer Zeit. Bitte versuch es in einer Stunde wieder.',
vorlage: 'Bitte wähle eine Vorlage aus der Liste.',
branche: 'Bitte wähle eine Branche aus der Liste.',
gesundheitsbereich: 'Für Betriebe im Gesundheitsbereich ist der Terminplaner nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.',
betrieb: 'Die Betriebsangaben sind ungültig.',
vorlageVorhanden: 'Es gibt schon Einstellungen. Eine Vorlage kannst du nur beim ersten Öffnen wählen.',
nichtErsetzbar: 'Die Vorlage kannst du nur wechseln, solange du den Terminplaner noch nicht bestellt hast.',
kundenansichtBestaetigen: 'In der Kundenansicht kann nichts bestätigt werden.',
version: 'Die Version der Einstellungen fehlt. Bitte lade die Seite neu.',
art: 'Bitte wähle „Termin eintragen“ oder „Zeit sperren“.',
zeitraum: 'Bitte gib von und bis als Datum an (JJJJ-MM-TT).',
@@ -88,12 +108,16 @@ const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.
/** Fehler des Kunden (4xx) mit Text in `fehler`; `fail` macht daraus die Antwort. */
const stop = (status, text, code, feld = '_') => Object.assign(new Error(text), { status, fehler: { [feld]: text }, ...(code ? { code } : {}) })
/** Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, alles andere 500 ohne Rohtext. */
/**
* Antwort für einen Fehler: Fehler des Kunden (4xx mit `fehler`, oder EINSTELLUNG_VERALTET) wie sie sind, ebenso das 503 der
* Abo-Prüfung des Entwurfs (`abo_pruefung`: "später noch einmal"); alles andere 500 ohne Rohtext.
*/
function fail(res, err) {
const status = Number.isInteger(err?.status) ? err.status : 500
const texte = istObjekt(err?.fehler) ? Object.fromEntries(Object.entries(err.fehler).filter(([, t]) => typeof t === 'string')) : {}
const eigen = Object.keys(texte).length > 0 || err?.code === 'EINSTELLUNG_VERALTET'
if (!eigen || status < 400 || status >= 500) {
const durchgelassen = (status >= 400 && status < 500) || (status === 503 && err?.code === 'abo_pruefung')
if (!eigen || !durchgelassen) {
console.error(`[termine] ${err?.name ?? 'Fehler'}: ${String(err?.message ?? '').slice(0, 200)}`)
return res.status(500).json({ error: 'Interner Serverfehler' })
}
@@ -125,16 +149,78 @@ export function resetTermineRoutesForTests() {
// ---------------------------------------------------------------- Projekt und Funktion
/**
* Was die Abschaltung dem Kunden sagt: Grund (disabledReason, ohne Grund = Altbestand => null), Zeitpunkt (disabledAt) und der Berliner
* Tag, ab dem die Daten gelöscht werden dürfen (loeschungAm, 30 Tage nach disabledAt, termine/frist.js; null ohne lesbaren Zeitpunkt).
* Diese Route gehört nur zum Terminplaner, deshalb gibt es das Löschdatum hier immer (R105: bei News nie, siehe siteFeatures.featureView).
*/
const abgeschaltetAnsicht = (feature) => ({
grund: feature.disabledReason || null,
seit: feature.disabledAt || null,
loeschungAm: loeschTag(feature.disabledAt),
})
/** Antwort auf einen Schreibversuch bei abgeschalteter Funktion (409, Code `abgeschaltet`): je Grund ein Satz, mit Löschdatum, wenn es eines gibt. */
function abgeschaltetMeldung(feature) {
const grund = feature.disabledReason
const satz = grund === 'abo_ende' ? MELDUNG.abgeschaltetAboEnde : grund === 'abo_ende_vor_einrichtung' ? MELDUNG.abgeschaltetVorEinrichtung : MELDUNG.abgeschaltet
const ende = loeschzeit(feature.disabledAt)
return ende ? `${satz} Ab dem ${datumKurz(ende)} löschen wir die Daten.` : satz
}
const projectIdOf = (req) => {
const raw = req.query.projectId ?? req.body?.projectId
return typeof raw === 'string' ? raw : ''
}
/**
* Das Abo konnte nicht gelesen werden: nur Art und Status ins Log, nie den Text (Appwrite-Texte könnten Namen oder IDs enthalten).
* Der Aufrufer entscheidet, was dann gilt (Vorschau: "nicht geprüft", Entwurf schreiben: 503).
*/
const logAboFehler = (err) => console.error(`[termine] Abo für den Entwurf nicht lesbar: ${err?.name ?? 'Fehler'} ${err?.status ?? ''}`.trim())
/**
* Schreibregeln des Entwurfs (PATCH /einstellungen), nach dem Zustand des Funktionsdokuments (`feature`, null = kein Dokument = off):
* active ja (die Einstellung läuft; der Abo-Abgleich beendet sie, nicht das Speichern)
* off ja, aber nur mit laufendem Wartungs-Abo (laeuft, nicht bestellbar: eine Kündigung oder offene Zahlung sperrt nur die
* Bestellung, Gate (h), nicht das Speichern des Entwurfs). Ohne Abo 409 `kein_abo`, bei Lesefehler 503 `abo_pruefung`.
* ordered, setup nur in der Kundenansicht (Mitarbeiter); der Kunde selbst bekommt 409 `gesperrt`: Änderungen laufen über den Chat
* disabled 409 `abgeschaltet` (Text von E11), auch in der Kundenansicht
* Ein unbekannter Zustand gilt als nicht aktiv (409). Liest nichts außer dem Abo und schreibt nichts.
*/
async function pruefeEntwurfSchreiben(req, customerId, feature) {
const state = feature ? feature.state : 'off'
if (state === 'active') return
if (state === 'disabled') throw stop(409, abgeschaltetMeldung(feature), 'abgeschaltet')
if (state === 'ordered' || state === 'setup') {
if (isKundenansicht(req.session)) return
throw stop(409, MELDUNG.gesperrt, 'gesperrt')
}
if (state !== 'off') throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv')
const register = featureConfig('termine')
let laufend
try {
laufend = await laeuft(customerId)
} catch (err) {
logAboFehler(err)
throw stop(503, aboMeldung(register, 'unbekannt'), 'abo_pruefung')
}
if (!laufend) throw stop(409, aboMeldung(register, 'kein_abo'), 'kein_abo')
}
/**
* Eigenes Projekt, Pilot-Schalter, Funktion active oder disabled => req.ctx (kontext.js). schreiben: nur active.
* Reihenfolge der Prüfungen: Projekt (403), Schalter (409), Funktion (409), abgeschaltet beim Schreiben (409).
*
* entwurf (TP2): Zugriff auf den Entwurf in jedem Zustand des Funktionsdokuments (off, ordered, setup, active, disabled) über
* kontextFuerPortal. Fehlt das Dokument (oder gehört es einem anderen Kunden), ist der Kontext ein virtueller `off` ohne
* Einstellung, Version 0 und leeren Fragebogen; dafür wird nichts gelesen und hier nie etwas angelegt (das macht der Handler
* mit ensureFeatureDoc, erst nach der Eingabeprüfung). Projekt (403) und Schalter (409) gelten wie sonst. Ohne `schreiben`
* (GET) liest jeder Zustand; mit `schreiben` gelten die Regeln von pruefeEntwurfSchreiben. Öffentlich wird der Entwurf nie:
* die öffentliche Schnittstelle liest nur Funktionen im Zustand active (kontextPerSiteKey).
*/
const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next) => {
const withTermine = ({ schreiben = false, entwurf = false } = {}) => route(async (req, _res, next) => {
const projectId = projectIdOf(req)
if (!projectId) throw stop(400, MELDUNG.projekt, undefined, 'projectId')
const customerId = getSessionCustomerId(req)
@@ -147,13 +233,27 @@ const withTermine = ({ schreiben = false } = {}) => route(async (req, _res, next
req.projectId = projectId
if (!termineFuerKunde(req.session?.email)) throw stop(409, MELDUNG.nichtVerfuegbar, 'nicht_verfuegbar')
const feature = await getFeatureDoc(customerId, projectId, 'termine')
if (entwurf) {
if (schreiben) await pruefeEntwurfSchreiben(req, customerId, feature)
req.ctx = feature ? await kontextFuerPortal(feature) : virtuellerKontext(customerId, projectId, req.project)
return next()
}
const ctx = feature && ['active', 'disabled'].includes(feature.state) ? await kontextPerFeature(feature.$id) : null
if (!ctx || ctx.feature.customerId !== customerId) throw stop(409, MELDUNG.nichtAktiv, 'nicht_aktiv')
if (schreiben && ctx.feature.state !== 'active') throw stop(409, MELDUNG.abgeschaltet, 'abgeschaltet')
if (schreiben && ctx.feature.state !== 'active') throw stop(409, abgeschaltetMeldung(ctx.feature), 'abgeschaltet')
req.ctx = ctx
return next()
})
/** Kontext ohne Funktionsdokument: Zustand `off`, nichts gespeichert (ohne $id: das Dokument gibt es nicht und wird hier nie angelegt). */
const virtuellerKontext = (customerId, projectId, project) => ({
feature: { customerId, projectId, featureKey: 'termine', state: 'off', enabled: false },
project,
einstellung: null,
version: 0,
fragebogen: leerFragebogen(),
})
/** Wer ändert? Für updatedBy der Einstellung (Kundenansicht: der Mitarbeiter, sonst der Kunde). */
const von = (req) => (isKundenansicht(req.session) ? `team:${req.session.appwriteUserId}` : `kunde:${getSessionCustomerId(req)}`)
@@ -179,38 +279,61 @@ function feedLinks(ctx) {
return links
}
const esc = (text) => String(text).replace(/[&<>"]/g, (z) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 [
`<div data-webklar-termine="${esc(ctx.feature.siteKey)}" data-api="${esc(config.publicBaseUrl)}" data-modus="inline">`,
` <p>${ausweich}</p>`,
'</div>',
`<script src="${esc(config.publicBaseUrl)}/embed/termine.js" defer></script>`,
].join('\n')
}
router.use((_req, res, next) => {
res.set('Cache-Control', 'no-store')
next()
})
router.get('/einstellungen', requireSession, withTermine(), route(async (req, res) => {
/** Das Abo für die Bestell-Vorschau: { ok, grund? } oder null, wenn es nicht lesbar war (dann gilt der Abo-Punkt "nicht geprüft"). Nie ein Fehler nach außen. */
async function aboFuerVorschau(customerId) {
try {
return await aboMemo().bestellbar(customerId)
} catch (err) {
logAboFehler(err)
return null
}
}
/**
* 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), aboHinweis (der Text dazu, wenn das
* Abo nicht bestellbar oder nicht lesbar ist, sonst null), offen (Punkte der
* 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) => {
const { ctx } = req
const e = ctx.einstellung
const state = ctx.feature.state
const laeuft = state === 'active' || state === 'disabled'
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: ctx.feature.state,
state,
abgeschaltet: state === 'disabled' ? abgeschaltetAnsicht(ctx.feature) : null,
aktivSeit: ctx.feature.activatedAt || null,
einstellung: e ? fuerKunde(e) : null,
version: ctx.version,
vorlagen: vorlagenListe(),
feeds: e && ctx.feature.state === 'active' ? feedLinks(ctx) : {},
branchen: BRANCHEN.map(({ key, name, gesundheit }) => ({ key, name, gesundheit })),
fragebogen: ctx.fragebogen,
kundenansicht: isKundenansicht(req.session),
...(e ? {} : { betriebVorschlag: await betriebVorschlag(customerId, req.project) }),
abo,
// der Text zum Abo (B2): einzige Quelle aboMeldung wie GET /api/funktionen (R111). In off ohne bestellbares Abo, auch wenn es nicht lesbar war
// oder sich zwischen den beiden Abfragen des Browsers geaendert hat; sonst null. Der Browser zeigt ihn an, er hat keine Kopie.
aboHinweis: state === 'off' && abo?.ok !== true ? aboMeldung(register, abo?.grund ?? 'unbekannt') : 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: { snippet: einbauSnippet(ctx) },
anfragenOffen: await zaehleAnfragen(ctx.feature.$id),
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
})
}))
@@ -227,48 +350,188 @@ async function betriebAusKonto(customerId) {
return { name: daten.company, adresse: daten.address, email: daten.email, telefon: typeof kunde?.phone === 'string' ? kunde.phone : '' }
}
router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
const key = req.body?.vorlage
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage')
if (req.ctx.einstellung) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden')
/** Adresse der Live-Website des Projekts (liveHost: nur aus liveDomain, nie die Vorschau-Adresse <subdomain>.project.webklar.com), sonst ''. */
function liveWebsite(project) {
const host = liveHost(project)
return host ? `https://${host}` : ''
}
const betrieb = await betriebAusKonto(getSessionCustomerId(req))
/** Vorschlag für die Betriebsangaben des Fragebogens, solange es noch keine Einstellung gibt: Konto (betriebAusKonto) und Live-Website. */
async function betriebVorschlag(customerId, project) {
const { name, adresse, email, telefon } = await betriebAusKonto(customerId)
return { name, adresse, email, telefon, websiteUrl: liveWebsite(project) }
}
// Betriebsfelder, die der Kunde beim Anlegen der Vorlage mitschicken darf (Anschrift und Telefon u. a. aus dem Konto vorbelegt)
const BETRIEB_FELDER = ['name', 'adresse', 'telefon', 'email', 'websiteUrl']
/**
* Die Betriebsfelder aus dem Rumpf der Vorlage-Route: nur die fünf bekannten, nur nichtleere Texte (ein leeres Feld lässt den
* Wert aus dem Konto stehen). Kein Objekt oder ein Feld, das kein Text ist => 400. Prüft nur die Form; Länge, Adresse und
* Mail-Format prüft die Einstellung selbst (ausVorlage).
*/
function betriebFelder(eingabe) {
if (eingabe === undefined || eingabe === null) return {}
if (!istObjekt(eingabe)) throw stop(400, MELDUNG.betrieb, undefined, 'betrieb')
const felder = {}
for (const feld of BETRIEB_FELDER) {
const wert = eingabe[feld]
if (wert === undefined || wert === null) continue
if (typeof wert !== 'string') throw stop(400, MELDUNG.betrieb, undefined, `betrieb.${feld}`)
if (wert.trim() !== '') felder[feld] = wert.trim()
}
return felder
}
/**
* Entwurf aus einer Vorlage anlegen (TP2: aus einer Branche, auch als Ersatz für den bestehenden Entwurf).
* { projectId, branche } Branche laut BRANCHEN; unbekannt => 400; Gesundheitsbereich => 400 `gesundheitsbereich` (harter
* Ausschluss, nichts wird angelegt, auch kein Funktionsdokument); die Branche steht danach im Fragebogen
* { projectId, vorlage } wie in TP1: Vorlage nach Schlüssel, der Fragebogen bleibt unberührt
* betrieb { name, adresse, telefon, email, websiteUrl }: überschreibt nur mit nichtleeren Werten das Konto
* ersetzen: true + version eine bestehende Einstellung ersetzen: nur im Zustand `off` und nur mit der gelesenen Version (sonst 409
* `EINSTELLUNG_VERALTET`); neue IDs und Kalender-Token, Fragebogen neu (Branche, keine bestätigten
* Schritte, Preise und Bundesland ungeprüft), die Einbauwünsche bleiben. Einstellung und Fragebogen
* werden in einem Vorgang unter der Sperre geschrieben (speichereEinstellung, Option fragebogen).
* Ohne `ersetzen` gilt wie in TP1: gibt es schon Einstellungen, ist es 409 `einstellung_vorhanden`.
*/
router.post('/einstellungen/vorlage', requireSession, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
const body = istObjekt(req.body) ? req.body : {}
let key
let brancheKey = null
if (body.branche !== undefined && body.branche !== null) {
const branche = typeof body.branche === 'string' ? BRANCHEN.find((b) => b.key === body.branche) : undefined
if (!branche) throw stop(400, MELDUNG.branche, undefined, 'branche')
if (branche.gesundheit) throw stop(400, MELDUNG.gesundheitsbereich, 'gesundheitsbereich', 'branche')
key = branche.vorlage
brancheKey = branche.key
} else {
key = body.vorlage
if (typeof key !== 'string' || !Object.hasOwn(VORLAGEN, key)) throw stop(400, MELDUNG.vorlage, undefined, 'vorlage')
}
const eigene = betriebFelder(body.betrieb)
const ersetzen = body.ersetzen === true
if (req.ctx.einstellung && !ersetzen) throw stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden')
if (ersetzen) {
if (!Number.isInteger(body.version) || body.version < 0) throw stop(400, MELDUNG.version, undefined, 'version')
if (req.ctx.feature.state !== 'off') throw stop(409, MELDUNG.nichtErsetzbar, 'nicht_ersetzbar')
}
const customerId = getSessionCustomerId(req)
const betrieb = { ...(await betriebAusKonto(customerId)), ...eigene }
const now = uhr()
const baue = () => {
try {
return ausVorlage(key, betrieb, now)
} catch (err) {
throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } })
}
}
// Ohne Funktionsdokument legt erst dieser Handler es an, nach der Eingabeprüfung (eine abgelehnte Vorlage hinterlässt nichts)
let feature = req.ctx.feature
if (!feature.$id) {
baue()
feature = await ensureFeatureDoc(customerId, req.projectId, 'termine')
}
// Branche oder Ersetzen: der Fragebogen beginnt neu, nur die Einbauwünsche bleiben (aus dem Stand unter der Sperre)
const fragebogen = brancheKey !== null || ersetzen
? (bisher) => ({ ...leerFragebogen(), branche: brancheKey ?? key, einbau: bisher.einbau })
: undefined
let gespeichert
try {
// version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen
gespeichert = await speichereEinstellung(req.ctx.feature, () => {
try {
return ausVorlage(key, betrieb, now)
} catch (err) {
throw Object.assign(new Error(err.message), { status: 400, fehler: err.fehler ?? { _: err.message } })
}
}, { version: 0, by: von(req), now })
// ohne ersetzen version 0: nur wenn noch nichts gespeichert ist, auch bei zwei gleichzeitigen Anfragen
gespeichert = await speichereEinstellung(feature, baue, { version: ersetzen ? body.version : 0, by: von(req), now, fragebogen })
} catch (err) {
throw err?.code === 'EINSTELLUNG_VERALTET' ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err
throw err?.code === 'EINSTELLUNG_VERALTET' && !ersetzen ? stop(409, MELDUNG.vorlageVorhanden, 'einstellung_vorhanden') : err
}
await logIfKundenansicht(req, 'termin_einstellungen', `Vorlage ${key}`)
res.status(201).json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
await logIfKundenansicht(req, 'termin_einstellungen', ersetzen ? `Vorlage ${key} ersetzt` : `Vorlage ${key}`)
res.status(201).json({
einstellung: fuerKunde(gespeichert.einstellung),
version: gespeichert.version,
fehlt: fehlendePflichtangaben(gespeichert.einstellung),
fragebogen: gespeichert.fragebogen ?? req.ctx.fragebogen,
})
}))
router.patch('/einstellungen', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
/** Schritte bestätigt nur der Kunde selbst: jede Sitzung der Kundenansicht (viewAs) bekommt 403, vor jeder anderen Prüfung. */
const nichtInKundenansicht = route(async (req, _res, next) => {
if (req.session?.viewAs) throw stop(403, MELDUNG.kundenansichtBestaetigen)
return next()
})
/**
* Fragebogen speichern (Branche, bestätigte Schritte, Einbauwünsche; PUT ersetzt den ganzen Fragebogen). Nie in der Kundenansicht:
* Schritte bestätigt nur der Kunde selbst (die Erlaubnisliste des Wächters kennt den Pfad nicht, hier gilt es noch einmal).
* Veralteter Tab (die gespeicherte Branche ist eine andere): 409 `EINSTELLUNG_VERALTET`; bestaetigt wird mit dem gespeicherten Stand vereinigt.
* Zustandsregeln wie beim Entwurf (off mit laufendem Wartungs-Abo und active ja, ordered, setup und disabled 409). Schreibt nur
* fragebogen, updatedAt und updatedBy: die Version der Einstellung bleibt, ein offener Einstellungs-Editor kann weiter speichern.
* Ohne Einstellung 409 `keine_einstellung`. Antwort { fragebogen, offen }; `offen` wie im GET nur in `off`. Kein Protokolleintrag.
*/
router.put('/fragebogen', requireSession, nichtInKundenansicht, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
const geprueft = normalisiereFragebogen(req.body?.fragebogen)
if (geprueft.fehler) throw Object.assign(new Error(Object.values(geprueft.fehler)[0]), { status: 400, fehler: geprueft.fehler })
const { einstellung, feature } = mitEinstellung(req)
let gespeichert
try {
// Unter der Sperre gegen den gespeicherten Stand gelesen (ein zweiter Tab kann dazwischen gespeichert haben): die Branche muss die
// gespeicherte sein, sonst ist dieser Tab veraltet (409 EINSTELLUNG_VERALTET; die Branche wechselt nur POST /einstellungen/vorlage,
// und mit ihr Vorlage, Preise und Bestätigungen). Bestätigungen gehen nie verloren: bestaetigt ist die Vereinigung aus dem
// gespeicherten und dem gesendeten Stand (zurückgesetzt werden sie nur durch eine neue Vorlage).
gespeichert = await speichereFragebogen(
feature,
(bisher) => {
if (bisher.branche && bisher.branche !== geprueft.fragebogen.branche) throw veraltet()
const bestaetigt = SCHRITTE.filter((schritt) => bisher.bestaetigt?.includes(schritt) || geprueft.fragebogen.bestaetigt.includes(schritt))
return { ...geprueft.fragebogen, bestaetigt }
},
{ by: von(req), now: uhr() },
)
} catch (err) {
throw err?.code === 'keine_einstellung' ? stop(409, MELDUNG.keineEinstellung, 'keine_einstellung') : err
}
const abo = feature.state === 'off' ? await aboFuerVorschau(getSessionCustomerId(req)) : null
const offen = feature.state === 'off' ? bestellpruefung({ einstellung, fragebogen: gespeichert.fragebogen, feature: featureConfig('termine'), abo }) : null
res.json({ fragebogen: gespeichert.fragebogen, offen })
}))
/**
* Einstellungen speichern (Version wie beim Lesen, optimistisch gesperrt). Der Entwurf (alles außer active) darf unvollständig
* gespeichert werden: es entfallen nur die Pflichtangaben für Gäste (Telefon, Anschrift), alles andere prüft er wie active; die
* Antwort nennt sie in `fehlt`. In active sind sie Pflicht (400). Ohne Funktionsdokument legt erst dieser Handler es an, nach der
* Eingabeprüfung (ein abgelehnter Entwurf hinterlässt nichts).
*/
router.patch('/einstellungen', requireSession, withTermine({ schreiben: true, entwurf: true }), limitWrites, route(async (req, res) => {
const { version, einstellung: eingabe } = req.body ?? {}
if (!Number.isInteger(version) || version < 0) throw stop(400, MELDUNG.version, undefined, 'version')
const now = uhr()
const feature = req.ctx.feature
// Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40)
const gespeichert = await speichereEinstellung(feature, async (bisher) => {
const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null
const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, now })
const entwurf = req.ctx.feature.state !== 'active'
const pruefe = (bisher, kuenftigeTermine) => {
const ergebnis = normalisiereEinstellung(eingabe, { bisher, kuenftigeTermine, entwurf, now })
if (ergebnis.fehler) {
throw Object.assign(new Error(Object.values(ergebnis.fehler)[0]), { status: 400, fehler: ergebnis.fehler })
}
return ergebnis.einstellung
}
let feature = req.ctx.feature
if (!feature.$id) {
pruefe(null, null)
feature = await ensureFeatureDoc(getSessionCustomerId(req), req.projectId, 'termine')
}
// Entfernen-Prüfung (künftige Termine) und Schreiben laufen unter derselben Sperre (kontext.js, Ruling R40)
const gespeichert = await speichereEinstellung(feature, async (bisher) => {
const kuenftigeTermine = bisher ? await zaehleKuenftige(feature.$id, now) : null
return pruefe(bisher, kuenftigeTermine)
}, { version, by: von(req), now })
await logIfKundenansicht(req, 'termin_einstellungen', 'Einstellungen gespeichert')
res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version })
res.json({ einstellung: fuerKunde(gespeichert.einstellung), version: gespeichert.version, fehlt: fehlendePflichtangaben(gespeichert.einstellung) })
}))
router.post('/ressourcen/:id/feed', requireSession, withTermine({ schreiben: true }), limitWrites, route(async (req, res) => {
@@ -447,34 +710,65 @@ function csvZelle(wert) {
return /[;"\r\n]/.test(zelle) ? `"${zelle.replace(/"/g, '""')}"` : zelle
}
/** Eine Datenzeile der CSV-Datei (ohne Zeilenende). */
function csvZeile(b) {
const start = new Date(b.startsAt)
return [
buchungsNummer(b.$id),
STATUS_TEXT[b.status] ?? b.status,
datumKurz(start),
uhrzeit(start),
b.leistungName,
b.ressourceName,
b.gastName,
b.gastEmail,
b.gastTelefon,
b.gastNachricht,
].map(csvZelle).join(';')
}
/** Schreibt einen Teil der Antwort; ist der Puffer voll, wartet es auf 'drain' (oder das Ende der Verbindung), damit ein langsamer Browser den Speicher nicht füllt. */
function schreibe(res, text) {
if (text === '' || res.destroyed || res.write(text)) return Promise.resolve()
return new Promise((resolve) => {
const weiter = () => {
res.off('drain', weiter)
res.off('close', weiter)
resolve()
}
res.once('drain', weiter)
res.once('close', weiter)
})
}
/**
* CSV-Export, seitenweise gestreamt: je Seite höchstens EXPORT_SEITE Zeilen (nach Beginn, Cursor = letzte Zeile der
* ungefilterten Seite), Sperren werden je Seite herausgefiltert. Die erste Seite wird gelesen, bevor ein Header geht: ein Fehler
* dort ist ein normales JSON-500. Danach bricht ein Fehler die Verbindung ab (res.destroy), damit der Browser einen abgebrochenen
* Download statt einer still gekürzten Datei zeigt. Löscht der Tagesjob zwischen zwei Seiten genau die Buchung, die als Cursor
* dient (selten), lehnt Appwrite den Cursor ab: der Export bricht ab und wird neu geladen.
*/
router.get('/export.csv', requireSession, withTermine(), route(async (req, res) => {
const ctx = req.ctx
// alle Buchungen der Funktion (die Aufbewahrungsfristen halten die Menge klein); keine Sperren
const liste = (await imZeitraum(ctx.feature.$id, new Date(0), new Date('2100-01-01T00:00:00.000Z')))
.filter((b) => b.quelle !== 'sperre')
.sort((a, b) => new Date(a.startsAt) - new Date(b.startsAt))
const zeilen = [CSV_KOPF, ...liste.map((b) => {
const start = new Date(b.startsAt)
return [
buchungsNummer(b.$id),
STATUS_TEXT[b.status] ?? b.status,
datumKurz(start),
uhrzeit(start),
b.leistungName,
b.ressourceName,
b.gastName,
b.gastEmail,
b.gastTelefon,
b.gastNachricht,
]
})]
const csv = `\uFEFF${zeilen.map((zeile) => zeile.map(csvZelle).join(';')).join('\r\n')}\r\n`
const featureId = req.ctx.feature.$id
let seite = await exportSeite(featureId)
res.set({
'Content-Type': 'text/csv; charset=utf-8',
'Content-Disposition': 'attachment; filename="termine.csv"',
'X-Content-Type-Options': 'nosniff',
})
res.send(csv)
try {
await schreibe(res, `\uFEFF${CSV_KOPF.map(csvZelle).join(';')}\r\n`)
for (;;) {
await schreibe(res, seite.filter((b) => b.quelle !== 'sperre').map((b) => `${csvZeile(b)}\r\n`).join(''))
if (seite.length < EXPORT_SEITE || res.destroyed) break
seite = await exportSeite(featureId, { nach: seite.at(-1).$id })
}
res.end()
} catch (err) {
// nach dem ersten Byte geht kein JSON mehr; ins Log nur Art und Status, nie den Text (er könnte Gastdaten enthalten)
console.error(`[termine] Export abgebrochen: ${err?.name ?? 'Fehler'} ${err?.status ?? ''} ${err?.type ?? ''}`.trim())
res.destroy(err)
}
}))
export default router
+104
View File
@@ -0,0 +1,104 @@
import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js'
import { listContractEvents } from './contractEvents.js'
// Abo-Baustein fuer Funktionen, die ein laufendes Wartungs-Abo voraussetzen (Terminplaner, Abo-Abgleich) und fuer die
// Analyse. Fail-closed: ein Lesefehler wirft, er wird nie zu false bzw. ok:false. Wer die Fehler abfaengt, entscheidet
// selbst, was dann gilt (Bestellung ablehnen, Kunde im Listenlauf ueberspringen, nie abschalten).
//
// Alle Fragen (laeuft, bestellbar, aboLage und die Memo-Varianten) bauen auf EINEM privaten Lader auf, lage(): so
// urteilen Bestell-Gate, Freischaltung, Hinweis "kein Abo" und Abgleich nie verschieden.
//
// Zwei Regeln fuer "Wartungs-Abo":
// altbestand: true (Vorgabe, Terminplaner) grosszuegig: Produktschluessel `productKey || 'wartung'` wie routes/abo.js
// productKeyOf (Altbestand ohne productKey gilt als Wartung) UND die Wechsel-Regel unten.
// altbestand: false (Analyse) die Regel von analyse.js, unveraendert: `productKey === 'wartung'` (ein Spiegel ohne
// productKey zaehlt nicht), keine Ereignisabfrage.
// stripeBilling.js schreibt productKey immer; nur Altbestand koennte es nicht haben.
//
// Wechsel-Regel: Sagt der Spiegel "Wartung laeuft" und hat das juengste wechsel-Ereignis dieses Abos den Status
// widerrufen, laeuft das Wartungs-Abo nicht (art 'wechsel_widerrufen'). Der Widerruf eines Wechsels (Hosting ->
// Wartung) setzt den Spiegel nicht zurueck, das tut das Team von Hand (Kommentar zu withdrawOrder in routes/abo.js).
// Ohne die Regel schaltete der Abgleich einen per Sofortpfad beendeten Terminplaner am naechsten Tag wieder ein, und ein
// Kunde mit widerrufenem Wechsel duerfte bestellen und freischalten, der Abgleich schaltete am Folgetag wieder ab.
const laeuftImSpiegel = (abo, altbestand) =>
Boolean(abo) && (altbestand ? abo.productKey || 'wartung' : abo.productKey) === 'wartung' && ACTIVE_STATUSES.has(abo.status)
// Das juengste wechsel-Ereignis dieses Abos (listContractEvents liefert die neuesten zuerst) ist widerrufen.
async function wechselWiderrufen(customerId, abo) {
const juengstes = (await listContractEvents(customerId)).find((e) => e.type === 'wechsel' && e.subscriptionId === abo.$id)
return juengstes?.status === 'widerrufen'
}
const endeVon = (abo) => abo?.endedAt || abo?.canceledAt || null
/**
* Der gelesene Stand: { laeuft, art, endedAt, abo }. art 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet'.
* Ereignisse werden nur gelesen, wenn der Spiegel "Wartung laeuft" sagt und nur bei altbestand: true.
*/
async function lage(customerId, { altbestand }) {
const abo = await getCurrentSubscription(customerId)
if (laeuftImSpiegel(abo, altbestand)) {
if (altbestand && (await wechselWiderrufen(customerId, abo))) {
return { laeuft: false, art: 'wechsel_widerrufen', endedAt: endeVon(abo), abo }
}
return { laeuft: true, art: 'laeuft', endedAt: null, abo }
}
const art = abo?.productKey === 'hosting' && ACTIVE_STATUSES.has(abo.status) ? 'hosting' : 'beendet'
return { laeuft: false, art, endedAt: endeVon(abo), abo }
}
// Die Gruende gelten in dieser Reihenfolge fuer alle Bloecke: kein laufendes Wartungs-Abo (inkl. Wechsel-Regel) =>
// kein_abo; Kuendigung vorgemerkt (cancelAtPeriodEnd oder cancelAt, auch bei trialing und past_due) => gekuendigt;
// Status nicht active (trialing, past_due) => nicht_aktiv.
function bestellbarAus({ laeuft: laeuftJetzt, abo }) {
if (!laeuftJetzt) return { ok: false, grund: 'kein_abo' }
if (abo.cancelAtPeriodEnd || abo.cancelAt) return { ok: false, grund: 'gekuendigt' }
if (abo.status !== 'active') return { ok: false, grund: 'nicht_aktiv' }
return { ok: true }
}
// Nach aussen ohne das gelesene Abo-Dokument, bei jedem Aufruf ein neues Objekt.
const lageAus = ({ laeuft: laeuftJetzt, art, endedAt }) => ({ laeuft: laeuftJetzt, art, endedAt })
/**
* Hat der Kunde ein laufendes Wartungs-Abo (vorgemerkte Kuendigung zaehlt noch als laufend)? Wirft bei jedem Lesefehler.
* altbestand (Vorgabe true): grosszuegige Regel mit Wechsel-Regel; false: die Regel der Analyse (siehe Kopf).
*/
export async function laeuft(customerId, { altbestand = true } = {}) {
return (await lage(customerId, { altbestand })).laeuft
}
/** Darf der Kunde jetzt bestellen? { ok: true } oder { ok: false, grund: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }. Wirft bei Lesefehler. */
export async function bestellbar(customerId) {
return bestellbarAus(await lage(customerId, { altbestand: true }))
}
/**
* Lage des Abos: { laeuft, art: 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet', endedAt: string | null }.
* endedAt (endedAt || canceledAt des Abos, sonst null) nur, wenn das Wartungs-Abo nicht laeuft. Grosszuegige Regel
* inkl. Wechsel-Regel. Wirft bei jedem Lesefehler.
*/
export async function aboLage(customerId) {
return lageAus(await lage(customerId, { altbestand: true }))
}
/**
* Zwischenspeicher fuer einen Lauf (eine Anfrage, ein Listenlauf): { laeuft, bestellbar, lage } mit denselben Antworten
* wie oben (grosszuegige Regel), aber der Stand jedes Kunden wird je aboMemo()-Aufruf nur einmal gelesen, auch bei
* gleichzeitigen Fragen und fuer alle drei Fragen gemeinsam. Eine Ablehnung bleibt fuer diesen Lauf stehen: alle
* weiteren Fragen zu diesem Kunden werfen denselben Fehler, ohne neu zu lesen. Nie fuer Pruefungen unter einer Sperre
* (dort frisch lesen: aboLage).
*/
export function aboMemo() {
const gelesen = new Map()
const lade = (customerId) => {
if (!gelesen.has(customerId)) gelesen.set(customerId, lage(customerId, { altbestand: true }))
return gelesen.get(customerId)
}
return {
laeuft: async (customerId) => (await lade(customerId)).laeuft,
bestellbar: async (customerId) => bestellbarAus(await lade(customerId)),
lage: async (customerId) => lageAus(await lade(customerId)),
}
}
+3 -6
View File
@@ -8,7 +8,7 @@ import { withLocks } from './keyedLock.js'
import { sendCustomerMail } from './mailer.js'
import { triggerRebuild } from './previewRebuild.js'
import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js'
import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js'
import { laeuft } from './aboStatus.js'
import { createFeatureTicket } from './ticketAdmin.js'
import { berlinDateTime } from './berlinTime.js'
import { legalPaths, legalPdfAttachments } from './analyseLegal.js'
@@ -39,11 +39,8 @@ export const ELIGIBILITY_MESSAGES = {
}
const ELIGIBILITY_STATUS = { nicht_dein_projekt: 403, kein_abo: 403, nicht_oeffentlich: 409, keine_subdomain: 409 }
/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status. */
async function hasWartungsAbo(customerId) {
const sub = await getCurrentSubscription(customerId)
return Boolean(sub && sub.productKey === 'wartung' && ACTIVE_STATUSES.has(sub.status))
}
/** Nur Wartungs-Abo (kein Hosting) mit laufendem Status; strenge Regel des Abo-Bausteins: ein Spiegel ohne productKey zaehlt nicht. */
const hasWartungsAbo = (customerId) => laeuft(customerId, { altbestand: false })
async function loadOwnProject(customerId, projectId) {
try {
+7 -48
View File
@@ -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'] })
+53 -4
View File
@@ -8,6 +8,18 @@ function buildQueries(queries = []) {
})
}
/**
* URL einer Anfrage: Pfad plus eine `queries[]` je Abfrage (JSON, URL-kodiert). Die einzige Stelle, die sie baut (adminFetch und
* dokumenteUrl), damit die Laenge, die der nginx vor Appwrite zaehlt (siehe AUSSCHLUSS_MAX in termine/outbox.js), pruefbar bleibt.
*/
function buildUrl(path, queries = []) {
const url = new URL(`${config.appwrite.endpoint}${path}`)
for (const q of buildQueries(queries)) {
url.searchParams.append('queries[]', q)
}
return url
}
function adminHeaders() {
return {
'Content-Type': 'application/json',
@@ -33,10 +45,7 @@ async function adminFetch(path, { method = 'GET', body, queries = [], raw = fals
throw error
}
const url = new URL(`${config.appwrite.endpoint}${path}`)
for (const q of buildQueries(queries)) {
url.searchParams.append('queries[]', q)
}
const url = buildUrl(path, queries)
const useRawBody = raw || path.startsWith('/users')
const requestBody = formatRequestBody(body, method, { raw: useRawBody })
@@ -91,6 +100,32 @@ export const Query = {
cursorAfter: (documentId) => ({ method: 'cursorAfter', values: [documentId] }),
orderDesc: (attribute) => ({ method: 'orderDesc', attribute }),
orderAsc: (attribute) => ({ method: 'orderAsc', attribute }),
/** Nur diese Felder zurueckgeben (die $-Felder wie $id liefert Appwrite immer mit) */
select: (attributes) => ({ method: 'select', values: attributes }),
/**
* Kein Dokument mit diesem Wert. Genau EIN Wert: Appwrite 1.8.1 lehnt eine Liste ab (HTTP 400 general_query_invalid
* "NotEqual queries require exactly one value.", gemessen 2026-10-05, M1). Fuer mehrere Werte keinerVon(attribute, werte).
* Zwei Eigenheiten der echten Datenbank, die auch der Nachbau der Tests hat: mehrere notEqual in einer Anfrage werden
* geschnitten (M2, bis 40 getestet), und ein Dokument, dessen Feld null ist oder fehlt, passt NICHT zu notEqual, SQL-Semantik
* (M3): nur verwenden, wenn das Feld an jedem Dokument gesetzt ist.
*/
notEqual: (attribute, value) => {
if (Array.isArray(value)) throw new TypeError(`Query.notEqual('${attribute}') nimmt genau einen Wert (Appwrite lehnt eine Liste ab); fuer mehrere keinerVon('${attribute}', werte) benutzen`)
return { method: 'notEqual', attribute, values: [value] }
},
}
/**
* "Keiner von diesen Werten": je Wert eine notEqual-Abfrage mit genau einem Wert; Appwrite schneidet sie (M2). Ohne Werte
* keine Abfrage (leeres Array). Jede Abfrage steht als eigenes queries[] in der URL, die Zahl der Werte also begrenzen
* (M4: der nginx vor Appwrite antwortet mit 414, in der Abfrage der Mail-Warteschlange ab 46 Werten); fuer M1 bis M3
* siehe Query.notEqual.
* @param {string} attribute
* @param {Iterable<string|number|boolean>} werte
* @returns {object[]}
*/
export function keinerVon(attribute, werte) {
return [...(werte ?? [])].map((wert) => Query.notEqual(attribute, wert))
}
export const ID = {
@@ -101,6 +136,11 @@ function collectionPath(collectionId) {
return `/databases/${config.appwrite.databaseId}/collections/${collectionId}/documents`
}
/** Die URL der Listenabfrage einer Collection (ohne Netz), so wie listDocuments sie sendet: fuer Tests der URL-Laenge. */
export function dokumenteUrl(collectionId, queries = []) {
return buildUrl(collectionPath(collectionId), queries).toString()
}
export async function getUserById(userId) {
return adminFetch(`/users/${userId}`)
}
@@ -169,6 +209,15 @@ export async function listDocuments(collectionId, queries = []) {
return result.documents
}
/**
* Anzahl der Treffer: ein Aufruf mit limit(1), gelesen wird das Feld total der Antwort (Treffer ohne limit/cursor).
* queries sind nur Filter, nie limit/order/cursor. Liefert null, wenn die Antwort kein total hat.
*/
export async function countDocuments(collectionId, queries = []) {
const result = await adminFetch(collectionPath(collectionId), { queries: [...queries, Query.limit(1)] })
return typeof result?.total === 'number' ? result.total : null
}
/**
* Ein Aufruf mit allen Seiten: sortiert nach $id, Seite fuer Seite per cursorAfter, bis eine Seite kuerzer als
* pageSize ist. queries enthalten nur Filter, nie limit/order/cursor (die setzt die Funktion selbst). Mehr als max
+10 -1
View File
@@ -286,11 +286,20 @@ export async function deleteCustomerWithPortalAccess(customerId) {
// Fehler halten das Loeschen nicht auf (wie bei der Analyse).
try {
const { disableCustomerFeatures } = await import('./siteFeatures.js')
await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht' })
await disableCustomerFeatures(customerId, 'termine', { adminNote: 'Kunde gelöscht', reason: 'kunde_geloescht' })
} catch (err) {
console.error(`[customerAdmin] Terminplaner von Kunde ${customerId} nicht abgeschaltet:`, err.message)
}
// Entwuerfe (Terminplaner im Zustand off: nie bestellt oder nach dem Abo-Ende zurueckgesetzt) gehen sofort mit dem Kunden, Funktionsdokument
// und Einstellung. Fehler halten das Loeschen nicht auf (wie oben); ein uebrig gebliebener Entwurf faellt nach 90 Tagen im Tageslauf.
try {
const { loescheEntwuerfeVonKunde } = await import('./termine/entwuerfe.js')
await loescheEntwuerfeVonKunde(customerId)
} catch (err) {
console.error(`[customerAdmin] Entwürfe von Kunde ${customerId} nicht gelöscht:`, err.message)
}
const portalAccess = await getPortalAccessByCustomerId(customerId)
if (portalAccess) {
await deleteDocument(
+160
View File
@@ -0,0 +1,160 @@
// Zustimmungsnachweis zur Bestellung einer Funktion (Collection featureConsents, nur Server-Key).
// Nur anfuegen und lesen: es gibt bewusst keine Aenderungs- und Loeschfunktion (wie analyseConsent.js).
// Gespeichert wird, was dem Kunden gezeigt wurde: Texte in Anzeigereihenfolge, die Fassungen der Unterlagen, der
// Zeitpunkt des Klicks und ein SHA-256 ueber die Texte. Vollstaendig oder gar nicht: nichts wird gekuerzt.
import { createHash } from 'node:crypto'
import { config } from '../config.js'
import { createDocument, listDocuments, Query } from './appwriteAdmin.js'
// Grenzen der Collection (scripts/feature-setup.mjs, FEATURE_CONSENT_ATTRIBUTES)
const MAX_ID = 36
const MAX_FEATURE_KEY = 16
const MAX_SESSION = 128
const MAX_FASSUNGEN = 1000
const MAX_TEXTE = 20000
const MAX_IP = 64
const MAX_USER_AGENT = 300
const MAX_KONTO = 255
// Mehr Nachweise je Sitzung gibt es nur, wenn ein Speichern wiederholt wurde; gelesen wird eine kleine Seite
const SITZUNG_SEITE = 25
function httpError(status, message) {
const err = new Error(message)
err.status = status
return err
}
/** Kennung (Kunde, Projekt, Funktion, Sitzung): wird nie gekuerzt, sondern abgelehnt. */
function kennung(value, max, name, { optional = false } = {}) {
if (optional && (value === undefined || value === null || value === '')) return ''
if (typeof value !== 'string' || !value.trim() || value.length > max) throw httpError(400, `Zustimmungsnachweis: ${name} fehlt oder ist ungültig`)
return value
}
/** Freie Angaben aus der Anfrage (IP, Browser, Konto): fehlt etwas, bleibt es leer; zu Langes wird gekuerzt. */
const kurz = (value, max) => String(value ?? '').slice(0, max)
function zeitpunkt(at) {
const ms = at instanceof Date ? at.getTime() : typeof at === 'string' && /^\d{4}-\d\d-\d\dT/.test(at) ? Date.parse(at) : Number.NaN
if (Number.isNaN(ms)) throw httpError(400, 'Zustimmungsnachweis: Zeitpunkt fehlt oder ist ungültig')
return new Date(ms).toISOString()
}
function pruefeTexte(texte) {
if (!Array.isArray(texte) || texte.length === 0) throw httpError(400, 'Zustimmungsnachweis: Texte fehlen')
for (const eintrag of texte) {
const ok =
eintrag !== null &&
typeof eintrag === 'object' &&
!Array.isArray(eintrag) &&
typeof eintrag.id === 'string' &&
eintrag.id !== '' &&
typeof eintrag.text === 'string' &&
eintrag.text !== '' &&
(eintrag.haken === undefined || typeof eintrag.haken === 'boolean')
if (!ok) throw httpError(400, 'Zustimmungsnachweis: Text ungültig')
}
}
function pruefeFassungen(fassungen) {
if (fassungen === null || typeof fassungen !== 'object' || Array.isArray(fassungen)) {
throw httpError(400, 'Zustimmungsnachweis: Fassungen fehlen')
}
}
const sha256 = (text) => createHash('sha256').update(text).digest('hex')
/** SHA-256 (hex) ueber JSON.stringify der Texte in der gespeicherten Reihenfolge. */
export function texteHash(texte) {
return sha256(JSON.stringify(texte))
}
/**
* Nachweis anfuegen. fassungen: Name => Fassung (z. B. zustimmung, leistungsbeschreibung, avv); texte: [{ id, text, haken }]
* genau in Anzeigereihenfolge. Erlaubt leer: stripeSessionId, ip, userAgent (Nachweis der Aktivierung durch das Team) und
* haken je Text. Wirft 400 (und schreibt nichts), wenn etwas fehlt, ungueltig oder zu lang ist.
* @returns {Promise<object>} das angelegte Dokument
*/
export async function speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at } = {}) {
const daten = {
customerId: kennung(customerId, MAX_ID, 'Kunde'),
projectId: kennung(projectId, MAX_ID, 'Projekt'),
featureKey: kennung(featureKey, MAX_FEATURE_KEY, 'Funktion'),
stripeSessionId: kennung(stripeSessionId, MAX_SESSION, 'Sitzung', { optional: true }),
acceptedAt: zeitpunkt(at),
}
pruefeFassungen(fassungen)
pruefeTexte(texte)
const fassungenJson = JSON.stringify(fassungen)
const texteJson = JSON.stringify(texte)
if (fassungenJson.length > MAX_FASSUNGEN) throw httpError(400, 'Zustimmungsnachweis: Fassungen zu lang')
if (texteJson.length > MAX_TEXTE) throw httpError(400, 'Zustimmungsnachweis: Texte zu lang')
return createDocument(config.collections.featureConsents, {
...daten,
fassungen: fassungenJson,
texte: texteJson,
textHash: sha256(texteJson),
ip: kurz(ip, MAX_IP),
userAgent: kurz(userAgent, MAX_USER_AGENT),
konto: kurz(konto, MAX_KONTO),
})
}
/** Neueste Zustimmung (nach acceptedAt) dieses Kunden zu dieser Funktion im Projekt, sonst null. */
export async function letzteZustimmung({ customerId, projectId, featureKey } = {}) {
const vollstaendig = [customerId, projectId, featureKey].every((value) => typeof value === 'string' && value !== '')
if (!vollstaendig) return null
const [doc] = await listDocuments(config.collections.featureConsents, [
Query.equal('customerId', customerId),
Query.equal('projectId', projectId),
Query.equal('featureKey', featureKey),
Query.orderDesc('acceptedAt'),
Query.limit(1),
])
return doc || null
}
/**
* Nachweis zu genau dieser Stripe-Sitzung, sonst null. Eine leere Sitzung fragt nicht ab: Nachweise ohne Sitzung (Aktivierung
* durch das Team) tragen '' und wuerden sonst ueber idx_session getroffen. Gibt es mehrere Nachweise zur Sitzung (ein
* wiederholtes Speichern), gilt der erste, denn der Klick, der die Sitzung ausgeloest hat, ist der Anker der Widerrufsfrist.
* Lesefehler werden weitergereicht.
*/
export async function zustimmungFuerSitzung(stripeSessionId) {
if (typeof stripeSessionId !== 'string' || !stripeSessionId.trim()) return null
const docs = await listDocuments(config.collections.featureConsents, [Query.equal('stripeSessionId', stripeSessionId), Query.limit(SITZUNG_SEITE)])
if (!docs.length) return null
return [...docs].sort((a, b) => String(a.acceptedAt).localeCompare(String(b.acceptedAt)))[0]
}
/** Dokument mit geparsten Feldern: fassungen als Objekt, texte als Array (das Dokument selbst bleibt unveraendert). */
export function leseZustimmung(doc) {
return { ...doc, fassungen: JSON.parse(doc.fassungen), texte: JSON.parse(doc.texte) }
}
/** Fassungen, die der Kunde mit der Bestellung bestaetigt: die version jedes Registerfelds, das es gibt. */
export function zustimmungsfassungen(feature) {
const fassungen = { zustimmung: feature.consent.version }
for (const name of ['leistungsbeschreibung', 'avv', 'widerruf']) {
if (feature[name]?.version) fassungen[name] = feature[name].version
}
return fassungen
}
/**
* Texte, die der Kunde bei der Bestellung sieht, in Anzeigereihenfolge: alle consent.items, dann (falls uebergeben) der
* gezeigte Belehrungstext samt Muster-Formular, zuletzt der Knopf.
* @param {object} feature Registereintrag
* @param {{ belehrung?: string | null }} [optionen]
* @returns {{ id: string, text: string, haken: boolean }[]}
*/
export function nachweisTexte(feature, { belehrung = null } = {}) {
const texte = feature.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) }))
if (belehrung !== null && belehrung !== undefined) {
if (typeof belehrung !== 'string' || !belehrung) throw new Error('Nachweistexte: Belehrung muss ein nichtleerer Text sein')
texte.push({ id: 'belehrung', text: belehrung, haken: false })
}
texte.push({ id: 'knopf', text: feature.consent.knopf, haken: false })
return texte
}
+134
View File
@@ -0,0 +1,134 @@
import { config } from '../config.js'
import { aboMemo } from './aboStatus.js'
import { oeffentlicheAnsicht } from './featureRegistry.js'
import { istObjekt } from './termine/ablauf.js'
import { holeOderNull, ladeEinstellung } from './termine/kontext.js'
// Bausteine der Bestell-Vorpruefungen (POST /api/funktionen/:key/checkout, Task B6): Zustimmung, Abo-Hinweistexte,
// Entwurf des Terminplaners. Die Route in routes/funktionen.js legt die Reihenfolge fest; jede Stufe hier ist
// ohne Seiteneffekt (liest nur, schreibt nie, legt keine Stripe-Sitzung an).
/** Einhaengepunkt fuer Tests: der Abo-Speicher je Anfrage (Tests zaehlen die Aufrufe, um die Reihenfolge zu pruefen). */
export const orderDeps = { aboMemo }
/**
* Fehler mit Status, Kundentext und Code. code steht am Fehler und im Antwortkoerper (err.body.code); weitere
* Felder fuer den Browser (consent, offen, grund) kommen in body. routes/funktionen.js fail() gibt body weiter.
*/
export function bestellFehler(status, message, code, extra = {}) {
const err = new Error(message)
err.status = status
if (code) {
err.code = code
err.body = { code, ...extra }
} else if (Object.keys(extra).length > 0) {
err.body = { ...extra }
}
return err
}
const ZUSTIMMUNG_FEHLT = 'Bitte stimme der Erklärung zu, um zu bestellen.'
const FASSUNG_VERALTET = 'Die Erklärungen wurden inzwischen geändert. Bitte lies sie noch einmal und stimme neu zu.'
const MAX_HAKEN = 50
const fehlt = () => bestellFehler(400, ZUSTIMMUNG_FEHLT, 'zustimmung_fehlt')
const hatWert = (wert) => wert !== undefined && wert !== null
/**
* "Zahlung fortsetzen" aus der Statuskarte: { projectId, fortsetzen: true } ohne Zustimmung. Enthaelt der Koerper auch
* zustimmung (oder die News-Altform consent: true), gilt die Zustimmung und sie wird geprueft.
*/
export function istFortsetzen(eingabe) {
return eingabe?.fortsetzen === true && !hatWert(eingabe.zustimmung) && eingabe.consent !== true
}
/**
* Pruefstufe 3: Sind alle Pflicht-Haken der Funktion gesetzt und passt die Fassung zur Serverfassung?
* eingabe ist der Anfragekoerper: { zustimmung: { version, haken: [id, …] } } oder, nur bei Funktionen mit genau einem
* Haken-Item (News), die Altform { consent: true }. Gibt die angenommenen Haken-IDs in Registerreihenfolge zurueck.
* Wirft 400 zustimmung_fehlt (fehlt, unvollstaendig, unbekannte ID) und 409 fassung_veraltet (err.body.consent ist die
* aktuelle Fassung mit allen Texten, wie die Bestellseite sie zeigt). Die Fassung geht vor den Haken: wer einen alten
* Wortlaut gesehen hat, soll erst den neuen lesen.
*/
export function pruefeZustimmung(feature, eingabe) {
const ids = feature.consent.items.filter((item) => item.haken).map((item) => item.id)
if (!hatWert(eingabe?.zustimmung)) {
// Altform: ein einziger Haken (News) laesst sich mit consent: true bestaetigen
if (eingabe?.consent === true && ids.length === 1) return { akzeptiert: [...ids] }
throw fehlt()
}
if (!istObjekt(eingabe.zustimmung)) throw fehlt()
const { version, haken } = eingabe.zustimmung
if (typeof version !== 'string' || !Array.isArray(haken) || haken.length > MAX_HAKEN) throw fehlt()
if (version !== feature.consent.version) {
throw bestellFehler(409, FASSUNG_VERALTET, 'fassung_veraltet', { consent: oeffentlicheAnsicht(feature.key).consent })
}
if (!haken.every((id) => typeof id === 'string' && ids.includes(id))) throw fehlt()
const gesetzt = new Set(haken)
if (!ids.every((id) => gesetzt.has(id))) throw fehlt()
return { akzeptiert: [...ids] }
}
// Abo-Hinweistexte (Du-Form, Akkusativ der Funktion aus mails.formen.akk, z. B. "den Terminplaner"). Einzige Quelle:
// Bestellseite, Vorschau (Gate-Punkt (h)), Entwurfsrechte und Abo-Abgleich importieren sie von hier.
const ABO_TEXTE = {
kein_abo: (akk) => `Für ${akk} brauchst du ein laufendes Wartungs-Abo.`,
gekuendigt: (akk) => `Dein Wartungs-Abo ist gekündigt. Für ${akk} brauchst du ein laufendes Abo.`,
nicht_aktiv: (akk) =>
`Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du ${akk} bestellen.`,
unbekannt: () => 'Das Abo konnte gerade nicht geprüft werden.',
}
/**
* Hinweistext zu einem Abo-Grund.
* @param {object} feature Registereintrag (config.features.<key>)
* @param {'kein_abo'|'gekuendigt'|'nicht_aktiv'|'unbekannt'} grund jeder andere Wert gilt wie 'unbekannt'
*/
export function aboMeldung(feature, grund) {
const text = Object.hasOwn(ABO_TEXTE, grund) ? ABO_TEXTE[grund] : ABO_TEXTE.unbekannt
return text(feature?.mails?.formen?.akk || 'diese Funktion')
}
// Fragebogen aus termineConfig.fragebogen (JSON-Text): nur ein Objekt zaehlt, fehlend oder kaputt ist null
function liesFragebogen(text) {
if (typeof text !== 'string' || text === '') return null
try {
const wert = JSON.parse(text)
return istObjekt(wert) ? wert : null
} catch {
return null
}
}
/**
* Entwurf des Terminplaners (Einstellung und Fragebogen aus termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
* Fehlende oder unlesbare Teile sind null; Lesefehler der Datenbank werden weitergereicht.
* @param {string} featureId
* @returns {Promise<{ einstellung: object|null, fragebogen: object|null }>}
*/
export async function ladeEntwurf(featureId) {
const [gelesen, doc] = await Promise.all([ladeEinstellung(featureId), holeOderNull(config.collections.termineConfig, featureId)])
return { einstellung: gelesen?.einstellung ?? null, fragebogen: liesFragebogen(doc?.fragebogen) }
}
/**
* Pruefstufe 7: Wartungs-Abo, wenn die Funktion es verlangt (requiresWartungsAbo). Wirft 409 mit code und body.grund
* (kein_abo | gekuendigt | nicht_aktiv, ohne Praefix) und dem Text aus aboMeldung. Ein Lesefehler wirft den Fehler der
* Datenbank weiter (die Route antwortet 500 mit allgemeinem Text): im Zweifel nicht bestellbar.
*/
export async function pruefeAbo(feature, customerId) {
if (feature.requiresWartungsAbo !== true) return
let ergebnis
try {
ergebnis = await orderDeps.aboMemo().bestellbar(customerId)
} catch (err) {
// egal mit welchem Status die Datenbank antwortet: 500, die Route zeigt nur ihren allgemeinen Text
throw Object.assign(new Error(`Abo-Prüfung fehlgeschlagen: ${err?.message}`), { status: 500, cause: err })
}
if (ergebnis?.ok === true) return
const grund = ergebnis?.grund
if (!Object.hasOwn(ABO_TEXTE, grund) || grund === 'unbekannt') throw new Error('Abo-Prüfung ohne gültiges Ergebnis')
throw bestellFehler(409, aboMeldung(feature, grund), grund, { grund })
}
+259
View File
@@ -0,0 +1,259 @@
import { config } from '../config.js'
import { termineFuerKunde } from './termine/schalter.js'
// Helfer fuer das Funktionen-Register config.features (News-Pinnwand, Terminplaner; Daten in server/config.js).
// Diese Datei wird von siteFeatures.js importiert und importiert selbst keine Dienste statisch (einzige Ausnahme:
// termine/schalter.js, das nur config liest): Standard-Haken laden ihre Dienste per dynamischem import()
// innerhalb der Funktion (sonst entstuenden Importzyklen).
/** Namen der Haken, die ein Registereintrag hat (optional): Bestell-, Freischalt- und Unterlagenpruefungen. */
const HAKEN_NAMEN = ['verfuegbar', 'bestellpruefung', 'unterlagenBereit', 'belehrung', 'freischaltbar']
/**
* Bestellpruefung des Terminplaners im Checkout: die offenen Punkte des Entwurfs (Einstellung und Fragebogen aus
* termineConfig; ohne Dokument beides null) nach termine/fragebogen.js. Ohne abo und mit mitAbo: false: den Abo-Punkt (h)
* wendet der Checkout nicht an, die Abo-Stufe (7) der Route liefert kein_abo | gekuendigt | nicht_aktiv.
*/
async function termineBestellpruefung({ customerId, projectId, feature }) {
const [{ getFeatureDoc }, { ladeEntwurf }, { bestellpruefung }] = await Promise.all([
import('./siteFeatures.js'),
import('./featureOrder.js'),
import('./termine/fragebogen.js'),
])
const doc = await getFeatureDoc(customerId, projectId, 'termine')
const { einstellung, fragebogen } = doc ? await ladeEntwurf(doc.$id) : { einstellung: null, fragebogen: null }
return bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false })
}
/**
* 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. 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 { freischaltHindernis } = await import('./termine/freischaltung.js')
return (await freischaltHindernis(doc)) === null
}
/**
* 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
* (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.
*/
const STANDARD_HAKEN = {
news: {},
termine: {
verfuegbar: (email) => termineFuerKunde(email),
bestellpruefung: termineBestellpruefung,
unterlagenBereit: termineUnterlagenBereit,
belehrung: termineBelehrung,
freischaltbar: termineFreischaltbar,
},
}
/** Registereintrag oder null; nur eigene Schluessel des Registers ('analyse', '__proto__', 'toString' => null). */
export function featureConfig(key) {
if (typeof key !== 'string' || !Object.hasOwn(config.features, key)) return null
return config.features[key]
}
/** Schluessel der Funktionen im Register, in Registerreihenfolge. */
export function featureKeys() {
return Object.keys(config.features)
}
/**
* Schluessel der Funktionen, die nach einem geschlossenen Einrichtungs-Ticket freigeschaltet werden (ticketFreischaltung: true,
* nur genau true), in Registerreihenfolge. config.features wird bei jedem Aufruf gelesen.
*/
export function ticketFreischaltungKeys() {
return featureKeys().filter((key) => config.features[key].ticketFreischaltung === true)
}
/**
* Haken einer Funktion: { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? }.
* Wer config.features.<key>.<haken> als Funktion setzt (Tests), ueberschreibt den Standard.
* freischaltbar(doc) => Promise<boolean>: darf das Dokument (Zustand 'setup', Ticket geschlossen) jetzt aktiv werden?
* Nur true gibt frei; false und jeder Fehler halten es in 'setup' (siehe activateClosedTickets).
*/
export function featureHaken(key) {
const eintrag = featureConfig(key)
if (!eintrag) return {}
const standard = Object.hasOwn(STANDARD_HAKEN, key) ? STANDARD_HAKEN[key] : {}
const haken = {}
for (const name of HAKEN_NAMEN) {
const fn = typeof eintrag[name] === 'function' ? eintrag[name] : standard[name]
if (typeof fn === 'function') haken[name] = fn
}
return haken
}
const NICHT_VERFUEGBAR = 'Diese Funktion ist noch nicht verfügbar.'
/**
* Pruefstufe 5 des Checkouts: Text, warum die Funktion diesem Kunden (noch) nicht angeboten wird, sonst null.
* Gefragt wird der Haken verfuegbar(email) (Terminplaner: Schalter TERMINE_ENABLED und Pilotliste); Funktionen ohne
* Haken (News) sind immer verfuegbar. Ein unbekannter Schluessel ist nicht verfuegbar (fail-closed).
*/
export function featureSperre(key, email) {
const eintrag = featureConfig(key)
if (!eintrag) return NICHT_VERFUEGBAR
const { verfuegbar } = featureHaken(key)
if (typeof verfuegbar !== 'function' || verfuegbar(email)) return null
return eintrag.unavailableMessage || NICHT_VERFUEGBAR
}
const fassung = (f) => (f ? { version: f.version, url: f.url ?? '' } : null)
/**
* Sicht fuer den Browser (Bestellseite): JSON-sicher, ohne Funktionen und ohne ticket/mails, eine Kopie
* (Aenderungen daran aendern das Register nicht). Unbekannter Schluessel => null.
*/
export function oeffentlicheAnsicht(key) {
const e = featureConfig(key)
if (!e) return null
return {
key: e.key,
name: e.label.name,
kurz: e.label.kurz,
priceLabel: e.priceLabel,
setupEstimate: e.setupEstimate,
consent: {
version: e.consent.version,
knopf: e.consent.knopf,
items: e.consent.items.map((item) => ({ id: item.id, text: item.text, haken: Boolean(item.haken) })),
},
leistungsbeschreibung: fassung(e.leistungsbeschreibung),
avv: fassung(e.avv),
widerruf: fassung(e.widerruf),
returnHash: e.returnHash,
requiresWartungsAbo: e.requiresWartungsAbo === true,
}
}
// ---------------------------------------------------------------------------------------------------------------
// Einrichtungs-Ticket aus dem Register (Task B8): Texte je Funktion, Laengendeckel und Notfall-Details.
// createFeatureTicket (ticketAdmin.js) ist die einzige Stelle, die das Ticket anlegt; markPaid holt die Texte hier.
// ---------------------------------------------------------------------------------------------------------------
/** Laengendeckel der Ticket-Felder (workorders.topic / workorders.details). */
export const TICKET_TOPIC_MAX = 128
export const TICKET_DETAILS_MAX = 7000
/** Ab dieser Laenge gelten Details als "lang": nur dann wiederholt createFeatureTicket nach einem 400 mit den Notfall-Details. */
export const LANGE_DETAILS = 1000
/** Einheitliche Schlusszeile eines gekuerzten Ticket-Textes. */
export const TICKET_GEKUERZT = '… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen'
const istHighSurrogat = (code) => code >= 0xd800 && code <= 0xdbff
/**
* Text auf hoechstens max Zeichen (UTF-16-Einheiten, wie string.length) kuerzen, surrogatsicher: nie ein halbes Zeichenpaar.
* Passt der Text, bleibt er unveraendert (ohne endzeile). Sonst wird so viel Text behalten, dass mit der Endzeile genau max
* nicht ueberschritten wird: anZeile schneidet an der letzten Zeilengrenze (ohne Zeilengrenze im Platz: harter Schnitt) und
* setzt die Endzeile in eine eigene Zeile; ohne anZeile haengt die Endzeile direkt an den Schnitt.
*/
export function kuerze(text, max, { anZeile = false, endzeile = '' } = {}) {
const s = typeof text === 'string' ? text : String(text ?? '')
if (s.length <= max) return s
const trenner = anZeile && endzeile ? '\n' : ''
const platz = max - endzeile.length - trenner.length
const hartSchnitt = (n) => {
let teil = s.slice(0, Math.max(0, n))
if (teil && istHighSurrogat(teil.charCodeAt(teil.length - 1))) teil = teil.slice(0, -1)
return teil
}
if (platz <= 0) return hartSchnitt(max)
let teil
if (anZeile) {
const grenze = s.lastIndexOf('\n', platz)
teil = grenze > 0 ? s.slice(0, grenze) : hartSchnitt(platz)
} else {
teil = hartSchnitt(platz)
}
teil = teil.trimEnd()
return teil ? `${teil}${trenner}${endzeile}` : endzeile
}
/** Link ins WOMS-Kundenpanel "Funktionen" mit geoeffneter Einbauanleitung des Projekts (Ticket, T16, Anleitungs-Route). */
export function anleitungsLink(customerId, projectId) {
return `${config.womsBaseUrl}/customers/${encodeURIComponent(customerId ?? '')}?tab=funktionen&anleitung=${encodeURIComponent(projectId ?? '')}`
}
const projektName = (project) => project?.projectName || project?.subdomain || ''
/**
* Notfall-Details des Tickets: Kopfzeilen (Funktion, Projekt, Domain, Kunde, siteKey) und ein Verweis auf die Anleitung im
* WOMS. Ohne Rechenarbeit und ohne Dienste, damit es auch dann klappt, wenn die Anleitung des Registers nicht entsteht.
* ctx = { feature, doc, project, customer } (customer best effort oder null).
*/
export function minimaleTicketDetails(ctx) {
const { feature, doc, project, customer } = ctx || {}
const domain = project?.liveDomain || project?.previewUrl || project?.subdomain || ''
return [
`Funktion: ${feature?.key || doc?.featureKey || ''}`,
`Projekt: ${projektName(project)}`,
`Domain: ${domain}`,
`Kunde: ${customer?.name || doc?.customerId || ''}`,
`siteKey: ${doc?.siteKey || ''}`,
`Verweis: ${anleitungsLink(doc?.customerId, doc?.projectId)}`,
].join('\n')
}
/**
* Texte des Einrichtungs-Tickets einer Funktion aus dem Register: { topic, details, fallbackDetails }.
* topic auf TICKET_TOPIC_MAX, details auf TICKET_DETAILS_MAX gekuerzt (Schnitt an der letzten Zeilengrenze, eine
* einheitliche Schlusszeile). Wirft feature.ticket.details oder liefert es Leeres/Nicht-Text, gilt fallbackDetails
* (minimaleTicketDetails): bezahlt ist bezahlt, das Ticket entsteht immer.
*/
export async function ticketTexte(ctx) {
const { feature, project } = ctx || {}
const kuerzeDetails = (text) => kuerze(text, TICKET_DETAILS_MAX, { anZeile: true, endzeile: TICKET_GEKUERZT })
const fallbackDetails = kuerzeDetails(minimaleTicketDetails(ctx))
let topic = ''
try {
topic = feature.ticket.topic(project)
} catch (err) {
console.error(`[funktionen] Ticket-Betreff (${feature?.key}) nicht berechenbar:`, err.message)
}
if (typeof topic !== 'string' || !topic.trim()) topic = `${feature?.label?.name || 'Funktion'} einbauen – ${projektName(project)}`.trim()
let details = ''
try {
details = await feature.ticket.details(ctx)
} catch (err) {
console.error(`[funktionen] Ticket-Details (${feature?.key}) nicht berechenbar, Notfall-Details:`, err.message)
}
if (typeof details !== 'string' || !details.trim()) details = fallbackDetails
return {
topic: kuerze(topic.trim(), TICKET_TOPIC_MAX, { endzeile: '…' }),
details: kuerzeDetails(details),
fallbackDetails,
}
}
+169
View File
@@ -0,0 +1,169 @@
// 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 (<slug>-<Fassung>.html|pdf),
* pdf = Dateiname des Mail-Anhangs (<pdf>-<Fassung>.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/<slug>-<Fassung>.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`) }])
)
}
/** Ein Anhang ist ein PDF: nicht leer und mit "%PDF-" am Anfang. */
const istPdf = (inhalt) => inhalt.length >= 5 && inhalt.subarray(0, 5).toString('latin1') === '%PDF-'
/**
* PDFs als Mail-Anhaenge in der Reihenfolge des Satzes (termine: lb, avv, widerruf; analyse: avv, lb); wirft, wenn eine
* Datei fehlt, nicht lesbar, leer oder kein PDF ist (nie ein leerer oder falscher Anhang). 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]) => {
const content = await readFile(f.path)
if (!istPdf(content)) throw new Error(`Rechtstexte: ${f.filename} ist leer oder kein PDF`)
return { filename: f.filename, content, 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/<slug>.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
}
+105 -34
View File
@@ -1,7 +1,7 @@
import { rm } from 'node:fs/promises'
import path from 'node:path'
import { config } from '../config.js'
import { deleteDocument, getDocument, listDocuments, Query } from './appwriteAdmin.js'
import { deleteDocument, getDocument, listDocuments, Query, updateDocument } from './appwriteAdmin.js'
import { deleteNewsImage } from './newsImages.js'
import { disable } from './siteFeatures.js'
import { getStripe } from './stripeBilling.js'
@@ -21,6 +21,13 @@ import { filesOf, STATUS } from './takeoverCredits.js'
* - News-Pinnwand: Wird ein Kunde geloescht (Frist wie oben), loeschen wir auch seine News-Beitraege samt Fotos
* (newsPosts, Bilddateien) und setzen seine Funktionen (portalFeatures) auf disabled. Der Plan fuehrt sie in
* plan.news (nur Kunden mit Beitraegen oder noch nicht abgeschalteten Funktionen), damit plan.customers unveraendert bleibt.
* - Funktionsbestellungen: Die Zustimmungsnachweise zu bestellten Funktionen (featureConsents, Datenschutzerklaerung Abschnitt 7:
* "Diese Angaben loeschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet") loescht derselbe Lauf fuer faellige
* Kunden mit (plan.news[].consentIds). Kunden ohne Abo, zum Beispiel nur mit News, sind nie faellig; ihre Nachweise bleiben, wie ihre
* Beitraege, ausserhalb dieser Frist.
* - Bestellangaben am Funktionsdokument: Fruehere Bestellungen speicherten IP, Browser, Zustimmungstext und -zeit auch am Dokument
* (portalFeatures.consentIp, consentUserAgent, consentText, consentAt). Fuer faellige Kunden werden sie an ALLEN ihren
* Funktionsdokumenten geleert, auch an abgeschalteten (plan.news[].featureFieldIds); die Dokumente selbst bleiben (siteKey).
* Zeitrechnung in deutscher Zeit: Silvester 23:30 in Berlin ist noch das alte Jahr.
* Nicht abgedeckt: Vertragsereignisse ohne customerId bzw. ohne zugehoeriges Abo-Dokument (Waisen) werden
* weder gefunden noch geloescht; sie muessen von Hand geprueft werden. Listen werden nicht paginiert: wird eine
@@ -32,6 +39,12 @@ const EVENTS = () => config.collections.contractEvents
const TAKEOVER = () => config.collections.takeoverCredits
const NEWS_POSTS = () => config.collections.newsPosts
const FEATURES = () => config.collections.portalFeatures
const CONSENTS = () => config.collections.featureConsents
/** Felder am Funktionsdokument, die die Zustimmung zur Bestellung festhalten (leer => nichts zu loeschen). */
const FEATURE_CONSENT_FIELDS = ['consentIp', 'consentUserAgent', 'consentText', 'consentAt']
const BLANK_FEATURE_FIELDS = { consentIp: '', consentUserAgent: '', consentText: '', consentAt: null }
const hasConsentFields = (doc) => FEATURE_CONSENT_FIELDS.some((key) => doc[key])
export const RETENTION_YEARS = 3
export const RECEIPT_YEARS = 8
@@ -68,6 +81,8 @@ function endOf(doc) {
/**
* @param {Date} [now]
* @returns {Promise<{ customers: { customerId: string, lastEndAt: string, subscriptionIds: string[], eventIds: string[] }[],
* news: { customerId: string, posts: { id: string, projectId: string, imageId: string | null }[], featureIds: string[], consentIds: string[],
* featureFieldIds: string[] }[],
* takeover: { id: string, reason: string, action: 'loeschen' | 'melden' }[],
* pending: { customerId: string, subscriptionIds: string[], reason: string }[], skipped: { id: string, reason: string }[] }>}
*/
@@ -111,12 +126,18 @@ export async function findDueDeletions(now = new Date(), { limit = LIST_LIMIT }
const news = []
for (const { customerId } of customers) {
const posts = await list(NEWS_POSTS(), [Query.equal('customerId', customerId)])
const features = (await list(FEATURES(), [Query.equal('customerId', customerId)])).filter((f) => f.state !== 'disabled')
if (!posts.length && !features.length) continue
const allFeatures = await list(FEATURES(), [Query.equal('customerId', customerId)])
const features = allFeatures.filter((f) => f.state !== 'disabled')
// Bestellangaben (IP, Browser, Text, Zeit) leeren wir an jedem Dokument des Kunden, auch am schon abgeschalteten
const withFields = allFeatures.filter(hasConsentFields)
const consents = await list(CONSENTS(), [Query.equal('customerId', customerId)])
if (!posts.length && !features.length && !consents.length && !withFields.length) continue
news.push({
customerId,
posts: posts.map((p) => ({ id: p.$id, projectId: p.projectId, imageId: p.imageId || null })),
featureIds: features.map((f) => f.$id),
consentIds: consents.map((c) => c.$id),
featureFieldIds: withFields.map((f) => f.$id),
})
}
@@ -161,18 +182,23 @@ async function deleteQuietly(collection, id) {
}
/**
* Faellige Daten loeschen. Reihenfolge je Kunde: erst Stripe-Metadaten (Abo-ID steht noch im Plan), dann die
* Appwrite-Dokumente. Lehnt Stripe ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
* Faellige Daten loeschen. Reihenfolge je Kunde: erst die abhaengigen Daten (News-Beitraege samt Fotos, Funktionen abschalten,
* Zustimmungsnachweise der Funktionsbestellungen), dann die Stripe-Metadaten (Abo-ID steht noch im Plan) und zuletzt die
* Appwrite-Dokumente der Abos (Vertragsereignisse, dann subscriptions). Grund: findDueDeletions leitet faellige Kunden allein aus den
* Abo-Dokumenten ab. Schlaegt ein Loeschen abhaengiger Daten fehl (oder das eines Vertragsereignisses), bleiben die Abo-Dokumente des
* Kunden stehen (result.held), der naechste Lauf findet den Kunden wieder und versucht es erneut; sonst blieben die uebrig gebliebenen
* Daten fuer immer liegen. Lehnt Stripe die Aenderung ab (beendete Abos lassen sich oft nicht mehr aendern), steht das Abo in manual;
* die Appwrite-Dokumente werden trotzdem geloescht, denn die Frist ist abgelaufen.
* @returns {Promise<{ deleted: { subscriptions: number, contractEvents: number, takeover: number, stripeCleared: number,
* newsPosts: number, newsImages: number, features: number },
* newsPosts: number, newsImages: number, features: number, featureConsents: number, featureFields: number },
* manual: { customerId: string, subscriptionId: string, reason: string }[],
* failed: { what: string, id: string, reason: string }[] }>}
* failed: { what: string, id: string, reason: string }[], held: string[] }>}
*/
export async function applyDeletions(plan) {
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0 }
const deleted = { subscriptions: 0, contractEvents: 0, takeover: 0, stripeCleared: 0, newsPosts: 0, newsImages: 0, features: 0, featureConsents: 0, featureFields: 0 }
const manual = []
const failed = []
const held = []
const clear = Object.fromEntries(CONSENT_METADATA_KEYS.map((key) => [key, '']))
const attempt = async (what, id, fn) => {
try {
@@ -183,30 +209,10 @@ export async function applyDeletions(plan) {
}
}
for (const customer of plan.customers || []) {
for (const id of customer.subscriptionIds) {
try {
await getStripe().subscriptions.update(id, { metadata: clear })
deleted.stripeCleared += 1
} catch (err) {
manual.push({
customerId: customer.customerId,
subscriptionId: id,
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
})
}
}
for (const id of customer.eventIds) {
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
}
for (const id of customer.subscriptionIds) {
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
}
}
for (const entry of plan.news || []) {
// News-Beitraege, Funktionen und Zustimmungsnachweise eines Kunden (Eintrag aus plan.news)
const deleteDependents = async (entry) => {
for (const post of entry.posts) {
// erst das Bild (Dokument bleibt bei Fehler und wird beim naechsten Lauf erneut gefunden), dann der Beitrag
// erst das Bild (Dokument bleibt bei Fehler), dann der Beitrag
if (post.imageId) {
const removed = await attempt('newsImages', post.imageId, async () => {
await deleteNewsImage(post.projectId, post.imageId)
@@ -225,8 +231,64 @@ export async function applyDeletions(plan) {
})
if (off) deleted.features += 1
}
// IP, Browser, Text und Zeit der Bestellung am Funktionsdokument (auch am abgeschalteten): das Dokument bleibt, die Angaben gehen
for (const id of entry.featureFieldIds || []) {
const blanked = await attempt('portalFeatures', id, async () => {
try {
await updateDocument(FEATURES(), id, BLANK_FEATURE_FIELDS)
return true
} catch (err) {
if (isGone(err)) return false
throw err
}
})
if (blanked) deleted.featureFields += 1
}
// Zustimmungsnachweise der Funktionsbestellungen zuletzt: ein Fehler stoppt den Rest nicht
for (const id of entry.consentIds || []) {
if (await attempt('featureConsents', id, () => deleteQuietly(CONSENTS(), id))) deleted.featureConsents += 1
}
}
const newsOf = new Map((plan.news || []).map((entry) => [entry.customerId, entry]))
for (const customer of plan.customers || []) {
const failedBefore = failed.length
const entry = newsOf.get(customer.customerId)
if (entry) {
newsOf.delete(customer.customerId)
await deleteDependents(entry)
}
if (failed.length > failedBefore) {
held.push(customer.customerId)
continue
}
for (const id of customer.subscriptionIds) {
try {
await getStripe().subscriptions.update(id, { metadata: clear })
deleted.stripeCleared += 1
} catch (err) {
manual.push({
customerId: customer.customerId,
subscriptionId: id,
reason: `Stripe lehnt die Änderung ab (${err.message}); Zustimmungsangaben in den Metadaten von Hand entfernen`,
})
}
}
for (const id of customer.eventIds) {
if (await attempt('contractEvents', id, () => deleteQuietly(EVENTS(), id))) deleted.contractEvents += 1
}
if (failed.length > failedBefore) {
// ein Vertragsereignis ist stehen geblieben: das Abo-Dokument bleibt, sonst wuerde das Ereignis zur Waise
held.push(customer.customerId)
continue
}
for (const id of customer.subscriptionIds) {
if (await attempt('subscriptions', id, () => deleteQuietly(SUBSCRIPTIONS(), id))) deleted.subscriptions += 1
}
}
// Eintraege ohne Kunden-Eintrag im Plan (kein Abo-Dokument, das zu halten waere)
for (const entry of newsOf.values()) await deleteDependents(entry)
for (const item of (plan.takeover || []).filter((t) => t.action === 'loeschen')) {
const removed = await attempt('takeoverCredits', item.id, async () => {
await rm(path.join(config.takeover.uploadDir, item.id), { recursive: true, force: true })
@@ -235,7 +297,7 @@ export async function applyDeletions(plan) {
if (!removed) continue // Dokument bleibt, solange die Nachweise nicht weg sind
if (await attempt('takeoverCredits', item.id, () => deleteQuietly(TAKEOVER(), item.id))) deleted.takeover += 1
}
return { deleted, manual, failed }
return { deleted, manual, failed, held }
}
const fmtDate = (iso) => new Date(iso).toLocaleDateString('de-DE', { timeZone: 'Europe/Berlin' })
@@ -251,7 +313,11 @@ export function formatReport(plan, result = null) {
}
for (const n of plan.news || []) {
const images = n.posts.filter((p) => p.imageId).length
lines.push(`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten`)
const consents = n.consentIds?.length || 0
const fields = n.featureFieldIds?.length || 0
lines.push(
`Kunde ${n.customerId}: ${n.posts.length} News-Beitrag/-Beiträge mit ${images} Foto(s) zu löschen, ${n.featureIds.length} Funktion(en) abzuschalten, ${consents} Zustimmungsnachweis(e) zu löschen, ${fields} Funktionsdokument(e) mit Zustimmungsangaben zu leeren`
)
}
const del = plan.takeover.filter((t) => t.action === 'loeschen')
const report = plan.takeover.filter((t) => t.action === 'melden')
@@ -268,11 +334,16 @@ export function formatReport(plan, result = null) {
lines.push(
'',
`Gelöscht: ${d.subscriptions} Abo(s), ${d.contractEvents} Vertragsereignis(se), ${d.takeover} Übernahme-Antrag/-Anträge; Stripe-Metadaten geleert bei ${d.stripeCleared} Abo(s).`,
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`
`News: ${d.newsPosts} Beitrag/Beiträge und ${d.newsImages} Foto(s) gelöscht, ${d.features} Funktion(en) abgeschaltet.`,
`Zustimmungsnachweise zu Funktionsbestellungen: ${d.featureConsents} gelöscht.`,
`Zustimmungsangaben an Funktionsdokumenten (IP, Browser, Text, Zeit): ${d.featureFields} geleert.`
)
if (result.failed?.length) {
lines.push('', 'Fehlgeschlagen (Lauf erneut starten):', ...result.failed.map((f) => ` ${f.what} ${f.id}: ${f.reason}`))
}
if (result.held?.length) {
lines.push('', 'Abo-Daten bleiben stehen, bis die abhängigen Daten gelöscht sind (der nächste Lauf findet den Kunden wieder):', ...result.held.map((id) => ` Kunde ${id}`))
}
if (result.manual.length) {
lines.push('', 'Von Hand erledigen:', ...result.manual.map((m) => ` Abo ${m.subscriptionId} (Kunde ${m.customerId}): ${m.reason}`))
}
File diff suppressed because it is too large. Load diff
+34 -10
View File
@@ -8,6 +8,7 @@ import {
updateDocument,
} from './appwriteAdmin.js'
import { lateStartAt } from './deadlines.js'
import { featureConfig } from './featureRegistry.js'
import { creditMinutesForInvoice, describeItems, getProduct, priceIdsOfInvoice, productKeyForLookupKey } from './products.js'
/**
@@ -51,19 +52,26 @@ export function isStripeConfigured() {
}
// Produkt-Metadaten "webklar" je Katalog-Schluessel (F09/Produces: ensurePrice)
const PRODUCT_METADATA = { wartung: 'wartungs-abo', hosting: 'hosting-abo', mailStorage: 'mail-speicher', newsSetup: 'news-einrichtung' }
const PRODUCT_METADATA = {
wartung: 'wartungs-abo',
hosting: 'hosting-abo',
mailStorage: 'mail-speicher',
newsSetup: 'news-einrichtung',
termineSetup: 'termine-einrichtung',
}
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, der Zusatzspeicher aus config.mailStorage. */
/** Katalogeintrag zum Schluessel: die beiden Abos aus products.js, Zusatzspeicher und Einrichtungen aus config. */
function catalogFor(key) {
if (key === 'mailStorage') return config.mailStorage
if (key === 'newsSetup') return config.newsSetup
if (key === 'termineSetup') return config.termineSetup
return getProduct(key)
}
const priceIdCache = new Map()
/**
* Preis per lookup_key holen oder (einmalig) samt Produkt anlegen. key: 'wartung' | 'hosting' |
* 'mailStorage' | 'newsSetup' (Einmalpreis). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* 'mailStorage' | 'newsSetup' | 'termineSetup' (Einmalpreise). Ersetzt das fruehere ensureAboPrice (nur Wartung); lookup_keys werden bei Stripe
* nie uebertragen, ein neuer Preis bekaeme also ohnehin einen neuen lookup_key im Katalog.
*/
export async function ensurePrice(key) {
@@ -639,14 +647,26 @@ export async function createAboCheckout(customer, consent, productKey, offer = n
}
/**
* Checkout fuer die einmalige Einrichtung einer Funktion (News-Pinnwand, 30 € brutto, kein Abo). Die Sitzung
* verfaellt nach 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die
* Rechnung erzeugt Stripe selbst. Nur die Zuordnung steht in den Metadaten, der Zustimmungswortlaut nicht.
* Checkout fuer die einmalige Einrichtung einer Funktion aus dem Register config.features (News-Pinnwand 30 €,
* Terminplaner 149 €; Einmalpreis, kein Abo). Preis, Rechnungstext und Rueckkehradresse kommen aus dem Register-Eintrag
* zu `key`; ein unbekannter Schluessel ist ein Fehler 400, bevor Stripe angesprochen wird. Die Sitzung verfaellt nach
* 24 Stunden (Stripe-Hoechstwert; fuenf Minuten Luft fuer die Laufzeit des Aufrufs), die Rechnung erzeugt Stripe
* selbst und traegt featureKey und projectId in ihren Metadaten. Nur die Zuordnung steht in den Metadaten der Sitzung,
* der Zustimmungswortlaut nicht.
* @returns {Promise<{ id: string, url: string }>}
*/
export async function createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) {
const feature = featureConfig(key)
if (!feature) {
const err = new Error('Unbekannte Funktion.')
err.status = 400
throw err
}
const stripe = getStripe()
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice('newsSetup'), getOrCreateStripeCustomer(customer)])
const [priceId, stripeCustomerId] = await Promise.all([ensurePrice(feature.product), getOrCreateStripeCustomer(customer)])
// Rueckkehr ins Dashboard: bei Funktionen mit returnProjekt (Terminplaner) mit dem Projekt, damit die Seite es oeffnet
const projekt = feature.returnProjekt ? encodeURIComponent(projectId) : ''
const returnUrl = `${config.publicBaseUrl}/dashboard.html#${feature.returnHash}`
const params = {
mode: 'payment',
customer: stripeCustomerId,
@@ -658,12 +678,16 @@ export async function createFeatureCheckout(customer, { projectId, key = 'news',
customer_update: { address: 'auto', name: 'auto' },
invoice_creation: {
enabled: true,
invoice_data: { description: config.newsSetup.name, footer: config.stripe.invoiceFooter },
invoice_data: {
description: catalogFor(feature.product).name,
footer: config.stripe.invoiceFooter,
metadata: { featureKey: key, projectId },
},
},
metadata: { customerId: customer.$id, projectId, featureKey: key, ...(consentAt ? { consentAt } : {}) },
expires_at: Math.floor(Date.now() / 1000) + 24 * 60 * 60 - 5 * 60,
success_url: `${config.publicBaseUrl}/dashboard.html#news?bestellt=1`,
cancel_url: `${config.publicBaseUrl}/dashboard.html#news`,
success_url: `${returnUrl}?bestellt=1${projekt ? `&projekt=${projekt}` : ''}`,
cancel_url: `${returnUrl}${projekt ? `?projekt=${projekt}` : ''}`,
}
let session
try {
+2 -1
View File
@@ -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)
+434
View File
@@ -0,0 +1,434 @@
import { config } from '../../config.js'
import { aboLage, aboMemo } from '../aboStatus.js'
import { Query, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { sendCustomerMail } from '../mailer.js'
import { activate, customerOf, disable, listCustomerFeatureDocs, meldeErstattung, projectName } from '../siteFeatures.js'
import { loeschzeit } from './frist.js'
import { ladeEinstellung } from './kontext.js'
import { pruefeNow, zeitMs } from './util.js'
import { datumKurz } from './zeit.js'
/**
* Abo-Abgleich des Terminplaners (Terminplaner TP2, Block E): der Terminplaner gehoert zum Wartungs-Abo und endet mit ihm.
* gleicheAboAb(now) ist der Baustein fuer den Tagesjob (angeschlossen in E7); er kennt weder Schalter noch Ueberlappungssperre.
*
* Schritt 1 aktive, bezahlte Terminplaner (state active, unlockedByPurchase true) ohne laufendes Wartungs-Abo => abschalten
* (disabledReason 'abo_ende'; ab disabledAt laeuft die 30-Tage-Frist bis zur Loeschung, jobs.js)
* Schritt 1b Einrichtungen, die das Abo-Ende erreicht (state ordered oder setup) => abschalten (disabledReason
* 'abo_ende_vor_einrichtung'); eine bezahlte Einrichtung wird dem Team als Erstattung gemeldet (T19), der Kunde bekommt K22
* mit dem Block vor_einrichtung. Die offene Stripe-Sitzung wird nicht storniert, das Ticket nicht angefasst.
* Schritt 2 fuer jedes abgeschaltete Dokument mit Grund 'abo_ende' oder (bezahlt) 'abo_ende_vor_einrichtung' ohne aboEndeMailAt
* die Meldung nachholen (auch fuer die eben abgeschalteten); der Marker aboEndeMailAt wird erst nach erfolgreichem
* Versand gesetzt
* Schritt 2b Erstattung nachholen, unabhaengig vom Abo (R107, E5b): fuer jedes BEZAHLTE (unlockedByPurchase true), abgeschaltete
* Dokument mit Grund 'abo_ende_vor_einrichtung' und zahlungMeldung !== 'gemeldet' geht T19 an das Team, ob das Wartungs-
* Abo inzwischen wieder laeuft oder nicht lesbar ist (die Erstattung bleibt nach D3 voll geschuldet, die Rueckkehr nach
* Schritt 3 gilt dafuer nicht). Der Nachlauf liest das Abo nie. Danach, in dieser Reihenfolge und nur nach erfolgreichem
* T19: K22 (Block vor_einrichtung), falls aboEndeMailAt noch leer ist, und der Marker. Ein unbezahltes Dokument bekommt nichts.
* Schritt 3 Rueckkehr: ein abgeschaltetes Dokument mit Grund 'abo_ende', dessen Wartungs-Abo wieder laeuft, wird wieder eingeschaltet
* (activate: Daten und Einstellungen bleiben, Grund, Marker und disabledAt enden), solange die Einstellung noch da ist, auch
* nach Tag 30 (der Abgleich laeuft im Tagesjob vor der Loeschung; sonst blockierten sich Loeschung und Rueckkehr).
* K23 geht nur an Kunden, die K22 bekommen haben (Marker aboEndeMailAt, vor dem activate gelesen). Nach der Loeschung ist
* das Dokument `off` und wird nie automatisch aktiv. Nicht automatisch: 'abo_ende_vor_einrichtung' (die Erstattung laeuft,
* das Team entscheidet per WOMS), 'team', 'kunde_geloescht' und Altbestand ohne Grund.
*
* Sofortpfad (E9): endeTermineFuerKunde(customerId) beendet die Terminplaner eines Kunden sofort, wenn dessen Wechsel ins Wartungs-
* Abo in der Frist widerrufen wird (routes/abo.js, withdrawOrder). Dieselben Aktionen wie Schritt 1 und 1b, aber OHNE Abo-Pruefung
* (der Spiegel steht noch auf Wartung, das Team setzt ihn von Hand zurueck); die Meldung (meldeAboEnde) ist dieselbe wie im
* Tagesabgleich. Danach laesst der Tagesabgleich diese Dokumente in Ruhe (aboLage kennt die Wechsel-Regel: kein Flattern).
*
* Regeln (Block E):
* - Eine Listenabfrage fuer alle Kunden (Entwuerfe `off` bleiben ungelesen), ein aboMemo() je Lauf: die Abo-Lage jedes Kunden wird
* fuer die Vorauswahl nur einmal gelesen. Aktive Dokumente, die nicht vom Kauf freigeschaltet wurden (Pilot, Kulanz:
* unlockedByPurchase nicht true), fasst der Abgleich nie an; sie zaehlen in `ausgenommen` und kosten keine Abo-Abfrage.
* - Geschrieben wird nur unter der Sperre feature-admin:<projectId> (wie die Team-Route) und nach frischem Lesen von Dokument UND
* Abo (aboLage direkt, nie der Memo): zwischen Vorauswahl und Sperre kann das Abo zurueckkommen oder das Team abschalten.
* Die Mails (T19, K22) und die Marker folgen NACH der Sperre (meldeAboEnde), denn T19 laeuft ueber meldeErstattung mit eigener
* Sperre feature-meldung:<docId> und darf nie innerhalb einer feature-admin- oder feature-paid-Sperre gerufen werden.
* - Ausnahme von "nach frischem Lesen des Abos": der Nachlauf der Erstattung (Schritt 2b) liest nur das Dokument frisch (unter
* feature-admin:<projectId>), nie das Abo - er schaltet nichts ab und nichts ein, er meldet nur, was geschuldet ist.
* - Fail-closed: ist etwas nicht lesbar (Liste, Abo, Ereignisse, Dokument), wird nichts abgeschaltet. Kunde bzw. Dokument werden
* uebersprungen, `fehler` zaehlt, der naechste Lauf versucht es wieder. Das Log nennt weder Namen noch Adressen.
* - disabledAt aendert dieser Abgleich nie nach dem Abschalten: schlaegt eine Mail fehl, wiederholt der naechste Tag den Versand mit
* denselben Daten (Enddatum, Loeschdatum), die Loeschfrist waechst nicht.
* - Nur die Gruende aus siteFeatures.DISABLED_REASONS gelten. 'team', 'kunde_geloescht' und Altbestand ohne Grund fasst der
* Abgleich nie an.
*/
const GRUND = 'abo_ende'
const NOTIZ = 'Abgeschaltet: Wartungs-Abo beendet'
const GRUND_VOR = 'abo_ende_vor_einrichtung'
const NOTIZ_VOR = 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung'
const NOTIZ_WIEDER = 'Wieder eingeschaltet: Wartungs-Abo läuft wieder'
const NOTIZ_WECHSEL = 'Abgeschaltet: Wechsel widerrufen'
// Lage fuer den Sofortpfad: das Abo bleibt (oder wird wieder) ein Hosting-Abo, K22 nimmt den Block hosting
const LAGE_WECHSEL_WIDERRUFEN = { laeuft: false, art: 'wechsel_widerrufen', endedAt: null }
const ADRESSE = /[^\s<>"',;()]+@[^\s<>"',;()]+/g
/** Fehlertext fuers Log: Name und gekuerzte Meldung, E-Mail-Adressen (SMTP-Antworten nennen sie) unkenntlich. */
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(ADRESSE, '[Adresse]').slice(0, 200)}`
const istAusgenommen = (doc) => doc.state === 'active' && doc.unlockedByPurchase !== true
const istBewertet = (doc) =>
(doc.state === 'active' && doc.unlockedByPurchase === true) ||
doc.state === 'ordered' ||
doc.state === 'setup' ||
(doc.state === 'disabled' && typeof doc.disabledReason === 'string' && doc.disabledReason.startsWith('abo_ende'))
/**
* Schritt 2b: bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist.
* Das Abo spielt keine Rolle: die Erstattung bleibt geschuldet, auch wenn es zurueckkommt.
*/
export const istErstattungOffen = (doc) =>
doc.state === 'disabled' && doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true && doc.zahlungMeldung !== 'gemeldet'
/** Schritt 3: abgeschaltet wegen des Abo-Endes (nur 'abo_ende', nicht 'abo_ende_vor_einrichtung'). */
const istRueckkehr = (doc) => doc.state === 'disabled' && doc.disabledReason === GRUND
const portalFeatures = () => config.collections.portalFeatures
/**
* Der gemeinsame Melder fuer das Ende des Terminplaners (Schritt 2, Schritt 1b und der Sofortpfad E9 rufen nur ihn, nie T19 oder
* K22 einzeln). Reihenfolge: (T19,) K22 an die E-Mail des Kundenkontos, danach der Marker aboEndeMailAt = now.
* - Grund 'abo_ende': K22 mit Block `abo_ende` (Wartungs-Abo beendet) bzw. `hosting` (lage.art 'hosting' oder 'wechsel_widerrufen':
* das Abo ist ein Hosting-Abo), dazu der Kasten `daten`. enddatum = Berliner Tag des Abo-Endes (lage.endedAt), sonst von disabledAt;
* loeschdatum = 30 Tage nach disabledAt.
* - Grund 'abo_ende_vor_einrichtung': ein unbezahltes Dokument (unlockedByPurchase nicht true, die Bestellung wartet noch auf die
* Zahlung) hat nichts zu erstatten: weder T19 noch K22 noch Marker, kein Schreibzugriff (kommt die Zahlung spaeter, meldet
* siteFeatures das als T19 und der Abgleich sendet dann K22). Ein bezahltes Dokument meldet ZUERST die Erstattung an das Team
* (meldeErstattung, T19, genau einmal je Dokument, auch ohne erreichbaren Kunden), dann K22 mit Block `vor_einrichtung` (ohne
* `daten`: es gibt nichts zu loeschen). Scheitert T19 (weder gesendet noch schon gemeldet), folgen weder K22 noch Marker: der
* naechste Tag wiederholt beides in dieser Reihenfolge. Der Marker aboEndeMailAt gilt nur fuer K22: steht er schon, aber T19 ist
* noch nicht gemeldet (R107), geht nur T19 und K22 nicht noch einmal. `lage` braucht dieser Grund nicht (nur 'abo_ende' fuer
* den Block hosting).
* Der Aufrufer liest `doc` frisch und haelt KEINE feature-admin- und keine feature-paid-Sperre (meldeErstattung sperrt selbst
* feature-meldung:<docId>). Ohne Kunde oder ohne E-Mail gibt es keine K22 zu senden: der Marker wird gesetzt, damit es kein
* Wiederholen gibt. Wirft nie.
* @param {object} doc portalFeatures-Dokument (disabled)
* @param {{ lage?: { art: string } | null, now: Date, onGesendet?: () => void }} options onGesendet wird nach jedem versendeten K22 gerufen
* @returns {Promise<boolean>} true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand oder Marker
* gescheitert (der naechste Tag wiederholt)
*/
export async function meldeAboEnde(doc, { lage = null, now, onGesendet = null }) {
const nowMs = pruefeNow(now, 'meldeAboEnde')
const vorEinrichtung = doc.disabledReason === GRUND_VOR
if (doc.disabledReason !== GRUND && !vorEinrichtung) return true
if (vorEinrichtung && doc.unlockedByPurchase !== true) return true // nichts zu erstatten, nichts zu sagen: die Zahlung kommt eventuell noch
const erstattungOffen = vorEinrichtung && doc.zahlungMeldung !== 'gemeldet'
if (doc.aboEndeMailAt && !erstattungOffen) return true // mit Marker ist alles gesagt
const setzeMarker = () => updateDocument(portalFeatures(), doc.$id, { aboEndeMailAt: new Date(nowMs).toISOString() })
let gesendet = false
try {
const customer = await customerOf(doc.customerId)
if (vorEinrichtung) {
const erstattung = await meldeErstattung(doc, { customer, nachAboEnde: false })
if (!erstattung.sent && !erstattung.schonGemeldet) {
console.error(`[termine-abo] T19 für Funktion ${doc.$id} nicht gesendet, der nächste Lauf wiederholt (K22 folgt danach)`)
return false
}
}
if (doc.aboEndeMailAt) return true // K22 ging schon, hier fehlte nur T19
if (!customer?.email) {
console.warn('[termine-abo] K22 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse, Marker wird gesetzt')
await setzeMarker()
return true
}
const projekt = (await projectName(doc.projectId)) || 'Dein Projekt'
const beginn = new Date(Number.isFinite(zeitMs(doc.disabledAt)) ? zeitMs(doc.disabledAt) : nowMs) // disabledAt fehlt nie, ausser bei Altdaten
// enddatum ist der Tag des Abo-Endes (lage.endedAt: endedAt || canceledAt des Abos), wenn bekannt - das Abschalten kann Tage danach liegen;
// sonst (Sofortpfad, Nachlauf ohne Abo-Lesen, kein lesbares Datum) der Tag des Abschaltens. loeschdatum bleibt 30 Tage nach dem Abschalten.
// Nur bei art 'beendet' ist lage.endedAt das Ende des Wartungs-Abos; bei 'hosting' gehoert es zum Hosting-Abo (etwa eine vorgemerkte Kuendigung).
const aboEnde = lage?.art === 'beendet' ? zeitMs(lage.endedAt) : NaN
const ende = Number.isFinite(aboEnde) ? new Date(aboEnde) : beginn
const grundBlock = vorEinrichtung
? { vor_einrichtung: true }
: { [lage?.art === 'hosting' || lage?.art === 'wechsel_widerrufen' ? 'hosting' : 'abo_ende']: true, daten: true }
await sendCustomerMail(customer.email, {
template: 'K22',
values: {
kunde_name: customer.name || '',
projekt,
enddatum: datumKurz(ende),
loeschdatum: datumKurz(loeschzeit(beginn)),
preis: config.features.termine.priceLabel,
portal_url: `${config.publicBaseUrl}/dashboard.html#termine`,
},
blocks: grundBlock,
})
gesendet = true
onGesendet?.()
await setzeMarker()
return true
} catch (err) {
console.error(`[termine-abo] Meldung für Funktion ${doc.$id}: ${gesendet ? 'Marker nicht gesetzt' : 'nicht gesendet'}, der nächste Lauf wiederholt: ${fehlerText(err)}`)
return false
}
}
/**
* Was der Abgleich mit einem Dokument tut, das er aus der Liste bekommt (null = nichts):
* 'abschalten' aktiv und vom Kauf freigeschaltet (Grund abo_ende), 'einrichtung' ordered/setup (Grund abo_ende_vor_einrichtung),
* 'nachholen' abgeschaltet mit dem Grund abo_ende oder (bezahlt) abo_ende_vor_einrichtung ohne Marker. Ein unbezahltes,
* abgeschaltetes abo_ende_vor_einrichtung-Dokument hat nichts nachzuholen.
*/
function aktionFuer(doc) {
if (doc.state === 'active') return doc.unlockedByPurchase === true ? 'abschalten' : null
if (doc.state === 'ordered' || doc.state === 'setup') return 'einrichtung'
if (doc.state !== 'disabled' || doc.aboEndeMailAt) return null
if (doc.disabledReason === GRUND) return 'nachholen'
return doc.disabledReason === GRUND_VOR && doc.unlockedByPurchase === true ? 'nachholen' : null
}
/**
* Ein Dokument: unter der Sperre feature-admin:<projectId> frisch lesen, frisch das Abo lesen, erst dann schreiben; die Meldung
* (meldeAboEnde) folgt nach der Sperre.
* 'abschalten': in der Liste aktiv => noch aktiv und vom Kauf freigeschaltet, Abo laeuft noch nicht wieder => abschalten.
* 'einrichtung': in der Liste ordered/setup => noch ordered/setup (das Team kann inzwischen aktiviert haben), Abo laeuft nicht
* => abschalten (abo_ende_vor_einrichtung). Eine bezahlte Bestellung, die zwischen Liste und Sperre vom Webhook auf setup
* gestellt wurde, liefert disable() bereits als bezahlt zurueck.
* 'nachholen': in der Liste abgeschaltet mit Grund und ohne Marker => noch so und ohne Marker, Abo laeuft weiter nicht.
*/
async function bearbeiteDokument(doc, ctx) {
const { erg, now } = ctx
const aktion = aktionFuer(doc)
const grund = aktion === 'einrichtung' ? GRUND_VOR : GRUND
const gefunden = await withLocks([`feature-admin:${doc.projectId}`], async () => {
const frisch = await getDocument(portalFeatures(), doc.$id)
if (aktion === 'abschalten') {
if (frisch.state !== 'active' || frisch.unlockedByPurchase !== true) return null // das Team war schneller
} else if (aktion === 'einrichtung') {
if (frisch.state !== 'ordered' && frisch.state !== 'setup') return null // aktiviert oder abgeschaltet
} else if (frisch.state !== 'disabled' || frisch.disabledReason !== doc.disabledReason || frisch.aboEndeMailAt) {
return null
}
const lage = await aboLage(doc.customerId) // wirft bei Lesefehler: dann bleibt alles, wie es ist
if (lage.laeuft) return null // das Abo ist zurueck: nichts mehr abzuschalten oder nachzuholen (die Rueckkehr ist Schritt 3, schalteWiederEin)
let zuMelden = frisch
if (aktion !== 'nachholen') {
zuMelden = await disable(frisch.$id, { reason: grund, adminNote: grund === GRUND_VOR ? NOTIZ_VOR : NOTIZ })
if (zuMelden.state !== 'disabled' || zuMelden.disabledReason !== grund) return null // jemand war zwischen Lesen und Schreiben schneller
erg.abgeschaltet += 1
}
return { zuMelden, lage }
})
if (!gefunden) return
const fertig = await meldeAboEnde(gefunden.zuMelden, { lage: gefunden.lage, now, onGesendet: () => (erg.mails += 1) })
if (!fertig) erg.fehler += 1
}
/**
* Schritt 2b, ein Dokument (R107): in der Liste bezahlt, abgeschaltet mit Grund 'abo_ende_vor_einrichtung' und T19 nicht gemeldet.
* Unter der Sperre feature-admin:<projectId> frisch lesen (das Team kann inzwischen aktiviert haben, der Webhook T19 gemeldet), das
* Abo wird NICHT gelesen. Die Meldung (T19, danach K22 und Marker, meldeAboEnde) folgt nach der Sperre. Wirft nie.
* @returns {Promise<boolean>} true = erledigt oder gescheitert (fehler gezaehlt, der naechste Tag wiederholt): das Dokument ist fuer
* diesen Lauf versorgt; false = nichts zu tun (die frische Fassung ist nicht mehr offen): der Abo-Pfad darf es noch bewerten
*/
async function holeErstattungNach(doc, ctx) {
const { erg, now } = ctx
try {
const frisch = await withLocks([`feature-admin:${doc.projectId}`], () => getDocument(portalFeatures(), doc.$id))
if (!istErstattungOffen(frisch)) return false
const fertig = await meldeAboEnde(frisch, { now, onGesendet: () => (erg.mails += 1) })
if (!fertig) erg.fehler += 1
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Erstattung für Funktion ${doc.$id} nicht gemeldet, der nächste Lauf wiederholt: ${fehlerText(err)}`)
}
return true
}
/**
* Sofortpfad, ein Dokument: unter der Sperre feature-admin:<projectId> frisch lesen (das Team kann inzwischen aktiviert oder
* abgeschaltet, der Webhook die Zahlung gebucht haben) und nach dem FRISCHEN Zustand abschalten: aktiv und vom Kauf freigeschaltet =>
* 'abo_ende', ordered/setup => 'abo_ende_vor_einrichtung' (aktionFuer, wie im Tagesabgleich). Das Abo wird nicht gelesen.
* @returns {Promise<object|null>} das abgeschaltete Dokument (mit Kauf- und Zahlungsfeldern fuer meldeAboEnde) oder null, wenn nichts zu tun war
*/
async function beendeSofort(doc) {
return withLocks([`feature-admin:${doc.projectId}`], async () => {
const frisch = await getDocument(portalFeatures(), doc.$id)
const aktion = aktionFuer(frisch)
const grund = aktion === 'abschalten' ? GRUND : aktion === 'einrichtung' ? GRUND_VOR : null
if (!grund) return null
const abgeschaltet = await disable(frisch.$id, { reason: grund, adminNote: NOTIZ_WECHSEL })
return abgeschaltet.state === 'disabled' && abgeschaltet.disabledReason === grund ? abgeschaltet : null // sonst war jemand schneller
})
}
/**
* Sofortpfad beim Widerruf des Wechsels ins Wartungs-Abo (E9): alle Terminplaner des Kunden, die der Wechsel getragen hat, werden
* sofort abgeschaltet, ohne Abo-Pruefung (der Spiegel ist veraltet). Aktiv und vom Kauf freigeschaltet => 'abo_ende'; ordered/setup =>
* 'abo_ende_vor_einrichtung'. Aktive Dokumente ohne Kauf (Pilot, Kulanz), News, 'off' und schon Abgeschaltete bleiben unberuehrt.
* Danach je Dokument NACH der Sperre meldeAboEnde: K22 (Block hosting bzw. vor_einrichtung); eine bezahlte Einrichtung meldet zuerst
* T19 (genau einmal), ein unbezahltes ordered bekommt weder T19 noch K22 noch Marker. Scheitert eine Meldung, holt der Tagesabgleich
* sie nach. Ein Fehler bei einem Dokument (Lesen, Abschalten) stoppt die anderen nicht; Fehler stehen im Log (ohne Namen und Adressen).
* Wirft nie, wie endAnalyseForCustomer.
* @param {string} customerId
* @param {{ now?: Date }} [options] Zeitpunkt des Widerrufs (Marker aboEndeMailAt)
* @returns {Promise<{ beendet: string[] }>} die IDs der abgeschalteten Funktionsdokumente
*/
export async function endeTermineFuerKunde(customerId, { now = new Date() } = {}) {
const beendet = []
if (!customerId) return { beendet }
try {
const kandidaten = (await listCustomerFeatureDocs(customerId)).filter(
(doc) => doc.featureKey === 'termine' && doc.projectId && ['abschalten', 'einrichtung'].includes(aktionFuer(doc))
)
for (const doc of kandidaten) {
try {
const abgeschaltet = await beendeSofort(doc)
if (!abgeschaltet) continue
beendet.push(abgeschaltet.$id)
await meldeAboEnde(abgeschaltet, { lage: LAGE_WECHSEL_WIDERRUFEN, now }) // nach der Sperre; bei Misserfolg wiederholt der Tagesabgleich
} catch (err) {
console.error(`[termine-abo] Sofortpfad: Funktion ${doc.$id} nicht vollständig beendet, der Tagesabgleich holt nach: ${fehlerText(err)}`)
}
}
} catch (err) {
console.error(`[termine-abo] Sofortpfad: Funktionen eines Kunden nicht lesbar, nichts beendet: ${fehlerText(err)}`)
}
return { beendet }
}
/**
* K23 an die E-Mail des Kundenkontos. Nur nach geschriebenem `active` gerufen, nie innerhalb einer Sperre. Scheitert der Versand,
* bleibt die Reaktivierung bestehen: das Log (ohne Adresse) und `fehler` halten es fest; der Marker aboEndeMailAt ist mit dem
* Aktivieren geleert, es gibt also keine Wiederholung am naechsten Tag (K23 geht hoechstens einmal je Rueckkehr). Ohne Kunde oder
* ohne E-Mail-Adresse entfaellt die Mail (Hinweis im Log, kein Fehler). Wirft nie.
*/
async function sendeWiederEin(doc, ctx) {
const { erg } = ctx
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) {
console.warn('[termine-abo] K23 entfällt: Kunde nicht mehr vorhanden oder ohne E-Mail-Adresse')
return
}
await sendCustomerMail(customer.email, {
template: 'K23',
values: {
kunde_name: customer.name || '',
projekt: (await projectName(doc.projectId)) || 'Dein Projekt',
portal_url: `${config.publicBaseUrl}/dashboard.html#termine`,
},
})
erg.mails += 1
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] K23 für Funktion ${doc.$id} nicht gesendet, der Terminplaner bleibt eingeschaltet: ${fehlerText(err)}`)
}
}
/**
* Schritt 3, ein Dokument: in der Liste abgeschaltet mit Grund 'abo_ende' und das Abo laeuft. Unter der Sperre feature-admin:<projectId>
* frisch lesen (noch disabled/abo_ende: das Team kann inzwischen aktiviert oder mit anderem Grund abgeschaltet haben), frisch das
* Abo lesen (laeuft es wieder nicht, bleibt alles), die Einstellung lesen (fehlt sie, ist sie geloescht oder nie gespeichert worden:
* nie automatisch aktiv), dann activate. Ob K22 ging, zeigt der Marker aboEndeMailAt, der VOR dem activate gelesen wird (activate
* leert ihn). K23 folgt nach der Sperre und nur, wenn K22 ging; wer nie von dem Ende erfuhr, hoert auch nicht von der Rueckkehr.
* Lesefehler werfen (der Aufrufer zaehlt fehler): dann bleibt das Dokument abgeschaltet.
*/
async function schalteWiederEin(doc, ctx) {
const { erg } = ctx
const eingeschaltet = await withLocks([`feature-admin:${doc.projectId}`], async () => {
const frisch = await getDocument(portalFeatures(), doc.$id)
if (!istRueckkehr(frisch)) return null
const lage = await aboLage(doc.customerId)
if (!lage.laeuft) return null // das Abo endete zwischen Vorauswahl und Sperre wieder
if ((await ladeEinstellung(frisch.$id)) === null) return null // Daten geloescht oder nie da: Rueckkehr nur, solange sie existieren
const k22Ging = Boolean(frisch.aboEndeMailAt)
await activate(frisch.$id, { adminNote: NOTIZ_WIEDER })
erg.wiederEin += 1
return { k22Ging }
})
if (eingeschaltet?.k22Ging) await sendeWiederEin(doc, ctx)
}
async function bearbeiteKunde(customerId, dokumente, ctx) {
const { erg } = ctx
erg.ausgenommen += dokumente.filter(istAusgenommen).length
const bewertet = dokumente.filter(istBewertet)
if (bewertet.length === 0) return // nichts zu bewerten: das Abo wird nicht gelesen
// Schritt 2b (R107) VOR dem Abo: die Erstattung haengt nicht davon ab, ob das Abo lesbar ist oder wieder laeuft
const versorgt = new Set()
for (const doc of bewertet.filter(istErstattungOffen)) {
if (await holeErstattungNach(doc, ctx)) versorgt.add(doc)
}
erg.geprueft += versorgt.size
const uebrige = bewertet.filter((doc) => !versorgt.has(doc))
if (uebrige.length === 0) return // alles versorgt: das Abo wird nicht gelesen
let lage
try {
lage = await ctx.memo.lage(customerId)
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Abo-Lage eines Kunden nicht lesbar, ${uebrige.length} Funktion(en) übersprungen: ${fehlerText(err)}`)
return
}
erg.geprueft += uebrige.length
for (const doc of uebrige) {
// laeuft das Abo, gibt es nur die Rueckkehr (Schritt 3); sonst abschalten, einrichten, nachholen (Schritte 1, 1b, 2)
const bearbeite = lage.laeuft ? istRueckkehr(doc) && schalteWiederEin : aktionFuer(doc) && bearbeiteDokument
if (!bearbeite) continue
try {
await bearbeite(doc, ctx)
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Funktion ${doc.$id} nicht bearbeitet: ${fehlerText(err)}`)
}
}
}
/**
* Taeglicher Abgleich des Terminplaners mit dem Wartungs-Abo (siehe Kopf der Datei). Wirft nie (ausser bei fehlendem `now`, wie
* alle Termine-Jobs); jedes Problem steht im Log und in `fehler`.
* @param {Date} now
* @returns {Promise<{ geprueft: number, abgeschaltet: number, wiederEin: number, mails: number, ausgenommen: number, fehler: number }>}
* geprueft = bewertete Dokumente (aktiv mit unlockedByPurchase true, ordered/setup, disabled mit Grund abo_ende*, deren Kunde
* lesbar war; der Nachlauf der Erstattung, Schritt 2b, braucht das Abo nicht und zaehlt immer), abgeschaltet = in diesem Lauf abgeschaltet, wiederEin = in diesem Lauf wieder eingeschaltet (Schritt 3), mails =
* versendete Kundenmails K22 und K23 (T19 geht ans Team und zaehlt nicht), ausgenommen = aktive Dokumente ohne Kauf (Pilot, Kulanz),
* fehler = Kunden und Dokumente, die nicht bearbeitet werden konnten, sowie ein gescheiterter K23-Versand (das Dokument ist dann
* trotzdem wieder aktiv)
*/
export async function gleicheAboAb(now) {
pruefeNow(now, 'gleicheAboAb')
const erg = { geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 }
try {
let dokumente
try {
dokumente = await listAllDocuments(portalFeatures(), [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')])
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Funktionen nicht lesbar, der Abgleich entfällt: ${fehlerText(err)}`)
return erg
}
const nachKunde = new Map()
for (const doc of dokumente) {
if (!doc.customerId || !doc.projectId) {
// ohne Kunde gaelte "kein Abo": nie abschalten, was sich nicht zuordnen laesst
erg.fehler += 1
console.error(`[termine-abo] Funktion ${doc.$id} ohne Kunde oder Projekt übersprungen`)
continue
}
if (!nachKunde.has(doc.customerId)) nachKunde.set(doc.customerId, [])
nachKunde.get(doc.customerId).push(doc)
}
const ctx = { erg, now, memo: aboMemo() }
for (const [customerId, liste] of nachKunde) {
try {
await bearbeiteKunde(customerId, liste, ctx)
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Kunde nicht bearbeitet: ${fehlerText(err)}`)
}
}
} catch (err) {
erg.fehler += 1
console.error(`[termine-abo] Abgleich abgebrochen: ${fehlerText(err)}`)
}
return erg
}
+309
View File
@@ -0,0 +1,309 @@
import { config } from '../../config.js'
import { Query, countDocuments } from '../appwriteAdmin.js'
import { sendAdminNotice } from '../mailer.js'
import { mailBudget, pausiertBis, verworfeneG01, warteschlangeAlter } from './outbox.js'
import { pruefeNow, pruefeText } from './util.js'
import { datumKurz, uhrzeit } from './zeit.js'
/**
* Betriebspruefung des Terminplaners (Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 8, Punkt 5
* „Sichtbarkeit“): Das Team soll es erfahren, bevor Gaeste es merken. Der Minutenjob ruft pruefeBetrieb am Ende jedes Laufs auf
* (Modus off: gar nicht), der Tagesjob meldeTagesjob.
*
* Vorfaelle dieses Bausteins (Schluessel):
* budget mindestens 70 % von config.termine.mailsProStunde im Stundenfenster belegt (mailBudget)
* queue der aelteste faellige Eintrag der Warteschlange ist mehr als 10 Minuten ueberfaellig (warteschlangeAlter);
* eine Wiederholung mit naechsterVersuch in der Zukunft ist nicht faellig und zaehlt nicht
* pause die Anmeldepause des Mailservers laeuft (pausiertBis)
*
* Vorfaelle des Tagesjobs (meldeTagesjob, am Ende von taeglicherJob):
* tagesjob der Tagesjob endete mit Fehlern (fehler > 0) oder brach ab
* deckel die Loeschlaeufe endeten an 3 Tagesjobs in Folge am Deckel (irgendein Bereich: Fristen, abgeschaltete Funktionen, Zellen)
* betriebe mindestens 20 aktive Betriebe (Entscheidung D8: ab 25 wird der Umstieg auf einen EU-Versanddienst geplant)
* Daneben merkt meldeTagesjob den Stand des letzten Tagesjobs fuer die Statusroute (letzterTagesjob: nur Zaehlungen).
*
* Gemeinsamer Melder (meldeVorfall, auch fuer die Tagesjob-Vorfaelle): je Schluessel
* - Warnlog `[termine-betrieb] ...` hoechstens stuendlich (nur Zahlen, nie Adresse oder Name eines Gastes),
* - Team-Mail T20 (sendAdminNotice) beim Beginn und danach fruehestens nach 6 Stunden, auch wenn der Vorfall zwischendurch
* endete. Die Abkuehlzeit beginnt mit dem Versuch, nicht erst mit dem Erfolg: faellt der Mailserver aus (bei der Anmeldepause
* ist er es), scheitert auch die Team-Mail, und das Log bleibt der Rueckfall, statt dass jede Minute ein neuer Versuch folgt.
* Zustand (Zeit der letzten Warnung und der letzten Mail je Schluessel) lebt im Speicher dieses Prozesses wie das Mailbudget.
*
* Lesefehler (Appwrite) ergeben keinen Vorfall und keine Mail, nur ein Log (ebenfalls hoechstens stuendlich je Pruefung).
* `now` ist immer ein Parameter (feste Uhr in Tests).
*
* Statusroute (statusDaten, GET /api/team/termine/status): derselbe Stand auf Abruf fuers Team, ohne Vorfall, Mail oder
* Abkuehlzeit; Lesefehler ergeben null-Werte und ein Log, nie einen Fehler.
*/
const LOG = '[termine-betrieb]'
const MIN_MS = 60 * 1000
const STUNDE_MS = 60 * MIN_MS
const LOG_ABSTAND_MS = STUNDE_MS
const MAIL_ABKUEHLZEIT_MS = 6 * STUNDE_MS
/** Die Warteschlange gilt als gestaut, wenn der aelteste faellige Eintrag laenger als so viele Minuten ueberfaellig ist. */
const QUEUE_ALTER_MIN = 10
/** Das Budget gilt als fast aufgebraucht ab 7/10 (ganzzahlig gerechnet: benutzt x 10 >= max x 7, kein Fliesskommafehler). */
const budgetErreicht = (benutzt, max) => max > 0 && benutzt * 10 >= max * 7
/** Der Loeschdeckel gilt als Dauerzustand, wenn so viele Tagesjobs in Folge daran endeten. */
const DECKEL_FOLGE_AB = 3
/** Ab so vielen aktiven Betrieben (D8) gehen Warnlog und Team-Mail; ab BETRIEBE_UMSTIEG wird der EU-Versanddienst geplant. */
const BETRIEBE_AB = 20
const BETRIEBE_UMSTIEG = 25
/** Kurze Ueberschrift der Team-Mail (Betreff `[Terminplaner] <Ueberschrift>`); ohne Eintrag gilt die allgemeine. */
const UEBERSCHRIFTEN = new Map([
['budget', 'Mailbudget fast aufgebraucht'],
['queue', 'Mail-Warteschlange staut sich'],
['pause', 'Mailversand pausiert'],
['tagesjob', 'Tagesjob mit Fehlern'],
['deckel', 'Löschläufe am Deckel'],
['betriebe', 'Viele aktive Betriebe'],
])
const ALLGEMEINE_UEBERSCHRIFT = 'Betriebsmeldung'
/** schluessel => { logMs, mailMs } (null = noch nie). */
const gemerkt = new Map()
/** Stand des letzten Tagesjobs ({ at, fehler, gedeckelt, geloescht }, nur Zaehlungen) oder null (nach einem Neustart bis zum Tagesjob). */
let letzter = null
/** Zahl der Tagesjobs in Folge, die in irgendeinem Bereich am Loeschdeckel endeten. */
let deckelFolge = 0
function merkzettel(schluessel) {
let zettel = gemerkt.get(schluessel)
if (!zettel) {
zettel = { logMs: null, mailMs: null }
gemerkt.set(schluessel, zettel)
}
return zettel
}
/** Ist seit dem letzten Mal (ms oder null = nie) mindestens abstandMs vergangen? */
const abgelaufen = (letzteMs, nowMs, abstandMs) => letzteMs === null || nowMs - letzteMs >= abstandMs
/** '05.10.2026, 10:15' (Berliner Wanduhr), so steht die Zeit in T20 und in den Meldungen. */
const zeitText = (date) => `${datumKurz(date)}, ${uhrzeit(date)}`
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').replace(/\s+/g, ' ').slice(0, 200)}`
/**
* Meldet einen Vorfall: Warnlog (hoechstens stuendlich je Schluessel) und Team-Mail T20 (beim Beginn und danach fruehestens nach
* 6 Stunden je Schluessel). Wirft nur bei ungueltigen Eingaben (TypeError); ein Mailfehler steht im Log.
* @param {string} schluessel Kennung des Vorfalls ('budget', 'queue', 'pause', ...); die Abkuehlzeit gilt je Schluessel
* @param {string} text die Meldung (Log und Mail-Details): nur Zahlen, nie Adressen oder Namen
* @param {Date} now
* @returns {Promise<void>}
*/
export async function meldeVorfall(schluessel, text, now) {
pruefeText(schluessel, 'schluessel')
pruefeText(text, 'text')
const nowMs = pruefeNow(now, 'meldeVorfall')
const zettel = merkzettel(schluessel)
if (abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) {
zettel.logMs = nowMs
console.warn(`${LOG} ${text}`)
}
if (!abgelaufen(zettel.mailMs, nowMs, MAIL_ABKUEHLZEIT_MS)) return
// Vor dem Senden gemerkt: ein zweiter Aufruf, waehrend die Mail noch unterwegs ist, sendet nicht noch einmal
zettel.mailMs = nowMs
const ergebnis = await sendAdminNotice({
template: 'T20',
values: { vorfall: UEBERSCHRIFTEN.get(schluessel) ?? ALLGEMEINE_UEBERSCHRIFT, details: text, zeit: zeitText(now) },
})
if (!ergebnis?.sent) {
console.error(`${LOG} Team-Mail zu „${schluessel}“ nicht gesendet: ${String(ergebnis?.reason ?? 'unbekannt').slice(0, 200)}`)
}
}
/** Lesefehler einer Pruefung ins Log: hoechstens stuendlich je Pruefung, damit ein langer Ausfall das Log nicht fuellt. */
function meldeFehler(schluessel, err, nowMs) {
const zettel = merkzettel(`fehler:${schluessel}`)
if (!abgelaufen(zettel.logMs, nowMs, LOG_ABSTAND_MS)) return
zettel.logMs = nowMs
console.error(`${LOG} Prüfung „${schluessel}“ nicht möglich: ${fehlerText(err)}`)
}
// Die Pruefungen: Text des Vorfalls oder null (kein Vorfall); werfen duerfen sie (Lesefehler), pruefeBetrieb faengt das ab.
function budgetVorfall(now) {
const { benutzt, max } = mailBudget(now).stunde
if (!budgetErreicht(benutzt, max)) return null
return `Mailbudget ${benutzt}/${max} in der letzten Stunde (Hinweis ab 70 %); ist es voll, warten weitere Mails bis zur nächsten freien Stunde`
}
async function queueVorfall(now) {
const { wartet, aeltesteMin } = await warteschlangeAlter(now)
if (!(aeltesteMin > QUEUE_ALTER_MIN)) return null
const anzahl = wartet === null ? '' : `, ${wartet} wartend`
return `Mail-Warteschlange: der älteste fällige Eintrag ist seit ${aeltesteMin} Min überfällig${anzahl}`
}
function pauseVorfall(now) {
const bis = pausiertBis()
if (!bis || !(bis.getTime() > now.getTime())) return null
return `Mailversand pausiert bis ${zeitText(bis)} Uhr (SMTP-Anmeldung fehlgeschlagen), die Mails warten in der Warteschlange`
}
/** In dieser Reihenfolge werden Vorfaelle geprueft und gemeldet. */
const PRUEFUNGEN = [
['budget', budgetVorfall],
['queue', queueVorfall],
['pause', pauseVorfall],
]
/**
* Prueft Mailbudget, Warteschlange und Anmeldepause und meldet jeden aktiven Vorfall ueber meldeVorfall. Wirft nie (ausser mit
* TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): ein Lesefehler oder ein fehlgeschlagener Mailversand steht im Log
* und laesst die uebrigen Pruefungen laufen. Eine Pruefung, die nicht lesen konnte, meldet keinen Vorfall.
* @param {Date} now
* @returns {Promise<string[]>} die Schluessel der aktiven Vorfaelle ('budget', 'queue', 'pause')
*/
export async function pruefeBetrieb(now) {
const nowMs = pruefeNow(now, 'pruefeBetrieb')
const aktiv = []
for (const [schluessel, pruefe] of PRUEFUNGEN) {
try {
const text = await pruefe(now)
if (text === null) continue
aktiv.push(schluessel)
await meldeVorfall(schluessel, text, now)
} catch (err) {
meldeFehler(schluessel, err, nowMs)
}
}
return aktiv
}
// ---------------------------------------------------------------- Tagesjob
/** Eine Zaehlung: ganze Zahl ab 0, alles andere (fehlt, Text, negativ, Bruch) zaehlt als 0. */
const zaehlung = (wert) => (Number.isInteger(wert) && wert > 0 ? wert : 0)
/** gedeckelt = { loeschfristen, abgeschaltet, zellen } (Schritte, die am Deckel endeten) => ihre Summe. */
const summeGedeckelt = (gedeckelt) =>
gedeckelt !== null && typeof gedeckelt === 'object' ? Object.values(gedeckelt).reduce((summe, n) => summe + zaehlung(n), 0) : 0
/**
* Anzahl der aktiven Terminplaner-Betriebe: portalFeatures featureKey termine im Zustand active (ein Aufruf mit limit(1), gelesen wird
* total). Entwurf (off), bestellt, im Einbau und abgeschaltet zaehlen nicht.
* @returns {Promise<number|null>} null, wenn Appwrite keine Anzahl liefert; wirft bei einem Lesefehler
*/
export function zaehleAktiveBetriebe() {
return countDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'active')])
}
/** Stand des letzten Tagesjobs als Kopie ({ at (ISO), fehler, gedeckelt, geloescht }) oder null, solange seit dem Start keiner gemeldet hat. */
export function letzterTagesjob() {
return letzter === null ? null : { ...letzter }
}
/**
* Meldung am Ende des Tagesjobs (taeglicherJob ruft sie mit seinem Ergebnis): merkt den Stand fuer letzterTagesjob() und meldet ueber
* meldeVorfall die Vorfaelle tagesjob (fehler > 0 oder abbruch), deckel (Loeschdeckel an 3 Tagesjobs in Folge) und betriebe (ab 20
* aktiven Betrieben). Die Texte tragen nur Zahlen. Wirft nie (ausser mit TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): ein fehlgeschlagener Mailversand steht
* im Log, die uebrigen Vorfaelle werden trotzdem gemeldet. Zustand (letzter Lauf, Deckel-Folge) lebt im Speicher dieses Prozesses.
* @param {{ fehler?: number, gedeckelt?: { loeschfristen?: number, abgeschaltet?: number, zellen?: number }, geloescht?: number, aktiveBetriebe?: number|null, abbruch?: boolean }} ergebnis
* fehler = Zahl der Fehler des Laufs; gedeckelt = Schritte je Bereich, die am Deckel endeten; geloescht = Summe aller Loeschzaehler;
* aktiveBetriebe = Zahl oder null (unbekannt); abbruch = der Job brach unerwartet ab (dann fehlen die uebrigen Zahlen und die
* Deckel-Folge bleibt unveraendert)
* @param {Date} now
* @returns {Promise<string[]>} die Schluessel der Vorfaelle dieses Laufs ('tagesjob', 'deckel', 'betriebe')
*/
export async function meldeTagesjob(ergebnis, now) {
const nowMs = pruefeNow(now, 'meldeTagesjob')
const gemeldet = []
try {
const abbruch = ergebnis?.abbruch === true
const fehler = zaehlung(ergebnis?.fehler)
const gedeckelt = summeGedeckelt(ergebnis?.gedeckelt)
const betriebe = Number.isInteger(ergebnis?.aktiveBetriebe) && ergebnis.aktiveBetriebe >= 0 ? ergebnis.aktiveBetriebe : null
letzter = { at: new Date(nowMs).toISOString(), fehler, gedeckelt, geloescht: zaehlung(ergebnis?.geloescht) }
if (!abbruch) deckelFolge = gedeckelt > 0 ? deckelFolge + 1 : 0
const vorfaelle = []
if (abbruch || fehler > 0) {
vorfaelle.push([
'tagesjob',
abbruch
? `Tagesjob abgebrochen (unerwarteter Fehler), ${fehler} Fehler gezählt; Einzelheiten stehen im Log`
: `Tagesjob mit ${fehler} Fehler${fehler === 1 ? '' : 'n'}; Einzelheiten stehen im Log`,
])
}
if (deckelFolge >= DECKEL_FOLGE_AB) {
vorfaelle.push([
'deckel',
`Löschläufe endeten an ${deckelFolge} Tagesjobs in Folge am Deckel (im letzten Lauf ${gedeckelt} Schritte); der Rest folgt an den nächsten Tagen, hält der Rückstand an, sind die Löschdeckel zu prüfen`,
])
}
if (betriebe !== null && betriebe >= BETRIEBE_AB) {
vorfaelle.push([
'betriebe',
`${betriebe} aktive Betriebe (Hinweis ab ${BETRIEBE_AB}); ab ${BETRIEBE_UMSTIEG} aktiven Betrieben ist der Umstieg auf einen EU-Versanddienst zu planen`,
])
}
for (const [schluessel, text] of vorfaelle) {
gemeldet.push(schluessel)
try {
await meldeVorfall(schluessel, text, now)
} catch (err) {
meldeFehler(`meldung:${schluessel}`, err, nowMs)
}
}
} catch (err) {
meldeFehler('meldung', err, nowMs)
}
return gemeldet
}
// ---------------------------------------------------------------- Statusroute
/**
* Liest einen Wert fuer die Statusroute: ein Lesefehler ergibt `ersatz` und ein Log (hoechstens stuendlich je Wert, eigener
* Merkzettel `status-<wert>`), nie einen Fehler.
*/
async function leseOderErsatz(wert, nowMs, lese, ersatz) {
try {
return await lese()
} catch (err) {
meldeFehler(`status-${wert}`, err, nowMs)
return ersatz
}
}
/**
* Stand des Terminplaner-Betriebs fuer die Team-Route GET /api/team/termine/status: nur Zahlen, Wahrheitswerte, Zeitpunkte und
* Funktions-IDs, nie eine Adresse oder ein Name eines Gastes.
* budget mailBudget(now) plus verworfeneG01 (veraltete G01, die dieser Prozess seit dem Start verworfen hat)
* queue { wartet, aeltesteMin } wie warteschlangeAlter; bei einem Lesefehler beide null
* pause { aktiv, bis }: aktiv, solange das Ende der Anmeldepause nach `now` liegt, dann ist bis dessen Zeitpunkt (ISO), sonst null
* letzterTagesjob null (seit dem Start lief noch kein Tagesjob) oder { at (ISO), fehler, gedeckelt, geloescht }
* aktiveBetriebe Zahl der aktiven Termine-Dokumente (zaehleAktiveBetriebe); null bei einem Lesefehler
* Wirft nie (ausser mit TypeError bei ungueltigem `now`, wie alle Termine-Bausteine): was nicht lesbar war, ist null und steht im Log.
* @param {Date} now
* @returns {Promise<{ budget: object, queue: { wartet: number|null, aeltesteMin: number|null }, pause: { aktiv: boolean, bis: string|null },
* letzterTagesjob: { at: string, fehler: number, gedeckelt: number, geloescht: number }|null, aktiveBetriebe: number|null }>}
*/
export async function statusDaten(now) {
const nowMs = pruefeNow(now, 'statusDaten')
const [queue, aktiveBetriebe] = await Promise.all([
leseOderErsatz('queue', nowMs, () => warteschlangeAlter(now), { wartet: null, aeltesteMin: null }),
leseOderErsatz('betriebe', nowMs, zaehleAktiveBetriebe, null),
])
const bis = pausiertBis()
const pauseAktiv = bis !== null && bis.getTime() > nowMs
return {
budget: { ...mailBudget(now), verworfeneG01: verworfeneG01() },
queue: { wartet: queue.wartet, aeltesteMin: queue.aeltesteMin },
pause: { aktiv: pauseAktiv, bis: pauseAktiv ? bis.toISOString() : null },
letzterTagesjob: letzterTagesjob(),
aktiveBetriebe,
}
}
/** Nur fuer Tests: den gemerkten Zustand (Zeit der letzten Warnung und Mail je Schluessel, letzter Tagesjob, Deckel-Folge) zuruecksetzen. */
export function resetBetriebForTests() {
gemerkt.clear()
letzter = null
deckelFolge = 0
}
+59 -6
View File
@@ -31,6 +31,10 @@ const LOESCH_BEZUG = {
nichterschienen: 'endsAt',
}
/** Eine Seite von loeschKandidaten und die Felder, die sie liest (nie Gastfelder). */
const LOESCH_SEITE = 100
const LOESCH_FELDER = ['$id', 'featureId', 'status', 'endsAt', 'updatedAt', 'heldUntil']
const iso = (wert) => new Date(wert).toISOString()
const kleinGetrimmt = (email) => (typeof email === 'string' ? email.trim().toLowerCase() : '')
@@ -181,14 +185,63 @@ export async function vergangeneAnfragen(now) {
}
/**
* Buchungen eines Status (alle Funktionen), deren Bezugszeit vor `vor` liegt. Bezugszeit je Status: verfallen =
* heldUntil, erledigt und nichterschienen = endsAt (Terminende), abgesagt und abgelehnt = updatedAt (letzte
* Aenderung, also der Zeitpunkt der Absage; endgueltige Status werden danach nicht mehr geaendert). Die Frist je
* Funktion (aufbewahrungTage) wendet der Aufrufer auf das Ergebnis an. Andere Status: TypeError.
* Buchungen eines Status, deren Bezugszeit vor `vor` liegt (die Frist steht in der Abfrage, nie nachtraeglich im Code):
* hoechstens `limit` (Standard 100, eine Seite), die mit der aeltesten Bezugszeit zuerst. Bezugszeit je Status: verfallen =
* heldUntil, erledigt und nichterschienen = endsAt (Terminende), abgesagt und abgelehnt = updatedAt (letzte Aenderung, also
* der Zeitpunkt der Absage; endgueltige Status werden danach nicht mehr geaendert). Mit featureId nur diese Funktion, sonst alle.
* Ohne Cursor: wer loescht, liest die Liste danach neu (Geloeschtes fehlt). Die Treffer tragen nur $id, featureId, status,
* endsAt, updatedAt und heldUntil, nie Gastfelder (Name, Adresse). Die Frist je Funktion (aufbewahrungTage) wendet der
* Aufrufer auf das Ergebnis an. Andere Status: TypeError.
*/
export async function loeschKandidaten(status, vor) {
export async function loeschKandidaten(status, vor, { featureId = null, limit = LOESCH_SEITE } = {}) {
if (!Object.hasOwn(LOESCH_BEZUG, status)) throw new TypeError(`loeschKandidaten: Status ${status} wird nicht automatisch geloescht`)
return listAllDocuments(col(), [Query.equal('status', status), Query.lessThan(LOESCH_BEZUG[status], iso(vor))])
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`loeschKandidaten: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
const bezug = LOESCH_BEZUG[status]
return listDocuments(col(), [
Query.equal('status', status),
Query.lessThan(bezug, iso(vor)),
...(featureId === null ? [] : [Query.equal('featureId', featureId)]),
Query.orderAsc(bezug),
Query.limit(limit),
Query.select(LOESCH_FELDER),
])
}
/** Feld der Bezugszeit einer Loeschfrist (siehe loeschKandidaten); undefined fuer Status, die nicht automatisch geloescht werden. */
export const loeschBezugsfeld = (status) => (Object.hasOwn(LOESCH_BEZUG, status) ? LOESCH_BEZUG[status] : undefined)
/**
* Eine Seite Buchungen einer Funktion (jeder Status, auch Sperren): hoechstens `limit` (Standard 100), nur mit der Kennung ($id),
* nie mit Gastfeldern. Fuer die Loeschung abgeschalteter Funktionen: wer loescht, liest die Liste danach neu (Geloeschtes fehlt).
* Ohne Funktion oder mit ungueltiger Grenze: TypeError (nie eine Abfrage ohne Funktionsfilter oder ohne Grenze, die Treffer werden
* unwiderruflich geloescht).
*/
export async function ersteBuchungen(featureId, limit = LOESCH_SEITE) {
if (typeof featureId !== 'string' || featureId === '') throw new TypeError('ersteBuchungen: featureId muss ein nicht leerer Text sein')
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`ersteBuchungen: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
return listDocuments(col(), [Query.equal('featureId', featureId), Query.limit(limit), Query.select(['$id'])])
}
/** Zeilen je Seite des CSV-Exports (exportSeite). */
export const EXPORT_SEITE = 500
/**
* Eine Seite Buchungen einer Funktion fuer den CSV-Export (jeder Status, auch Sperren; die filtert der Aufrufer je Seite
* heraus): hoechstens `limit` (Standard 500), nach Beginn (startsAt) aufsteigend, ab dem Dokument `nach` (dessen ID, null =
* erste Seite). Der Cursor ist immer die letzte Zeile der ungefilterten Seite. Sortiert wird nur nach startsAt (Index
* idx_feature_start; Appwrite haengt die interne Reihenfolge fuer gleiche Beginne selbst an), nie nach $id.
* Ohne Funktion, mit ungueltigem Cursor oder ungueltiger Grenze: TypeError (nie eine Abfrage ohne Funktionsfilter: Gastdaten).
*/
export async function exportSeite(featureId, { nach = null, limit = EXPORT_SEITE } = {}) {
if (typeof featureId !== 'string' || featureId === '') throw new TypeError('exportSeite: featureId muss ein nicht leerer Text sein')
if (nach !== null && (typeof nach !== 'string' || nach === '')) throw new TypeError('exportSeite: nach muss null oder eine Dokument-ID sein')
if (!Number.isInteger(limit) || limit < 1) throw new TypeError(`exportSeite: limit muss eine ganze Zahl ab 1 sein, nicht ${limit}`)
return listDocuments(col(), [
Query.equal('featureId', featureId),
Query.orderAsc('startsAt'),
Query.limit(limit),
...(nach === null ? [] : [Query.cursorAfter(nach)]),
])
}
/** Zahl der offenen Anfragen (Status angefragt) einer Funktion. */
+856
View File
@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' })[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 [
`<div ${attribute.join(' ')}>`,
` <p>${ausweich}</p>`,
'</div>',
`<script src="${esc(apiBase)}/embed/termine.js" defer></script>`,
].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,
}
}
+3 -3
View File
@@ -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]
@@ -559,13 +559,13 @@ function farbe(fehl, wert) {
// ---------------------------------------------------------------- Pflichtangaben und Widerruf
const MELDUNG_PFLICHT = {
export const MELDUNG_PFLICHT = {
'betrieb.telefon': 'Bitte gib die Telefonnummer deines Betriebs an. Gäste sehen sie als Ausweichweg und in ihren Mails.',
'betrieb.adresse': 'Bitte gib die Anschrift deines Betriebs an. Sie steht in der Widerrufsbelehrung, die Gäste vor der Buchung sehen.',
}
/** Leistungen, die Gaeste sehen: aktiv und mit mindestens einer aktiven Ressource (wie in oeffentlicheEinstellung). */
function sichtbareLeistungen(einstellung) {
export function sichtbareLeistungen(einstellung) {
const aktiveIds = new Set((einstellung?.ressourcen || []).filter((r) => r?.aktiv).map((r) => r.id))
return (einstellung?.leistungen || []).filter((l) => l?.aktiv && (l.ressourcen || []).some((id) => aktiveIds.has(id)))
}
+204
View File
@@ -0,0 +1,204 @@
import { config } from '../../config.js'
import { Query, deleteDocument, listDocuments } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { listCustomerFeatureDocs } from '../siteFeatures.js'
import { ersteBuchungen } from './buchungen.js'
import { holeOderNull } from './kontext.js'
import { pruefeNow, pruefeText, zeitMs } from './util.js'
/**
* Entwuerfe des Terminplaners aufbewahren und loeschen (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md, Abschnitt 3.7).
*
* Ein Entwurf ist ein Funktionsdokument im Zustand `off` (portalFeatures, featureKey termine) samt Einstellung und Fragebogen
* (termineConfig, gleiche ID), die ein Kunde vor der Bestellung ausfuellt. Bestellt er nie, bleiben diese Angaben nicht ewig liegen:
*
* - loescheEntwuerfe (Baustein des Tagesjobs, jobs.js): ein Entwurf, dessen letzte Aenderung laenger als ENTWURF_TAGE (90) Tage her
* ist, verliert seine Einstellung und, wenn es nie in Betrieb oder bezahlt war, auch das Funktionsdokument. Die letzte Aenderung
* ist der juengere Zeitpunkt aus `$updatedAt` des Funktionsdokuments (Zustandswechsel, z. B. nach einer abgebrochenen Zahlung) und
* `termineConfig.updatedAt` (Speichern der Einstellung oder des Fragebogens; PUT /fragebogen aendert nur das termineConfig-Dokument).
* - loescheEntwuerfeVonKunde (Kundenloeschung, customerAdmin.js): alle `off`-Entwuerfe des Kunden sofort und vollstaendig.
*
* Das Funktionsdokument bleibt, sobald es einmal in Betrieb oder bezahlt war (activatedAt, ticketId, disabledAt, unlockedByPurchase,
* purchaseStatus paid), und ebenso das von E8 zurueckgesetzte (purchaseStatus zurueckgesetzt, dauerhafte Marke der Loeschung nach dem
* Abo-Ende): sein siteKey steckt im Snippet auf der Website des Kunden und muss gueltig bleiben. Solche Dokumente verlieren hoechstens
* ihre Einstellung. Nie angefasst werden ordered, setup, active und disabled (die Abfrage liest nur `off`).
*
* Sicherheit gegen Rennen: Die Liste ist nur die Vorauswahl. Vor jedem Loeschen werden unter den Sperren
* feature-order:<kunde>:<projekt> (Bestellung), feature-admin:<projekt> (Team) und termine-cfg:<id> (Speichern der Einstellung) das
* Funktionsdokument und die Einstellung frisch gelesen und alle Bedingungen erneut geprueft (noch `off`, Frist noch ueberschritten,
* keine Buchung zur Funktion). Im Zweifel (Lesefehler, unlesbares Datum) wird nichts geloescht.
*
* Deckel: eine Seite der Liste hat hoechstens SEITE (100) Dokumente (sortiert nach $id, per cursorAfter). Die Liste wird ganz gelesen,
* bevor das erste Dokument geloescht wird (ein geloeschtes Dokument taugt nicht mehr als Cursor); nach MAX_GEPRUEFT (2000) Dokumenten
* endet sie mit einer Logzeile. Danach werden hoechstens MAX_GELOESCHT (100) Entwuerfe je Lauf geloescht; die uebrigen faelligen
* zaehlen als `uebrig`. Geloeschte Entwuerfe fehlen am naechsten Tag in der Liste, der Rest laeuft also an Folgetagen weiter.
* Einzelfehler zaehlen in `fehler` und brechen den Lauf nicht ab (Log ohne Kennungen: weder Kunden-, Projekt- und Funktions-IDs noch
* siteKeys).
*/
export const ENTWURF_TAGE = 90
export const MAX_GEPRUEFT = 2000
export const MAX_GELOESCHT = 100
const FUNKTION = 'termine'
const SEITE = 100
const TAG_MS = 24 * 60 * 60 * 1000
/** So viele Einzelfehler stehen je Lauf im Log; die Zahl steht danach in einer Schlusszeile (fehler im Ergebnis zaehlt alle). */
const MAX_FEHLER_IM_LOG = 5
const fehlerText = (err) => `${err?.name ?? 'Fehler'} ${String(err?.message ?? '').slice(0, 200)}`
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
/** Dokument loeschen. => true, wenn es geloescht wurde, false, wenn es schon weg war (404 ist kein Fehler). */
async function loescheOderWeg(collectionId, id) {
try {
await deleteDocument(collectionId, id)
return true
} catch (err) {
if (dokumentFehlt(err)) return false
throw err
}
}
/**
* Zeitpunkt (ms) der letzten Aenderung eines Entwurfs: der juengere aus $updatedAt des Funktionsdokuments und updatedAt der
* Einstellung (fehlt das Attribut, deren $updatedAt). NaN, wenn sich ein vorhandener Zeitpunkt nicht lesen laesst (dann gilt der
* Entwurf nie als faellig).
*/
function letzteAenderung(funktion, einstellung) {
const zeiten = [zeitMs(funktion.$updatedAt)]
if (einstellung) {
const eigene = zeitMs(einstellung.updatedAt)
zeiten.push(Number.isFinite(eigene) ? eigene : zeitMs(einstellung.$updatedAt))
}
return zeiten.every(Number.isFinite) ? Math.max(...zeiten) : Number.NaN
}
/** Darf auch das Funktionsdokument weg? Nur, wenn es nie in Betrieb oder bezahlt war und nicht von E8 zurueckgesetzt wurde (siteKey). */
const nieInBetrieb = (funktion) =>
!funktion.activatedAt &&
!funktion.ticketId &&
!funktion.disabledAt &&
!funktion.unlockedByPurchase &&
funktion.purchaseStatus !== 'paid' &&
funktion.purchaseStatus !== 'zurueckgesetzt'
/**
* Einen Entwurf entfernen: unter den drei Sperren frisch lesen, pruefen, dann die Einstellung loeschen und, wenn es zulaessig ist,
* das Funktionsdokument. `grenze` (ms): nur Entwuerfe, deren letzte Aenderung davor liegt; null = Kundenloeschung, alles was `off`
* ist, ohne Frist und ohne Pruefung auf Spuren (der siteKey stirbt mit dem Kunden). Buchungen zur Funktion halten den Entwurf in
* beiden Faellen zurueck (ein `off`-Dokument hat keine; waeren doch welche da, blieben sonst Gastdaten ohne Funktion zurueck).
* Die Einstellung wird zuerst geloescht: scheitert danach das Dokument, holt der naechste Lauf es nach (die Frist ist unveraendert).
* Ein Lesefehler oder ein Fehler beim Loeschen wirft; der Aufrufer zaehlt ihn.
* => true, wenn etwas geloescht wurde (Einstellung oder Funktionsdokument), sonst false
*/
function entferne(kandidat, grenze) {
const { $id: id, customerId, projectId } = kandidat
if (!id || !customerId || !projectId) throw new Error('Entwurf ohne Kunde oder Projekt')
return withLocks([`feature-order:${customerId}:${projectId}`, `feature-admin:${projectId}`, `termine-cfg:${id}`], async () => {
const funktion = await holeOderNull(config.collections.portalFeatures, id)
if (!funktion || funktion.featureKey !== FUNKTION || funktion.state !== 'off') return false
if (funktion.customerId !== customerId || funktion.projectId !== projectId) return false
const einstellung = await holeOderNull(config.collections.termineConfig, id)
if (grenze !== null && !(letzteAenderung(funktion, einstellung) < grenze)) return false
if ((await ersteBuchungen(id, 1)).length > 0) return false
let geloescht = false
if (einstellung && (await loescheOderWeg(config.collections.termineConfig, id))) geloescht = true
if ((grenze === null || nieInBetrieb(funktion)) && (await loescheOderWeg(config.collections.portalFeatures, id))) geloescht = true
return geloescht
})
}
/**
* Entwuerfe nach 90 Tagen loeschen (Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung). Wirft nie wegen eines
* einzelnen Dokuments oder einer nicht lesbaren Liste (das zaehlt in `fehler`); nur eine ungueltige Uhrzeit ist ein TypeError.
* @param {Date} now
* @returns {Promise<{ geprueft: number, geloescht: number, uebrig: number, fehler: number }>}
* geprueft = gelesene Kandidaten (hoechstens MAX_GEPRUEFT), geloescht = Entwuerfe, bei denen Einstellung oder Funktionsdokument
* geloescht wurden (hoechstens MAX_GELOESCHT), uebrig = faellig wirkende Kandidaten, die der Deckel aufhielt, fehler = Einzelfehler
*/
export async function loescheEntwuerfe(now) {
const nowMs = pruefeNow(now, 'loescheEntwuerfe')
const grenze = nowMs - ENTWURF_TAGE * TAG_MS
const erg = { geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 }
const melde = (was, err) => {
erg.fehler += 1
if (erg.fehler <= MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${was}: ${fehlerText(err)}`)
}
// 1 Vorauswahl: die ganze Liste lesen, bevor etwas geloescht wird. $updatedAt des Funktionsdokuments kann nur wachsen, ein
// Dokument, das schon hier juenger als die Frist ist, bleibt also auch nach dem frischen Lesen juenger.
const faellig = []
let cursor = null
liste: for (;;) {
let seite
try {
seite = await listDocuments(config.collections.portalFeatures, [
Query.equal('featureKey', FUNKTION),
Query.equal('state', 'off'),
Query.orderAsc('$id'),
Query.limit(SEITE),
...(cursor ? [Query.cursorAfter(cursor)] : []),
])
} catch (err) {
melde('Liste der Entwürfe nicht gelesen', err)
break
}
for (const kandidat of seite) {
if (erg.geprueft >= MAX_GEPRUEFT) {
console.warn(`[termine-entwuerfe] Prüfung endet nach ${MAX_GEPRUEFT} Dokumenten, der Rest folgt an den nächsten Tagen`)
break liste
}
erg.geprueft += 1
if (zeitMs(kandidat.$updatedAt) < grenze) faellig.push(kandidat)
}
if (seite.length < SEITE) break
cursor = seite[seite.length - 1].$id
}
// 2 Loeschen, je Kandidat unter den Sperren mit frischem Lesen (entferne); am Deckel zaehlt der Rest als uebrig
for (const kandidat of faellig) {
if (erg.geloescht >= MAX_GELOESCHT) {
erg.uebrig += 1
continue
}
try {
if (await entferne(kandidat, grenze)) erg.geloescht += 1
} catch (err) {
melde('Entwurf nicht bearbeitet', err)
}
}
if (erg.uebrig > 0) {
console.warn(`[termine-entwuerfe] Löschung endet am Deckel von ${MAX_GELOESCHT} je Lauf, ${erg.uebrig} Entwürfe offen, der Rest folgt an den nächsten Tagen`)
}
if (erg.fehler > MAX_FEHLER_IM_LOG) console.error(`[termine-entwuerfe] ${erg.fehler - MAX_FEHLER_IM_LOG} weitere Fehler (nicht einzeln protokolliert)`)
return erg
}
/**
* Kunde wird geloescht: alle seine Entwuerfe (Terminplaner im Zustand `off`) vollstaendig entfernen, Funktionsdokument und
* Einstellung, ohne Frist. Andere Zustaende bleiben (disableCustomerFeatures schaltet sie ab, die Loeschfrist von 30 Tagen
* entscheidet), ebenso andere Funktionen und andere Kunden. Scheitert ein Entwurf, werden die uebrigen trotzdem entfernt und danach der
* erste Fehler weitergereicht (der Aufrufer protokolliert ihn und loescht den Kunden trotzdem).
* @param {string} customerId
* @returns {Promise<{ geloescht: number }>}
*/
export async function loescheEntwuerfeVonKunde(customerId) {
pruefeText(customerId, 'customerId')
const entwuerfe = (await listCustomerFeatureDocs(customerId)).filter(
(doc) => doc.featureKey === FUNKTION && doc.state === 'off' && doc.customerId === customerId,
)
let geloescht = 0
let ersterFehler = null
for (const entwurf of entwuerfe) {
try {
if (await entferne(entwurf, null)) geloescht += 1
} catch (err) {
ersterFehler ??= err
}
}
if (ersterFehler) throw ersterFehler
return { geloescht }
}
+314
View File
@@ -0,0 +1,314 @@
import { config } from '../../config.js'
import { aboMeldung } from '../featureOrder.js'
import { featureHaken, oeffentlicheAnsicht } from '../featureRegistry.js'
import { MELDUNG_PFLICHT, fehlendePflichtangaben, hatPreis, sichtbareLeistungen, text } from './einstellungen.js'
import { vorlagenListe } from './vorlagen.js'
/**
* Bestell-Fragebogen des Terminplaners (TP2, Spezifikation 2026-10-03-terminplaner-tp2-verkauf-design.md): Der Kunde
* richtet den Terminplaner vor der Bestellung ein; der Entwurf steht in termineConfig.einstellung, der Fragebogen
* (Branche, bestaetigte Schritte, Wuensche zum Einbau) im Attribut termineConfig.fragebogen als JSON-Text.
* Teil 1: Schritte, Branchen, Grundform und Normalisierung. Teil 2: Bestell-Gate (bestellpruefung), weiche
* Gesundheits-Stichwortpruefung und die Sicht der Bestellseite (bestellungAnsicht). Ohne Appwrite und ohne Uhr
* (bestellungAnsicht liest nur das Register und den Haken belehrung).
* Fehler sind { [pfad]: text } wie bei normalisiereEinstellung (Pfad '_' = das Ganze); Texte in Du-Form.
*/
/** Die acht Schritte des Fragebogens in fester Reihenfolge; `bestaetigt` nennt die vom Kunden abgehakten. */
export const SCHRITTE = Object.freeze(['branche', 'wer', 'leistungen', 'zeiten', 'regeln', 'gastfelder', 'benachrichtigung', 'website'])
/**
* Branchen zur Auswahl: alle Vorlagen in der Reihenfolge von vorlagenListe(), danach der Gesundheitsbereich
* (ohne Vorlage; er fuehrt in den Hinweis- und Pruefweg fuer Gesundheitsangaben). Schluessel und Name der letzten
* Branche gelten woertlich fuer Bestellpruefung, Oberflaeche, Einbauanleitung und Ticket.
*/
export const BRANCHEN = Object.freeze([
...vorlagenListe().map(({ key, name }) => Object.freeze({ key, name, vorlage: key, gesundheit: false })),
Object.freeze({ key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }),
])
/** Obergrenze fuer den JSON-Text des Fragebogens (Zeichen); die Spalte in Appwrite ist mit 20000 groesser. */
export const FRAGEBOGEN_MAX = 16000
const EINBAU_MODI = ['inline', 'knopf']
const TEXTGRENZEN = { knopfText: 40, stelle: 300, gestaltung: 1000, bemerkung: 1000 }
const MEHRZEILIG = new Set(['stelle', 'gestaltung', 'bemerkung'])
const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.isArray(wert)
/** Ein leerer Fragebogen; bei jedem Aufruf ein neues Objekt. */
export function leerFragebogen() {
return {
schema: 1,
branche: null,
bestaetigt: [],
bundeslandGewaehlt: false,
preiseGeprueft: false,
einbau: { modus: null, knopfText: '', stelle: '', gestaltung: '', bemerkung: '' },
}
}
function wahrheit(fehl, pfad, wert) {
if (wert === undefined || wert === null) return false
if (typeof wert !== 'boolean') {
fehl(pfad, 'Bitte „ja“ oder „nein“ wählen.')
return false
}
return wert
}
function einbauFelder(fehl, eingabe) {
const quelle = eingabe === undefined || eingabe === null ? {} : eingabe
if (!istObjekt(quelle)) {
fehl('einbau', 'Die Angaben zum Einbau sind ungültig.')
return leerFragebogen().einbau
}
const einbau = { modus: null }
if (quelle.modus !== undefined && quelle.modus !== null) {
if (EINBAU_MODI.includes(quelle.modus)) einbau.modus = quelle.modus
else fehl('einbau.modus', 'Bitte wähle, ob der Terminplaner im Text oder als Knopf erscheint.')
}
for (const [feld, max] of Object.entries(TEXTGRENZEN)) {
const wert = quelle[feld]
if (wert === undefined || wert === null) einbau[feld] = ''
else if (typeof wert !== 'string') {
fehl(`einbau.${feld}`, 'Der Text ist ungültig.')
einbau[feld] = ''
} else einbau[feld] = text(wert, max, { mehrzeilig: MEHRZEILIG.has(feld) })
}
return einbau
}
/**
* Prueft und bereinigt einen Fragebogen aus Kundeneingabe oder gespeichertem JSON.
* Baut ein neues Objekt Feld fuer Feld (unbekannte Schluessel und __proto__ fallen weg), kuerzt Texte, entfernt
* Steuerzeichen und ordnet `bestaetigt` nach SCHRITTE ohne Doppelte und Fremde. Fehlende Felder sind leer/false.
* Ergebnis: { fragebogen } oder { fehler: { [pfad]: text } }; idempotent.
*/
export function normalisiereFragebogen(eingabe) {
if (!istObjekt(eingabe)) return { fehler: { _: 'Der Fragebogen ist ungültig.' } }
const fehler = {}
const fehl = (pfad, meldung) => { if (!Object.hasOwn(fehler, pfad)) fehler[pfad] = meldung }
let branche = null
if (eingabe.branche !== undefined && eingabe.branche !== null) {
if (BRANCHEN.some((b) => b.key === eingabe.branche)) branche = eingabe.branche
else fehl('branche', 'Bitte wähle eine Branche aus der Liste.')
}
let bestaetigt = []
if (eingabe.bestaetigt !== undefined && eingabe.bestaetigt !== null) {
if (Array.isArray(eingabe.bestaetigt)) bestaetigt = SCHRITTE.filter((schritt) => eingabe.bestaetigt.includes(schritt))
else fehl('bestaetigt', 'Die bestätigten Schritte sind ungültig.')
}
const fragebogen = {
schema: 1,
branche,
bestaetigt,
bundeslandGewaehlt: wahrheit(fehl, 'bundeslandGewaehlt', eingabe.bundeslandGewaehlt),
preiseGeprueft: wahrheit(fehl, 'preiseGeprueft', eingabe.preiseGeprueft),
einbau: einbauFelder(fehl, eingabe.einbau),
}
return Object.keys(fehler).length ? { fehler } : { fragebogen }
}
// ---------------------------------------------------------------- Teil 2: Bestell-Gate
/**
* Titel der Schritte (und der Bestellseite) fuer Meldungen und Oberflaeche; Schluessel wie SCHRITTE plus `bestellen`
* ("Prüfen und bestellen"). Der Titel steht in "Bitte bestätige den Schritt „…“." und wird dort woertlich gezeigt.
*/
export const SCHRITT_TITEL = Object.freeze({
branche: 'Branche und Betrieb',
wer: 'Wer wird gebucht',
leistungen: 'Leistungen',
zeiten: 'Zeiten',
regeln: 'Regeln',
gastfelder: 'Gastfelder',
benachrichtigung: 'Benachrichtigung',
website: 'Website',
bestellen: 'Prüfen und bestellen',
})
// Schritt, in dem der Kunde eine fehlende Pflichtangabe nachtraegt (Name und Anschrift stehen in Schritt 1, Telefon in Schritt 8)
const PFLICHT_SCHRITT = { 'betrieb.telefon': 'website', 'betrieb.adresse': 'branche' }
const SCHRITT_FOLGE = [...SCHRITTE, 'bestellen']
const istText = (wert) => typeof wert === 'string' && wert.trim() !== ''
/**
* Bestellpruefung des Terminplaners: Liste der offenen Punkte `[{ schritt, text }]`, leer = bestellbar. Jeder Punkt ist
* ein eigener Eintrag; sortiert nach Schrittfolge (SCHRITTE, dann `bestellen`), innerhalb eines Schritts in der
* Reihenfolge der Kriterien (a) bis (h):
* (a) fehlende Pflichtangaben (Telefon ⇒ website, Anschrift ⇒ branche; Text aus MELDUNG_PFLICHT),
* (b) keine fuer Gaeste sichtbare Leistung ⇒ leistungen, (c) je unbestaetigter Schritt ein Punkt, dazu leistungen,
* wenn eine sichtbare Leistung einen Preis hat und die Preise nicht geprueft sind, (d) Bundesland nicht ausdruecklich
* gewaehlt ⇒ zeiten (der Serverstandard RP zaehlt nicht), (e) Einbau-Modus fehlt oder Knopf ohne Knopftext ⇒ website,
* (f) Branche fehlt oder Gesundheitsbereich ⇒ branche, (g) Impressum- oder Datenschutz-Adresse fehlt ⇒ website (je
* Adresse ein Punkt), (h) nur bei `mitAbo !== false`: das Abo laeuft nicht sauber ⇒ bestellen.
* `feature` = Register-Eintrag (nur label.name und aboMeldung); `abo` = Ergebnis von bestellbar(), gelesen nur bei
* mitAbo !== false: fehlt es (Lesefehler), gilt der Grund 'unbekannt' (im Zweifel nicht bestellbar). Der Checkout (B6)
* ruft mit `mitAbo: false` und ohne `abo`, dort liefert die Abo-Stufe (7) die Codes; nur GET /api/termine/einstellungen
* (C4a) uebergibt `abo`. Ohne Einstellung gibt es genau einen Punkt (branche). Rein: veraendert nichts, neues Array.
*/
export function bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true } = {}) {
if (!istObjekt(einstellung)) return [{ schritt: 'branche', text: 'Bitte starte den Fragebogen mit der Wahl deiner Branche.' }]
const stand = istObjekt(fragebogen) ? fragebogen : {}
const betrieb = istObjekt(einstellung.betrieb) ? einstellung.betrieb : {}
const einbau = istObjekt(stand.einbau) ? stand.einbau : {}
const offen = []
const punkt = (schritt, meldung) => offen.push({ schritt, text: meldung })
// (a) Pflichtangaben des Betriebs
for (const pfad of fehlendePflichtangaben(einstellung)) punkt(PFLICHT_SCHRITT[pfad], MELDUNG_PFLICHT[pfad])
// (b) mindestens eine Leistung, die Gaeste sehen
const sichtbar = sichtbareLeistungen(einstellung)
if (sichtbar.length === 0) {
punkt('leistungen', 'Bitte lege mindestens eine aktive Leistung an, die einer aktiven Person oder einem aktiven Platz zugeordnet ist. Nur solche Leistungen sehen Gäste.')
}
// (c) jeder Schritt bestaetigt; Preise geprueft, wenn eine sichtbare Leistung einen hat
const bestaetigt = Array.isArray(stand.bestaetigt) ? stand.bestaetigt : []
for (const schritt of SCHRITTE) {
if (!bestaetigt.includes(schritt)) punkt(schritt, `Bitte bestätige den Schritt „${SCHRITT_TITEL[schritt]}“.`)
}
if (sichtbar.some(hatPreis) && stand.preiseGeprueft !== true) {
punkt('leistungen', 'Bitte prüfe die Preise deiner Leistungen und bestätige sie. Die Preise der Vorlage sind nur Platzhalter.')
}
// (d) Bundesland ausdruecklich gewaehlt (der Serverstandard RP ist keine Wahl)
if (stand.bundeslandGewaehlt !== true) {
punkt('zeiten', 'Bitte wähle das Bundesland deines Betriebs. Danach richten sich die Feiertage.')
}
// (e) Einbau: Modus, bei einem Knopf auch dessen Text
if (!EINBAU_MODI.includes(einbau.modus)) {
punkt('website', 'Bitte wähle, ob der Terminplaner direkt auf deiner Seite oder hinter einem Knopf erscheint.')
} else if (einbau.modus === 'knopf' && !istText(einbau.knopfText)) {
punkt('website', 'Bitte gib an, was auf dem Knopf stehen soll, hinter dem sich der Terminplaner öffnet.')
}
// (f) Branche gewaehlt und nicht der Gesundheitsbereich
const branche = BRANCHEN.find((b) => b.key === stand.branche)
if (!branche) {
punkt('branche', 'Bitte wähle deine Branche.')
} else if (branche.gesundheit) {
const name = feature?.label?.name || 'Terminplaner'
punkt('branche', `Für Betriebe im Gesundheitsbereich ist „${name}“ nicht verfügbar. Wähle eine andere Branche, wenn dein Angebot nicht dazugehört.`)
}
// (g) Impressum und Datenschutzerklaerung (der Gaestehinweis verweist darauf)
if (!istText(betrieb.impressumUrl)) punkt('website', 'Bitte trage die Adresse deines Impressums ein. Gäste finden dort die Angaben zu deinem Betrieb.')
if (!istText(betrieb.datenschutzUrl)) punkt('website', 'Bitte trage die Adresse deiner Datenschutzerklärung ein. Der Hinweis für Gäste verweist darauf.')
// (h) Wartungs-Abo sauber laufend (nur wenn gefragt); fehlendes abo = Lesefehler = nicht bestellbar
if (mitAbo !== false && !(istObjekt(abo) && abo.ok === true)) {
punkt('bestellen', aboMeldung(feature, istObjekt(abo) ? abo.grund : 'unbekannt'))
}
// stabil nach Schrittfolge (Array.prototype.sort ist ab Node 12 stabil)
return offen.sort((a, b) => SCHRITT_FOLGE.indexOf(a.schritt) - SCHRITT_FOLGE.indexOf(b.schritt))
}
// ---------------------------------------------------------------- Gesundheits-Stichwortpruefung
// Regel: Treffer nur am Wortanfang (vor dem Treffer steht kein Buchstabe), Woerter = Folgen von Buchstaben.
// Gruppe 1: Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zaehlen: Physiotherapie, Arztpraxis).
const STAEMME_FREI = ['physio', 'therap', 'arzt', 'ärzt', 'zahnarzt', 'zahnärzt', 'heilpraktik', 'krankengymnast', 'osteopath', 'diagnos']
// Gruppe 2: Stamm plus hoechstens eine Beugungsendung, dann Wortende (Rezepte, Beschwerden, Behandlungen; nicht Rezeption, Rezeptur).
const STAEMME_BEUGUNG = ['rezept', 'beschwerde', 'behandlung']
const ENDUNGEN = new Set(['', 'e', 'en', 'n', 's', 'es'])
const MAX_WARNUNGEN = 10
const MAX_WORT = 60
const MAX_NAME = 40
const istGesundheitswort = (wort) =>
STAEMME_FREI.some((stamm) => wort.startsWith(stamm))
|| STAEMME_BEUGUNG.some((stamm) => wort.startsWith(stamm) && ENDUNGEN.has(wort.slice(stamm.length)))
/** Woerter eines Textes (Folgen von Buchstaben, NFC), die ein Gesundheitsstichwort sind, in Textreihenfolge. */
function gesundheitsWoerter(wert) {
if (typeof wert !== 'string') return []
return [...wert.normalize('NFC').matchAll(/[\p{L}\p{M}]+/gu)]
.map((m) => m[0])
.filter((wort) => istGesundheitswort(wort.toLowerCase()))
}
const kurz = (wert, max) => (wert.length > max ? `${wert.slice(0, max - 1)}…` : wert)
/**
* Weiche Pruefung auf Gesundheitsangaben (Spec 3.5, D6): Stichwortliste ueber Betriebsname, Leistungsnamen,
* Beschreibungen sowie Beschriftungen und Auswahlen der Zusatzfelder. Ergebnis: hoechstens 10 Meldungen (Klartext, ohne
* Doppelte), jede nennt den Fundort und das gefundene Wort. Nur Warnblock fuers Team (T16, Ticket): dem Kunden zeigt
* Block C nichts, eine Meldung ist kein Fehler. Liest nie Gaestedaten (nur die Einstellung des Betriebs).
* @param {object|null} einstellung
* @returns {string[]}
*/
export function gesundheitsWarnungen(einstellung) {
if (!istObjekt(einstellung)) return []
const meldungen = new Set()
const pruefe = (wert, fundort) => {
for (const wort of gesundheitsWoerter(wert)) {
if (meldungen.size >= MAX_WARNUNGEN) return
meldungen.add(`${fundort} enthält „${kurz(wort, MAX_WORT)}“`)
}
}
pruefe(einstellung.betrieb?.name, 'Betriebsname')
for (const leistung of Array.isArray(einstellung.leistungen) ? einstellung.leistungen : []) {
if (!istObjekt(leistung)) continue
pruefe(leistung.name, 'Leistungsname')
const name = typeof leistung.name === 'string' ? kurz(leistung.name.trim(), MAX_NAME) : ''
pruefe(leistung.beschreibung, name ? `Beschreibung der Leistung „${name}“` : 'Beschreibung einer Leistung')
}
const zusatz = einstellung.gastFelder?.zusatz
for (const feld of Array.isArray(zusatz) ? zusatz : []) {
if (!istObjekt(feld)) continue
pruefe(feld.label, 'Zusatzfeld-Beschriftung')
const label = typeof feld.label === 'string' ? kurz(feld.label.trim(), MAX_NAME) : ''
for (const option of Array.isArray(feld.optionen) ? feld.optionen : []) {
pruefe(option, label ? `Auswahl im Zusatzfeld „${label}“` : 'Auswahl in einem Zusatzfeld')
}
}
return [...meldungen]
}
// ---------------------------------------------------------------- Sicht der Bestellseite
// Titel der Rechtsunterlagen (Registerfelder) auf der Bestellseite
const DOKUMENT_TITEL = [
['leistungsbeschreibung', 'Leistungsbeschreibung'],
['avv', 'Auftragsverarbeitungsvertrag (AVV)'],
['widerruf', 'Widerrufsbelehrung'],
]
/**
* Sicht der Bestellseite ("Prüfen und bestellen"): reine Abbildung von oeffentlicheAnsicht(feature.key) (B1) plus die
* Belehrung. `consent` bleibt unveraendert, inklusive `knopf` und des `haken`-Flags je Eintrag (der Knopf zeigt genau diesen
* Text, B speichert genau diesen Text). `dokumente` stammen aus den Registerfeldern leistungsbeschreibung/avv/widerruf und
* nur mit gesetzter `url`. `belehrung` ist das Ergebnis des Hakens belehrung (derselbe Text, den B7 im Nachweis speichert;
* ohne Haken null). Keine internen Felder (ticket, mails). Unbekannte Funktion ⇒ null.
* @param {{ key: string }} feature Register-Eintrag
* @returns {Promise<{ preis: string, preisHinweis: string, einrichtung: string, consent: object, dokumente: object[], belehrung: string|null }|null>}
*/
export async function bestellungAnsicht(feature) {
const ansicht = typeof feature?.key === 'string' ? oeffentlicheAnsicht(feature.key) : null
if (!ansicht) return null
const dokumente = []
for (const [id, titel] of DOKUMENT_TITEL) {
const fassung = ansicht[id]
if (fassung && typeof fassung.url === 'string' && fassung.url !== '') dokumente.push({ id, titel, version: fassung.version, url: fassung.url })
}
const belehrung = (await featureHaken(feature.key).belehrung?.()) ?? null
return {
preis: ansicht.priceLabel,
preisHinweis: config.ustHinweis,
einrichtung: ansicht.setupEstimate,
consent: ansicht.consent,
dokumente,
belehrung,
}
}
+62
View File
@@ -0,0 +1,62 @@
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<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'>}
*/
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).
* Liefert true, wenn K16 rausging; false, wenn nicht (Fehler, Kunde ohne Adresse oder unbekannt). Nur die Staff-Aktivierung mit
* Unterlagen wertet das aus (sie muss die Unterlagen dann von Hand nachsenden); alle anderen Aufrufer ignorieren den Wert wie bisher.
* @param {{ customerId: string, projectId: string, featureKey: string }} doc portalFeatures-Dokument
* @param {{ attachments?: object[] }} [optionen]
* @returns {Promise<boolean>}
*/
export async function meldeFreigeschaltet(doc, { attachments = [] } = {}) {
try {
const customer = await customerOf(doc.customerId)
if (!customer?.email) return false
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 })
return true
} catch (err) {
console.error(`[funktionen] K16 an Kunde ${doc.customerId} fehlgeschlagen:`, err.message)
return false
}
}
+29
View File
@@ -0,0 +1,29 @@
/**
* Loeschfrist abgeschalteter Terminplaner: 30 Tage nach `disabledAt` loescht der Tageslauf (jobs.js) die Buchungen und die
* Einstellung. Diese Datei haelt die Frist an einer Stelle, damit Anzeige (siteFeatures.featureView), Mails und Rechtstexte
* dieselbe Zahl und dieselbe Rechnung benutzen. Reine Funktionen, nur Importe aus zeit.js und util.js (kein Kreis mit siteFeatures.js).
*/
import { ausUtc } from './zeit.js'
import { zeitMs } from './util.js'
/** Tage zwischen dem Abschalten (disabledAt) und der Loeschung. */
export const LOESCHFRIST_TAGE = 30
const TAG_MS = 24 * 60 * 60 * 1000
/**
* Zeitpunkt, ab dem die Daten geloescht werden duerfen: disabledAt plus 30 x 24 Stunden (keine Wanduhr-Rechnung, wie der Tageslauf).
* disabledAt: ISO-String (auch mit +00:00 wie von Appwrite), Date oder Zahl; fehlend oder ungueltig => null.
*/
export function loeschzeit(disabledAt) {
const ms = zeitMs(disabledAt)
if (!Number.isFinite(ms)) return null
const ende = new Date(ms + LOESCHFRIST_TAGE * TAG_MS)
return Number.isNaN(ende.getTime()) ? null : ende
}
/** Berliner Kalendertag der Loeschung ('JJJJ-MM-TT') oder null, wenn disabledAt fehlt oder ungueltig ist. */
export function loeschTag(disabledAt) {
const ende = loeschzeit(disabledAt)
return ende ? ausUtc(ende).tag : null
}
+613 -118
View File
@@ -1,17 +1,25 @@
import { config } from '../../config.js'
import { Query, deleteDocument, getDocument, listAllDocuments } from '../appwriteAdmin.js'
import { aboLage } from '../aboStatus.js'
import { Query, deleteDocument, getDocument, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { stateFields } from '../siteFeatures.js'
import { gibFreiSicher, iso, vertragAm } from './ablauf.js'
import { gleicheAboAb, istErstattungOffen } from './aboAbgleich.js'
import { meldeTagesjob, pruefeBetrieb, zaehleAktiveBetriebe } from './betrieb.js'
import {
abgelaufeneHaltungen,
aendere,
erinnerungsKandidaten,
ersteBuchungen,
hole,
loesche,
loeschBezugsfeld,
loeschKandidaten,
vergangeneAnfragen,
zuErledigen,
} from './buchungen.js'
import { loescheEntwuerfe } from './entwuerfe.js'
import { LOESCHFRIST_TAGE } from './frist.js'
import { kontextPerFeature, ladeEinstellung } from './kontext.js'
import { anGast } from './mails.js'
import { beendeFestgefahrene, raeumeAuf, verarbeite } from './outbox.js'
@@ -24,15 +32,36 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* Hintergrund-Jobs des Terminplaners (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 4 und 11). server/index.js
* ruft sie mit new Date() auf; `now` ist hier immer ein Parameter.
*
* minutenJob jede Minute abgelaufene Haltungen => verfallen (Zellen frei); Mail-Warteschlange senden
* minutenJob jede Minute zwei Teile, gemeinsam gestartet (Promise.all) und je Teil gegen Ueberlappung geschuetzt:
* Verfall: abgelaufene Haltungen => verfallen (Zellen frei);
* Mailversand: Mail-Warteschlange senden, zuletzt die Betriebspruefung (Budget, Warteschlange,
* Anmeldepause => Log und Team-Mail, betrieb.js)
* fuenfMinutenJob alle 5 Minuten Erinnerungen (G05); bestaetigt 1 Stunde nach Ende => erledigt; Anfragen mit Beginn in
* der Vergangenheit => abgelehnt (vom System, Zellen frei, keine Mail)
* taeglicherJob taeglich Loeschfristen (11), festgefahrene und alte Mails, abgeschaltete Funktionen, verwaiste Zellen
* taeglicherJob taeglich Loeschfristen (11), Abo-Abgleich (aboAbgleich.js: der Terminplaner endet mit dem Wartungs-Abo),
* abgeschaltete Funktionen, Entwuerfe (entwuerfe.js: Terminplaner im Zustand off, 90 Tage nach der
* letzten Aenderung; nur im vollen Lauf, Entwuerfe sind nie bezahlt), festgefahrene und alte Mails,
* verwaiste Zellen; der Abgleich laeuft VOR
* der Loeschung abgeschalteter Funktionen, damit ein Terminplaner, dessen Abo heute zurueckkehrt,
* nicht mehr geloescht wird; zuletzt die aktiven Betriebe zaehlen und den Lauf melden (Fehler,
* Loeschdeckel in Folge, ab 20 Betrieben => Log und Team-Mail, meldeTagesjob in betrieb.js)
* Abgeschaltete Funktionen: nach LOESCHFRIST_TAGE (30) Tagen Buchungen, Zellen, Einstellung loeschen
* (Grund abo_ende: vor jedem unumkehrbaren Schritt frisch pruefen, ob das Wartungs-Abo wieder laeuft),
* danach das Funktionsdokument auf `off` zuruecksetzen (siteKey bleibt; mit offenem Zahlungsfall,
* zahlungMeldung nicht leer und nicht 'gemeldet', bleibt es disabled bis zur Meldung, R108)
*
* Gemeinsame Regeln:
* - Modus off: jeder Job kehrt sofort zurueck, ohne Abfrage, mit { aus: true }.
* - Modus off: Minuten- und Fuenf-Minuten-Job kehren sofort zurueck, ohne Abfrage, mit { aus: true }. Der Tagesjob stellt eine einzige
* Frage (Guard: gibt es ein vom Kauf freigeschaltetes Termine-Dokument, unlockedByPurchase true?); ohne bezahltes Dokument oder bei
* einem Lesefehler (Warnung) kehrt auch er mit { aus: true } zurueck. Mit bezahltem Dokument laeuft ein eingeschraenkter Tagesjob
* (`eingeschraenkt: true`): nur der Abo-Abgleich und die Loeschung abgeschalteter Funktionen, damit ein beendetes Abo auch bei
* TERMINE_ENABLED=off zum Abschalten und zur Loeschung nach 30 Tagen fuehrt. Loeschfristen der Buchungen, Warteschlange und Zellen
* bleiben aus.
* - Keine ueberlappenden Laeufe: laeuft ein Job noch, wird der naechste Takt uebersprungen ({ uebersprungen: true }, einmal im
* Log je Ueberlappung).
* Log je Ueberlappung). Die Sperre gilt je `job:teil` (fuehreAus mit { teil }): haengt beim Minutenjob der Verfall, versendet der
* naechste Takt trotzdem die Warteschlange und umgekehrt; uebersprungen gilt der Takt erst, wenn beide Teile uebersprungen sind
* (sonst steht verfallUebersprungen bzw. mailUebersprungen im Ergebnis). Die Warnung nennt den Teil (`minutenJob (Verfall)`), das
* Fehlerlog behaelt `minutenJob` als Jobnamen.
* - Ein Schritt eines Jobs scheitert nie den ganzen Job: der Fehler steht im Log (Text des Fehlers, bei einer Buchung mit
* Buchungsnummer, nie mit Gastdaten), `fehler` im Ergebnis zaehlt ihn, die uebrigen Schritte laufen. Eine fehlende Collection
* (404 collection_not_found, Einrichtung unvollstaendig) meldet ein Job hoechstens einmal je Stunde.
@@ -40,6 +69,14 @@ import { gibZellenFrei, raeumeVerwaisteZellen } from './zellen.js'
* Bedingung (Verfall nur, solange noch `gehalten` mit heldUntil <= jetzt usw.): ein Gast, der gerade bestaetigt, und der Job
* ergeben genau einen Endzustand. Mails gehen erst nach der Aenderung und ausserhalb der Sperre in die Warteschlange.
* - Zellen gibt nur zellen.js frei (gibZellenFrei / raeumeVerwaisteZellen, jeweils unter der Ressourcensperre).
* - Loeschlaeufe (Aufbewahrung) arbeiten seitenweise (arbeiteSeiten): eine Seite hoechstens 100 Buchungen ohne Gastfelder,
* Frist in der Abfrage, danach neu lesen. Ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen je Lauf (erledigt
* und nichterschienen zusammen je Funktion), alle Schritte eines Laufs zusammen hoechstens config.termine.loeschDeckelGesamt;
* der Rest folgt am naechsten Tag und wird gezaehlt (`gedeckelt`), nie still uebersprungen. Ebenso die Loeschung abgeschalteter
* Funktionen: je Funktion Seiten zu 100, hoechstens config.termine.loeschDeckel je Lauf, die Einstellung erst nach einer leeren Seite.
* Das Funktionsdokument wird danach auf `off` gesetzt und fehlt damit in der Liste des naechsten Tages (loescheAbgeschaltete).
* Ebenso der Zellen-Sweep (raeumeVerwaisteZellen): Zellen mit Ende vor mehr als einem Tag, Seiten zu 100 ueber idx_bis, hoechstens
* config.termine.zellenDeckel je Lauf; am Deckel zaehlt `gedeckelt.zellen`.
*/
const MIN_MS = 60 * 1000
@@ -47,7 +84,8 @@ const STUNDE_MS = 60 * MIN_MS
const TAG_MS = 24 * STUNDE_MS
const VERFALLEN_BEHALTEN_MS = 48 * STUNDE_MS
const ABGESAGT_BEHALTEN_MS = 30 * TAG_MS
const ABGESCHALTET_BEHALTEN_MS = 30 * TAG_MS
/** Frist zwischen dem Abschalten (disabledAt) und der Loeschung: eine Zahl fuer Anzeige, Mails, Rechtstexte und diesen Job (frist.js). */
const ABGESCHALTET_BEHALTEN_MS = LOESCHFRIST_TAGE * TAG_MS
/** Aufbewahrung von erledigt/nichterschienen: Vorgabe und erlaubter Bereich (Tage nach Terminende). */
const AUFBEWAHRUNG_VORGABE = 90
const AUFBEWAHRUNG_MIN = 14
@@ -87,28 +125,45 @@ async function schritt(lauf, was, fn, ersatz) {
/**
* Rahmen jedes Jobs: Schalter, Ueberlappung, Laufdaten. => Ergebnis von arbeit(lauf) | { aus: true } | { uebersprungen: true }
* `teil` (Name eines Teiljobs, z. B. „Verfall“) gibt dem Teil eine eigene Ueberlappungssperre `job:teil` und nennt ihn in der
* Warnung (`job (teil)`); `lauf.job` bleibt der Jobname (Fehlerlog, Warnung bei fehlender Collection).
* `trotzAus` (async, => wahr/falsch): im Modus off laeuft der Job nur, wenn es wahr liefert; dann steht `lauf.eingeschraenkt` auf true
* und der Job beschraenkt sich auf das, was auch bei abgeschaltetem Terminplaner gelten muss. Wirft es, wird gewarnt (ohne Kennungen)
* und { aus: true } geliefert; ohne `trotzAus` gilt Modus off wie bisher (sofort { aus: true }, ohne Abfrage). Die Ueberlappungssperre
* gilt unveraendert je Job (bzw. `job:teil`), der eingeschraenkte Lauf teilt sie mit dem vollen.
*/
async function fuehreAus(job, now, arbeit) {
async function fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) {
const nowMs = pruefeNow(now, job)
if (termineAus()) return { aus: true }
if (laufend.has(job)) {
if (!ueberlappGemeldet.has(job)) {
ueberlappGemeldet.add(job)
console.warn(`[termine-jobs] ${job}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
let eingeschraenkt = false
if (termineAus()) {
if (trotzAus === null) return { aus: true }
try {
if (!(await trotzAus())) return { aus: true }
} catch (err) {
console.warn(`[termine-jobs] ${job}: Modus off, die Prüfung auf bezahlte Terminplaner ist fehlgeschlagen, der Job bleibt aus: ${fehlerText(err)}`)
return { aus: true }
}
eingeschraenkt = true
}
const sperre = teil === null ? job : `${job}:${teil}`
if (laufend.has(sperre)) {
if (!ueberlappGemeldet.has(sperre)) {
ueberlappGemeldet.add(sperre)
console.warn(`[termine-jobs] ${teil === null ? job : `${job} (${teil})`}: der vorige Lauf ist noch nicht fertig, dieser Takt wird übersprungen`)
}
return { uebersprungen: true }
}
laufend.add(job)
laufend.add(sperre)
try {
return await arbeit({ job, nowMs, fehler: 0 })
return await arbeit({ job, nowMs, fehler: 0, gedeckelt: 0, eingeschraenkt })
} finally {
laufend.delete(job)
ueberlappGemeldet.delete(job)
laufend.delete(sperre)
ueberlappGemeldet.delete(sperre)
}
}
/** Laufdaten fuer einen Baustein, der ohne Job-Rahmen aufgerufen wird (Tests). */
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0 })
const einzelLauf = (job, now) => ({ job, nowMs: pruefeNow(now, job), fehler: 0, gedeckelt: 0 })
/**
* Eine Buchung unter ihrer Sperre, frisch gelesen: fn(frisch) laeuft nur, wenn es sie noch gibt.
@@ -126,6 +181,63 @@ async function mitFrischer(lauf, was, id, fn) {
}
}
/**
* Seitenweise Arbeit ohne Cursor: Seite lesen (holeSeite), jeden Eintrag bearbeiten (bearbeite), Liste neu lesen – was bearbeitet
* ist, fehlt dann. Gedacht fuer Loeschlaeufe (Aufbewahrung, abgeschaltete Funktionen, Zellen), die wegen der Zahl der Betriebe nie
* alles auf einmal lesen duerfen. Der Lauf endet
* - bei einer leeren Seite (alles erledigt),
* - am Deckel: sind `deckel` Eintraege geloescht und es kommt noch einer, zaehlt lauf.gedeckelt, eine Warnung ohne Kennungen
* steht im Log und der Rest folgt im naechsten Lauf (`deckel` 0 = nur nachsehen, ob noch etwas ansteht: eine leere Seite endet
* ohne Zaehlung, sonst zaehlt es als Deckel); `deckelText` benennt den Deckel in der Warnung,
* - bei einer Seite ohne einen bearbeiteten Eintrag (alle gescheitert oder schon versucht): die Fehler stehen im Log; eine Menge
* merkt die versuchten IDs, damit ein Eintrag, der sich nicht bearbeiten laesst, keinen Endlosdurchlauf ausloest.
* holeSeite() => Eintraege mit $id (hoechstens eine Seite); bearbeite(eintrag) => true (geloescht, zaehlt zum Deckel) | false (nichts
* zu tun, z. B. inzwischen anders oder weg: kein Fehler) | undefined (Fehler, schon gemeldet). Wirft holeSeite, steht der Fehler im
* Log und der Lauf endet unvollstaendig.
* => { geloescht, gedeckelt (am Deckel beendet), vollstaendig (leere Seite erreicht und kein Fehler in diesem Lauf) }
*/
async function arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel, was = 'Löschung', deckelText = `von ${deckel} je Lauf` }) {
if (!Number.isInteger(deckel) || deckel < 0) throw new TypeError(`arbeiteSeiten: deckel muss eine ganze Zahl ab 0 sein, nicht ${deckel}`)
const versucht = new Set()
let geloescht = 0
let fehlerBeiEintrag = false
for (;;) {
let seite
try {
seite = await holeSeite()
} catch (err) {
melde(lauf, `${was}: Liste nicht gelesen`, err)
return { geloescht, gedeckelt: false, vollstaendig: false }
}
if (seite.length === 0) return { geloescht, gedeckelt: false, vollstaendig: !fehlerBeiEintrag }
let bearbeitet = 0
for (const eintrag of seite) {
if (versucht.has(eintrag.$id)) continue
if (geloescht >= deckel) {
lauf.gedeckelt += 1
console.warn(`[termine-jobs] ${lauf.job}: ${was} endet am Deckel ${deckelText}, der Rest folgt an den nächsten Tagen`)
return { geloescht, gedeckelt: true, vollstaendig: false }
}
versucht.add(eintrag.$id)
let ergebnis
try {
ergebnis = await bearbeite(eintrag)
} catch (err) {
melde(lauf, was, err)
ergebnis = undefined
}
if (ergebnis === undefined) {
fehlerBeiEintrag = true
continue
}
bearbeitet += 1
if (ergebnis) geloescht += 1
}
if (bearbeitet === 0) return { geloescht, gedeckelt: false, vollstaendig: false }
}
}
// ---------------------------------------------------------------- Minutenjob
/** Abgelaufene Haltungen: verfallen und Zellen frei – nur, wenn die Buchung unter der Sperre noch `gehalten` und abgelaufen ist. */
@@ -144,16 +256,48 @@ async function verfalleHaltungen(lauf, now) {
}
/**
* Jede Minute: abgelaufene Haltungen verfallen lassen, Warteschlange senden.
* Jede Minute, in zwei unabhaengig geschuetzten Teilen, die gemeinsam starten (Promise.all):
* - Verfall: abgelaufene Haltungen verfallen lassen;
* - Mailversand: Warteschlange senden, zuletzt den Betrieb pruefen (Vorfaelle stehen im Log und gehen als T20 ans Team; die Pruefung
* wirft nie und aendert das Ergebnis des Laufs nicht).
* Haengt ein Teil, wird nur dieser im naechsten Takt uebersprungen (verfallUebersprungen bzw. mailUebersprungen im Ergebnis), der
* andere laeuft weiter. { uebersprungen: true } kommt erst, wenn beide Teile uebersprungen werden.
* @param {Date} now
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number } | { aus: true } | { uebersprungen: true }>}
* @returns {Promise<{ verfallen: number, faellig: number, gesendet: number, fehler: number, verfallUebersprungen?: true, mailUebersprungen?: true } | { aus: true } | { uebersprungen: true }>}
*/
export async function minutenJob(now) {
return fuehreAus('minutenJob', now, async (lauf) => {
const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0)
const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 })
return { verfallen, faellig, gesendet, fehler: lauf.fehler }
})
const [verfall, mail] = await Promise.all([
fuehreAus(
'minutenJob',
now,
async (lauf) => {
const verfallen = await schritt(lauf, 'Verfall', () => verfalleHaltungen(lauf, now), 0)
return { verfallen, fehler: lauf.fehler }
},
{ teil: 'Verfall' },
),
fuehreAus(
'minutenJob',
now,
async (lauf) => {
const { faellig, gesendet } = await schritt(lauf, 'Warteschlange', () => verarbeite(now), { faellig: 0, gesendet: 0 })
// Zuletzt: die Pruefung sieht den Stand nach dem Versand (ein eben gesendeter Eintrag staut nichts mehr)
await schritt(lauf, 'Betriebsprüfung', () => pruefeBetrieb(now), [])
return { faellig, gesendet, fehler: lauf.fehler }
},
{ teil: 'Mailversand' },
),
])
if (verfall.aus || mail.aus) return { aus: true }
if (verfall.uebersprungen && mail.uebersprungen) return { uebersprungen: true }
return {
verfallen: verfall.verfallen ?? 0,
faellig: mail.faellig ?? 0,
gesendet: mail.gesendet ?? 0,
fehler: (verfall.fehler ?? 0) + (mail.fehler ?? 0),
...(verfall.uebersprungen ? { verfallUebersprungen: true } : {}),
...(mail.uebersprungen ? { mailUebersprungen: true } : {}),
}
}
// ---------------------------------------------------------------- Fuenf-Minuten-Job
@@ -275,175 +419,526 @@ async function aufbewahrungTage(lauf, featureId) {
}
}
/** Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). => true | false | undefined (Fehler) */
function loescheMitZellen(lauf, id, status) {
/**
* Buchung samt Zellen loeschen, wenn sie unter der Sperre noch im erwarteten Status ist (status: null = jeder). Mit `frist`
* ({ feld, vor } in ms) muss ausserdem die frisch gelesene Bezugszeit noch vor `vor` liegen (zweiter Boden gegen eine Liste, die
* seit dem Lesen veraltet ist). => true | false | undefined (Fehler)
*/
function loescheMitZellen(lauf, id, status, frist = null) {
return mitFrischer(lauf, 'Buchung nicht gelöscht', id, async (frisch) => {
if (status !== null && frisch.status !== status) return false
if (frist !== null && !(zeitMs(frisch[frist.feld]) < frist.vor)) return false
await gibZellenFrei(id)
await loesche(id)
return true
})
}
/**
* Aufbewahrungs-Loeschung (Spezifikation 11) fuer Buchungen in drei Durchgaengen, jeweils seitenweise (arbeiteSeiten):
* (1) verfallen (48 Stunden nach heldUntil), abgesagt und abgelehnt (30 Tage nach der letzten Aenderung) ueber alle Funktionen,
* (2) erledigt und nichterschienen je Funktion (portalFeatures featureKey termine, active oder disabled; nur die IDs werden
* gelesen) nach deren aufbewahrungTage (Vorgabe 90). Ist die Einstellung einer Funktion nicht lesbar, bleibt sie in diesem
* Lauf unberuehrt (nie frueher loeschen als eingestellt),
* (3) Sicherheitsnetz: erledigt und nichterschienen ohne Funktionsfilter nach 365 Tagen, der groessten erlaubten Aufbewahrung.
* Es faengt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spaetestens dann (Buchungen ohne
* Funktionsdokument bleiben bis dahin, Waisen gibt es in Produktion nicht).
* Die Frist steht in der Abfrage; unter der Buchungssperre wird Status und Bezugszeit noch einmal frisch geprueft (zweiter Boden).
* Deckel: ein Schritt loescht hoechstens config.termine.loeschDeckel Buchungen (erledigt und nichterschienen einer Funktion
* zusammen), alle Schritte des Laufs zusammen hoechstens config.termine.loeschDeckelGesamt. Jeder Schritt, der daran endet, zaehlt
* in `gedeckelt` (Warnung ohne Kennungen im Log); der Rest folgt im naechsten Lauf. Endet ein Schritt am Gesamtdeckel, ist der Lauf
* zu Ende: die uebrigen Schritte folgen am naechsten Tag. Ist der Gesamtdeckel genau erreicht, sieht jeder weitere Schritt nur nach,
* ob noch etwas ansteht (leere Seite = nicht gedeckelt).
* => Loeschzaehler je Status und `gedeckelt` = Zahl der Schritte, die am Deckel endeten
*/
async function loeschfristen(lauf, now) {
const geloescht = { verfallen: 0, abgesagt: 0, abgelehnt: 0, erledigt: 0, nichterschienen: 0 }
const tage = new Map()
const tageFuer = (featureId) => {
if (!tage.has(featureId)) tage.set(featureId, aufbewahrungTage(lauf, featureId))
return tage.get(featureId)
const deckelJeSchritt = config.termine.loeschDeckel
const deckelGesamt = config.termine.loeschDeckelGesamt
let gedeckelt = 0
let rest = deckelGesamt // was vom Gesamtdeckel noch uebrig ist
let amGesamtdeckel = false // ein Schritt endete am Gesamtdeckel: keine weiteren Schritte in diesem Lauf
/**
* Ein Loeschschritt fuer Buchungen eines Status mit Bezugszeit vor `vor` (ms), optional nur einer Funktion, unter beiden Deckeln:
* hoechstens `eigener`, hoechstens der Rest des Gesamtdeckels. => { geloescht, gedeckelt }
*/
const loescheSeiten = async (was, status, eigener, eigenerText, vor, featureId = null) => {
if (amGesamtdeckel) return { geloescht: 0, gedeckelt: false }
const amGesamt = rest <= eigener
const frist = { feld: loeschBezugsfeld(status), vor }
const erg = await schritt(
lauf,
was,
() =>
arbeiteSeiten(lauf, {
was,
deckel: amGesamt ? rest : eigener,
deckelText: amGesamt ? `von ${deckelGesamt} für alle Schritte des Laufs zusammen (Gesamtdeckel)` : eigenerText,
holeSeite: () => loeschKandidaten(status, new Date(vor), featureId === null ? {} : { featureId }),
bearbeite: async (kandidat) => {
const weg = await loescheMitZellen(lauf, kandidat.$id, status, frist)
if (weg) geloescht[status] += 1
return weg
},
}),
{ geloescht: 0, gedeckelt: false },
)
rest -= erg.geloescht
if (erg.gedeckelt) {
gedeckelt += 1
if (amGesamt) amGesamtdeckel = true
}
return erg
}
// Je Status: Kandidaten ab der fruehesten moeglichen Frist; bei erledigt/nichterschienen entscheidet die Frist der Funktion
const fristen = [
['verfallen', VERFALLEN_BEHALTEN_MS, false],
['abgesagt', ABGESAGT_BEHALTEN_MS, false],
['abgelehnt', ABGESAGT_BEHALTEN_MS, false],
['erledigt', AUFBEWAHRUNG_MIN * TAG_MS, true],
['nichterschienen', AUFBEWAHRUNG_MIN * TAG_MS, true],
]
for (const [status, behalten, jeFunktion] of fristen) {
await schritt(lauf, `Löschfrist ${status}`, async () => {
for (const kandidat of await loeschKandidaten(status, new Date(lauf.nowMs - behalten))) {
if (jeFunktion) {
const t = await tageFuer(kandidat.featureId)
if (t === null || !(zeitMs(kandidat.endsAt) < lauf.nowMs - t * TAG_MS)) continue
}
if (await loescheMitZellen(lauf, kandidat.$id, status)) geloescht[status] += 1
}
})
// Durchgang (1): verfallen, abgesagt, abgelehnt
for (const [status, behalten] of [
['verfallen', VERFALLEN_BEHALTEN_MS],
['abgesagt', ABGESAGT_BEHALTEN_MS],
['abgelehnt', ABGESAGT_BEHALTEN_MS],
]) {
await loescheSeiten(`Löschfrist ${status}`, status, deckelJeSchritt, undefined, lauf.nowMs - behalten)
}
return geloescht
// Durchgang (2): erledigt/nichterschienen je Funktion nach deren Aufbewahrung, die beiden Status teilen sich den Deckel der Funktion
const funktionen = amGesamtdeckel
? []
: await schritt(
lauf,
'Löschfrist: Funktionen nicht gelesen',
() => listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', ['active', 'disabled']), Query.select(['$id'])]),
[],
)
for (const { $id: featureId } of funktionen) {
if (amGesamtdeckel) break
const tage = await aufbewahrungTage(lauf, featureId)
if (tage === null) continue
const vor = lauf.nowMs - tage * TAG_MS
let geloeschtJeFunktion = 0
for (const status of ['erledigt', 'nichterschienen']) {
const erg = await loescheSeiten(`Löschfrist ${status} (je Funktion)`, status, deckelJeSchritt - geloeschtJeFunktion, `von ${deckelJeSchritt} je Funktion`, vor, featureId)
geloeschtJeFunktion += erg.geloescht
if (erg.gedeckelt) break // die Funktion endete am Deckel, einmal gezaehlt
}
}
// Durchgang (3): Sicherheitsnetz, die groesste erlaubte Aufbewahrung gilt fuer jede Buchung, auch ohne lesbare Funktion
for (const status of ['erledigt', 'nichterschienen']) {
await loescheSeiten(`Löschfrist ${status} (Sicherheitsnetz)`, status, deckelJeSchritt, undefined, lauf.nowMs - AUFBEWAHRUNG_MAX * TAG_MS)
}
return { ...geloescht, gedeckelt }
}
/** So viele Buchungen einer abgeschalteten Funktion hoechstens, bevor ihr Zustand erneut gelesen wird. */
const ABGESCHALTET_NEU_PRUEFEN_ALLE = 50
/**
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Loeschschritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate sperrt nicht; es leert disabledAt): featureKey termine, state disabled
* und disabledAt vor der Grenze. Sonst false mit Log, ohne Wurf – auch bei einem Lesefehler: im Zweifel wird nichts geloescht.
* Gilt die Funktion unmittelbar vor einem unwiderruflichen Schritt noch als abgeschaltet? Frisch gelesen, weil das Personal
* sie zwischen Liste und Loeschen wieder einschalten kann (siteFeatures.activate leert disabledAt) und ein Wartungs-Abo zurueckkommen
* kann: featureKey termine, state disabled und disabledAt vor der Grenze. Bei Grund `abo_ende` (nur dort haengt die Loeschung am Abo)
* zusaetzlich frisch aboLage (nie der Memo): laeuft das Wartungs-Abo wieder, gilt sie nicht als abgeschaltet. Fuer `team`,
* `kunde_geloescht`, `abo_ende_vor_einrichtung` und Altbestand ohne Grund gibt es keine Abo-Abfrage. mitAbo false: nichts
* Unumkehrbares mehr offen (Zuruecksetzen), dann entfaellt auch die Abo-Pruefung. Sonst null mit Log, ohne Wurf – auch bei einem
* Lesefehler (Funktion oder Abo): im Zweifel wird nichts geloescht.
* => das frisch gelesene Funktionsdokument, wenn es noch als abgeschaltet gilt, sonst null
*/
async function nochAbgeschaltet(lauf, funktionId, grenze) {
async function leseAbgeschaltet(lauf, funktionId, grenze, { mitAbo = true } = {}) {
let frisch
try {
const frisch = await getDocument(config.collections.portalFeatures, funktionId)
if (frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze) return true
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
frisch = await getDocument(config.collections.portalFeatures, funktionId)
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion wurde inzwischen entfernt, die Löschung wird für sie abgebrochen`)
} else {
melde(lauf, 'Funktion vor dem Löschen nicht gelesen', err)
}
return null
}
return false
if (!(frisch.featureKey === 'termine' && frisch.state === 'disabled' && zeitMs(frisch.disabledAt) < grenze)) {
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion ist inzwischen wieder aktiv oder neu abgeschaltet, die Löschung wird für sie abgebrochen`)
return null
}
if (!mitAbo || frisch.disabledReason !== 'abo_ende') return frisch
try {
if (!frisch.customerId) throw new Error('Funktion ohne Kunde')
if (!(await aboLage(frisch.customerId)).laeuft) return frisch
console.warn(`[termine-jobs] ${lauf.job}: Abo läuft wieder, Löschung abgebrochen`)
} catch (err) {
melde(lauf, 'Abo vor dem Löschen nicht gelesen', err)
}
return null
}
/** Wie leseAbgeschaltet, nur als Ja/Nein: gilt die Funktion unmittelbar vor einem unwiderruflichen Schritt noch als abgeschaltet? */
const nochAbgeschaltet = async (lauf, funktionId, grenze, optionen) => (await leseAbgeschaltet(lauf, funktionId, grenze, optionen)) !== null
/**
* Einstellung (termineConfig) einer abgeschalteten Funktion loeschen, unter der Sperre termine-cfg:<id>. Ist sie noch da, kommt vor
* dem unumkehrbaren Loeschen die letzte Pruefung (nochAbgeschaltet, bei Grund abo_ende auch das frische Abo); ist sie schon weg (404),
* ist nichts Unumkehrbares mehr offen und die Abo-Pruefung entfaellt. Ein Lesefehler wirft (der Aufrufer meldet ihn).
* => 'geloescht' (in diesem Lauf) | 'weg' (war schon weg) | 'bleibt' (die Funktion gilt nicht mehr als abgeschaltet)
*/
function loescheEinstellung(lauf, funktionId, grenze) {
const schonWeg = (err) => err?.status === 404 && !fehlendeCollection(err)
return withLocks([`termine-cfg:${funktionId}`], async () => {
try {
await getDocument(config.collections.termineConfig, funktionId)
} catch (err) {
if (schonWeg(err)) return 'weg'
throw err
}
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) return 'bleibt'
try {
await deleteDocument(config.collections.termineConfig, funktionId)
return 'geloescht'
} catch (err) {
if (schonWeg(err)) return 'weg'
throw err
}
})
}
/**
* Funktionen im Zustand `disabled` seit mehr als 30 Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die Einstellung
* loeschen. Vor der Buchungsliste, nach jeweils 50 Buchungen und vor dem Loeschen der Einstellung wird die Funktion frisch gelesen
* (nochAbgeschaltet); gilt sie nicht mehr als abgeschaltet, endet die Loeschung fuer diese Funktion sofort (Log, kein Wurf).
* Scheitert eine Buchung, bleibt die Einstellung bis zum naechsten Lauf. Zellen ohne Buchung raeumt danach der Tagesjob.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung).
* Das Funktionsdokument nach der Loeschung zuruecksetzen, damit der Betrieb neu bestellen kann: unter den Sperren
* feature-admin:<projectId> (wie Aktivieren und Abschalten im WOMS) und termine-cfg:<id> frisch lesen und nochmals pruefen
* (leseAbgeschaltet ohne Abo: es gibt nichts Unumkehrbares mehr), dann schreiben. Hat das Team es inzwischen aktiviert, bleibt es.
* R108: steht am frisch gelesenen Dokument ein offener Zahlungsfall (zahlungMeldung nicht leer und nicht 'gemeldet', oder C2: bezahlte
* Einrichtung mit Grund abo_ende_vor_einrichtung und T19 nicht gemeldet, auch bei leerer zahlungMeldung), wird NICHT
* zurueckgesetzt (Warnung ohne Kennungen, +1 in lauf.fehler, kein Wurf): das Dokument bleibt disabled samt unlockedByPurchase, ticketId
* und zahlungMeldung, der taegliche Abo-Abgleich (aboAbgleich, Nachholen der Erstattungsmeldung T19) bleibt zustaendig und ein
* spaeterer Lauf setzt zurueck, sobald 'gemeldet' steht. Buchungen und Einstellung sind dann schon geloescht (bleibt so).
* purchaseStatus 'zurueckgesetzt' ist die dauerhafte Marke "hier wurde nach dem Abo-Ende geloescht" (weder paid noch Meldezustand;
* Block C6 loescht ein so markiertes Funktionsdokument nie, damit der siteKey besteht). siteKey sowie Bestell- und Rechnungsfelder
* bleiben stehen bis zur naechsten Bestellung.
* => true, wenn zurueckgesetzt
*/
function setzeFunktionZurueck(lauf, funktion, grenze) {
return withLocks([`feature-admin:${funktion.projectId}`, `termine-cfg:${funktion.$id}`], async () => {
const frisch = await leseAbgeschaltet(lauf, funktion.$id, grenze, { mitAbo: false })
if (!frisch) return false
// R108: ein offener Zahlungsfall (zahlungMeldung gesetzt, nicht 'gemeldet') bleibt sichtbar. Das Dokument bleibt disabled samt Markern
// (unlockedByPurchase, ticketId, zahlungMeldung), damit der Abo-Abgleich die Meldung (T19) weiter nachholt; ein spaeterer Lauf setzt zurueck,
// sobald 'gemeldet' steht. Ohne PII im Log, zaehlt in lauf.fehler (Tagesbericht), kein Wurf.
// C2: dazu die bezahlte, wegen des Abo-Endes vor der Einrichtung abgeschaltete Einrichtung, deren Erstattung (T19) noch nicht beim Team ist, auch
// wenn zahlungMeldung noch leer ist (T19 scheiterte seit dem Abschalten): das Zuruecksetzen wuerde unlockedByPurchase, ticketId und
// zahlungMeldung loeschen, und mit ihnen die Spur der geschuldeten Erstattung. Der Abo-Abgleich (Schritt 2b) meldet T19 weiter.
if ((frisch.zahlungMeldung && frisch.zahlungMeldung !== 'gemeldet') || istErstattungOffen(frisch)) {
lauf.fehler += 1
console.warn(`[termine-jobs] ${lauf.job}: eine Funktion hat einen offenen Zahlungsfall, sie wird nicht zurückgesetzt (der Abo-Abgleich meldet ihn weiter)`)
return false
}
await updateDocument(config.collections.portalFeatures, funktion.$id, {
...stateFields('off'),
unlockedByPurchase: false,
purchaseStatus: 'zurueckgesetzt',
zahlungMeldung: '',
ticketId: '',
activatedAt: null,
disabledAt: null,
disabledReason: null,
aboEndeMailAt: null,
adminNote: 'Daten gelöscht (Löschfrist abgelaufen)',
})
return true
})
}
/**
* Buchungen EINER abgeschalteten Funktion seitenweise loeschen (arbeiteSeiten): Seite lesen (hoechstens 100, nur Kennungen), jede
* Buchung samt Zellen unter ihrer Sperre loeschen, neu lesen, bis die Seite leer ist. Die Funktion wird frisch gelesen
* (nochAbgeschaltet, bei Grund abo_ende samt frischem Abo) vor jeder Seite nach dem Lesen und nach jeweils 50 Buchungen; gilt sie
* nicht mehr als abgeschaltet, endet die Loeschung fuer sie sofort (Log, kein Wurf). Am Deckel config.termine.loeschDeckel endet der
* Lauf mit Warnung (der Rest folgt am naechsten Tag). Die Einstellung wird nur nach einer leeren Seite ohne Fehler geloescht
* (loescheEinstellung, mit letzter Pruefung unter der Sperre termine-cfg:<id>); sind alle Buchungen weg und die Einstellung geloescht
* oder schon nicht mehr da, setzt der Lauf das Funktionsdokument auf `off` zurueck (setzeFunktionZurueck, fuer jeden Grund, ohne
* Abo-Pruefung). Sonst bleibt die Funktion samt altem disabledAt stehen und der naechste Tag macht weiter; scheitert nur das
* Zuruecksetzen, holt der naechste Tag es nach (die Einstellung ist dann weg, das Abo spielt keine Rolle mehr); bei einem offenen
* Zahlungsfall (R108) bleibt das Dokument disabled, bis zahlungMeldung 'gemeldet' ist.
* => { geloescht, gedeckelt, einstellungWeg } (einstellungWeg: in diesem Lauf geloescht; ein reines Zuruecksetzen zaehlt nicht)
*/
async function loescheAbgeschalteteFunktion(lauf, funktion, grenze) {
const funktionId = funktion.$id
const deckel = config.termine.loeschDeckel
let abgebrochen = false // die Funktion gilt nicht mehr als abgeschaltet (oder war nicht lesbar): nichts mehr loeschen
let begonnen = 0 // Buchungen, deren Loeschung begonnen wurde
let geprueftBei = 0 // Stand von `begonnen` bei der letzten Pruefung
const pruefe = async () => {
geprueftBei = begonnen
if (!(await nochAbgeschaltet(lauf, funktionId, grenze))) abgebrochen = true
return !abgebrochen
}
const seiten = await schritt(
lauf,
'Buchungen abgeschalteter Funktion',
() =>
arbeiteSeiten(lauf, {
was: 'Löschung abgeschalteter Funktion',
deckel,
deckelText: `von ${deckel} je Funktion`,
holeSeite: async () => {
if (abgebrochen) return []
const seite = await ersteBuchungen(funktionId)
// vor dem Loeschen der Seite frisch pruefen: das Personal kann die Funktion zwischen Lesen und Loeschen wieder einschalten
return seite.length > 0 && !(await pruefe()) ? [] : seite
},
bearbeite: async (buchung) => {
if (abgebrochen) return false
if (begonnen - geprueftBei >= ABGESCHALTET_NEU_PRUEFEN_ALLE && !(await pruefe())) return false
begonnen += 1
return loescheMitZellen(lauf, buchung.$id, null)
},
}),
{ geloescht: 0, gedeckelt: false, vollstaendig: false },
)
let einstellungWeg = false
if (seiten.vollstaendig && !abgebrochen) {
const stand = await schritt(lauf, 'Einstellung abgeschalteter Funktion', () => loescheEinstellung(lauf, funktionId, grenze), 'bleibt')
einstellungWeg = stand === 'geloescht'
if (stand !== 'bleibt') {
await schritt(
lauf,
'Funktion zurücksetzen',
async () => {
if (!funktion.projectId) throw new Error('Funktion ohne Projekt') // ohne Projekt gibt es die Sperre feature-admin:<projectId> nicht: im Zweifel nichts schreiben
return setzeFunktionZurueck(lauf, funktion, grenze)
},
false,
)
}
}
return { geloescht: seiten.geloescht, gedeckelt: seiten.gedeckelt, einstellungWeg }
}
/**
* Funktionen im Zustand `disabled` seit mehr als LOESCHFRIST_TAGE (30) Tagen (disabledAt): alle Buchungen mit Zellen und zuletzt die
* Einstellung loeschen, je Funktion seitenweise mit Deckel (loescheAbgeschalteteFunktion), danach das Funktionsdokument auf `off`
* zuruecksetzen (es steht dann nicht mehr in der Liste dieses Jobs; bei einem offenen Zahlungsfall erst nach der Meldung, R108). Bei Grund `abo_ende` wird vor jedem unumkehrbaren Schritt frisch
* geprueft, ob das Wartungs-Abo wieder laeuft (dann bleibt alles). Endet eine Funktion am Deckel oder an einem Fehler, bleibt sie
* `disabled` mit altem disabledAt und der naechste Tag macht weiter; die anderen Funktionen kommen im selben Lauf dran.
* funktionen zaehlt die Funktionen, bei denen etwas geloescht wurde (Buchungen oder Einstellung; ein reines Zuruecksetzen zaehlt nicht),
* gedeckelt die Funktionen, die am Deckel endeten. Zellen ohne Buchung raeumt danach der Tagesjob.
*/
async function loescheAbgeschaltete(lauf) {
const erg = { funktionen: 0, buchungen: 0 }
const erg = { funktionen: 0, buchungen: 0, gedeckelt: 0 }
const grenze = lauf.nowMs - ABGESCHALTET_BEHALTEN_MS
const funktionen = await listAllDocuments(config.collections.portalFeatures, [Query.equal('featureKey', 'termine'), Query.equal('state', 'disabled')])
for (const funktion of funktionen) {
if (!(zeitMs(funktion.disabledAt) < grenze)) continue
const buchungen = await schritt(lauf, 'Buchungen abgeschalteter Funktion', () => listAllDocuments(config.collections.termineBookings, [Query.equal('featureId', funktion.$id)]), null)
if (buchungen === null || !(await nochAbgeschaltet(lauf, funktion.$id, grenze))) continue
let geloescht = 0
let vollstaendig = true
for (const [i, buchung] of buchungen.entries()) {
if (i > 0 && i % ABGESCHALTET_NEU_PRUEFEN_ALLE === 0 && !(await nochAbgeschaltet(lauf, funktion.$id, grenze))) {
vollstaendig = false
break
}
const weg = await loescheMitZellen(lauf, buchung.$id, null)
if (weg) geloescht += 1
if (weg === undefined) vollstaendig = false
}
erg.buchungen += geloescht
let einstellungWeg = false
if (vollstaendig) {
einstellungWeg = await schritt(
lauf,
'Einstellung abgeschalteter Funktion',
() =>
withLocks([`termine-cfg:${funktion.$id}`], async () => {
if (!(await nochAbgeschaltet(lauf, funktion.$id, grenze))) return false
try {
await deleteDocument(config.collections.termineConfig, funktion.$id)
return true
} catch (err) {
if (err?.status === 404 && !fehlendeCollection(err)) return false // schon weg
throw err
}
}),
false,
)
}
if (geloescht > 0 || einstellungWeg) erg.funktionen += 1
const weg = await loescheAbgeschalteteFunktion(lauf, funktion, grenze)
erg.buchungen += weg.geloescht
if (weg.gedeckelt) erg.gedeckelt += 1
if (weg.geloescht > 0 || weg.einstellungWeg) erg.funktionen += 1
}
return erg
}
/**
* Loeschfristen (Spezifikation 11) fuer Buchungen: verfallen 48 Stunden nach heldUntil, abgesagt/abgelehnt 30 Tage nach der letzten
* Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende; jeweils samt Zellen. Baustein des
* Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests).
* Aenderung, erledigt/nichterschienen aufbewahrungTage der Funktion (Vorgabe 90) nach Terminende, spaetestens nach 365 Tagen
* (Sicherheitsnetz); jeweils samt Zellen, je Schritt hoechstens config.termine.loeschDeckel und je Lauf hoechstens
* config.termine.loeschDeckelGesamt (der Rest folgt im naechsten Lauf). Baustein des Tagesjobs, ohne Schalter- und
* Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number }>}
* @returns {Promise<{ verfallen: number, abgesagt: number, abgelehnt: number, erledigt: number, nichterschienen: number, gedeckelt: number }>}
* gedeckelt = Zahl der Schritte, die am Deckel endeten
*/
export function wendeLoeschfristenAn(now) {
return loeschfristen(einzelLauf('loeschfristen', now), now)
}
/**
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen. Baustein des Tagesjobs, ohne Schalter- und
* Ueberlappungspruefung (fuer Tests).
* Abgeschaltete Funktionen nach 30 Tagen: Buchungen, Zellen, Einstellung loeschen, seitenweise und je Funktion bis config.termine.
* loeschDeckel Buchungen je Lauf. Baustein des Tagesjobs, ohne Schalter- und Ueberlappungspruefung (fuer Tests).
* @param {Date} now
* @returns {Promise<{ funktionen: number, buchungen: number }>}
* @returns {Promise<{ funktionen: number, buchungen: number, gedeckelt: number }>}
* gedeckelt = Zahl der Funktionen, die am Deckel endeten
*/
export function loescheAbgeschalteteFunktionen(now) {
return loescheAbgeschaltete(einzelLauf('abgeschalteteFunktionen', now))
}
/** Summe aller Loeschzaehler eines Tagesjobs: Buchungen aus den Loeschfristen und aus abgeschalteten Funktionen (Zellen, Outbox und Mails zaehlen nicht). */
const buchungenGeloescht = (loeschfristen, abgeschaltet) => Object.values(loeschfristen).reduce((summe, n) => summe + n, 0) + abgeschaltet.buchungen
/** Zaehler des Abo-Abgleichs, wenn der Schritt selbst scheitert (gleicheAboAb wirft nie; der Fehler steht dann schon in lauf.fehler). */
const aboErsatz = () => ({ geprueft: 0, abgeschaltet: 0, wiederEin: 0, mails: 0, ausgenommen: 0, fehler: 0 })
/** Zaehler der Entwurfs-Loeschung, wenn der Schritt selbst scheitert (loescheEntwuerfe wirft nur bei ungueltiger Uhrzeit). */
const entwuerfeErsatz = () => ({ geprueft: 0, geloescht: 0, uebrig: 0, fehler: 0 })
/**
* Entwuerfe (entwuerfe.js) als Schritt des Tagesjobs: ein Fehler stoppt den Rest nicht. Die Einzelfehler der Loeschung (schon im Log)
* kommen in lauf.fehler, damit der Tagesbericht und die Meldung ans Team sie nennen. => Zaehler { geprueft, geloescht, uebrig, fehler }
*/
async function entwuerfeSchritt(lauf, now) {
const entwuerfe = await schritt(lauf, 'Entwürfe', () => loescheEntwuerfe(now), entwuerfeErsatz())
lauf.fehler += entwuerfe.fehler
return entwuerfe
}
/**
* Guard fuer den Modus off (fuehreAus: trotzAus): gibt es ein vom Kauf freigeschaltetes Termine-Dokument? Eine einzige Abfrage
* (limit 1) ohne Kunden- oder Gastdaten; ein Lesefehler wirft und haelt den Tagesjob aus.
*/
async function gibtBezahlteTerminplaner() {
const treffer = await listDocuments(config.collections.portalFeatures, [
Query.equal('featureKey', 'termine'),
Query.equal('unlockedByPurchase', true),
Query.limit(1),
])
return treffer.length > 0
}
/**
* Abo-Abgleich (aboAbgleich.js) als Schritt des Tagesjobs: ein Fehler stoppt weder die Loeschung noch den Rest. Die Fehler des
* Abgleichs (Lese- und Versandfehler, schon im Log) kommen in lauf.fehler. => Zaehler des Abgleichs
*/
async function aboAbgleichSchritt(lauf, now) {
const abgleich = await schritt(lauf, 'Abo-Abgleich', () => gleicheAboAb(now), aboErsatz())
lauf.fehler += abgleich.fehler
return abgleich
}
/**
* Taeglich: festgefahrene Mails beenden (vor dem Aufraeumen, damit sie 48 Stunden lang als Zustellfehler sichtbar bleiben),
* Loeschfristen, abgeschaltete Funktionen, alte Warteschlangen-Eintraege, verwaiste Zellen.
* Loeschfristen, Abo-Abgleich (der Terminplaner endet mit dem Wartungs-Abo; VOR der Loeschung, damit ein Betrieb, dessen Abo heute
* zurueckkehrt, nicht mehr geloescht wird), abgeschaltete Funktionen, Entwuerfe (entwuerfe.js; die Einzelfehler zaehlen in `fehler`),
* alte Warteschlangen-Eintraege, verwaiste Zellen; zuletzt die
* aktiven Betriebe zaehlen (ein nicht lesbarer Zaehler ergibt null und einen Fehler) und den Lauf ans Team melden (meldeTagesjob:
* Fehler, Loeschdeckel an 3 Tagen in Folge, ab 20 Betrieben; wirft nie). Bricht der Job unerwartet ab, erfaehrt das Team es ebenfalls,
* und der Fehler geht unveraendert an den Aufrufer.
* `geloescht` = Summe aller Loeschzaehler (Buchungen aus Loeschfristen und abgeschalteten Funktionen), `aktiveBetriebe` = Zahl oder null,
* `aboAbgleich` = Zaehler des Abgleichs { geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler },
* `entwuerfe` = Zaehler der Entwurfs-Loeschung { geprueft, geloescht, uebrig, fehler } (geloescht zaehlt Entwuerfe, nicht Buchungen).
* Modus off: ohne vom Kauf freigeschaltetes Termine-Dokument { aus: true } nach genau einer Abfrage (Guard). Mit einem bezahlten
* Dokument laeuft der eingeschraenkte Tagesjob nur mit Abo-Abgleich und Loeschung abgeschalteter Funktionen (Loeschfristen der
* Buchungen, Entwuerfe, Warteschlange und Zellen bleiben aus; ob die uebrigen Schritte dort auch laufen sollen, ist offen). Sein Ergebnis traegt
* dieselben Felder, die meldeTagesjob und die Statusroute lesen: { eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt
* { loeschfristen: 0, abgeschaltet, zellen: 0 }, geloescht, aktiveBetriebe: null, fehler }. Beide Laeufe teilen die Ueberlappungssperre.
* @param {Date} now
* @returns {Promise<object | { aus: true } | { uebersprungen: true }>}
*/
export async function taeglicherJob(now) {
return fuehreAus('taeglicherJob', now, async (lauf) => {
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
const loeschung = await loeschfristen(lauf, now)
const abgeschaltet = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), { funktionen: 0, buchungen: 0 })
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
const zellen = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), 0)
return { festgefahren, loeschfristen: loeschung, abgeschaltet, aufgeraeumt, zellen, fehler: lauf.fehler }
})
let aktuellerLauf = null // gesetzt, sobald der Rahmen den Job startet (fuer die Meldung eines Abbruchs)
try {
return await fuehreAus(
'taeglicherJob',
now,
async (lauf) => {
aktuellerLauf = lauf
if (lauf.eingeschraenkt) return eingeschraenkterTagesjob(lauf, now)
const festgefahren = await schritt(lauf, 'Festgefahrene Mails', () => beendeFestgefahrene(now), 0)
const { gedeckelt: loeschfristenGedeckelt, ...loeschung } = await loeschfristen(lauf, now)
const aboAbgleich = await aboAbgleichSchritt(lauf, now)
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
const entwuerfe = await entwuerfeSchritt(lauf, now)
const aufgeraeumt = await schritt(lauf, 'Warteschlange aufräumen', () => raeumeAuf(now), 0)
const zellenErgebnis = await schritt(lauf, 'Verwaiste Zellen', () => raeumeVerwaisteZellen(now), { geloescht: 0, gedeckelt: false })
if (zellenErgebnis.gedeckelt) {
console.warn(`[termine-jobs] ${lauf.job}: Zellen-Sweep endet am Deckel von ${config.termine.zellenDeckel} je Lauf, der Rest folgt an den nächsten Tagen`)
}
const aktiveBetriebe = await schritt(lauf, 'Aktive Betriebe', () => zaehleAktiveBetriebe(), null)
// gedeckelt: Schritte bzw. Funktionen, die am Deckel endeten (zellen: 1, wenn der Zellen-Sweep daran endete); getrennt vom Loeschzaehler, damit die Summe im Tagesbericht stimmt
const gedeckelt = { loeschfristen: loeschfristenGedeckelt, abgeschaltet: abgeschaltetGedeckelt, zellen: zellenErgebnis.gedeckelt ? 1 : 0 }
const ergebnis = {
festgefahren,
loeschfristen: loeschung,
gedeckelt,
abgeschaltet,
aboAbgleich,
entwuerfe,
aufgeraeumt,
zellen: zellenErgebnis.geloescht,
geloescht: buchungenGeloescht(loeschung, abgeschaltet),
aktiveBetriebe,
fehler: lauf.fehler,
}
// Zuletzt: die Meldung sieht den ganzen Lauf; sie wirft nie, ein Fehler dort stuende nur im Log
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
return ergebnis
},
{ trotzAus: gibtBezahlteTerminplaner },
)
} catch (err) {
// Abbruch ausserhalb der Schritte: das Team erfaehrt es; der Fehler (Text im Log des Aufrufers) geht unveraendert weiter
if (aktuellerLauf) await schritt(aktuellerLauf, 'Tagesjob-Meldung', () => meldeTagesjob({ abbruch: true, fehler: aktuellerLauf.fehler + 1 }, now), [])
throw err
}
}
/**
* Der eingeschraenkte Tagesjob (Modus off, es gibt bezahlte Terminplaner): erst der Abo-Abgleich, dann die Loeschung abgeschalteter
* Funktionen (gleiche Reihenfolge wie im vollen Lauf). Mehr nicht: keine Loeschfristen der Buchungen, keine Warteschlange, keine
* Zellen, keine Zaehlung der aktiven Betriebe (`aktiveBetriebe: null`). Das Ergebnis traegt auch die Felder, die meldeTagesjob liest,
* und wird wie im vollen Lauf gemeldet (Fehler gehen als Vorfall ans Team).
*/
async function eingeschraenkterTagesjob(lauf, now) {
const aboAbgleich = await aboAbgleichSchritt(lauf, now)
const { gedeckelt: abgeschaltetGedeckelt, ...abgeschaltet } = await schritt(lauf, 'Abgeschaltete Funktionen', () => loescheAbgeschaltete(lauf), {
funktionen: 0,
buchungen: 0,
gedeckelt: 0,
})
const ergebnis = {
eingeschraenkt: true,
aboAbgleich,
abgeschaltet,
gedeckelt: { loeschfristen: 0, abgeschaltet: abgeschaltetGedeckelt, zellen: 0 },
geloescht: abgeschaltet.buchungen,
aktiveBetriebe: null,
fehler: lauf.fehler,
}
await schritt(lauf, 'Tagesjob-Meldung', () => meldeTagesjob(ergebnis, now), [])
return ergebnis
}
/** Zahl fuer die Berichtszeile: ganze Zahl ab 0, alles andere (fehlt, Text) zaehlt als 0. */
const zahl = (wert) => (Number.isInteger(wert) && wert > 0 ? wert : 0)
/**
* Eine Zeile fuers Log nach dem Tagesjob: nur Zahlen, keine personenbezogenen Daten und keine Buchungs- oder Funktions-IDs
* (Spur der unwiderruflichen Loeschungen). null, wenn der Job nicht gelaufen ist (Modus off, uebersprungen).
* (Spur der unwiderruflichen Loeschungen). Reihenfolge der Segmente (verbindlich): `…, N Mails nicht zugestellt, [Löschläufe am
* Deckel: n, Rest folgt an den nächsten Tagen, ]Entwürfe n gelöscht (m offen), Abo-Abgleich g geprüft / a abgeschaltet / w wieder ein /
* m Mails / x ausgenommen, N Fehler, n aktive Betriebe`; „n aktive Betriebe“ steht ganz am Ende und fehlt, wenn die Zahl unbekannt ist,
* „Abo-Abgleich“ und „Entwürfe“ fehlen, wenn das Ergebnis keine Zaehler dafuer traegt. Der eingeschraenkte Lauf (Modus off) hat eine
* eigene Zeile „[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): …“ ohne Entwürfe, Zellen, Outbox, Mails und Betriebe.
* null, wenn der Job nicht gelaufen ist (Modus off ohne bezahlte Terminplaner, uebersprungen).
* @param {object} ergebnis Rueckgabe von taeglicherJob
* @returns {string|null}
*/
export function tagesbericht(ergebnis) {
if (!ergebnis || ergebnis.aus || ergebnis.uebersprungen) return null
const l = ergebnis.loeschfristen
const a = ergebnis.abgeschaltet
const gesamt = Object.values(l).reduce((summe, n) => summe + n, 0) + a.buchungen
const amDeckel = Object.values(ergebnis.gedeckelt ?? {}).reduce((summe, n) => summe + n, 0)
const deckel = amDeckel > 0 ? `Löschläufe am Deckel: ${amDeckel}, Rest folgt an den nächsten Tagen, ` : ''
const e = ergebnis.entwuerfe
const entwuerfeSegment = e === null || typeof e !== 'object' ? '' : `Entwürfe ${zahl(e.geloescht)} gelöscht (${zahl(e.uebrig)} offen), `
const abo = ergebnis.aboAbgleich
const aboSegment =
abo === null || typeof abo !== 'object'
? ''
: `Abo-Abgleich ${zahl(abo.geprueft)} geprüft / ${zahl(abo.abgeschaltet)} abgeschaltet / ${zahl(abo.wiederEin)} wieder ein / ${zahl(abo.mails)} Mails / ${zahl(abo.ausgenommen)} ausgenommen, `
if (ergebnis.eingeschraenkt) {
return (
`[termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ${a.buchungen} Buchungen gelöscht (abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${deckel}${aboSegment}${ergebnis.fehler} Fehler`
)
}
const l = ergebnis.loeschfristen
const gesamt = buchungenGeloescht(l, a)
const betriebe = Number.isInteger(ergebnis.aktiveBetriebe) && ergebnis.aktiveBetriebe >= 0 ? `, ${ergebnis.aktiveBetriebe} aktive Betriebe` : ''
return (
`[termine] Tagesjob: ${gesamt} Buchungen gelöscht (verfallen ${l.verfallen}, abgesagt ${l.abgesagt}, abgelehnt ${l.abgelehnt}, ` +
`erledigt ${l.erledigt}, nichterschienen ${l.nichterschienen}, abgeschaltete Funktionen ${a.buchungen}), ` +
`${a.funktionen} Funktionen bereinigt, ${ergebnis.zellen} Zellen, ${ergebnis.aufgeraeumt} Outbox, ` +
`${ergebnis.festgefahren} Mails nicht zugestellt, ${ergebnis.fehler} Fehler`
`${ergebnis.festgefahren} Mails nicht zugestellt, ${deckel}${entwuerfeSegment}${aboSegment}${ergebnis.fehler} Fehler${betriebe}`
)
}
+181 -17
View File
@@ -4,6 +4,7 @@ import { analyseHosts } from '../analyse.js'
import { withLocks } from '../keyedLock.js'
import { findActiveBySiteKey } from '../siteFeatures.js'
import { fehlendePflichtangaben } from './einstellungen.js'
import { leerFragebogen, normalisiereFragebogen } from './fragebogen.js'
import { termineAus } from './schalter.js'
import { pruefeNow, pruefeText } from './util.js'
@@ -11,11 +12,12 @@ import { pruefeNow, pruefeText } from './util.js'
* Kontext einer Terminplaner-Funktion (Spezifikation 2026-10-02-terminplaner-design.md, Abschnitte 2 und 8) und
* Speicher der Betriebs-Einstellung (termineConfig, Dokument-ID = ID des portalFeatures-Dokuments).
*
* ctx = { feature, project, einstellung, version }
* ctx = { feature, project, einstellung, version, fragebogen }
* feature portalFeatures-Dokument (featureKey 'termine')
* project websiteProjects-Dokument oder null (Projekt fehlt)
* einstellung gespeicherte Einstellung (Objekt) oder null (noch nie gespeichert / unlesbar)
* version Zaehler fuer die optimistische Sperre (0 = noch nie gespeichert; bei unlesbarem JSON die Version des Dokuments)
* fragebogen Bestell-Fragebogen (TP2), immer normalisiert; fehlt er oder ist er unlesbar (Logzeile), der leere (leerFragebogen)
* Ein ctx ist nur zum Lesen: kontextPerSiteKey gibt innerhalb von 30 Sekunden dasselbe Objekt zurueck.
*
* - kontextPerSiteKey (oeffentliche Schnittstelle): nur Funktionen im Zustand active und nur mit gespeicherter
@@ -25,6 +27,11 @@ import { pruefeNow, pruefeText } from './util.js'
* leert den Speicher. Eine Abfrage, die waehrend eines Speicherns lief, landet nie im Speicher (generation).
* - kontextPerFeature (Portal, Gaesteseiten per Token): active und disabled (Gaeste duerfen weiter absagen und widerrufen),
* ohne Speicher; liefert auch ohne gespeicherte Einstellung einen ctx (einstellung null, version 0).
* - kontextFuerPortal (Portal, Entwurf und Bestellung): das uebergebene portalFeatures-Dokument in jedem Zustand (off, ordered,
* setup, active, disabled) ohne Modus- und Zustandspruefung (die macht der Aufrufer), ohne Speicher.
* Der Fragebogen steht im selben Dokument wie die Einstellung (termineConfig.fragebogen, JSON-Text) und gehoert nur dem
* Entwurf: speichereFragebogen aendert ihn ohne die Version der Einstellung anzufassen (der Fragebogen-Editor und der
* Einstellungs-Editor stoeren sich so nicht), beide Schreibwege laufen unter derselben Sperre termine-cfg:<featureId>.
* Fehlt eine Collection (404 collection_not_found), wird der Fehler weitergereicht und nicht als "nicht gefunden" gelesen.
*/
@@ -42,7 +49,8 @@ const istObjekt = (wert) => wert !== null && typeof wert === 'object' && !Array.
/** 404 "Dokument fehlt" (nicht: Collection oder Datenbank fehlt). */
const dokumentFehlt = (err) => err?.status === 404 && err.type !== 'collection_not_found' && err.type !== 'database_not_found'
async function holeOderNull(collectionId, id) {
/** Dokument lesen; fehlt es (404 "Dokument fehlt"), ist das Ergebnis null. Fehlt Collection oder Datenbank, wird der Fehler weitergereicht. */
export async function holeOderNull(collectionId, id) {
try {
return await getDocument(collectionId, id)
} catch (err) {
@@ -68,15 +76,72 @@ function liesDokument(doc) {
}
/**
* Die gespeicherte Einstellung einer Funktion.
* Der Fragebogen aus einem termineConfig-Dokument, normalisiert. Fehlt das Attribut (Altbestand, noch nie gespeichert), ist es
* der leere Fragebogen ohne Meldung; kaputtes JSON oder ein Inhalt, den die Normalisierung ablehnt, ergibt ebenfalls den leeren
* mit genau einer Logzeile (Dokument-ID und Grund) und wirft nie: der Fragebogen darf Einstellung und Kontext nicht blockieren.
*/
function fragebogenAus(doc) {
const roh = doc.fragebogen
if (roh === undefined || roh === null || roh === '') return leerFragebogen()
try {
const erg = normalisiereFragebogen(JSON.parse(roh))
if (erg.fehler) throw new Error(`ungueltige Angaben (${Object.keys(erg.fehler).join(', ')})`)
return erg.fragebogen
} catch (err) {
console.error(`[termine-kontext] Fragebogen ${doc.$id} ist unlesbar: ${err.message}`)
return leerFragebogen()
}
}
/**
* Die gespeicherte Einstellung einer Funktion samt Fragebogen.
* @param {string} featureId ID des portalFeatures-Dokuments
* @returns {Promise<{ einstellung: object, version: number }|null>} null, wenn es noch keine (lesbare) gibt
* @returns {Promise<{ einstellung: object, version: number, fragebogen: object }|null>} null, wenn es noch keine (lesbare)
* Einstellung gibt; fragebogen ist immer normalisiert (leer, wenn nichts oder nichts Lesbares gespeichert ist)
*/
export async function ladeEinstellung(featureId) {
pruefeText(featureId, 'featureId')
const doc = await holeOderNull(config.collections.termineConfig, featureId)
const gelesen = doc ? liesDokument(doc) : null
return gelesen?.einstellung ? gelesen : null
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) {
@@ -85,7 +150,13 @@ async function baueKontext(feature) {
holeOderNull(config.collections.termineConfig, feature.$id),
])
const gelesen = doc ? liesDokument(doc) : null
return { feature, project, einstellung: gelesen?.einstellung ?? null, version: gelesen?.version ?? 0 }
return {
feature,
project,
einstellung: gelesen?.einstellung ?? null,
version: gelesen?.version ?? 0,
fragebogen: doc ? fragebogenAus(doc) : leerFragebogen(),
}
}
/**
@@ -131,12 +202,37 @@ export async function kontextPerFeature(featureId) {
return baueKontext(feature)
}
const veraltet = () =>
/**
* Kontext fuer das Portal (Entwurf, Bestellung, Einbau): das uebergebene portalFeatures-Dokument in jedem Zustand, ohne Speicher.
* Es gibt keine Modus- und keine Zustandspruefung und keinen Abgleich mit der Datenbank: das Dokument ist das, das der Aufrufer
* schon (frisch) gelesen und geprueft hat. Oeffentlich ist dieser Kontext nie, dafuer gibt es kontextPerSiteKey.
* @param {{ $id: string, projectId?: string }} feature portalFeatures-Dokument
* @returns {Promise<object>} ctx; ohne termineConfig-Dokument einstellung null, version 0, leerer Fragebogen
*/
export async function kontextFuerPortal(feature) {
pruefeText(feature?.$id, 'feature.$id')
return baueKontext(feature)
}
export const veraltet = () =>
Object.assign(new Error('Die Einstellungen wurden inzwischen geändert. Bitte neu laden.'), {
status: 409,
code: 'EINSTELLUNG_VERALTET',
})
const keineEinstellung = () =>
Object.assign(new Error('Es gibt noch keinen Entwurf für den Terminplaner. Bitte wähle zuerst eine Branche.'), {
status: 409,
code: 'keine_einstellung',
})
/** Eingabe normalisieren und als JSON-Text fuer termineConfig.fragebogen bereitstellen; ungueltig => 400 mit err.fehler. */
function pruefeFragebogen(eingabe) {
const erg = normalisiereFragebogen(eingabe)
if (erg.fehler) throw Object.assign(new Error('Der Fragebogen ist ungültig.'), { status: 400, fehler: erg.fehler })
return { fragebogen: erg.fragebogen, json: JSON.stringify(erg.fragebogen) }
}
/**
* Speichert die (bereits geprueften) Einstellungen. Optimistisch gesperrt: version ist die Version, auf der die
* Aenderung beruht (0 = noch nie gespeichert); weicht sie von der gespeicherten ab, passiert nichts und es folgt
@@ -148,12 +244,19 @@ const veraltet = () =>
* die den gespeicherten Stand und die Buchungen brauchen (Entfernen mit kuenftigen Terminen), unter derselben Sperre wie
* das Schreiben. version null ueberspringt die Versionspruefung (nur fuer Aenderungen, die auf dem aktuellen Stand
* aufbauen, z. B. neuer Kalender-Link); die Version zaehlt trotzdem hoch.
* Mit der Option `fragebogen` (TP2: Neuanlage mit Branche, "Vorlage ersetzen" mit zurueckgesetzten Bestaetigungen) schreibt
* derselbe Vorgang unter derselben Sperre auch den Fragebogen: entweder die Eingabe (Objekt, wird normalisiert) oder eine
* Funktion `(bisher) => Eingabe` (bisher = gespeicherter Fragebogen, leer wenn keiner da ist), die wie die Einstellungs-Funktion
* nach der Versionspruefung laeuft, sodass ein Einbau-Wunsch, der zwischendurch gespeichert wurde, nicht verloren geht. Ist sie
* ungueltig, gibt es { status: 400, fehler } und es wird nichts geschrieben (weder Einstellung noch Version noch Dokument).
* Ohne die Option bleibt der gespeicherte Fragebogen unberuehrt.
* @param {{ $id: string, customerId: string, projectId: string }} feature portalFeatures-Dokument
* @param {object|((bisher: object|null) => object|Promise<object>)} einstellungOderFn
* @param {{ version: number|null, by?: string, now?: Date }} optionen
* @returns {Promise<{ einstellung: object, version: number }>} wie gespeichert, mit der neuen Version
* @param {{ version: number|null, by?: string, now?: Date, fragebogen?: object|((bisher: object) => object|Promise<object>) }} optionen
* @returns {Promise<{ einstellung: object, version: number, fragebogen?: object }>} wie gespeichert, mit der neuen Version;
* fragebogen (normalisiert) nur, wenn die Option gesetzt war
*/
export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date() } = {}) {
export async function speichereEinstellung(feature, einstellungOderFn, { version, by = '', now = new Date(), fragebogen } = {}) {
pruefeText(feature?.$id, 'feature.$id')
const alsFunktion = typeof einstellungOderFn === 'function'
if (!alsFunktion && !istObjekt(einstellungOderFn)) throw new TypeError('einstellung fehlt')
@@ -169,6 +272,13 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
if (!istObjekt(einstellung)) throw new TypeError('einstellung fehlt')
const json = JSON.stringify(einstellung)
// der Fragebogen wird vor dem ersten Schreibzugriff geprueft: ein ungueltiger laesst auch die Einstellung ungespeichert
let neuerFragebogen
if (fragebogen !== undefined) {
const eingabe = typeof fragebogen === 'function' ? await fragebogen(vorhanden ? fragebogenAus(vorhanden) : leerFragebogen()) : fragebogen
neuerFragebogen = pruefeFragebogen(eingabe)
}
const felder = {
customerId: feature.customerId,
projectId: feature.projectId,
@@ -176,6 +286,7 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
version: aktuell + 1,
updatedAt: new Date(nowMs).toISOString(),
updatedBy: String(by ?? '').slice(0, 100),
...(neuerFragebogen ? { fragebogen: neuerFragebogen.json } : {}),
}
try {
if (vorhanden) await updateDocument(col, feature.$id, felder)
@@ -185,14 +296,69 @@ export async function speichereEinstellung(feature, einstellungOderFn, { version
generation += 1
cache.clear()
}
return { einstellung: JSON.parse(json), version: aktuell + 1 }
return { einstellung: JSON.parse(json), version: aktuell + 1, ...(neuerFragebogen ? { fragebogen: neuerFragebogen.fragebogen } : {}) }
})
}
/**
* Speichert den Fragebogen einer Funktion (die Eingaben des Kunden im Bestell-Fragebogen). Gleiche Sperre wie
* speichereEinstellung (termine-cfg:<featureId>); `aendere(bisher)` laeuft darin, bekommt den gespeicherten Fragebogen
* (leer, wenn keiner gespeichert oder lesbar ist) und liefert die zu speichernde Eingabe, die normalisiert wird. Sie darf werfen
* (der Fehler geht unveraendert durch, es wird nichts geschrieben). Eine ungueltige Eingabe wirft { status: 400, fehler }.
* Geschrieben werden nur fragebogen, updatedAt und updatedBy: `version` und `einstellung` bleiben unberuehrt, ein offener
* Einstellungs-Editor mit der gelesenen Version kann also weiter speichern. Gibt es noch kein termineConfig-Dokument, wird keins
* angelegt, sondern { status: 409, code: 'keine_einstellung' } geworfen. Danach ist der Speicher von kontextPerSiteKey leer.
* @param {{ $id: string }} feature portalFeatures-Dokument
* @param {(bisher: object) => object|Promise<object>} aendere
* @param {{ by?: string, now?: Date }} optionen
* @returns {Promise<{ fragebogen: object }>} der gespeicherte, normalisierte Fragebogen
*/
export async function speichereFragebogen(feature, aendere, { by = '', now = new Date() } = {}) {
pruefeText(feature?.$id, 'feature.$id')
if (typeof aendere !== 'function') throw new TypeError('aendere fehlt')
const nowMs = pruefeNow(now, 'speichereFragebogen')
return withLocks([`termine-cfg:${feature.$id}`], async () => {
const col = config.collections.termineConfig
const vorhanden = await holeOderNull(col, feature.$id)
if (!vorhanden) throw keineEinstellung()
const { fragebogen, json } = pruefeFragebogen(await aendere(fragebogenAus(vorhanden)))
try {
await updateDocument(col, feature.$id, {
fragebogen: json,
updatedAt: new Date(nowMs).toISOString(),
updatedBy: String(by ?? '').slice(0, 100),
})
} finally {
// auch bei einem Fehler: unklar, was in der Datenbank steht
generation += 1
cache.clear()
}
return { fragebogen }
})
}
/** Vorschau-Host des Projekts (<subdomain>.project.webklar.com, klein) oder null; gleiche Schreibweise wie in analyseHosts. */
function vorschauHost(project) {
return project.subdomain ? `${String(project.subdomain).trim().toLowerCase()}.project.webklar.com` : null
}
/**
* Host der echten (Live-)Website eines Projekts: nur wenn project.liveDomain gesetzt ist, der erste gueltige Host aus
* analyseHosts(project), der nicht die Vorschau-Adresse <subdomain>.project.webklar.com ist; sonst undefined.
* Einzige Quelle fuer websiteUrl() und den Betriebsvorschlag des Fragebogens (routes/termine.js).
*/
export function liveHost(project) {
if (!project?.liveDomain) return undefined
const vorschau = vorschauHost(project)
return analyseHosts(project).find((host) => host !== vorschau)
}
/**
* Adresse der Website des Betriebs (Link auf Gaesteseiten und im Mailfuss), immer die echte Seite statt der Vorschau:
* betrieb.websiteUrl; sonst https:// + Live-Domain des Projekts (wenn project.liveDomain einen gueltigen Host ergibt);
* sonst https:// + <subdomain>.project.webklar.com; sonst die Portal-Adresse (config.publicBaseUrl).
* betrieb.websiteUrl; sonst https:// + Live-Domain des Projekts (liveHost); sonst https:// + <subdomain>.project.webklar.com;
* sonst die Portal-Adresse (config.publicBaseUrl).
*/
export function websiteUrl(ctx) {
const eigene = ctx?.einstellung?.betrieb?.websiteUrl
@@ -200,10 +366,8 @@ export function websiteUrl(ctx) {
const project = ctx?.project
if (project) {
// analyseHosts: [<subdomain>.project.webklar.com, Live-Domain], ungueltige und doppelte Hosts fehlen
const hosts = analyseHosts(project)
const vorschau = project.subdomain ? `${String(project.subdomain).trim().toLowerCase()}.project.webklar.com` : null
const live = project.liveDomain ? hosts.find((host) => host !== vorschau) : undefined
const host = live ?? hosts.find((h) => h === vorschau)
const vorschau = vorschauHost(project)
const host = liveHost(project) ?? analyseHosts(project).find((h) => h === vorschau)
if (host) return `https://${host}`
}
return config.publicBaseUrl
+32 -5
View File
@@ -13,8 +13,11 @@ import { pruefeNow, pruefeText } from './util.js'
* Buchung, ein Treffer bekommt seine Mails immer.
* Oeffentliche Schnittstelle (routes/publicTermine.js): darfAnfrage / darfAnfrageSite (lesende Abrufe und Token-Aufrufe
* je IP und je siteKey), darfBuchungsversuch (je IP), darfHaltung (je siteKey), darfWiderrufsversuch (je IP) und darfG01
* (Bestaetigungsmails je Adresse und Funktion). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist
* (Bestaetigungsmails je Adresse). IPv6-Adressen zaehlen je /64 (ipSchluessel): ein Anschluss bekommt meist
* ein ganzes /64, die Adresse darin wechselt beliebig.
* Empfaenger-Kappe: eine fremde Adresse bekommt ueber alle Betriebe (Funktionen) zusammen hoechstens 3 Mails je Stunde und
* 10 je Tag, die ein Gast oder ein Betrieb ausloest (darfG01 beim Gast-Formular, darfEmpfaenger fuer den Freitext des
* Betriebs in G04/G06, mails.js anGast); beide teilen dieselben Zaehler.
*
* Speicher: zwei getrennte Maps mit je hoechstens 10 000 Schluesseln. Die Zaehler je IP (Schluessel mit 'ip:') haben
* beliebig viele, von Angreifern frei waehlbare Schluessel; sie duerfen die langlebigen Zaehler je Adresse, Funktion
@@ -41,11 +44,14 @@ const ANFRAGEN_JE_SITE = 600
/** Buchungsversuche (jedes POST auf /buchungen, auch Honeypot): je IP und 10 Minuten; gehaltene Termine: je siteKey und Stunde. */
const BUCHUNGEN_JE_IP = 10
const BUCHUNGEN_FENSTER_MS = 10 * MINUTE_MS
const HALTUNGEN_JE_SITE = 60
const HALTUNGEN_JE_SITE = 25
/** Widerrufserklaerungen: je IP und Stunde. */
const WIDERRUFE_JE_IP = 10
/** G01 (Bestaetigungsmail, auch "zu viele"): je Adresse und Funktion und Tag. */
const G01_JE_ADRESSE = 5
/** Empfaenger-Kappe ueber alle Funktionen: je (normalisierter) Adresse und Stunde, je Adresse und Tag. */
const EMPFAENGER_JE_STUNDE = 3
const EMPFAENGER_JE_TAG = 10
/**
* Eine Map schluessel -> { start, ende (ms), n }; die Einfuegereihenfolge ist die Reihenfolge der Fensterbeginne.
@@ -182,7 +188,10 @@ export function darfBuchungsversuch(ip, now) {
return zaehle(`ip:buchung:${ipSchluessel(ip)}`, BUCHUNGEN_JE_IP, BUCHUNGEN_FENSTER_MS, now)
}
/** Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 60 je Stunde. */
/**
* Darf diese Funktion (siteKey) jetzt einen weiteren Termin halten? Hoechstens 25 je Stunde: so loest eine Funktion hoechstens 25 G01
* je Stunde aus, unter dem Mailkontingent je Funktion (config.termine.mailsProFunktion, outbox.js).
*/
export function darfHaltung(siteKey, now) {
return zaehle(`haltung-site:${siteKey}`, HALTUNGEN_JE_SITE, STUNDE_MS, now)
}
@@ -192,9 +201,27 @@ export function darfWiderrufsversuch(ip, now) {
return zaehle(`ip:widerruf:${ipSchluessel(ip)}`, WIDERRUFE_JE_IP, STUNDE_MS, now)
}
/** Darf an diese (normalisierte) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag (gehalten wie "zu viele"). */
/** Die zwei Regeln der Empfaenger-Kappe (Stunde und Tag) fuer eine normalisierte Adresse, ueber alle Funktionen. */
const empfaengerRegeln = (adresse) => [
{ schluessel: `empfaenger-h:${adresse}`, max: EMPFAENGER_JE_STUNDE, fensterMs: STUNDE_MS },
{ schluessel: `empfaenger-d:${adresse}`, max: EMPFAENGER_JE_TAG, fensterMs: TAG_MS },
]
/**
* Darf an diese (normalisierte: klein, getrimmt) Adresse eine G01 gehen? Hoechstens 5 je Adresse und Funktion und Tag
* (gehalten wie "zu viele") und die Empfaenger-Kappe ueber alle Funktionen (3 je Stunde, 10 je Tag). Alles oder nichts:
* eine abgelehnte Anfrage erhoeht keinen Zaehler.
*/
export function darfG01(featureId, adresse, now) {
return zaehle(`g01:${featureId}:${adresse}`, G01_JE_ADRESSE, TAG_MS, now)
return zaehleAlle([{ schluessel: `g01:${featureId}:${adresse}`, max: G01_JE_ADRESSE, fensterMs: TAG_MS }, ...empfaengerRegeln(adresse)], now)
}
/**
* Darf an diese (normalisierte) Adresse eine Mail mit Freitext des Betriebs gehen (G04, G06)? Dieselben Zaehler wie
* darfG01 (3 je Stunde, 10 je Tag ueber alle Funktionen), aber ohne die Regel je Funktion.
*/
export function darfEmpfaenger(adresse, now) {
return zaehleAlle(empfaengerRegeln(adresse), now)
}
/** Leert alle Zaehler (Tests). */
+25 -3
View File
@@ -3,6 +3,7 @@ import { getDocument } from '../appwriteAdmin.js'
import { preisHinweis, preisText, zahlungText } from './einstellungen.js'
import { icsTermin } from './ics.js'
import { websiteUrl } from './kontext.js'
import { darfEmpfaenger } from './limits.js'
import { einreihen } from './outbox.js'
import { widerrufsbelehrung } from './rechtstexte.js'
import { buchungsNummer, verwaltungsToken } from './token.js'
@@ -21,6 +22,8 @@ import { datumKurz, datumLang, uhrzeit } from './zeit.js'
* - Person oder Platz (Block person in G02/G05) nur, wenn der Betrieb mehrere Ressourcen hat; K20/K21 zeigen sie immer.
* - G01 und G08 enthalten keinen Text, den der Gast eingegeben hat (die Mail geht an eine ungeprueft angegebene Adresse);
* in keinem Betreff steht ein Gastname.
* - Freitext des Betriebs (G04 nachricht, G06 grund und nachricht bei Absage durch den Betrieb) geht nur bis zur
* Empfaenger-Kappe (limits.js darfEmpfaenger) an eine Adresse; danach geht die Mail ohne diesen Text raus (anGast).
* - Verwaltungslink `${publicBaseUrl}/termin.html#m=<verwaltungsToken>` (G02, G03, G05, G07), Bestaetigungslink `#v=<token>`
* (G01, Token von extra.verifyToken), Portal-Link der Betriebsmails `${publicBaseUrl}/dashboard.html#termine`.
*
@@ -52,6 +55,8 @@ const GAST_ARTEN = ['G01', 'G02', 'G03', 'G04', 'G05', 'G06', 'G07', 'G08']
const BETRIEBS_ARTEN = ['K20', 'K21']
const ICS_METHODE = { G02: 'REQUEST', G07: 'REQUEST', G06: 'CANCEL', K20: 'PUBLISH' }
const VERIFY_TOKEN_RE = /^[A-Za-z0-9_-]{43}$/
/** Felder mit Freitext des Betriebs je Gastmail (G06 nur bei von === 'betrieb'). */
const FREITEXT_FELDER = { G04: ['nachricht'], G06: ['grund', 'nachricht'] }
function einfrieren(wert) {
if (wert && typeof wert === 'object' && !Object.isFrozen(wert)) {
@@ -458,10 +463,26 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) {
// ---------------------------------------------------------------- Einreihen
/**
* Zaehlt eine Mail mit Freitext des Betriebs gegen die Empfaenger-Kappe der Adresse (limits.js darfEmpfaenger) und gibt
* das extra zurueck, mit dem die Mail rausgeht: unveraendert, solange die Kappe nicht erreicht ist oder kein Freitext
* dabei ist (dann zaehlt nichts), sonst ohne die Freitextfelder (Log-Warnung ohne Adresse). G05, K20/K21 und der Rest
* sind nicht betroffen, ebenso G06 des Gastes oder des Systems.
*/
function begrenzeFreitext(art, extra, empfaenger, now) {
const felder = FREITEXT_FELDER[art]
if (!felder || (art === 'G06' && extra.von !== 'betrieb') || !felder.some((feld) => hatText(extra[feld]))) return extra
const adresse = hatText(empfaenger) ? empfaenger.trim().toLowerCase() : ''
if (adresse === '' || darfEmpfaenger(adresse, now)) return extra
console.warn(`[termine-mails] ${art}: Empfänger-Kappe erreicht, die Mail geht ohne Freitext raus`)
return { ...extra, ...Object.fromEntries(felder.map((feld) => [feld, undefined])) }
}
/**
* Reiht eine Mail an den Gast ein (Absender: Betriebsname, Antworten an die Betriebs-E-Mail, nie BCC). Empfaenger ist
* extra.an, sonst buchung.gastEmail. G02 und G07 tragen eine Einladung (REQUEST), G06 die Absage (CANCEL), jeweils
* ausser mit extra.ohneIcs.
* ausser mit extra.ohneIcs. Freitext des Betriebs in G04/G06 geht nur bis zur Empfaenger-Kappe der Adresse mit (siehe
* begrenzeFreitext).
* @param {string} art 'G01' ... 'G08'
* @param {object} ctx
* @param {object|null} buchung
@@ -472,7 +493,8 @@ function kalenderDatei(art, ctx, buchung, extra, jetzt) {
export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) {
if (!GAST_ARTEN.includes(art)) throw new TypeError(`anGast: ${String(art).slice(0, 20)} ist keine Gastmail`)
pruefeNow(now, 'anGast')
extra = extra ?? {}
const an = hatText(extra?.an) ? extra.an : buchung?.gastEmail
extra = begrenzeFreitext(art, extra ?? {}, an, now)
const { values, blocks } = werteFuer(art, ctx, buchung, extra)
const b = ctx.einstellung.betrieb
const ics = extra.ohneIcs === true ? null : kalenderDatei(art, ctx, buchung, extra, now)
@@ -481,7 +503,7 @@ export async function anGast(art, ctx, buchung, extra = {}, now = new Date()) {
featureId: ctx.feature?.$id,
bookingId: buchung?.$id || extra.bookingId || OHNE_BUCHUNG,
art,
an: hatText(extra.an) ? extra.an : buchung?.gastEmail,
an,
fromName: b.name,
replyTo: b.email,
template: art,
+276 -52
View File
@@ -1,5 +1,5 @@
import { config } from '../../config.js'
import { Query, createDocument, deleteDocument, getDocument, listAllDocuments, updateDocument } from '../appwriteAdmin.js'
import { Query, countDocuments, createDocument, deleteDocument, getDocument, keinerVon, listAllDocuments, listDocuments, updateDocument } from '../appwriteAdmin.js'
import { withLocks } from '../keyedLock.js'
import { sendGuestMail } from '../mailer.js'
import { renderMail } from '../mailTemplates.js'
@@ -19,14 +19,34 @@ import { pruefeNow, pruefeText, zeitMs } from './util.js'
* Fehlschlag `fehler`
* Nach `gesendet` werden an, html, text und ics geleert (personenbezogene Daten).
*
* Veraltete Bestaetigungsmail (G01 mit Link): Wird eine G01 erst nach Ablauf des Halte-Fensters faellig (Warteschlange voll,
* Pause, Wiederholungen), waere ihr Bestaetigungslink schon verfallen. Sie geht dann nicht mehr raus, sondern wird `verworfen`
* (siehe sende): kein Transportaufruf, kein Budgetplatz, an/html/text/ics geleert. Das ist kein Zustellfehler (zustellFehler
* zaehlt nur `fehler`); raeumeAuf loescht `verworfen` wie `fehler` 48 Stunden spaeter. G01 ohne Link (bookingId '-', zu_viele)
* und alle anderen Arten verfallen nie. Ein Eintrag, der schon rausging, aber nicht verbucht werden konnte (`unverbucht`),
* wird nie verworfen und nie doppelt gesendet.
*
* `naechsterVersuch` ist die Faelligkeit des Eintrags: bei `wartet` der naechste Sendeversuch, bei `gesendet` der
* Loeschtermin (Versand + 7 Tage), bei `fehler` ebenfalls (Fehlschlag + 48 Stunden). So findet raeumeAuf alles ueber
* den vorhandenen Index idx_status_next (status, naechsterVersuch); ein eigenes Fehlerdatum gibt es im Schema nicht.
*
* Stundenbudget (config.termine.mailsProStunde) und Anmeldepause sind Zustand dieses Prozesses: gleitendes Fenster der
* Sendezeitpunkte bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt (gleichzeitige Versuche koennen das Budget
* nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist immer ein Parameter (feste Uhr in Tests);
* ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
* belegten Plaetze { t, f, n } (Zeit, Funktion, nachrangig) bzw. ein Zeitpunkt. Ein Platz wird vor dem Senden belegt
* (gleichzeitige Versuche koennen das Budget nicht ueberziehen) und bei einem Fehlschlag wieder freigegeben. `now` ist
* immer ein Parameter (feste Uhr in Tests); ohne Angabe gilt bei einreihen und sendeEintrag die Wanduhr.
*
* Fairness (pruefePlatz, in dieser Reihenfolge; der Eintrag bleibt dann unveraendert `wartet`, kein Versuch):
* pause Anmeldepause laeuft
* budget Plaetze im Fenster >= mailsProStunde
* reserve nachrangige Mails (G05, K20, K21) im Fenster >= max(1, floor(mailsProStunde x (1 - mailsReserve))): so bleibt
* ein Teil der Stunde den Vorrang-Mails (G01, G02, G08) und den normalen Mails vorbehalten
* kontingent Mails dieser Funktion im Fenster >= mailsProFunktion: eine Funktion kann die Stunde nicht allein fuellen
*
* Minutenjob (verarbeite): liest nie alle wartenden Eintraege. Bei Pause oder leerem Gesamtbudget endet er ohne Abfrage.
* Sonst je Klasse (mailKlasse) in der Reihenfolge vorrang, normal, nachrangig eine begrenzte Liste (config.termine.mailListe,
* nur die Felder $id, featureId, art, naechsterVersuch, nie ein Mailtext), die faelligsten zuerst. Eine Funktion mit
* ausgeschoepftem Kontingent kommt in die Ausschlussliste; ist die Seite voll und hat sich etwas bewegt, folgen hoechstens
* vier weitere Runden mit der neuen Ausschlussliste, damit hinter einer vollen Funktion keine andere verhungert.
*/
const col = () => config.collections.termineOutbox
@@ -39,6 +59,10 @@ const WIEDERHOLUNG_MS = [1 * MIN_MS, 5 * MIN_MS, 30 * MIN_MS]
const ANMELDE_PAUSE_MS = 15 * MIN_MS
const GESENDET_BEHALTEN_MS = 7 * TAG_MS
const FEHLER_BEHALTEN_MS = 48 * STUNDE_MS
const VERWORFEN_BEHALTEN_MS = 48 * STUNDE_MS
/** bookingId der Mails ohne Buchung (G01 zu_viele): sie haben keinen Bestaetigungslink und verfallen nie. */
const OHNE_BUCHUNG = '-'
const VERWORFEN_GRUND = 'Verworfen: Bestätigungslink abgelaufen'
/** Laenger als das in `wartet` (ab createdAt) ist ein Eintrag festgefahren (beendeFestgefahrene). */
const WARTEN_MAX_MS = 48 * STUNDE_MS
const ICS_METHODEN = ['REQUEST', 'CANCEL', 'PUBLISH']
@@ -47,35 +71,96 @@ const ID_BLOCK = 50
const iso = (wertMs) => new Date(wertMs).toISOString()
/** Mails, die vor allen anderen rausgehen duerfen (Bestaetigung, Termin, Eingangsbestaetigung des Widerrufs). */
export const MAIL_VORRANG = Object.freeze(['G01', 'G02', 'G08'])
/** Mails, die zuerst warten (Erinnerung, Hinweise an den Betrieb): sie nehmen nie die ganze Stunde. */
export const MAIL_NACHRANGIG = Object.freeze(['G05', 'K20', 'K21'])
/** 'vorrang' | 'normal' | 'nachrangig' nach der Art (Vorlagen-ID) der Mail. */
export function mailKlasse(art) {
if (MAIL_VORRANG.includes(art)) return 'vorrang'
if (MAIL_NACHRANGIG.includes(art)) return 'nachrangig'
return 'normal'
}
// Zustand des Prozesses
let pauseBisMs = 0
/** Zeitpunkte (ms) der Sendeversuche im letzten Stundenfenster (belegte Plaetze). */
let sendeZeiten = []
/** Belegte Plaetze im letzten Stundenfenster: { t: Sendezeit (ms), f: featureId, n: nachrangig }. */
let plaetze = []
/** Veraltete G01, die dieser Prozess verworfen hat (verworfeneG01). */
let verworfenG01 = 0
/** Laufende sendeEintrag-Aufrufe (fuer warteAufVersand). */
const laufend = new Set()
/** Eintraege, die schon rausgingen, deren Ergebnis aber nicht gespeichert werden konnte: id => Versandzeitpunkt (ms). */
const unverbucht = new Map()
/** Anzahl freier Plaetze im Stundenbudget zum Zeitpunkt nowMs; raeumt abgelaufene Plaetze aus dem Fenster. */
function freieBudgetPlaetze(nowMs) {
const grenze = nowMs - STUNDE_MS
sendeZeiten = sendeZeiten.filter((zeit) => zeit > grenze)
return Math.max(0, config.termine.mailsProStunde - sendeZeiten.length)
/** Die Plaetze, die zum Zeitpunkt nowMs noch im Stundenfenster stehen (nur lesen; ein Platz laeuft 60 Minuten nach dem Versand ab). */
const imFenster = (nowMs) => plaetze.filter((platz) => platz.t > nowMs - STUNDE_MS)
/** Hoechstens so viele nachrangige Mails (G05, K20, K21) im Stundenfenster; die Toleranz faengt Fliesskommafehler wie 100 x 0.58 ab. */
const nachrangigMax = () => Math.max(1, Math.floor(config.termine.mailsProStunde * (1 - config.termine.mailsReserve) + 1e-9))
/** Sind die nachrangigen Plaetze (G05, K20, K21) im uebergebenen Fenster aufgebraucht? */
const reserveErreicht = (fenster) => fenster.filter((platz) => platz.n).length >= nachrangigMax()
/** 'pause' | 'budget' | null: die Bremsen, die fuer jede Mail gelten (nur lesen). */
function gesamtBremse(nowMs) {
if (pauseBisMs > nowMs) return 'pause'
if (imFenster(nowMs).length >= config.termine.mailsProStunde) return 'budget'
return null
}
/** Pause oder ausgeschoepftes Budget (nur lesen). */
const gebremst = (nowMs) => pauseBisMs > nowMs || freieBudgetPlaetze(nowMs) === 0
/** Belegt einen Platz im Budget (synchron, ohne Zwischenschritt); false bei Pause oder leerem Budget. */
function belegePlatz(nowMs) {
if (gebremst(nowMs)) return false
sendeZeiten.push(nowMs)
return true
/**
* Darf eine Mail dieser Funktion und Art zum Zeitpunkt nowMs raus? null = ja, sonst der erste Grund (siehe Kopfkommentar):
* 'pause' | 'budget' | 'reserve' | 'kontingent'. Nur lesen: belegt keinen Platz.
* @param {number} nowMs
* @param {string} featureId
* @param {string} art Vorlagen-ID der Mail ('G01' ... 'K21')
* @returns {null|'pause'|'budget'|'reserve'|'kontingent'}
*/
export function pruefePlatz(nowMs, featureId, art) {
const bremse = gesamtBremse(nowMs)
if (bremse) return bremse
const fenster = imFenster(nowMs)
if (mailKlasse(art) === 'nachrangig' && reserveErreicht(fenster)) return 'reserve'
if (fenster.filter((platz) => platz.f === featureId).length >= config.termine.mailsProFunktion) return 'kontingent'
return null
}
function gibPlatzFrei(nowMs) {
const stelle = sendeZeiten.indexOf(nowMs)
if (stelle >= 0) sendeZeiten.splice(stelle, 1)
/** Belegt einen Platz (synchron, ohne Zwischenschritt); der Platz oder null, wenn pruefePlatz einen Grund nennt. */
function belegePlatz(nowMs, featureId, art) {
if (pruefePlatz(nowMs, featureId, art) !== null) return null
const platz = { t: nowMs, f: featureId, n: mailKlasse(art) === 'nachrangig' }
plaetze = [...imFenster(nowMs), platz]
return platz
}
function gibPlatzFrei(platz) {
plaetze = plaetze.filter((belegt) => belegt !== platz)
}
/**
* Stand des Mailbudgets zum Zeitpunkt now (fuer die Betriebspruefung und die Statusroute): nur Zahlen und Funktions-IDs,
* nie eine Adresse. `spitzen` sind die bis zu drei Funktionen mit den meisten Mails im Fenster, die groesste zuerst.
* @param {Date} now
* @returns {{ stunde: { benutzt: number, max: number }, nachrangig: { benutzt: number, max: number }, jeFunktionMax: number,
* spitzen: Array<{ featureId: string, benutzt: number }> }}
*/
export function mailBudget(now) {
const nowMs = pruefeNow(now, 'mailBudget')
const fenster = imFenster(nowMs)
const jeFunktion = new Map()
for (const platz of fenster) jeFunktion.set(platz.f, (jeFunktion.get(platz.f) ?? 0) + 1)
const spitzen = [...jeFunktion]
.map(([featureId, benutzt]) => ({ featureId, benutzt }))
.sort((a, b) => b.benutzt - a.benutzt || (a.featureId < b.featureId ? -1 : 1))
.slice(0, 3)
return {
stunde: { benutzt: fenster.length, max: config.termine.mailsProStunde },
nachrangig: { benutzt: fenster.filter((platz) => platz.n).length, max: nachrangigMax() },
jeFunktionMax: config.termine.mailsProFunktion,
spitzen,
}
}
/** 'dauerhaft' | 'anmeldung' | 'vorlaeufig' (siehe Kopfkommentar). */
@@ -145,12 +230,13 @@ export async function einreihen(
/**
* Einziger Sendepfad. Macht hoechstens einen Sendeversuch fuer den Eintrag, wenn er `wartet`, faellig ist, keine Pause
* laeuft und das Stundenbudget reicht.
* laeuft und Stundenbudget, Vorrang-Reserve und Kontingent der Funktion reichen (pruefePlatz).
* @param {string} id
* @param {Date} [now]
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, noch nicht faellig, vorlaeufiger Fehler); fehler = dieser
* Aufruf hat ihn endgueltig scheitern lassen; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen
* @returns {Promise<'gesendet'|'wartet'|'fehler'|'verworfen'|'uebersprungen'>} gesendet = dieser Aufruf hat die Mail gesendet;
* wartet = Eintrag bleibt in der Warteschlange (Pause, Budget, Reserve, Kontingent, noch nicht faellig, vorlaeufiger
* Fehler); fehler = dieser Aufruf hat ihn endgueltig scheitern lassen; verworfen = veraltete G01 (Bestaetigungslink
* abgelaufen), nicht gesendet; uebersprungen = Eintrag fehlt oder ist schon gesendet/fehlgeschlagen/verworfen
*/
export function sendeEintrag(id, now = new Date()) {
const lauf = sende(id, now).finally(() => laufend.delete(lauf))
@@ -161,7 +247,7 @@ export function sendeEintrag(id, now = new Date()) {
async function sende(id, now) {
const nowMs = pruefeNow(now, 'sendeEintrag')
pruefeText(id, 'id')
if (gebremst(nowMs)) return 'wartet'
if (gesamtBremse(nowMs)) return 'wartet'
return withLocks([`termine-mail:${id}`], async () => {
let doc
@@ -179,9 +265,16 @@ async function sende(id, now) {
await speichereGesendet(doc, unverbucht.get(id))
return 'gesendet'
}
// Vor dem Budget: eine veraltete G01 braucht keinen Platz und keinen Sendeversuch
if (istVeraltetG01(doc, nowMs)) {
await speichereVerworfen(doc, nowMs)
return 'verworfen'
}
if (new Date(doc.naechsterVersuch).getTime() > nowMs) return 'wartet'
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen das Budget nicht ueberziehen
if (!belegePlatz(nowMs)) return 'wartet'
// Der Platz wird ohne Zwischenschritt vor dem Senden belegt: gleichzeitige Versuche koennen Budget und Kontingent nicht
// ueberziehen. Ein gebremster Eintrag bleibt unveraendert `wartet` (kein Versuch).
const platz = belegePlatz(nowMs, doc.featureId, doc.art)
if (!platz) return 'wartet'
try {
await sendGuestMail({
@@ -195,7 +288,7 @@ async function sende(id, now) {
icsMethod: doc.icsMethod || 'REQUEST',
})
} catch (err) {
gibPlatzFrei(nowMs)
gibPlatzFrei(platz)
return speichereFehlschlag(doc, err, nowMs)
}
await speichereGesendet(doc, nowMs)
@@ -203,6 +296,30 @@ async function sende(id, now) {
})
}
/**
* Ist der Bestaetigungslink dieser G01 schon verfallen? Das Halte-Fenster (config.termine.holdMinutes, bei jedem Aufruf
* gelesen) plus eine Minute Toleranz ab Einreihen; genau so alt ist noch nicht veraltet. G01 ohne Buchung (zu_viele) hat
* keinen Link, andere Arten verfallen nie.
*/
function istVeraltetG01(doc, nowMs) {
if (doc.art !== 'G01' || doc.bookingId === OHNE_BUCHUNG) return false
return nowMs - zeitMs(doc.createdAt) > (config.termine.holdMinutes + 1) * MIN_MS
}
/** Veraltete G01 verwerfen: Status `verworfen`, personenbezogene Felder leeren, Loeschtermin jetzt + 48 Stunden (raeumeAuf). */
async function speichereVerworfen(doc, nowMs) {
await updateDocument(col(), doc.$id, {
status: 'verworfen',
an: '',
html: '',
text: '',
ics: '',
fehler: VERWORFEN_GRUND,
naechsterVersuch: iso(nowMs + VERWORFEN_BEHALTEN_MS),
})
verworfenG01 += 1
}
/**
* Nach dem Versand: Status, Zeiten setzen und personenbezogene Felder leeren. Scheitert das Speichern (auch beim
* zweiten Versuch), merkt sich der Prozess den Versand, damit der Eintrag nicht noch einmal gesendet wird.
@@ -251,27 +368,126 @@ async function speichereFehlschlag(doc, err, nowMs) {
return 'fehler'
}
/** Klassen in der Reihenfolge, in der verarbeite sie bedient. */
const KLASSEN = ['vorrang', 'normal', 'nachrangig']
/** Hoechstens so viele Abfragen je Klasse und Lauf: die erste und bis zu vier weitere Runden mit neuer Ausschlussliste. */
const RUNDEN_MAX = 5
/**
* Minutenjob: sendet alle faelligen wartenden Eintraege, aelteste zuerst, bis Pause oder Stundenbudget bremsen.
* Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
* Hoechstens so viele Funktionen in der Ausschlussliste einer Abfrage. Jede ist ein eigenes notEqual in der URL (Appwrite 1.8.1
* nimmt je notEqual nur einen Wert, M1). Gemessen am Produktionsserver (M4, 2026-10-05): in dieser Abfrageform antwortet der nginx
* ab 46 Funktions-IDs mit 36 Zeichen mit 414 (URL zu lang); 30 lassen Luft fuer laengere IDs. Eine blockierte Funktion ausserhalb
* der Liste wird gelesen und uebersprungen (verarbeiteKlasse), das kostet nur Platz in der Liste.
*/
export const AUSSCHLUSS_MAX = 30
/** Die Felder der Liste: genug fuer pruefePlatz und sendeEintrag, nie ein Mailtext. */
const LISTENFELDER = ['$id', 'featureId', 'art', 'naechsterVersuch']
/**
* Filter auf die Art der Mail, immer eine Liste von Abfragen: die Arten der Klasse (equal mit Liste, das erlaubt Appwrite) bzw.
* alle anderen (die Klasse normal: je Art ein notEqual, Appwrite schneidet sie; art ist an jedem Eintrag gesetzt, einreihen prueft es).
*/
function klassenFilter(klasse) {
if (klasse === 'vorrang') return [Query.equal('art', [...MAIL_VORRANG])]
if (klasse === 'nachrangig') return [Query.equal('art', [...MAIL_NACHRANGIG])]
return keinerVon('art', [...MAIL_VORRANG, ...MAIL_NACHRANGIG])
}
/** Eine Runde der Klasse: faellige wartende Eintraege, fuer die Funktionen ohne Kontingent nicht mitgelesen werden. */
function listenAbfrage(klasse, nowMs, blockiert) {
return [
Query.equal('status', 'wartet'),
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
...klassenFilter(klasse),
...keinerVon('featureId', [...blockiert].slice(0, AUSSCHLUSS_MAX)),
Query.orderAsc('naechsterVersuch'),
Query.limit(config.termine.mailListe),
Query.select(LISTENFELDER),
]
}
/**
* Eine Klasse bedienen (siehe Kopfkommentar). Zaehler und Ausschlussliste des Laufs stehen in `lauf`.
* @returns {Promise<boolean>} false, wenn Pause oder Gesamtbudget den ganzen Lauf beenden
*/
async function verarbeiteKlasse(klasse, nowMs, now, lauf) {
for (let runde = 0; runde < RUNDEN_MAX; runde++) {
if (gesamtBremse(nowMs)) return false
if (klasse === 'nachrangig' && reserveErreicht(imFenster(nowMs))) return true
const seite = await listDocuments(col(), listenAbfrage(klasse, nowMs, lauf.blockiert))
lauf.faellig += seite.length
let bewegt = false
for (const eintrag of seite) {
if (lauf.versucht.has(eintrag.$id)) continue
// Pause und leeres Gesamtbudget beenden den Lauf, die Reserve die nachrangige Klasse; das Kontingent betrifft nur die Funktion
const grund = pruefePlatz(nowMs, eintrag.featureId, eintrag.art)
if (grund === 'pause' || grund === 'budget') return false
if (grund === 'reserve') return true
if (grund === 'kontingent') {
if (!lauf.blockiert.has(eintrag.featureId)) {
lauf.blockiert.add(eintrag.featureId)
bewegt = true
}
continue
}
lauf.versucht.add(eintrag.$id)
try {
const ergebnis = await sendeEintrag(eintrag.$id, now)
if (ergebnis === 'gesendet') lauf.gesendet += 1
// auch ein verworfener Eintrag verlaesst die Liste (`wartet`); so kommen die Eintraege dahinter in der naechsten Runde dran
if (ergebnis === 'gesendet' || ergebnis === 'verworfen') bewegt = true
} catch (err) {
console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`)
}
}
// Weiter nur, wenn die Seite voll war (es kann mehr geben) und sich etwas bewegt hat; sonst kaeme dieselbe Seite wieder
if (seite.length < config.termine.mailListe || !bewegt) return true
}
return true
}
/**
* Minutenjob: sendet faellige wartende Eintraege, Vorrang-Mails (G01, G02, G08) vor normalen vor nachrangigen (G05, K20, K21),
* in jeder Klasse die faelligsten zuerst, bis Pause oder Stundenbudget bremsen. Er liest begrenzte Listen statt aller Eintraege
* (siehe Kopfkommentar); Eintraege, die Vorrang-Reserve oder Kontingent ihrer Funktion bremsen, bleiben `wartet`, und die uebrigen
* kommen trotzdem dran. Fehler bei einzelnen Eintraegen (z. B. Appwrite) stehen im Log und stoppen den Lauf nicht.
* @param {Date} now
* @returns {Promise<{ faellig: number, gesendet: number }>}
* @returns {Promise<{ faellig: number, gesendet: number }>} faellig = die gelesenen Eintraege (0 bei Pause oder leerem Budget)
*/
export async function verarbeite(now) {
const nowMs = pruefeNow(now, 'verarbeite')
if (pauseBisMs > nowMs) return { faellig: 0, gesendet: 0 }
const faellig = await listAllDocuments(col(), [Query.equal('status', 'wartet'), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
faellig.sort((a, b) => new Date(a.createdAt) - new Date(b.createdAt) || (a.$id < b.$id ? -1 : 1))
let gesendet = 0
for (const eintrag of faellig) {
if (gebremst(nowMs)) break
try {
if ((await sendeEintrag(eintrag.$id, now)) === 'gesendet') gesendet += 1
} catch (err) {
console.error(`[termine-outbox] Eintrag ${eintrag.$id}: ${err.message}`)
}
// Fruehausstieg: ohne Pause und mit freiem Gesamtbudget lohnt sich das Lesen, sonst nicht
if (gesamtBremse(nowMs)) return { faellig: 0, gesendet: 0 }
// blockiert: Funktionen mit ausgeschoepftem Kontingent (gilt fuer alle Klassen); versucht: Eintraege, die dieser Lauf schon angefasst hat
const lauf = { faellig: 0, gesendet: 0, blockiert: new Set(), versucht: new Set() }
for (const klasse of KLASSEN) {
if (!(await verarbeiteKlasse(klasse, nowMs, now, lauf))) break
}
return { faellig: faellig.length, gesendet }
return { faellig: lauf.faellig, gesendet: lauf.gesendet }
}
/**
* Stand der Warteschlange fuer die Betriebspruefung und die Statusroute: nur Zahlen, nie ein Mailtext. `wartet` = alle Eintraege mit
* Status `wartet` (auch Wiederholungen, die erst spaeter faellig werden), `aeltesteMin` = ganze Minuten (abgerundet), die der aelteste
* faellige Eintrag (naechsterVersuch <= jetzt) schon ueberfaellig ist, 0 ohne faelligen Eintrag. Zwei kurze Abfragen ueber den Index
* idx_status_next, parallel: die Anzahl und der aelteste faellige Eintrag (limit 1, nur $id und naechsterVersuch). Scheitert eine,
* wirft die Funktion; der Aufrufer meldet den Lesefehler.
* @param {Date} now
* @returns {Promise<{ wartet: number|null, aeltesteMin: number }>} wartet ist null, wenn Appwrite keine Anzahl liefert
*/
export async function warteschlangeAlter(now) {
const nowMs = pruefeNow(now, 'warteschlangeAlter')
const [wartet, aelteste] = await Promise.all([
countDocuments(col(), [Query.equal('status', 'wartet')]),
listDocuments(col(), [
Query.equal('status', 'wartet'),
Query.lessThanEqual('naechsterVersuch', iso(nowMs)),
Query.orderAsc('naechsterVersuch'),
Query.limit(1),
Query.select(['$id', 'naechsterVersuch']),
]),
])
const faelligSeit = aelteste.length > 0 ? zeitMs(aelteste[0].naechsterVersuch) : Number.NaN
return { wartet, aeltesteMin: Number.isNaN(faelligSeit) ? 0 : Math.max(0, Math.floor((nowMs - faelligSeit) / MIN_MS)) }
}
/** Nur fuer Tests: wartet, bis alle laufenden Sendeversuche (auch die aus einreihen) beendet sind. */
@@ -280,8 +496,10 @@ export async function warteAufVersand() {
}
/**
* Buchungen (aus bookingIds) mit mindestens einer endgueltig gescheiterten Mail (Status fehler). Das Portal zeigt dort
* Buchungen (aus bookingIds) mit mindestens einer endgueltig gescheiterten Mail (Status fehler; verworfene veraltete G01
* zaehlen nicht: der Betrieb sieht fuer einen verfallenen Halte-Eintrag keinen Zustellfehler). Das Portal zeigt dort
* „Bestätigung konnte nicht zugestellt werden“. Die Eintraege verschwinden mit raeumeAuf (48 Stunden nach dem Fehler).
* Fragt ueber den Index idx_booking (bookingId) ab.
* @param {string[]} bookingIds
* @returns {Promise<Set<string>>}
*/
@@ -296,14 +514,14 @@ export async function zustellFehler(bookingIds) {
}
/**
* Taeglicher Job: loescht gesendete Eintraege 7 Tage nach dem Versand und fehlgeschlagene 48 Stunden nach dem Fehler
* (beides steht in naechsterVersuch). Wartende Eintraege bleiben.
* Taeglicher Job: loescht gesendete Eintraege 7 Tage nach dem Versand, fehlgeschlagene 48 Stunden nach dem Fehler und
* verworfene (veraltete G01) 48 Stunden nach dem Verwerfen (alles steht in naechsterVersuch). Wartende Eintraege bleiben.
* @param {Date} now
* @returns {Promise<number>} Anzahl geloeschter Eintraege
*/
export async function raeumeAuf(now) {
const nowMs = pruefeNow(now, 'raeumeAuf')
const alt = await listAllDocuments(col(), [Query.equal('status', ['gesendet', 'fehler']), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
const alt = await listAllDocuments(col(), [Query.equal('status', ['gesendet', 'fehler', 'verworfen']), Query.lessThanEqual('naechsterVersuch', iso(nowMs))])
let geloescht = 0
for (const eintrag of alt) {
try {
@@ -320,7 +538,7 @@ export async function raeumeAuf(now) {
* Gastdaten loeschen (Portal "Gastdaten löschen"): noch nicht versandte Mails einer Buchung (Status wartet) werden
* geloescht, denn sie halten Adresse und Inhalt. Je Eintrag unter der Sendesperre `termine-mail:<id>` nach neuem Lesen:
* ein Eintrag, der inzwischen gesendet wurde (seine Felder sind dann schon leer), bleibt; ein inzwischen geloeschter
* stoert nicht. Gesendete und fehlgeschlagene Eintraege behandelt raeumeAuf.
* stoert nicht. Gesendete, fehlgeschlagene und verworfene Eintraege behandelt raeumeAuf. Fragt ueber den Index idx_booking ab.
* @param {string} bookingId
* @returns {Promise<number>} Anzahl geloeschter Eintraege
*/
@@ -387,14 +605,20 @@ export async function beendeFestgefahrene(now) {
return beendet
}
/** Wie viele veraltete G01 dieser Prozess seit dem Start verworfen hat (Betriebsmeldung, A10b). */
export function verworfeneG01() {
return verworfenG01
}
/** Ende der letzten Anmeldepause (Date) oder null, wenn es noch keine gab. Eine Pause gilt, solange das Ende nach `now` liegt. */
export function pausiertBis() {
return pauseBisMs ? new Date(pauseBisMs) : null
}
/** Nur fuer Tests: Pause, Budgetfenster und nicht gespeicherte Versände zuruecksetzen. */
/** Nur fuer Tests: Pause, Budgetfenster, nicht gespeicherte Versände und den Zaehler verworfener G01 zuruecksetzen. */
export function resetOutboxForTests() {
pauseBisMs = 0
sendeZeiten = []
plaetze = []
unverbucht.clear()
verworfenG01 = 0
}
+247 -16
View File
@@ -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-<Fassung>.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
/**
@@ -159,11 +386,15 @@ export const ZUSTIMMUNG_VORZEITIG = (name) =>
+ '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).'
/** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat. */
/** Pflicht-Haken, wenn der Betrieb Stornobedingungen hat und die gebuchte Leistung einen Preis hat (bei kostenlosen Leistungen gibt es ihn nicht). */
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.'
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more