40 KiB
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 € (brutto, 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 (
bccTeamaus), 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 inportalFeatures(featureKey: 'termine'), mit eigenemsiteKey.
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 inTERMINE_PILOT_EMAILSsteht (Komma-Liste, MusterABO_PILOT_EMAILS). Bestellen gibt es in TP1 nicht; die Funktion wird im Ticketsystem mit „Aktivieren (ohne Zahlung)“ eingeschaltet (vorhandenesadminActivate, auf Schlüssel verallgemeinert).onkommt mit TP2 (Bestellung für alle mit Wartungs-Abo).- Zustände der Funktion wie bei News:
off → active → disabled(TP1);ordered/setupfolgen 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, Indizes nur bei neuen Collections). 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), data (Text, JSON bis 200 000 Zeichen), version (Ganzzahl),
updatedAt, updatedBy (100). Inhalt von data (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",
"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 } ],
"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, Haltezeit oder Terminende).
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).
Gesendete und endgültig gescheiterte Einträge werden nach 7 Tagen gelöscht (enthalten personenbezogene Daten).
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.berlinTimewird dafür nicht benutzt (kennt die Lücke nicht). - Feiertage (
feiertage.js): Paketfeiertagejs(MIT, ohne Abhängigkeiten) je Bundesland;sonderoeffnungenundabwesenheitenschlagen Feiertage/Plan. - Slots (
slots.js,nowwird übergeben): je Ressource und Berliner Kalendertag die Öffnungsfenster ausplan(odersonderoeffnungen), minus Feiertag (wenn geschlossen), minus Abwesenheiten, minus belegte Zeiten (Buchungen mit Statusgehalten|angefragt|bestaetigtund Sperren, jeweils bisblockEndsAt). Startzeiten imrasterab Fensterbeginn; Termin +pufferNachmuss ganz ins Fenster passen; nicht vornow + vorlaufMin; nicht nachhorizontTage. „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+startsAtzwischen, mit Seitenweise-Laden übercursorAfter), 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
- 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).
- Person wählen, wenn mehr als eine Ressource infrage kommt und
personWaehlbar(mit „Egal“ als erster Option); bei Plätzen kein Schritt. - Tag (Monatsraster, nur Tage mit freien Zeiten aktiv) und Uhrzeit.
- Daten: Name, E-Mail, Telefon/Nachricht/Zusatzfelder nach
gastFelder. Über der Nachricht steht „Bitte keine Gesundheitsangaben.“ - 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 aufdatenschutzUrl), Haken:- Pflicht: „Ich habe die Stornobedingungen gelesen.“ (nur wenn
storno.textgesetzt) - Pflicht nur bei verbindlicher Leistung mit Preis, Verbrauchern und Termin innerhalb von 14 Tagen nach der Buchung: 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).
- Pflicht: „Ich habe die Stornobedingungen gelesen.“ (nur wenn
- 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}) Zellen belegen (5), Buchung
gehalten mit heldUntil = now + 30 min anlegen, 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): zeigt den Termin und je nach
Lage
- „Termin absagen“ bis
absageBisStdvor 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; - „Vertrag widerrufen“, solange ein Widerrufsrecht besteht (verbindlich, Verbraucher, nicht
freizeit, innerhalb von 14 Tagen nachbestaetigtAt) – 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(vongast), G08 Eingangsbestätigung mit Datum und Uhrzeit, K21-Block „Widerruf“.
Widerruf ohne Link (§ 356a: auf der Oberfläche, über die der Vertrag geschlossen wurde): Das Widget zeigt im Fuß dauerhaft „Vertrag widerrufen“, sobald der Betrieb verbindliche Leistungen für Verbraucher anbietet. 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“; 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).
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, keinerinnerungGesendetAt,startsAtin[now + stundenVorher − 5 min, now + stundenVorher]; erst Marker setzen, dann einreihen);bestaetigtmitendsAt < now − 1 h⇒erledigt. - 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 eintermineClaims-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 …/documentsmitdocuments: [...], 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
withLocksje Ressource im Prozess (Reihenfolge, keine Fehlalarme bei Doppelklick). - 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.
| 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) |
| G03 | Gast | Ihre Terminanfrage bei {betrieb} | „Der Betrieb meldet sich“, Verwaltungslink (nur Absage) |
| G04 | Gast | Ihre Terminanfrage bei {betrieb} | abgelehnt, optionale Nachricht des Betriebs |
| G05 | Gast | Erinnerung: Ihr Termin morgen 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/<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>; Daten werden erst beim Klick geladen). 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 Zeitenaria-disabled), Fehler überaria-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“ (wenn nötig, siehe 4) und „Datenschutz“ (LinkdatenschutzUrl), klein „Terminbuchung über WEBklar“. - 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 Mitarbeiterdocs/funktionen/termine-einbau.md(Snippet, Variablen-Tabelle, Beispiele für drei Stile, Datenschutz-Absatz, Abnahme-Checkliste).
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. 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 /config |
Betriebsname, Anrede, Farbe, Leistungen (ohne interne Felder), wählbare Ressourcen, Regeln für die Anzeige, Zahlungsarten, Stornotext + Version, Rechtsflags (Widerruf ja/nein), Formular-Token (HMAC über siteKey + Zeit) | public, max-age=60, ETag |
GET /tage?leistung=&ressource=&monat=YYYY-MM |
Tage mit freien Zeiten | max-age=15 |
GET /zeiten?leistung=&ressource=&tag=YYYY-MM-DD |
freie Startzeiten; bei „egal“ ohne Angabe der Person | no-store |
POST /buchungen |
Formular abschicken ⇒ gehalten, G01 |
– |
POST /vorschau {token} |
Zusammenfassung für Bestätigen/Verwalten | – |
POST /bestaetigen {token} |
E-Mail bestätigt | – |
POST /absagen, POST /verschieben {token, …} |
Verwaltung durch den Gast | – |
POST /widerruf {name, email, nummer, token?} |
Widerrufsfunktion | – |
GET /kalender/:feedToken.ics |
Kalender-Abo des Betriebs (Schlüssel im Pfad statt siteKey) | max-age=300 |
Schutz (ohne Captcha-Dienst):
- CORS: GETs
Access-Control-Allow-Origin: *; POSTs nur von erlaubten Origins = Hosts des Projekts (Subdomain unterproject.webklar.com,liveDomain+ www-Gegenstück, wieanalyseHosts) undhttps://project.webklar.com(Gästeseiten); Preflight erlaubtContent-Type. - Honeypot (
website, unsichtbar) und Zeitfalle (Formular-Token muss ≥ 3 s und ≤ 2 h alt sein) ⇒ bei Treffer dieselbe Erfolgsantwort, aber nichts gespeichert, keine Mail. - 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öchstensmaxOffeneJeEmailoffene Termine und 5 Bestätigungsmails/Tag;widerruf10/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:
- 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.
- Leistungen – Liste mit Reihenfolge; Felder wie im Datenmodell; Hinweis, dass Leistungsnamen keine Gesundheitsangaben enthalten dürfen.
- 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).
- 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, 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 inserver/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,
GET /buchungen?von&bis&status, POST /buchungen (manuell/Sperre), POST /buchungen/:id/aktion
(annehmen|ablehnen|absagen|verschieben|nichterschienen), POST /ressourcen/:id/feed (neu erzeugen),
GET /export.csv.
Kundenansicht (Mitarbeiter als Kunde): Lesen und Schreiben auf /api/termine/* erlaubt
(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);activateClosedTicketsje Schlüssel (für TP2 vorbereitet);funktionenAdmin.jsRoutePOST /api/team/funktionen/:customerId/:key(key∈FEATURE_KEYS;/newsbleibt gleichwertig) undadminViewmit allen Schlüsseln (Ticket-Lookups gebündelt statt N+1). - WOMS:
src/lib/funktionenView.js(Register der Funktionen, Zustandstexte, Bestätigungstexte),CustomerFunktionen.jsxzeigt je Projekt News und Terminplaner (eigene Zeilen), Aktionen „Aktivieren (ohne Zahlung)“ / „Abschalten“ je Funktion, Ticketnummer als Link/tickets?woid=;teamApi.setFeature(customerId, key, …).viewLog.jsum 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.md11.x undrecherche-recht-zahlung.md7.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 nach 7 Tagen; 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).
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 ohneproblems,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)
- L1 Schema:
termine-setup.mjs --check, dann anlegen (im Container). Abhängigkeitfeiertagejskommt mit dem Deploy (deploy.shführtnpm installaus). - L2 Portal: Push
feature/terminplaner→main(der Nutzer pusht); Compose:TERMINE_ENABLED=pilot,TERMINE_PILOT_EMAILS=kenso.gri@gmail.com, Container neu erstellen (docker compose up -d --no-deps kundenbereich-server, niepreview-portal). - L3 WOMS: Push auf
test. - 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-websiteeinbauen (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. - L5 Nutzertest; Aufräumen der Testbuchungen nach Wunsch.
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
termineSetup14900 Cent). Verallgemeinerung des Funktionen-Checkouts: Registerconfig.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 inticket.details(≤ 8000 Zeichen), vollständig im WOMS-Reiter Funktionen (Kopieren-Knopf) überGET /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.jsherausgelö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ähigkeitcard_payments, Rahmenvertragfull; Einrichtung per Account Link (von Stripe gehostet); Status überaccount.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 Kontocharges_enabledhat, 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 ⇒
bestaetigtper Webhook; späte Zahlung bei vergebenem Slot ⇒ sofortige Erstattung. - Eigener Connect-Webhook (eigenes Secret,
event.account+livemodeprüfen, idempotent je Sitzung), nicht inroutes/abo.js. - Erstattungsregeln (
recherche-recht-zahlung.md3.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
- Spike Bulk-Create (TP1, Aufgabe 1) – Freigabe für Wegwerf-Collection in der Produktions-Appwrite.
- 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.
- TP3: Stripe-Live-Plattformeinrichtung durch den Nutzer; Ablauf „Bestätigungslink vor Zahlung“ bestätigen.