Files
Webklar-Kundenbereich/docs/superpowers/specs/2026-10-02-terminplaner-design.md
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

47 KiB
Raw Permalink Blame History

Terminplaner – Design

Stand: 2026-10-02 · Status: im Chat freigegeben (Entscheidungen E1–E8), Spec zur Durchsicht Teilprojekte: TP1 Kern (diese Spec im Detail, erster Plan) · TP2 Verkauf · TP3 Online-Zahlung (TP2 und TP3 sind hier verbindlich umrissen und bekommen je einen eigenen Plan; Regel des Nutzers: ein Teilprojekt live, vom Nutzer getestet, erst dann das nächste).

Recherche (nur lesend, Quellen dort): /root/reports/terminplaner-2026-10-02/ code-funktionen.md, code-kern.md, code-woms.md, recherche-branchen.md, recherche-recht.md, recherche-technik.md (+ spike-code/), recherche-stripe-connect.md, recherche-recht-zahlung.md.

Ziel

Betriebe (unsere Kunden) bekommen eine Online-Terminbuchung auf ihrer eigenen Website. Der Teil auf der Website sieht bei jedem Kunden anders aus (Design der Kundenseite), die Verwaltung im Kundenportal ist für alle gleich. Ein Fragebogen (TP2) klassifiziert den Betrieb und erzeugt für WEBklar eine fertige Einbauanleitung: wir bauen ein, schließen das Ticket, der Terminplaner läuft.

Die Funktion ersetzt die Kachel „Terminplaner · Kommt bald“ (public/funktionen.js:151).

Entscheidungen (vom Nutzer)

# 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 € (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.
E6 Der Fragebogen kommt vor der Zahlung; das Ticket enthält sofort die Einbauanleitung (TP2).
E7 Zahlungsmodus legt der Betrieb fest: nur vor Ort · nur online · online oder vor Ort (dann wählt der Gast). Online-Geld geht direkt an den Betrieb (Stripe Connect, WEBklar berührt kein Geld). TP1 kann nur „vor Ort“, TP3 bringt „online“.
E8 Reihenfolge: TP1 Kern (Pilot mit Kunde „test“) → TP2 Verkauf → TP3 Online-Zahlung.

Vorgaben aus dem Entwurf, denen der Nutzer nicht widersprochen hat:

  • Gebaut im Kundenportal (kein eigener Container, keine Fremdsoftware), Daten in Appwrite woms-database.
  • Ein Buchungssystem für alle Branchen; Branchen-Vorlagen sind Daten, kein Code je Nische.
  • Gesundheitsbereich ausgeschlossen (Arzt, Zahnarzt, Physio/Ergo/Logo/Podologie, Psychotherapie, Heilpraktiker, medizinische Kosmetik/Massage). Freitext beim Gast standardmäßig aus.
  • Erinnerung an den Gast nur per nicht vorangekreuztem Haken.
  • Gästemails: Anzeigename = Betrieb, Antworten an den Betrieb, keine Team-Kopie (bccTeam aus), kein Tracking, keine Werbung.
  • Mailversand über eine Warteschlange (Hetzner-Grenze 500 Mails/h für alles).
  • Löschfristen: unbestätigt 48 h, abgesagt 30 Tage, vergangen 90 Tage (einstellbar 14–365 Tage).
  • Schalter TERMINE_ENABLED = off | pilot | on.

Begriffe

  • Betrieb = unser Kunde (Portal-Kunde, Inhaber der Website). Gast = wer auf der Website bucht.
  • Ressource = was gebucht wird: eine Person oder ein Platz (je Kapazität 1; zwei Hebebühnen = zwei Plätze). Leistung = was gebucht wird (Dauer, Puffer, Preis, Buchungsart).
  • Funktion termine = Dokument in portalFeatures (featureKey: 'termine'), mit eigenem siteKey.

TP1 Kern

1. Lebenszyklus in TP1

  • TERMINE_ENABLED=off (Standard): Kachel „Kommt bald“, öffentliche Schnittstelle antwortet für jeden Schlüssel „nicht aktiv“, Routen des Kunden 409.
  • pilot: Kachel und Ansicht nur für Kunden, deren E-Mail in TERMINE_PILOT_EMAILS steht (Komma-Liste, Muster ABO_PILOT_EMAILS). Bestellen gibt es in TP1 nicht; die Funktion wird im Ticketsystem mit „Aktivieren (ohne Zahlung)“ eingeschaltet (vorhandenes adminActivate, auf Schlüssel verallgemeinert).
  • on kommt mit TP2 (Bestellung für alle mit Wartungs-Abo).
  • Zustände der Funktion wie bei News: off → active → disabled (TP1); ordered/setup folgen mit TP2. disabled ⇒ Widget zeigt den Ausweichtext, keine neuen Buchungen; bestehende Termine bleiben im Portal sichtbar, Gäste können weiter absagen und widerrufen.

2. Datenmodell (Appwrite, neues Setup-Skript scripts/termine-setup.mjs)

Muster von scripts/analyse-setup.mjs (exportierte Attributlisten, injizierbarer Client, --check, legt nur Fehlendes an: fehlende Attribute und Indizes auch in schon vorhandenen Collections, R6). Alle Collections permissions: [], documentSecurity: false (nur Server-Schlüssel). test/support/schema.js liest das neue Skript mit.

termineConfig – eine Einstellung je Funktion; Dokument-ID = ID des portalFeatures-Dokuments. customerId, projectId (64), einstellung (Text, JSON bis 200 000 Zeichen; nicht data – das Wort behandelt appwriteAdmin.formatRequestBody als fertig verpackten Body), version (Ganzzahl), updatedAt, updatedBy (100). Inhalt von einstellung (validiert in services/termine/einstellungen.js):

{
  "schema": 1,
  "vorlage": "salon",                       // Branchen-Vorlage, nur Herkunft
  "betrieb": { "name": "Salon Müller", "email": "info@salon.de", "telefon": "06301 1234", "kleinunternehmer": false,
               "adresse": "Hauptstr. 1, 67722 Winnweiler",
               "impressumUrl": "https://…", "datenschutzUrl": "https://…", "agbUrl": null },
  "anrede": "sie",                          // sie | du  (Widget, Gästeseiten, Gästemails)
  "farbe": "#7c3aed",                       // Akzent für Gästeseiten und -mails
  "bundesland": "RP", "feiertageGeschlossen": true,
  "sonderoeffnungen": { "2026-12-24": [["09:00","13:00"]] },   // Datum → Fenster, schlägt Plan/Feiertag
  "abwesenheiten": [ { "id": "a1", "ressourceId": null, "von": "2026-12-27", "bis": "2026-12-31", "titel": "Betriebsurlaub" } ],
  "ressourcen": [ { "id": "r1", "typ": "person", "name": "Anna", "aktiv": true,
                    "plan": { "1": [["09:00","12:30"],["13:30","18:00"]], "6": [["09:00","13:00"]] },
                    "feedToken": "…" } ],     // ISO-Wochentag 1=Mo … 7=So; Pausen = Lücken
  "leistungen": [ { "id": "s1", "name": "Damenschnitt", "beschreibung": "", "dauer": 45, "pufferNach": 5,
                    "preisArt": "fest", "preisCent": 4500,        // fest | ab | keiner
                    "buchungsart": "sofort",                       // sofort | anfrage
                    "ressourcen": ["r1"], "aktiv": true, "reihenfolge": 1 } ],   // reihenfolge: der Server setzt i+1 in Listenreihenfolge
  "regeln": { "raster": 15, "vorlaufMin": 120, "horizontTage": 60, "absageBisStd": 24,
              "maxOffeneJeEmail": 3, "personWaehlbar": true },
  "gastFelder": { "telefon": "optional", "nachricht": "aus", "zusatz": [] },  // aus | optional | pflicht
  "erinnerung": { "anbieten": true, "stundenVorher": 24 },
  "storno": { "text": "Kostenlose Absage bis 24 Stunden vor dem Termin …", "version": "2026-10-02-1" },
  "recht": { "gaesteSind": "verbraucher",  // verbraucher | unternehmer | gemischt
             "freizeit": false },          // true ⇒ kein Widerrufsrecht (§ 312g Abs. 2 Nr. 9), nur nach Prüfung
  "zahlung": { "modus": "vorort", "vorOrtArten": ["bar","ec","kreditkarte"] },   // online/beides erst TP3
  "aufbewahrungTage": 90
}

IDs von Ressourcen/Leistungen/Abwesenheiten sind kurze Zufalls-IDs (keine Appwrite-IDs). Änderungen sind optimistisch gesperrt: PATCH schickt version, der Server schreibt unter withLocks(['termine-cfg:<featureId>']) und antwortet bei abweichender Version 409 „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“

termineBookings – eine Buchung je Dokument (ID randomUUID). Felder: featureId, customerId, projectId (64); leistungId, leistungName (120, Schnappschuss), ressourceId, ressourceName (80); startsAt, endsAt, blockEndsAt (datetime UTC; blockEndsAt = Ende + Puffer); status (16, siehe 4); quelle (16: online | manuell | sperre); gastName (100), gastEmail (254), gastTelefon (40), gastNachricht (500), gastZusatz (2000, JSON); erinnerung (bool), erinnerungGesendetAt; heldUntil; verifyHash, manageHash (64, SHA-256 der Tokens); bestaetigtAt, angenommenAt, abgesagtAt, abgesagtVon (16: gast|betrieb|system), absageGrund (300), widerrufenAt; nachweis (4000, JSON: Fassung und Wortlaut-Hash von Storno-, Datenschutz- und Widerrufstext, Zeitpunkt der Zustimmungen, Knopftext, Anrede); preisCent, preisArt (8), buchungsart (8), verbraucher (bool); zahlart (8: vorort | online), zahlStatus (16: keine|offen|bezahlt|erstattet|teilerstattet|fehlgeschlagen; TP1 immer keine), stripeSessionId (128), stripePaymentIntent (64) (TP3); icsSequenz (Ganzzahl); createdAt, updatedAt. Indizes: idx_feature_start(featureId, startsAt), idx_status_held(status, heldUntil), idx_feature_email(featureId, gastEmail), idx_verify(verifyHash), idx_manage(manageHash), idx_status_start(status, startsAt).

termineClaims – Belegungszellen gegen Doppelbuchung (siehe 5). Dokument-ID deterministisch c_ + erste 34 Hex von sha1(featureId|ressourceId|zellenStartUtcIso). Felder featureId, ressourceId, bookingId, zelle (datetime), bis (datetime, = blockEndsAt der Buchung, nie die Haltezeit). Index idx_booking(bookingId), idx_bis(bis).

termineOutbox – Mail-Warteschlange. Felder featureId, bookingId, art (16), an (254), fromName (120), replyTo (254), subject (300), html (Text 100 000), text (Text 50 000), ics (Text 20 000), icsMethod (8), status (wartet|gesendet|fehler), versuche, naechsterVersuch, gesendetAt, fehler (300), createdAt. Index idx_status_next(status, naechsterVersuch). Nach dem Versand werden an, html, text, ics geleert (personenbezogene Daten); gesendete Einträge werden 7 Tage nach dem Versand gelöscht, endgültig gescheiterte (fehler) 48 Stunden nach dem Fehler.

portalFeatures bekommt keine neuen Attribute; featureKey 'termine' nutzt die vorhandenen.

3. Verfügbarkeit (reine Funktionen, server/services/termine/)

  • Zeit (zeit.js, ohne Abhängigkeit, Intl): Wanduhr Europe/Berlin ↔ UTC; Wochentag; Tage addieren. Nicht existierende Zeiten (Umstellung März, 02:00–02:59) werden ausgelassen, doppelte Zeiten (Oktober) nehmen das erste Vorkommen. Tests für 2026-03-29 und 2026-10-25. deadlines.berlinTime wird dafür nicht benutzt (kennt die Lücke nicht).
  • Feiertage (feiertage.js): Paket feiertagejs (MIT, ohne Abhängigkeiten) je Bundesland; sonderoeffnungen schlagen Feiertag und Plan, abwesenheiten schlagen alles, auch eine Sonderöffnung (R11).
  • Slots (slots.js, now wird übergeben): je Ressource und Berliner Kalendertag die Öffnungsfenster aus plan (oder sonderoeffnungen), minus Feiertag (wenn geschlossen), minus Abwesenheiten, minus belegte Zeiten (Buchungen mit Status gehalten|angefragt|bestaetigt und Sperren, jeweils bis blockEndsAt). Startzeiten im raster ab Fensterbeginn; Termin + pufferNach muss ganz ins Fenster passen; nicht vor now + vorlaufMin; nicht nach horizontTage. „Egal“ = Vereinigung über alle Ressourcen der Leistung; beim Buchen wird die Ressource mit den wenigsten Buchungen an dem Tag gewählt (bei Gleichstand Reihenfolge der Liste).
  • Ein Abruf liefert höchstens 31 Tage; der Server lädt dafür die Buchungen des Zeitraums per Indexabfrage (featureId + startsAt zwischen, mit Seitenweise-Laden über cursorAfter), nie „alles laden“.

4. Buchungsablauf und Zustände

Zustände (status): gehalten (Formular abgeschickt, wartet auf E-Mail-Bestätigung) → angefragt (Leistung „Anfrage“, wartet auf den Betrieb) oder bestaetigt → erledigt (automatisch 1 h nach Ende) · nichterschienen (Betrieb) · abgesagt (Gast, Betrieb, System) · abgelehnt (Betrieb lehnt Anfrage ab) · verfallen (Link nicht innerhalb von 30 Minuten bestätigt). Sperren (quelle: sperre) sind immer bestaetigt und haben keinen Gast.

Gast im Widget

  1. Leistung wählen (Name, Dauer, Preis „45 €“ / „ab 45 €“ / ohne, Kurzbeschreibung). Zahlungsart steht schon hier („Bezahlung vor Ort: bar, EC-Karte, Kreditkarte“, § 312j Abs. 1 BGB).
  2. Person wählen, wenn mehr als eine Ressource infrage kommt und personWaehlbar (mit „Egal“ als erster Option); bei Plätzen kein Schritt.
  3. Tag (Monatsraster, nur Tage mit freien Zeiten aktiv) und Uhrzeit.
  4. Daten: Name, E-Mail, Telefon/Nachricht/Zusatzfelder nach gastFelder. Über der Nachricht steht „Bitte keine Gesundheitsangaben.“
  5. Zusammenfassung: Leistung, Person/Platz, Datum, Uhrzeit, Preis inkl. USt (bzw. Hinweis Kleinunternehmer aus dem Einstellungsfeld des Betriebs), Ort, Zahlungsart, Stornoregel im Klartext + Link, Hinweis zum Datenschutz (Text 11.2 aus recherche-recht.md, Link auf datenschutzUrl), bei Verbraucher-Gästen die vollständige Widerrufsbelehrung samt Muster-Widerrufsformular (aufklappbar, bei jeder sichtbaren Leistung, auch bei Anfragen; R14, R55), Haken:
    • Pflicht: „Ich habe die Stornobedingungen gelesen.“ (nur wenn storno.text gesetzt)
    • Pflicht bei verbindlicher Leistung mit Preis für Verbraucher, wenn der Termin vor dem Ende der Widerrufsfrist liegt (Frist nach §§ 187, 188, 193 BGB ab Vertragsschluss, Berliner Zeit), und bei bepreisten Anfragen für Verbraucher immer: Zustimmung zum vorzeitigen Beginn (§ 356 Abs. 5 Nr. 2 BGB, Wortlaut im Rechtsteil).
    • Freiwillig, nicht vorangekreuzt (nur wenn erinnerung.anbieten): „Ich möchte {24} Stunden vorher eine Erinnerung per E-Mail.“ Knopf: „Zahlungspflichtig buchen“ (verbindlich + Preis), „Termin buchen“ (verbindlich, ohne Preis), „Termin anfragen“ (Buchungsart Anfrage).
  6. Ergebnis: „Fast geschafft: Bitte bestätigen Sie den Termin über den Link, den wir an m***@gmail.com geschickt haben. Der Termin ist 30 Minuten für Sie reserviert.“

Server beim Abschicken (POST …/buchungen): Prüfungen (Abschnitt 8), Slot neu berechnen, unter withLocks(['termine-res:<featureId>:<ressourceId>'], {waitMs: 5000}) die Buchung gehalten mit heldUntil = now + 30 min anlegen und danach die Zellen belegen (5; die Gültigkeit einer Zelle hängt allein an ihrer Buchung), Mail G01 in die Warteschlange (sofort senden).

Bestätigungslink https://project.webklar.com/termin.html#v=<token>: Die Seite liest das Token aus dem Fragment (geht nicht an Server-Logs), zeigt per POST …/vorschau die Zusammenfassung und einen Knopf mit demselben Wortlaut wie im Widget (bei Preis „Zahlungspflichtig bestätigen“). Erst POST …/bestaetigen ändert den Zustand (Mailscanner öffnen nur Links): gehalten → bestaetigt (G02 an Gast mit .ics, Verwaltungslink und ggf. Widerrufsbelehrung; K20 an den Betrieb mit .ics) bzw. → angefragt (G03 an Gast, K20-Block „Anfrage“ an den Betrieb). Abgelaufen ⇒ „Diese Reservierung ist abgelaufen“ + Link zur Website. Ein Token gilt genau einmal.

Verwaltungslink termin.html#m=<token> (gültig bis Terminende + 1 Tag; Token = HMAC über die Buchungs-ID, damit spätere Mails wie die Erinnerung denselben Link enthalten; gespeichert nur sein Hash): zeigt den Termin und je nach Lage

  • „Termin absagen“ bis absageBisStd vor Beginn (danach Text „Absage nur telefonisch: …“) ⇒ abgesagt, Zellen frei, G06 an Gast (ICS CANCEL), K21 an den Betrieb;
  • „Termin verschieben“ bis zur selben Frist ⇒ neue Zeit wählen; neuer Slot wird belegt, alter freigegeben (eine Sperre über beide Ressourcen), Status bleibt, icsSequenz + 1, G07 + K21; das gilt auch für angefragt (R26: G03 enthält nicht nur „Absage“, die Seite bietet auch „Termin verschieben“);
  • „Vertrag widerrufen“, solange ein Widerrufsrecht besteht (verbindlich, Verbraucher, nicht freizeit, Status bestaetigt, Termin noch nicht begonnen und die Widerrufsfrist läuft: 14 Tage ab dem Tag nach dem Vertragsschluss, bei Wochenende oder Feiertag am letzten Tag bis zum nächsten Werktag, bis 23:59:59 Berliner Zeit, §§ 187, 188, 193 BGB; Vertragsschluss ist bestaetigtAt bei einer sofort buchbaren Leistung und angenommenAt bei einer vom Betrieb angenommenen Anfrage; R29, R55) – unabhängig von der Stornofrist (§ 361 BGB) ⇒ Formular nach § 356a BGB (Name, Buchungsnummer, E-Mail für die Eingangsbestätigung vorbefüllt, Knopf „Widerruf bestätigen“) ⇒ widerrufenAt, abgesagt (von gast), G08 Eingangsbestätigung mit Datum und Uhrzeit, K21-Block „Widerruf“. Nach Ablauf der Frist oder nach Beginn des Termins bleibt das Formular der Weg für eine Erklärung: sie wird nur vermerkt und dem Betrieb gemeldet (G08/K21 nach_frist bzw. begonnen), der Betrieb prüft (R27). Die Verwaltungsseite bietet den Knopf nur bis zum Beginn des Termins an; G02 sagt das und nennt danach das Formular auf der Website des Betriebs oder die E-Mail an den Betrieb.

Widerruf ohne Link (§ 356a: auf der Oberfläche, über die der Vertrag geschlossen wurde): Das Widget zeigt im Fuß dauerhaft „Vertrag widerrufen“ als zweiten Knopf (mindestens 44 px, kein Textlink), sobald Verbraucher-Gäste bei dem Betrieb buchen oder anfragen können, also bei jeder sichtbaren Leistung, auch bei Anfragen (R54, R55; Anschrift des Betriebs ist dann Pflicht). Im Modus knopf steht der Fuß nur im geöffneten Dialog: Dort lädt das Widget schon beim Laden der Seite die Konfiguration (ein einziger Abruf) und zeigt bei Widerrufsrecht dauerhaft einen zweiten Knopf „Vertrag widerrufen“ neben dem Buchungsknopf, der den Dialog gleich beim Widerrufsformular öffnet (R54). Formular: Name, E-Mail, Buchungsnummer (steht in G02), Knopf „Widerruf bestätigen“. Der Server antwortet immer gleich („Ihre Erklärung ist eingegangen“), speichert die Erklärung an der passenden Buchung (E-Mail + Nummer), schickt G08 an die angegebene Adresse und leitet die Erklärung per K21 an den Betrieb weiter – auch wenn keine Buchung passt (dann Block „nicht zugeordnet“, der Betrieb prüft).

Betrieb im Portal: Anfrage annehmen (angefragt → bestaetigt, G02) oder ablehnen (abgelehnt, G04, Zellen frei); bestätigten Termin absagen (Pflicht-Grund-Auswahl + optionale Nachricht, G06) oder verschieben (G07); „nicht erschienen“; Gastdaten auf Wunsch löschen (anonymisieren: Gastfelder und absageGrund werden geleert, noch nicht versandte Mails der Buchung aus der Warteschlange gelöscht); Termin von Hand eintragen (quelle: manuell, Gast-E-Mail optional, G02 nur wenn der Haken „Bestätigung an den Gast senden“ gesetzt ist; ohne Widerrufsbelehrung durch das System, die ist bei telefonischen Buchungen Sache des Betriebs); Zeit sperren (quelle: sperre, höchstens 500 Minuten am Stück; längeres über Abwesenheiten).

Hintergrund-Jobs (server/index.js, Muster der vorhandenen setInterval-Jobs, je Job eigene Fehlerbehandlung):

  • jede Minute: abgelaufene gehalten ⇒ verfallen + Zellen löschen; Warteschlange senden (6).
  • alle 5 Minuten: Erinnerungen (bestaetigt, erinnerung, kein erinnerungGesendetAt, now + 1 h < startsAt ≤ now + stundenVorher und Vertragsschluss vor startsAt − stundenVorher, damit ein Ausfall des Jobs keine Erinnerung verschluckt; erst Marker setzen, dann einreihen); bestaetigt mit endsAt < now − 1 h ⇒ erledigt; angefragt mit startsAt < now ⇒ abgelehnt (vom System, Zellen frei).
  • täglich: Löschfristen (11), verwaiste Zellen (Buchung fehlt oder nicht mehr belegend).

5. Doppelbuchung verhindern

  • Zellen à 5 Minuten von Beginn bis blockEndsAt. Jede Zelle ist ein termineClaims-Dokument mit deterministischer ID; eine zweite Belegung derselben Zelle scheitert in der Datenbank mit 409.
  • Alle Zellen einer Buchung werden in einem Aufruf angelegt (Appwrite 1.8.1 Bulk-Create POST …/documents mit documents: [...], eine Transaktion, höchstens 100 Dokumente ⇒ Termine bis 500 Minuten). 409 ⇒ „Dieser Termin wurde gerade vergeben. Bitte wählen Sie eine andere Zeit.“
  • Zusätzlich withLocks je Ressource im Prozess (Reihenfolge, keine Fehlalarme bei Doppelklick).
  • Reihenfolge beim Buchen: erst die Buchung (gehalten), dann die Zellen. Ob eine Zelle noch gilt, entscheidet allein ihre Buchung (fehlt ⇒ frei; Status nicht belegend ⇒ frei; gehalten mit abgelaufenem heldUntil ⇒ frei). Sperrenfolge einheitlich Gast → Buchung → Ressource.
  • Spike vor dem Bau (Pflicht, Plan-Aufgabe 1): auf einer Wegwerf-Collection in der Produktions-Appwrite 50 parallele Bulk-Creates mit überlappenden IDs ⇒ genau einer 201, Rest 409, keine Teil-Schreibungen; danach Collection löschen. Braucht die Freigabe des Nutzers. Fällt der Spike negativ aus: Fallback Transactions-API (/v1/databases/transactions, ebenfalls in 1.8.1), sonst Einzel-Creates mit Rückbau unter der Prozess-Sperre.
  • Der Test-Nachbau (test/support/appwriteMock.js) lernt Bulk-Create mit Alles-oder-nichts und 409, between/greaterThanEqual/lessThanEqual/cursorAfter.

6. Mails

Versand: neue Funktion sendGuestMail in mailer.js (nutzt den vorhandenen Transport): From "<betrieb.name>" <SMTP_FROM> (Anzeigename = Betrieb, Adresse bleibt noreply@webklar.com, also keine DNS-/Postfach-Änderung), replyTo = betrieb.email, nie BCC, icalEvent für Kalenderdaten. Alle Termine-Mails laufen über termineOutbox (Muster „erst speichern, dann senden“): sofortiger Sendeversuch nach dem Einreihen, sonst Job; höchstens 200 Mails/Stunde für den Terminplaner (Rest des Hetzner-Budgets bleibt Portal/Abo); Wiederholung nach 1, 5, 30 Minuten, dann fehler und Hinweis im Portal an der Buchung („Bestätigung konnte nicht zugestellt werden“). Sicherung: bei einem SMTP-Anmeldefehler pausiert die Warteschlange 15 Minuten (Hetzner sperrt die IP nach mehreren Fehlversuchen für ~10 Minuten).

Vorlagen (Engine mailTemplates.js; Kennungen um G erweitern, Test mailTemplates.test.js EXPECTED mitpflegen). Gästemails haben ein neutrales Layout ohne WEBklar-Logo: Kopf mit Betriebsname in farbe, Fuß mit Betrieb (Name, Adresse, Telefon, E-Mail, Links Impressum/Datenschutz) und dem Satz „Diese E-Mail wurde im Auftrag von {Betrieb} über den Terminplaner von WEBklar versendet.“ Jede Vorlage hat Blöcke sie/du.

Der Betreff ist je Vorlage der Platzhalter {{betreff}}; der Server setzt ihn je Anrede und Fall (Du-Form sinngemäß: „Bitte bestätige deinen Termin …“). G01 und G08 enthalten keinen vom Gast eingegebenen Text (kein Missbrauch als Mail-Relais).

ID an Betreff (Sie-Form) Inhalt
G01 Gast Bitte bestätigen Sie Ihren Termin bei {betrieb} Datum, Uhrzeit, Leistung, Knopf „Termin bestätigen“, „Das waren Sie nicht? Dann ignorieren Sie diese E-Mail.“ Nichts Werbliches.
G02 Gast Ihr Termin bei {betrieb} am {datum} Zusammenfassung, Buchungsnummer, Zahlungsart, Stornoregel (Wortlaut), Verwaltungslink, .ics (REQUEST); bei Widerrufsrecht: Widerrufsbelehrung + Muster-Widerrufsformular + Hinweis auf die Widerrufsfunktion (§ 312f Abs. 2 BGB): Bis zum Beginn des Termins über den Knopf „Vertrag widerrufen“, danach über das Formular auf der Website des Betriebs oder per E-Mail an den Betrieb
G03 Gast Ihre Terminanfrage bei {betrieb} „Der Betrieb meldet sich“, Verwaltungslink (Absage und Verschieben, R26)
G04 Gast Ihre Terminanfrage bei {betrieb} – leider abgelehnt abgelehnt, optionale Nachricht des Betriebs
G05 Gast Erinnerung: Ihr Termin am {datum} um {zeit} Termin, Verwaltungslink; sonst nichts
G06 Gast Ihr Termin am {datum} wurde abgesagt wer abgesagt hat, Grund/Nachricht, .ics (CANCEL)
G07 Gast Ihr Termin wurde verschoben alte/neue Zeit, .ics (REQUEST, Sequenz + 1)
G08 Gast Eingangsbestätigung Ihres Widerrufs Inhalt der Erklärung, Datum und Uhrzeit des Eingangs
K20 Betrieb Neue Buchung: {leistung} am {datum} um {zeit} (Block „anfrage“: Neue Terminanfrage …) Gastdaten, Link ins Portal, .ics (PUBLISH)
K21 Betrieb Termin abgesagt / verschoben / widerrufen: … Block je Fall, Link ins Portal

K20/K21 im WEBklar-Layout (Du-Form wie K15–K19), Empfänger betrieb.email, ohne Team-BCC (enthalten Gästedaten).

ICS (ics.js, eigener Schreiber, ~50 Zeilen, Zeilenfaltung/Escaping nach RFC 5545): UID = <bookingId>@termine.webklar.com (stabil), SEQUENCE = icsSequenz, DTSTART/DTEND in UTC, SUMMARY = „{Leistung} – {Betrieb}“ (an den Betrieb: „{Leistung}: {Gastname}“), LOCATION = Adresse, ORGANIZER = Betrieb, ATTENDEE = Gast (RSVP=FALSE), METHOD REQUEST/CANCEL. Keine „In Google eintragen“-Links.

Kalender-Abo für den Betrieb: je Ressource geheimer Link …/v1/kalender/<siteKey>/<feedToken>.ics (nur bestätigte Termine und Sperren der nächsten 90 Tage, X-PUBLISHED-TTL: PT15M; Google aktualisiert nur alle paar Stunden – steht so im Portal). Link neu erzeugen macht den alten ungültig.

7. Website-Teil

Einbau (vom Mitarbeiter, je Kunde gestaltet):

<div data-webklar-termine="SITEKEY" data-api="https://project.webklar.com" data-modus="inline">
  <p>Termine telefonisch unter 06301 1234.</p>   <!-- Ausweichtext, bleibt ohne JS/bei Fehlern stehen -->
</div>
<script src="https://project.webklar.com/embed/termine.js" defer></script>

data-modus: inline (Buchungsstrecke direkt in der Seite) oder knopf (Knopf mit Text aus data-knopf, öffnet ein <dialog>; beim Laden der Seite ruft das Widget einmal die Konfiguration ab, für den dauerhaften Knopf „Vertrag widerrufen“ (R54), alles Weitere erst beim Klick). Optional data-leistung="s1" (vorausgewählt, z. B. Knopf neben einer Leistung).

public/embed/termine.js (ohne Abhängigkeiten, ohne Build, IIFE wie news.js):

  • Light DOM mit Klassenpräfix wkt-, Standard-Stile in @layer wkt, Gestaltung über CSS-Variablen der Seite: --wkt-akzent, --wkt-akzent-text, --wkt-hintergrund, --wkt-text, --wkt-rand, --wkt-radius, --wkt-schrift, --wkt-schrift-titel; Containerabfragen für schmale Spalten/Handy.
  • Kein Cookie, kein Web-Storage, kein Auslesen von Referrer/Bildschirm/Zeitzone, keine Drittanfragen; Text nur per textContent.
  • Barrierefrei nach WCAG 2.1 AA: echte Formularelemente, sichtbarer Fokus, Monatsraster nach dem APG-Muster „Date Picker Dialog“ (Pfeiltasten, aria-selected, Tage ohne Zeiten aria-disabled), Fehler über aria-live, Zielgröße ≥ 24 px, kein Zeitlimit im Widget (die 30 Minuten gelten erst nach dem Absenden).
  • Deutsche Texte in Sie- oder Du-Form nach anrede. Fuß: „Vertrag widerrufen“ als zweiter Knopf (wenn nötig, siehe 4; im Modus knopf zusätzlich dauerhaft neben dem Buchungsknopf) und „Datenschutz“ (Link datenschutzUrl), klein „Terminbuchung über WEBklar“.
  • Zeitfalle (R53): GET /config nennt formMinSekunden. Vor jedem Absenden wartet das Widget, bis das Formular-Token (gemessen mit performance.now() ab Eintreffen der Konfiguration) so alt ist plus 0,5 Sekunden (Anzeige „Einen Moment …“, Knopf bleibt gesperrt); sonst würde der Server eine zu junge Buchung wie einen Automaten stillschweigend verwerfen. Gilt auch nach dem Neuladen der Konfiguration und beim Wiederholen nach neu_laden.
  • Die Telefonnummer des Betriebs (freier Text) wird nur zum tel:-Link, wenn sie nur aus Ziffern, Leerzeichen und + - ( ) / . besteht; die nationale Null „(0)“ hinter der Landesvorwahl entfällt. Sonst bleibt sie Text (Widget und Gästeseite, dieselbe Regel).
  • Fehler/inaktiv/Abo beendet: Ausweichtext bleibt sichtbar; bei Netzfehler mitten in der Strecke „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“.
  • Demo public/embed/termine-demo.html, Einbauanleitung für Mitarbeiter docs/funktionen/termine-einbau.md (Snippet, Variablen-Tabelle, Beispiele für drei Stile, Datenschutz-Absatz, Abnahme-Checkliste). siteKey und fertigen Einbau-Code finden Mitarbeiter im Portal (Kundenansicht → Terminplaner → Einstellungen, Kasten „Einbau auf deiner Website“) und den siteKey im WOMS (Kunde → Funktionen).

Gästeseiten public/termin.html + public/termin.js + public/termin.css: Bestätigen, Verwalten, Widerrufen (Token im Fragment). Kopf mit Betriebsname in farbe, Fuß mit Links auf Impressum und Datenschutz des Betriebs und auf das Impressum von WEBklar (https://webklar.com/impressum, R46; das Portal hat kein /impressum.html). Der Widerruf steht dort als gleichrangiger Knopf (R47). noindex, keine Cookies.

8. Öffentliche Schnittstelle /api/public/termine/v1/:siteKey/…

Eigener Router (server/routes/publicTermine.js), in server/index.js vor dem globalen express.json eingehängt (eigener Parser limit: '16kb'). Unbekannter oder inaktiver Schlüssel ⇒ immer dieselbe Antwort { aktiv: false } (200), nie ein Hinweis, ob der Schlüssel existiert. Antworten enthalten nie customerId, projectId, featureId, Appwrite-, Stripe- oder Ticket-IDs; nur Leistungs-/Ressourcen-IDs aus der Einstellung und die Buchungsnummer (8 Zeichen, aus der Buchungs-ID abgeleitet, nur zur Anzeige).

Methode, Pfad Zweck Cache
GET /:siteKey/config Betriebsname, Anrede, Farbe, Leistungen (ohne interne Felder), wählbare Ressourcen, Regeln für die Anzeige, Zahlungsarten, Stornotext + Version, Rechtsflags (Widerruf ja/nein, mit Belehrung), Formular-Token (HMAC über siteKey + Zeit), formMinSekunden (R53) public, max-age=60, kein ETag (das Formular-Token ist bei jedem Abruf neu)
GET /:siteKey/tage?leistung=&ressource=&monat=YYYY-MM Tage mit freien Zeiten public, max-age=15
GET /:siteKey/zeiten?leistung=&ressource=&tag=YYYY-MM-DD freie Startzeiten; bei „egal“ ohne Angabe der Person no-store
POST /:siteKey/buchungen Formular abschicken ⇒ gehalten, G01 –
POST /:siteKey/widerruf {name, email, nummer} Widerrufsfunktion ohne Link (tokenlos) –
POST /termin/vorschau, /termin/bestaetigen, /termin/absagen, /termin/verschieben, /termin/widerruf, /termin/tage, /termin/zeiten {token, …} Gästeseiten termin.html: Zusammenfassung, E-Mail bestätigen, Verwaltung durch den Gast (Widerruf mit Link), Tage/Zeiten zum Verschieben; der Token kommt aus dem Link der Mail; der Betrieb wird über den Token gefunden, nicht über den siteKey –
GET /kalender/:siteKey/:feedToken.ics Kalender-Abo des Betriebs (Token je Ressource, konstantzeitig verglichen) private, max-age=300 (enthält Gastnamen)

Schutz (ohne Captcha-Dienst):

  • CORS: GETs Access-Control-Allow-Origin: *; POSTs nur von erlaubten Origins = Hosts des Projekts (Subdomain unter project.webklar.com, liveDomain + www-Gegenstück, wie analyseHosts) und https://project.webklar.com (Gästeseiten); Preflight erlaubt Content-Type.
  • Honeypot (kontakt_url, unsichtbar; R38) und Zeitfalle (Formular-Token muss ≥ formMinSekunden, Vorgabe 3 s, und ≤ 2 h alt sein) ⇒ bei Treffer dieselbe Erfolgsantwort, aber nichts gespeichert, keine Mail. Ein Token älter als 2 h ⇒ 400 mit Code neu_laden; das Widget holt dann eine neue Konfiguration. Das Widget wartet vor dem Absenden selbst, bis das Token alt genug ist (7).
  • Mengen (im Speicher, mit Obergrenze der Schlüssel wie pushTime.js): je IP (IPv6 je /64) 10 Buchungen/10 min und 120 GET/min; je Funktion 60 gehaltene Buchungen/h; je E-Mail-Adresse und Funktion höchstens maxOffeneJeEmail offene Termine und 5 Bestätigungsmails/Tag; widerruf 10/h je IP.
  • Eingaben streng prüfen (Längen, E-Mail-Syntax, Telefon nur Ziffern/Leerzeichen/+/-/(), Zusatzfelder nur bekannte Schlüssel); Fehler als Feldfehler { fehler: { feld: text } }.
  • Appwrite nicht erreichbar ⇒ 503 { fehler: { _: 'Gerade nicht erreichbar' } }, nie Rohfehler.

9. Verwaltung im Portal (für alle gleich)

Neue Ansicht #view-termine (Hash #termine), Kachel „Terminplaner“ im Funktionen-Tab mit Pille Aus / Aktiv / Abgeschaltet (TP1) und Knopf „Termine öffnen“. In der App-Ansicht Unterseite von Funktionen (APP_PARENT.termine = 'funktionen'). Mehrere Website-Projekte ⇒ Projektauswahl oben (wie News). Eigenes Modul public/termine.js + public/termine.css (nicht in das schon 794 Zeilen lange funktionen.js).

Reiter:

  1. Termine – Liste nach Tagen (heute, morgen, …; „Woche“ / „Monat“ / Datumssprung), Filter offen/angefragt/bestätigt/abgesagt; Zeile: Uhrzeit, Leistung, Person/Platz, Gast, Status, Hinweise („Bestätigung nicht zugestellt“). Aktionen siehe 4; „+ Termin eintragen“, „Zeit sperren“. Zähler „{n} Anfragen warten“ auf der Kachel.
  2. Leistungen – Liste mit Reihenfolge; Felder wie im Datenmodell; Hinweis, dass Leistungsnamen keine Gesundheitsangaben enthalten dürfen.
  3. Team & Plätze – Ressourcen mit Wochenplan-Editor (je Tag mehrere Zeitfenster), Zuordnung der Leistungen, Abwesenheiten/Urlaub, Bundesland + „an Feiertagen geschlossen“, Sonderöffnungen, Kalender-Abo-Link je Ressource (Kopieren, Neu erzeugen).
  4. Einstellungen – Betriebsangaben für Gäste (vorbefüllt aus dem Kundenkonto), Anrede, Farbe, Regeln, Gastfelder, Erinnerung, Stornotext (Mustertext als Vorschlag, deutlich als Muster gekennzeichnet), Rechtliches („Ihre Gäste sind: Privatpersonen / Unternehmen / beides“; Hinweis, dass der Widerruf die Stornofrist schlägt), Zahlung (TP1: nur „vor Ort“ mit Zahlarten; „online“ ausgegraut „kommt bald“), Aufbewahrung, Einbau auf deiner Website (Einbau-Code aus GET /einstellungen einbau.snippet, nur lesen, mit Kopierknopf), Export (CSV der Buchungen), Status „aktiv seit …“.
  • Erstes Öffnen ohne Einstellung: Auswahl einer Branchen-Vorlage (salon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges; Daten in server/services/termine/vorlagen.js, die TP2 für den Fragebogen wiederverwendet), danach Bearbeiten.

Kunden-API server/routes/termine.js unter /api/termine (requireSession, assertOwnProject, Funktion active außer für reines Lesen, Schreiblimit 120/h je Kunde): GET/PATCH /einstellungen (die Antwort nennt auch siteKey, einbau.snippet, feeds, fehlt, anfragenOffen), POST /einstellungen/vorlage, GET /buchungen?von&bis&status, GET /zeiten (freie Zeiten für Termine von Hand und Verschieben), POST /buchungen (manuell/Sperre), DELETE /buchungen/:id (Sperre aufheben), POST /buchungen/:id/aktion (annehmen|ablehnen|absagen|verschieben|nichterschienen|anonymisieren), POST /ressourcen/:id/feed (neu erzeugen), GET /export.csv.

Kundenansicht (Mitarbeiter als Kunde): Lesen und Schreiben auf /api/termine/* erlaubt (GET, POST auf alles unter /api/termine/, PATCH /einstellungen, DELETE /buchungen/:id; Einstellungen und Termine kosten nichts, sind keine Erklärungen im Namen des Kunden); Einträge in KUNDENANSICHT_ALLOWED + test/kundenansichtGuard.test.js, Protokoll termin_einstellungen, termin_aktion, termin_manuell (Portal VIEW_ACTIONS, WOMS src/lib/viewLog.js).

10. Ticketsystem (WOMS) in TP1

  • Portal: FEATURE_KEYS = ['news','termine']; findActiveBySiteKey(siteKey, featureKey); activateClosedTickets bleibt in TP1 bei News (TP2 verallgemeinert es samt Mails); funktionenAdmin.js Route POST /api/team/funktionen/:customerId/:key (key ∈ FEATURE_KEYS; /news bleibt gleichwertig) und adminView mit allen Schlüsseln (Ticket-Lookups gebündelt statt N+1).
  • WOMS: src/lib/funktionenView.js (Register der Funktionen, Zustandstexte, Bestätigungstexte), CustomerFunktionen.jsx zeigt je Projekt News und Terminplaner (eigene Zeilen), Aktionen „Aktivieren (ohne Zahlung)“ / „Abschalten“ je Funktion, Ticketnummer als Link /tickets?woid=, beim Terminplaner zusätzlich der siteKey in kleiner Schrift mit Kopierknopf (für den Einbau); teamApi.setFeature(customerId, key, …). viewLog.js um die neuen Aktionen.

11. Recht und Datenschutz in TP1 (Pilot)

TP1 läuft nur im Pilot mit dem eigenen Testkunden; echte Gäste fremder Betriebe erst nach TP2 (AVV + Leistungsbeschreibung + Freigabe der Texte durch den Nutzer). TP1 liefert als Entwürfe (Kennzeichnung „Entwurf“, Dateien in docs/rechtliches/ bzw. docs/funktionen/):

  • Datenschutz-Absatz „Terminbuchung“ für die Website des Betriebs (Sie/Du), Hinweis unter dem Formular, Stornotext-Muster, Widerrufsbelehrung + Muster-Widerrufsformular für Dienstleistungen (Anlage 1/2 EGBGB, Platzhalter des Betriebs) – Grundlage recherche-recht.md 11.x und recherche-recht-zahlung.md 7.3.
  • Abschnitt „6c Terminplaner“ für public/datenschutz.html (WEBklar als Auftragsverarbeiter; was das Portal selbst verarbeitet) – nicht veröffentlicht vor TP2.
  • Zitat-Korrektur im Bestand („§ 356 Abs. 4“ ⇒ „§ 356 Abs. 5 Nr. 2 BGB“ in config.funktionen.consentText, funktionen-leistungsbeschreibung.html, Test) erfolgt in TP2 mit dem neuen Einwilligungstext je Funktion.

Löschfristen (täglicher Job im Terminplaner selbst, unabhängig von retention.js, weil die Fristen je Buchung laufen und nicht am Vertragsende des Kunden hängen): verfallen und unbestätigte nach 48 h; abgesagt/abgelehnt nach 30 Tagen; erledigt/nichterschienen nach aufbewahrungTage (Standard 90, erlaubt 14–365) ab Terminende; Warteschlange: gesendet 7 Tage nach dem Versand, gescheitert 48 Stunden nach dem Fehler; Zellen mit der Buchung. Funktion disabled ⇒ neue Buchungen aus, nach 30 Tagen (Zeit für den Export) Löschung aller Buchungen, Zellen, Einstellung. Kunde gelöscht ⇒ Funktion disabled (Haken in customerAdmin.deleteCustomerWithPortalAccess, wie Analyse). In TP1 löst nur disabled die Löschung aus (WOMS „Abschalten“, Kunde gelöscht); ein Haken für das Ende des Wartungs-Abos kommt erst mit TP2, obwohl der 6c-Entwurf das Abo-Ende schon nennt.

12. Fehlerfälle

  • Slot inzwischen weg (409 beim Belegen) ⇒ Meldung im Widget, Zeiten neu laden.
  • Bestätigungslink nach Ablauf ⇒ „abgelaufen“; zweiter Klick auf denselben Link ⇒ „bereits bestätigt“ + Verwaltungslink-Hinweis.
  • Mail scheitert endgültig ⇒ Hinweis an der Buchung im Portal; Buchung bleibt gültig.
  • Einstellung ungültig (z. B. Leistung ohne Ressource) ⇒ Speichern mit Feldfehler abgelehnt; Leistung ohne aktive Ressource wird im Widget nicht angezeigt.
  • Leistung oder Ressource gelöscht, während Termine bestehen ⇒ nur deaktivieren erlaubt, solange künftige Termine daran hängen (Meldung mit Anzahl).
  • Funktion abgeschaltet ⇒ öffentliche Schreibrouten { aktiv: false }, Gästeseiten erlauben weiter Absagen/Widerruf bestehender Termine.
  • Uhr-Umstellung ⇒ keine Slots in der fehlenden Stunde; Termine über die Umstellung behalten ihre echte Dauer.

13. Tests TP1

  • npm test (node --test): Zeit (beide Umstellungstage), Feiertage je Land (Stichproben 2026/2027), Slots (Pausen, Puffer, Vorlauf, Horizont, Abwesenheit, Feiertag, Sonderöffnung, „egal“), Zellen/Bulk (Mock: Alles-oder-nichts), Tokens (einmalig, Ablauf), ICS (Faltung, Escaping, CANCEL), Vorlagen (alle G/K-Vorlagen rendern ohne problems, EXPECTED), Einstellungs-Validierung, öffentliche Routen (keine internen IDs, {aktiv:false} einheitlich, CORS/Origin, Honeypot, Zeitfalle, Mengen, Knopftexte je Fall, Widerruf mit/ohne Treffer), Kunden-Routen (Rechte, fremdes Projekt 403, Versionskonflikt 409, Kundenansicht-Liste), Jobs (Verfall, Erinnerung genau einmal, Erledigt, Löschfristen), Warteschlange (Rate, Wiederholung, Pause nach Anmeldefehler).
  • Playwright (Fixture-Server, wie test/ui/*.ui.mjs): Widget inline und Knopf auf einer Testseite (Desktop, Handy, nur Tastatur, Ausweichtext bei Fehler), Gästeseiten (bestätigen, absagen, verschieben, widerrufen), Portal-Ansicht (alle Reiter, Vorlage wählen, Wochenplan-Editor) – Screenshots nach /root/reports/terminplaner-ui/.
  • WOMS: npm test (Register, Zustände), vite build.
  • Live-Spike Bulk-Create (5) vor dem Bau.

14. Livegang TP1 (je Schritt Freigabe des Nutzers)

  1. L1 Portal: Push feature/terminplaner → main (der Nutzer pusht), Schalter bleibt off; Abhängigkeit feiertagejs kommt mit dem Deploy (deploy.sh führt npm install aus).
  2. L2 Schema: termine-setup.mjs --check, dann anlegen (im Container; das Skript kommt mit L1). Danach Compose: TERMINE_ENABLED=pilot, TERMINE_PILOT_EMAILS=kenso.gri@gmail.com, Container neu erstellen (docker compose up -d --no-deps kundenbereich-server, nie preview-portal).
  3. L3 WOMS: Push auf test.
  4. L4 Pilot: im WOMS beim Kunden „test“ den Terminplaner für „Test-Website“ aktivieren; im Portal (Login des Testkunden) Vorlage wählen, Leistungen/Zeiten pflegen; Widget in WEBklar/test-website einbauen (siteKey im WOMS, fertiger Einbau-Code im Portal unter Einstellungen) (Push → Deploy) inkl. Datenschutz-Absatz-Entwurf; Testbuchungen mit kenso.gri@gmail.com: bestätigen, Erinnerung, absagen, verschieben, widerrufen, Anfrage annehmen/ablehnen, Kalender-Abo in Google.
  5. L5 Nutzertest; Aufräumen der Testbuchungen nach Wunsch.

Betrieb und Livegang – Hinweise

  • SESSION_SECRET darf nie rotiert werden: Verwaltungs-, Formular- und Feed-Links hängen daran (token.js, Schlüssel mit Zweckbindung; Compose fällt auf PREVIEW_SESSION_SECRET zurück). Eine Rotation entwertet alle schon versandten Links, auch den Widerrufs-Link in G02.
  • Der Modus off stoppt auch die Löschjobs (die Aufbewahrungsfristen laufen nicht mehr). Pilotbuchungen vor dem endgültigen Abschalten löschen (Funktion abschalten und Löschfrist abwarten, oder die Daten von Hand löschen); alternativ TERMINE_PILOT_EMAILS= leer lassen, statt off zu setzen.
  • Die tägliche Zellbereinigung liest alle Zellen (Deckel 100 000, idx_bis ungenutzt). Vor TP2 muss sie über idx_bis (lessThan('bis', …)) und seitenweise begrenzt werden, sonst steht sie bei vielen Betrieben still (list_limit_exceeded). Gleiches Muster: GET /export.csv und loescheAbgeschaltete (Standarddeckel 20 000).
  • verarbeite (Warteschlange) listet jede Minute alle fälligen Einträge samt Inhalt, auch wenn das Stundenbudget leer ist. In TP1 unkritisch; vor TP2 Frühausstieg bei leerem Budget.

TP2 Verkauf (eigener Plan nach TP1)

  • Fragebogen im Portal (Kachel „Terminplaner einrichten · 149 €“, nur mit Wartungs-Abo): 8 Schritte (Branche/Vorlage mit Gesundheits-Ausschluss · wer wird gebucht + Namen · Leistungen · Zeiten, Bundesland, Urlaub · Regeln + sofort/Anfrage · Gastfelder · Benachrichtigungen, Anrede, Erinnerung · Website: wo buchen, Knopf/inline, Gestaltungswunsch, Ausweichtelefon, Links Impressum/Datenschutz), Zwischenspeichern als Entwurf in termineConfig. Ergebnis = dieselbe Einstellung wie TP1 + Einbauwünsche.
  • Bestellung: Zusammenfassung, Haken Leistungsbeschreibung + AVV Terminplaner (Fassung), „kein Gesundheitsbereich“, Widerrufs-Zustimmung für Verbraucher (§ 356 Abs. 5 Nr. 2 BGB), Knopf „Zahlungspflichtig bestellen · 149 €“ ⇒ Stripe Checkout (einmalig, Katalogeintrag termineSetup 14900 Cent). Verallgemeinerung des Funktionen-Checkouts: Register config.features (Preis, Einwilligungstext, Zeiteinschätzung, Ticket-Text, Mails, Rücksprung-Hash) statt fester News-Werte; K15/K16/T16 mit Platzhalter {{funktion}}; Widerrufsformular-Beschriftung.
  • Ticket mit Einbauanleitung (services/termine/einbauanleitung.js, deterministisch aus der Einstellung): Kurzfassung in ticket.details (≤ 8000 Zeichen), vollständig im WOMS-Reiter Funktionen (Kopieren-Knopf) über GET /api/team/funktionen/:customerId/termine/anleitung. Inhalt: Modell, Leistungen, Ressourcen + Zeiten, Regeln, Gastfelder, Benachrichtigungen, Einbau (Snippet mit siteKey, Modus, CSS-Variablen-Vorschlag, Stelle auf der Website), Datenschutz-Absatz, Abnahme-Checkliste, Abrechnung („Einrichtung bezahlt, nicht auf Abo-Stunden“).
  • Abo-Kopplung: Bestellen nur mit laufendem Wartungs-Abo (gemeinsame Prüfung aus analyse.js herausgelöst); täglicher Abgleich: Abo beendet ⇒ disabled, Mail an den Kunden, Löschung nach 30 Tagen.
  • Rechtstexte: Leistungsbeschreibung Terminplaner, AVV Terminplaner (Geschwister des AVV Website-Analyse), Datenschutz 6c, Gästetexte final, Karten in WOMS „Rechtliches“; Prüfrunde wie bei der Analyse; danach TERMINE_ENABLED=on.

TP3 Online-Zahlung (eigener Plan nach TP2)

  • Stripe Connect, verbundene Konten mit Standard-Verhalten (controller: volles Dashboard, losses.payments=stripe, fees.payer=account, requirement_collection=stripe), Fähigkeit card_payments, Rahmenvertrag full; Einrichtung per Account Link (von Stripe gehostet); Status über account.updated + Rücksprung. Sandbox-Plattformeinrichtung ist erledigt (Testkonto zeigt Negativsalden/Anforderungen/Gebühren = Stripe); Live-Plattformeinrichtung vor dem Livegang.
  • Direct Charges über von Stripe gehostetes Checkout auf dem Konto des Betriebs (Stripe-Account), mode: payment, nur Festpreise ≥ 0,50 €, keine Plattformgebühr, SEPA ausgeschlossen (verzögert), PayPal nicht verfügbar (Stripe unterstützt es bei Direct Charges nicht), expires_at ≥ 30 Minuten ⇒ Haltezeit bei Online-Zahlung = Sitzungsdauer + 2 Minuten.
  • Zahlungsmodus je Betrieb vorort | online | beides (beides ⇒ Frage „Wie möchten Sie bezahlen?“ ohne Vorauswahl, gleicher Preis); online nur, wenn das verbundene Konto charges_enabled hat, sonst automatisch zurück auf vor Ort + Hinweis.
  • Ablauf online (Vorschlag, in TP3 zu bestätigen): Widget „Zahlungspflichtig buchen“ ⇒ Bestätigungslink ⇒ Weiterleitung zu Stripe ⇒ Zahlung ⇒ bestaetigt per Webhook; späte Zahlung bei vergebenem Slot ⇒ sofortige Erstattung.
  • Eigener Connect-Webhook (eigenes Secret, event.account + livemode prüfen, idempotent je Sitzung), nicht in routes/abo.js.
  • Erstattungsregeln (recherche-recht-zahlung.md 3.5): Betrieb sagt ab ⇒ voll; Gast in der Frist ⇒ voll; Widerruf ⇒ voll binnen 14 Tagen; zu spät/nicht erschienen ⇒ nach Regel des Betriebs; fehlendes Guthaben ⇒ „Erstattung ausstehend“ im Portal + Mail.
  • Rechtliches: Stripe-Absatz für die Datenschutzerklärung des Betriebs, AVV/Leistungsbeschreibung ergänzen, keine Kassenfunktion, kein Rechnungsdruck.

Nicht Teil (V1)

Gruppen/Kurse/Tischreservierung · Person + Platz gleichzeitig · Mehrere Leistungen in einer Buchung · Varianten (30/60/90) · Warteliste · SMS · Zwei-Wege-Kalender-Sync (Google/Outlook) · Anzahlungen · Kundenkonten für Gäste · Bewertungs- oder Werbemails · Mehrsprachigkeit · Gesundheitsbereich · Wechsel des Portal-Containers auf Node 24 (eigene Aufgabe; Node 20 ist seit 30.04.2026 ohne Pflege).

Offene Punkte

  1. Spike Bulk-Create (TP1, Aufgabe 1) – Freigabe für Wegwerf-Collection in der Produktions-Appwrite.
  2. Rechtstexte: alle als Entwurf; Prüfung vor TP2-Livegang (wie Website-Analyse), u. a. Einstufung Massage/Fotograf/Coaching als Widerrufsfälle und ob der Bestätigungslink die Bestellung ist.
  3. TP3: Stripe-Live-Plattformeinrichtung durch den Nutzer; Ablauf „Bestätigungslink vor Zahlung“ bestätigen.