- 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>
609 lines
47 KiB
Markdown
609 lines
47 KiB
Markdown
# 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.
|