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

609 lines
47 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```jsonc
{
"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):
```html
<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.