Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-02-terminplaner-tp1-kern.md
T

256 lines
56 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 TP1 Kern – Umsetzungsplan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Gäste buchen auf der Website eines Betriebs Termine (Person oder Platz, sofort oder als Anfrage, Bestätigung per E-Mail-Link, Absagen/Verschieben/Widerrufen per Link); der Betrieb verwaltet alles im Portal; Pilot mit dem Kunden „test“.
**Architecture:** Neue Funktion `termine` im Kundenportal (Express, Appwrite, vanilla JS). Reine Bausteine in `server/services/termine/` (Zeit, Feiertage, Einstellungen, Slots, Zellen gegen Doppelbuchung, ICS, Mails, Warteschlange, Ablauf, Jobs), dazu eine öffentliche, versionierte Schnittstelle `/api/public/termine/v1`, eine Kunden-Schnittstelle `/api/termine`, ein Einbau-Skript `embed/termine.js`, Gästeseiten `termin.html` und die Portal-Ansicht `#termine`. WOMS bekommt Aktivieren/Abschalten je Funktion.
**Tech Stack:** Node 20 (Produktion) / 24 (Host-Tests), Express 4, Appwrite 1.8.1 REST, nodemailer 9, `feiertagejs` 1.5.1 (neu), Playwright (`/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs`, Chromium `~/.cache/ms-playwright/chromium-1234`), React 18 (WOMS).
**Spec:** `docs/superpowers/specs/2026-10-02-terminplaner-design.md` (Abschnitte „TP1 Kern“ 1–14). Recherche: `/root/reports/terminplaner-2026-10-02/`.
**Worktrees:** Portal `/home/webklar/apps/kundenbereich.webklar.com/dev-termine` (Branch `feature/terminplaner` von `origin/main` ff7fe35, eigenes `node_modules` per `npm ci`, Basis 1463 Tests grün). WOMS siehe V3 und Task 22.
## Global Constraints
- Funktionsschlüssel `termine`; Schalter `TERMINE_ENABLED` = `off` (Vorgabe) | `pilot` | `on`; `TERMINE_PILOT_EMAILS` Komma-Liste (klein, getrimmt, `*` = alle). TP1: `on` = `pilot` ohne E-Mail-Filter; kein Bestellen. Bei `off` laufen keine Termine-Jobs und keine Termine-Abfragen.
- `config.termine` wird bei **jedem** Aufruf gelesen (Tests ändern `config.termine.*` zur Laufzeit). Werte: `mode`, `pilotEmails`, `holdMinutes: 30`, `zelleMin: 5`, `mailsProStunde: 200`, `formMinSekunden: 3` (Env `TERMINE_FORM_MIN_SECONDS`).
- Neue Dateien unter `server/services/termine/`; nie `server/services/appointments.js` (Test `adminPanelGone`).
- Einstellungs-Attribut heißt **`einstellung`** (nie `data`: `appwriteAdmin.formatRequestBody` hält Bodies mit `data` für fertig verpackt).
- Zeit: Wanduhr `Europe/Berlin`, gespeichert UTC (`toISOString()`); Appwrite liefert `+00:00` ⇒ Datumswerte immer mit `new Date()` lesen. Nicht existierende Wanduhrzeiten (März) auslassen, doppelte (Oktober) = erstes Vorkommen. Alle `HH:MM` in Plänen, Sonderöffnungen, Abwesenheiten und Sperren sind Vielfache von 5 Minuten. `deadlines.berlinTime` nicht verwenden.
- Zellen à **5 Minuten**, Zell-ID `c_` + 34 Hex von `sha1(featureId|ressourceId|zelleIso)`, Feld `bis` = `blockEndsAt` der Buchung (nie Haltezeit). Alle neuen Zellen einer Buchung in **einem** Bulk-Create (≤ 100 ⇒ jede Belegung ≤ 500 Minuten, auch Sperren und manuelle Termine). Ob eine Zelle noch gilt, entscheidet allein ihre Buchung (fehlt ⇒ frei; Status nicht belegend ⇒ frei; `gehalten` mit `heldUntil ≤ now` ⇒ frei).
- Status: `gehalten`, `angefragt`, `bestaetigt`, `erledigt`, `nichterschienen`, `abgesagt`, `abgelehnt`, `verfallen`. Belegend: `gehalten` (solange `heldUntil > now`), `angefragt`, `bestaetigt`.
- Tokens: **Bestätigungstoken** = 32 Zufallsbytes base64url, gespeichert nur `verifyHash` (SHA-256-Hex), wirksam einmal bis `heldUntil`. **Verwaltungstoken** = base64url(HMAC-SHA256(Schlüssel aus `config.sessionSecret` + `'termine-manage'`, bookingId)), gespeichert nur `manageHash`, jederzeit neu berechenbar, gültig bis Terminende + 1 Tag. Zustandsänderungen nur per POST. Vertragsschluss `vertragAt` = `bestaetigtAt` (sofort) bzw. `angenommenAt` (Anfrage).
- Knopftexte exakt: verbindlich + Preis ⇒ „Zahlungspflichtig buchen“ (Bestätigungsseite „Zahlungspflichtig bestätigen“); verbindlich ohne Preis ⇒ „Termin buchen“ („Termin bestätigen“); Anfrage ⇒ „Termin anfragen“ („Anfrage absenden“).
- Zustimmungstexte exakt (Sie-Form, auch bei Du-Betrieben, weil Rechtstext): vorzeitiger Beginn (Pflicht bei verbindlich + Preis + Verbraucher + Terminbeginn ≤ Ende der Widerrufsfrist = deadlines.withdrawalDeadline(jetzt + Haltezeit).endsAt; bei bepreisten Verbraucher-Anfragen immer — Ruling R31): „Ich verlange ausdrücklich, dass {Betrieb} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ · Storno: „Ich habe die Stornobedingungen gelesen.“ · Erinnerung (nie vorangekreuzt): „Ich möchte {n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.“ · über Freitext: „Bitte keine Gesundheitsangaben.“
- Verbraucher-Buchung ⇔ `recht.gaesteSind !== 'unternehmer' && !recht.freizeit` (beim Halten in `verbraucher` gespeichert). Widerruf möglich ⇔ verbindlich, `verbraucher`, Status `bestaetigt`, `now <= deadlines.withdrawalDeadline(vertragAt).endsAt` (§§ 187/188/193 BGB, Ruling R29), Termin noch nicht begonnen.
- Öffentliche Antworten: nie `customerId`, `projectId`, `featureId`, `$id`, Ticket-/Stripe-IDs; nur Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen). Fehlerform immer `{ fehler: { <feld|_>: text }, code? }`. Unbekannter, inaktiver, abgeschalteter siteKey oder Modus `off` ⇒ `200 { aktiv: false }` (GET und POST unter `/:siteKey`); `/termin/*` (Token) bleibt für `disabled` nutzbar (Absagen, Widerruf).
- Schutz: Router-eigener Parser 16 KB + Router-Fehlermiddleware (413/400/503, nie `err.message`); `content-length` > 16 384 ⇒ 413 vorab; Honeypot-Feld `kontakt_url`; Formular-Token HMAC, `zu_schnell` (< `formMinSekunden`) oder Honeypot ⇒ gleiche Erfolgsantwort ohne Speichern/Mail, `abgelaufen` (> 2 h) ⇒ 400 „Bitte laden Sie die Seite neu.“; Grenzen (Modul `limits.js`, Speicher-Maps mit Obergrenze 10 000 Schlüssel, `resetTermineLimitsForTests()`): je IP (IPv6 je /64) 120 GET/min und 10 Buchungen/10 min; je siteKey 600 GET/min und 60 Haltungen/h; je E-Mail+Funktion `regeln.maxOffeneJeEmail` (Vorgabe 3) offene Termine und 5 G01/Tag; Widerruf 10/h je IP, 3 G08/Tag je Empfängeradresse, 20 G08/h je Funktion, 10 K21 „nicht zugeordnet“/h je Funktion. Grenze „offene Termine“ ⇒ dieselbe 201-Antwort, statt der Bestätigung geht G01-Block `zu_viele` an die Adresse (kein Orakel für Dritte). E-Mail-Adressen: genau ein Postfach (kein `,;<>"`, ≤ 254), getrimmt und klein.
- Mails: Gast `from: { name: betrieb.name, address: SMTP_FROM }`, `replyTo` = Betriebs-E-Mail; K20/K21 `from: { name: 'WEBklar', address }` an die Betriebs-E-Mail; **nie BCC**, kein Tracking. Betreff = Platzhalter `{{betreff}}` (je Anrede/Fall vom Server). G01 und G08 ohne Text, den der Gast eingegeben hat. Alles über `termineOutbox`: einziger Sendepfad `sendeEintrag` prüft Pause und Stundenbudget (200/h); `EAUTH`/535 ⇒ 15 min Pause für alle, Eintrag bleibt `wartet`; Verbindungsfehler/4xx ⇒ erneut nach 1, 5, 30 min, dann `fehler`; 5xx (außer 535) ⇒ sofort `fehler`; nach `gesendet` werden `an`, `html`, `text`, `ics` geleert; Einträge 7 Tage nach `gesendet` bzw. 48 h nach `fehler` gelöscht.
- Löschfristen: `verfallen` 48 h nach `heldUntil`; `abgesagt`/`abgelehnt` 30 Tage; `erledigt`/`nichterschienen` `aufbewahrungTage` (Vorgabe 90, 14–365) nach Terminende; `angefragt` mit Beginn in der Vergangenheit ⇒ `abgelehnt` (System); Funktion `disabled` ⇒ alles 30 Tage nach `disabledAt`.
- Tests: Termine-Tests starten den Nachbau mit `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` (Task 2); feste `now` per Parameter; Mails mit `mailSink()` + `warteAufVersand()` prüfen; Kalenderdaten stehen im Sink als `icalEvent` (nicht als Anhang). Schwere Läufe (Playwright, `vite build`, Docker-Node-20) nacheinander, vorher `free -m`, mit `nice`.
- Portaltexte Du-Form, kein `confirm()`; Gästetexte Sie/Du nach `anrede`. Kundentext nur über `textContent`/`esc()`; `href` nur für geprüfte `https:`-URLs, mit `rel="noopener noreferrer"`.
- Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie `git add -A`.
## Review Focus
1. Gast-Browser in anderer Zeitzone (New York) sieht dieselben Daten und Uhrzeiten wie in Berlin; das Widget rechnet nie mit Browser-Datum. → Task 19 (Playwright `timezoneId`, statischer Test)
2. E-Mail mit Großbuchstaben/Leerzeichen (` Max@GMail.com `) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 12 (`email_normalisiert`)
3. Betrieb löscht Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, Termine bleiben. → Task 4 + 18
4. Bestätigungsklick genau während der Minutenjob die Haltung verfallen lässt ⇒ genau ein Endzustand. → Task 15 (`verfall_und_bestaetigung_gleichzeitig`)
5. Zwei Widgets auf einer Seite (`data-leistung`) stören sich nicht (IDs, Stile einmal). → Task 19
---
## Vorab (Controller, kein Subagent)
- [ ] **V1 Spike Bulk-Create** (Nutzer-Freigabe einholen): im Container (`docker exec -w /app webklar-kundenbereich-server node …`, Skript im Scratchpad) Wegwerf-Collection `termineSpike` (`bookingId` string 64), 50 parallele `POST …/collections/termineSpike/documents` mit `{ documents: [{ $id, bookingId }, …] }` (je 20 Zellen, Überlappungen) ⇒ je überlappender Gruppe genau ein 201, Rest 409 `document_already_exists`, keine Teil-Schreibungen; Collection löschen. Negativ ⇒ Task 2 baut `createDocuments` über die Transactions-API (`POST /databases/transactions`, Operationen, `PATCH {commit:true}`, 409 `transaction_conflict`).
- [ ] **V2 Gesetzestexte:** Anlage 1 und 2 zu Art. 246a § 1 EGBGB von `https://www.gesetze-im-internet.de/bgbeg/anlage_1.html` und `…/anlage_2.html` wörtlich nach `docs/rechtliches/quellen/anlage-1.txt` und `anlage-2.txt` (Kopfzeile mit Abrufdatum).
- [ ] **V3 WOMS-Worktree:** `git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/terminplaner ../dev-termine origin/test`, dort `git ls-files -z node_modules | xargs -0 git update-index --skip-worktree` und `npm install` (nie `git add -A`).
---
## Portal: Grundlagen
### Task 1: Schalter, Konfiguration, Schema
**Files:** Modify `server/config.js`, `test/support/schema.js`; Create `server/services/termine/schalter.js`, `server/services/termine/schema.js`, `scripts/termine-setup.mjs`, `test/termineSetup.test.js`, `test/termineSchalter.test.js`.
**Produces:**
- `config.collections.termineConfig|termineBookings|termineClaims|termineOutbox` (Env `APPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX`); `config.termine` laut Global Constraints.
- `schalter.js`: `termineModus()`, `termineFuerKunde(email) ⇒ boolean`, `termineAus() ⇒ boolean`.
- `schema.js` (reine Daten, **kein** Import von `config.js`): `TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, … }` exakt nach Spec 2 (mit `einstellung` statt `data`; `termineOutbox.icsMethod`); Attributtypen `string` (Größe), `integer`, `boolean`, `datetime`; Indizes `{ key, type: 'key', attributes }`; `TERMINE_DATETIMES = { collection: [keys] }`.
- `scripts/termine-setup.mjs` (Muster `analyse-setup.mjs`, `createAppwriteClient` von dort wiederverwenden; `createAttribute` nutzt `/attributes/<type>` für alle Typen): `main({ check, client, log })`, legt Fehlendes an, Indizes nur bei neuen Collections, `--check` ⇒ Exit 1 bei Fehlendem.
- `test/support/schema.js`: `schemaKeys(collection)` kennt auch `TERMINE_COLLECTIONS` (statischer Import aus `server/services/termine/schema.js`).
- [ ] Tests: Modus (leer/unbekannt ⇒ off; pilot + `KENSO.gri@gmail.com ` ⇒ true; `*`; Laufzeitänderung von `config.termine.mode` wirkt); Setup mit Fake-Client: 4 Collections + Attribute + Indizes, zweiter Lauf legt nichts an, `--check` legt nichts an; `schemaKeys('termineConfig')` enthält `einstellung`, nicht `data`; **Rundreise**: Nachbau mit `schemaKeys('termineConfig')`, `createDocument` + `updateDocument` mit `einstellung`/`version`/`updatedBy` und Zurücklesen.
- [ ] FAIL → implementieren → PASS (`node --test test/termineSetup.test.js test/termineSchalter.test.js`, dann `npm test`) → Commit „Terminplaner: Schalter, Konfiguration, Schema“
### Task 2: Appwrite-Wrapper und Nachbau
**Files:** Modify `server/services/appwriteAdmin.js`, `test/support/appwriteMock.js`, `test/support/testApp.js` (exportiert zusätzlich `send(method, path, opts)` = bisheriges privates `request`); Test `test/appwriteBulk.test.js`.
**Produces:**
- `Query.between`, `Query.greaterThanEqual`, `Query.lessThanEqual`, `Query.cursorAfter(id)`.
- `createDocuments(collectionId, documents: Array<{ $id, ...felder }>) ⇒ docs` (roher Body `{ documents }` über neue `adminFetch`-Option `raw: true`; 409 bei Duplikat; Spike-Ergebnis V1).
- `listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs` (`Query.limit(pageSize)`, `orderAsc('$id')`, `cursorAfter`, bis Seite < pageSize; über `max` ⇒ Fehler).
- Nachbau: `startAppwriteMock({ schema, datetimes = {}, defaultLimit = null })` – Bulk alles-oder-nichts (Duplikat ⇒ 409, unbekanntes Attribut je Dokument ⇒ 400, nichts geschrieben); neue Abfragen inkl. `cursorAfter`; Vergleich als Zeitpunkt nur, wenn beide Werte `^\d{4}-\d\d-\d\dT` sind; `datetimes`-Felder werden beim Schreiben auf `…+00:00` normalisiert, `''` ⇒ 400; `defaultLimit` (Termine-Tests: 25) gilt ohne `limit`-Abfrage wie Appwrite.
- [ ] Tests: Bulk 3 Dokumente; Bulk mit vorhandener ID ⇒ 409 und unverändert; `between` inklusiv; `listAllDocuments` über 3 Seiten; `defaultLimit` 25 liefert 25 von 30; Datum `…Z` kommt als `+00:00` zurück; alle bisherigen Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden, genauerer Nachbau“
### Task 3: Zeit und Feiertage
**Files:** Create `server/services/termine/zeit.js`, `server/services/termine/feiertage.js`, `test/termineZeit.test.js`; Modify `package.json`/`package-lock.json` (`npm install feiertagejs@1.5.1 --save-exact`).
**Produces:** `zeit.js` (nur `Intl`; `/root/reports/terminplaner-2026-10-02/spike-code/tz.mjs` nur als Vorlage für `offsetMs`): `TZ`; `zuUtc(tag, 'HH:MM') ⇒ Date | null` (null, wenn `ausUtc(Ergebnis)` nicht dieselbe Wanduhrzeit ergibt; `'24:00'` = Folgetag 00:00); `ausUtc(date) ⇒ { tag, uhrzeit }`; `wochentag(tag) ⇒ 1..7`; `plusTage(tag, n)`; `heute(now) ⇒ tag`; `monatInfo('YYYY-MM') ⇒ { label: 'Oktober 2026', ersterWochentag, anzahlTage, vorher, nachher }`; `datumLang(date) ⇒ 'Montag, 12.10.2026'`, `datumKurz(date) ⇒ '12.10.2026'`, `uhrzeit(date) ⇒ '10:30'`. `feiertage.js`: `BUNDESLAENDER` (16 Kürzel), `istFeiertag(tag, land)`.
- [ ] Tests: `zuUtc('2026-03-29','02:30') === null`; `zuUtc('2026-03-29','03:00')` = `01:00Z`; `zuUtc('2026-10-25','02:30')` = `00:30Z`; `ausUtc` rückwärts; `wochentag('2026-10-05') === 1`; `plusTage('2026-10-25', 1)`; `monatInfo('2026-02')` (28 Tage, So=7 als erster); Feiertage RP 2026-06-04 ja, BE 2026-06-04 nein, BE 2026-03-08 ja, SN 2026-11-18 ja, alle 2026-12-25 ja. Zusätzlich `docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zeit (Berlin, Umstellung) und Feiertage“
### Task 4: Einstellungen und Branchen-Vorlagen
**Files:** Create `server/services/termine/einstellungen.js`, `server/services/termine/vorlagen.js`, `test/termineEinstellungen.test.js`.
**Interfaces** (Datenform = Spec 2 `termineConfig.einstellung` + `betrieb.kleinunternehmer: boolean`, `betrieb.websiteUrl`):
- `neueId('r'|'s'|'a') ⇒ 'r_' + 8 [a-z0-9]`.
- `normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = { leistungen: {}, ressourcen: {} }, now }) ⇒ { einstellung } | { fehler: { [pfad]: text } }`: Vorgaben auffüllen; Steuerzeichen entfernen; Längen kürzen; Pflicht `betrieb.name` (1–80), `betrieb.email` (ein Postfach); URLs (`impressumUrl`, `datenschutzUrl`, `agbUrl`, `websiteUrl`) nur `https:`, ≤ 300, ohne Zugangsdaten; `farbe` `^#[0-9a-f]{6}$`; `anrede` sie|du; `bundesland` ∈ BUNDESLAENDER; IDs `^[rsa]_[a-z0-9]{8}$`, je Liste eindeutig, neue Einträge ohne ID ⇒ `neueId`; Obergrenzen 20 Ressourcen, 50 Leistungen, 100 Abwesenheiten, 200 Sonderöffnungen, `JSON.stringify ≤ 200 000`; Fenster `HH:MM` auf 5 min, aufsteigend, ohne Überlappung, Ende > Beginn (24:00 erlaubt); `dauer` 5–480, `pufferNach` 0–120 (je Vielfache von 5), `dauer + pufferNach ≤ 500`; `preisArt` fest|ab|keiner, `preisCent` 0–1 000 000; `buchungsart` sofort|anfrage; aktive Leistung braucht ≥ 1 aktive Ressource; `regeln.raster` ∈ {5,10,15,20,30,60}, `vorlaufMin` 0–20160, `horizontTage` 1–365, `absageBisStd` 0–336, `maxOffeneJeEmail` 1–10; `gastFelder` aus|optional|pflicht, Zusatzfelder max. 5 (`{ key: ^[a-z0-9_]{1,20}$, label, typ: text|auswahl, optionen ≤ 10, pflicht }`); `erinnerung.stundenVorher` ∈ {2,12,24,48}; `zahlung.modus` nur `vorort` (sonst Fehler „Online-Zahlung kommt bald“), `vorOrtArten` ⊆ bar|ec|kreditkarte|ueberweisung|paypal; `aufbewahrungTage` 14–365; Abwesenheiten `von ≤ bis`. Entfernen einer ID mit künftigen Terminen ⇒ Fehler „Für diese Leistung gibt es noch {n} künftige Termine. Deaktiviere sie stattdessen.“ (Ressource entsprechend); `storno.version` bei geändertem Text `YYYY-MM-DD-n` (aus `now`); `feedToken` nur aus `bisher` übernehmen, neue Ressourcen bekommen eines (32 Byte base64url), Eingabewerte werden ignoriert.
- `oeffentlicheEinstellung(einstellung, now) ⇒` Felder von `GET /config` außer `aktiv`, `hinweis`, `texte`, `formToken` (Form Task 16).
- `knopfText(leistung, { bestaetigen })`; `istVerbraucher(einstellung)`; `brauchtZustimmungVorzeitig(einstellung, leistung, start, now)`; `preisText(leistung) ⇒ '45,00 €' | 'ab 45,00 €' | ''`; `preisHinweis(einstellung) ⇒ 'inkl. USt' | 'kein Ausweis der USt nach § 19 UStG'`; `zahlungText(einstellung) ⇒ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'`.
- `vorlagen.js`: `VORLAGEN` (`salon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges`; je 2–5 Leistungen, Plan, `buchungsart` anfrage bei tattoo/handwerk/fotograf, werkstatt mit 2 Plätzen, Stornotext Variante 1 aus `recherche-recht-zahlung.md` 7.3 F), `vorlagenListe()`, `ausVorlage(key, betrieb, now) ⇒ einstellung` (valide).
- [ ] Tests: alle Vorlagen valide; Pflichtfelder; `javascript:alert(1)` als `impressumUrl` ⇒ Fehler am Pfad `betrieb.impressumUrl`; Fenster 09:07 ⇒ Fehler; Überlappung ⇒ Fehler am Pfad `ressourcen.0.plan.1`; Leistung ohne aktive Ressource; Löschen mit künftigen Terminen ⇒ Fehler mit Anzahl; Client-`feedToken` wird ignoriert; `online` ⇒ Fehler; Knopftexte; Zustimmung vorzeitig (3 Tage ⇒ true, 20 Tage ⇒ false, Unternehmer/kein Preis ⇒ false); `oeffentlicheEinstellung` ohne `feedToken`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einstellungen und Branchen-Vorlagen“
### Task 5: Verfügbarkeit
**Files:** Create `server/services/termine/slots.js`, `test/termineSlots.test.js`.
**Produces:** `belegungAus(buchungen, now, { ohneBuchung = null }) ⇒ [{ ressourceId, start, ende }]` (belegende Status, `ende` = `blockEndsAt`, ohne die genannte Buchung); `freieZeiten({ einstellung, leistungId, ressourceId /* id|'egal' */, tag, belegung, now, ohneRegeln = false }) ⇒ [{ uhrzeit, start, ressourceIds }]` (Sonderöffnung > Abwesenheit > Feiertag > Plan; Raster ab Fensterbeginn; `start + dauer + pufferNach` im Fenster; frei; `start ≥ now + vorlaufMin` und `tag ≤ heute + horizontTage` außer `ohneRegeln`; nie Vergangenheit); `freieTage({ …, monat }) ⇒ ['YYYY-MM-DD']`; `waehleRessource(ids, buchungenAmTag, einstellung)`; `blockEnde(start, leistung)`.
- [ ] Tests: Pause; Puffer am Pausenrand; Vorlauf; Horizont; Abwesenheit Ressource und Betrieb; Feiertag vs. Sonderöffnung; abgelaufene Haltung blockiert nicht; `egal` mit zwei Personen; `ohneBuchung` gibt die eigene Zeit frei; 2026-03-29 ohne `02:xx`; 2026-10-25 60-min-Termin um 02:00 dauert real 60 min; letzter Termin endet genau am Fensterende; Belegung, die am Vortag 22:00 beginnt und bis 06:00 läuft, blockiert 05:30.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Verfuegbarkeit“
### Task 6: Zellen, Tokens, Buchungsspeicher
**Files:** Create `server/services/termine/zellen.js`, `server/services/termine/token.js`, `server/services/termine/buchungen.js`, `test/termineZellen.test.js`.
**Consumes:** Task 2. **Produces:**
- `token.js`: `neuesToken() ⇒ { token, hash }`; `hashToken(t)`; `verwaltungsToken(bookingId) ⇒ token`; `buchungsNummer(bookingId) ⇒ 8 [A-Z0-9]`; `formToken(siteKey, now)`, `pruefeFormToken(siteKey, token, now) ⇒ 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig'` (Mindestalter `config.termine.formMinSekunden`).
- `zellen.js` (`spike-code/claims.mjs` nur als Vorlage für den Hash-Ansatz; Zellgröße/ID/Puffer wie hier): `zellenFuer(start, blockEnde) ⇒ Date[]`; `zellenId(featureId, ressourceId, zelle)`; `belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null }) ⇒ void` (Sperre `termine-res:<featureId>:<ressourceId>`, `waitMs 5000`; mit `ausser` zählen Zellen dieser Buchung als eigene: angelegt wird nur Neu − Eigen; bei 409 blockierende Zellen per ID lesen, Gültigkeit nach Global Constraints prüfen, ungültige löschen, genau ein zweiter Versuch, sonst Fehler `{ status: 409, code: 'SLOT_VERGEBEN' }`); `gibZellenFrei(bookingId, { behalte = [] })` (über `listAllDocuments`, behält die genannten Zellzeiten); `verwaisteZellen(now) ⇒ ids` (`bis < now − 1 Tag` oder Buchung nicht belegend).
- `buchungen.js` (nur Datenzugriff): `legeAn`, `hole`, `aendere` (setzt `updatedAt`), `imZeitraum(featureId, von, bis)` (`lessThan(startsAt, bis)` + `greaterThan(blockEndsAt, von)` + `featureId`, `listAllDocuments`), `perHash(feld, hash)`, `offeneFuerEmail(featureId, email, now)`, `perEmailUndNummer(featureId, email, nummer)`, `zaehleKuenftige(featureId, now) ⇒ { leistungen, ressourcen }`, `abgelaufeneHaltungen(now)`, `erinnerungsKandidaten(now)`, `zuErledigen(now)`, `vergangeneAnfragen(now)`, `loeschKandidaten(status, vor)`, `zaehleAnfragen(featureId)`.
- [ ] Tests (Nachbau, `defaultLimit 25`): zweite Buchung derselben Zeit ⇒ `SLOT_VERGEBEN`; 10 parallel ⇒ genau eine; abgelaufene Haltung und fehlende Buchung blockieren nicht; Verschieben 10:00→10:30 mit `ausser` gelingt, Fremdbuchung im neuen Bereich ⇒ `SLOT_VERGEBEN` und alte Zellen unverändert; `gibZellenFrei` löscht 40 Zellen (über 25er-Seiten); `imZeitraum` findet die Belegung vom Vortag; Verwaltungstoken stabil; Formular-Token zu jung/alt/gefälscht.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher“
### Task 7: Rechtstext-Bausteine für Gäste
**Files:** Create `docs/rechtliches/termine-widerrufsbelehrung-muster.md`, `docs/rechtliches/termine-datenschutz-absatz.md`, `docs/rechtliches/termine-hinweise.md`, `docs/rechtliches/termine-datenschutz-6c-entwurf.md`, `server/services/termine/rechtstexte.js`, `test/termineRechtstexte.test.js`.
**Consumes:** V2. **Produces:** Belehrung + Muster-Widerrufsformular für Dienstleistungen aus `docs/rechtliches/quellen/anlage-1.txt`/`anlage-2.txt` (Gestaltungshinweise angewendet: Dienstleistung, Betriebsdaten, Hinweis auf die Widerrufsfunktion), immer Sie-Form; Datenschutz-Absatz (Sie/Du) und Formular-Hinweis nach `recherche-recht.md` 11.1/11.2 mit den Löschfristen der Global Constraints; 6c-Entwurf (nicht einbauen). Alle Dateien mit Kopf „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“. `rechtstexte.js`: `RECHTSTEXTE_VERSION = '2026-10-02'`, `widerrufsbelehrung(betrieb) ⇒ string` (Text mit Zeilenumbrüchen), `formularHinweis(einstellung) ⇒ string`, `ZUSTIMMUNG_VORZEITIG(name)`, `ZUSTIMMUNG_STORNO`, `ZUSTIMMUNG_ERINNERUNG(stunden)`, `GESUNDHEIT_HINWEIS`.
- [ ] Tests: Belehrung enthält „Vertrag widerrufen“, die Betriebsanschrift, keinen offenen Platzhalter und die unveränderten Kernsätze der Quelle (Absätze aus `anlage-1.txt` wörtlich enthalten); Zustimmungstexte wortgleich zu den Global Constraints.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Rechtstext-Bausteine fuer Gaeste (Entwurf)“
### Task 8: Kalenderdatei, Gästeversand, Vorlagen-Engine
**Files:** Create `server/services/termine/ics.js`, `test/termineIcs.test.js`; Modify `server/services/mailTemplates.js` (`[GKT]\d{2}` in `templateIds` und im Kopfkommentar-Regex), `server/services/mailer.js`, `test/mailTemplates.test.js` (Ordner-Test-Regex `[GKT]`; Logo-Prüfung nur K/T; G-Vorlagen: kein `webklar-logo.png`).
**Produces:** `ics.js`: `icsTermin({ uid, sequenz, methode: 'REQUEST'|'CANCEL'|'PUBLISH', start, ende, titel, ort, beschreibung, organisator: { name, email }, teilnehmer: { name, email } | null, jetzt }) ⇒ string` (CRLF, Faltung 75 Oktette, Escaping `\ ; , \n`, CR/LF in Texten entfernt, `CN="…"` ohne `"` und Steuerzeichen, UTC, `RSVP=FALSE`, CANCEL mit `STATUS:CANCELLED`); `icsKalender(termine, { name, jetzt })` (`METHOD:PUBLISH`, `X-WR-CALNAME`, `X-PUBLISHED-TTL:PT15M`). `mailer.sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) ⇒ info` (wirft; `from: { name, address }`; `icalEvent: { method, filename: 'termin.ics', content }` nur mit `ics`; nie `bcc`).
- [ ] Tests: ICS-Zeilenlänge, `UID`/`SEQUENCE`, Escaping, CANCEL; `sendGuestMail` über `mailSink`: kein `bcc`, `replyTo`, From-Name = Betrieb, `sent[0].icalEvent.method === 'REQUEST'` und `content` enthält `BEGIN:VCALENDAR`, ohne `ics` kein `icalEvent`; bestehende Vorlagen-Tests grün (noch ohne neue Vorlagen).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen“
### Task 9: Mailvorlagen G01–G08, K20, K21
**Files:** Create `server/mail-templates/G01 Termin bestaetigen.html`, `G02 Termin bestaetigt.html`, `G03 Anfrage eingegangen.html`, `G04 Anfrage abgelehnt.html`, `G05 Erinnerung.html`, `G06 Termin abgesagt.html`, `G07 Termin verschoben.html`, `G08 Eingang Widerruf.html`, `K20 Neue Buchung.html`, `K21 Termin geaendert.html`; Modify `test/mailTemplates.test.js` (`EXPECTED` sortiert: G01–G08, K01–K21, T01–T18; Kommentar).
- Gerüst wie die vorhandenen Vorlagen: Kopfkommentar (`G01 · …`, `Betreff: {{betreff}}`, Platzhalter-, Block-Liste), `<title>{{betreff}}</title>`, `<!--[if mso]>`-Block, sonst keine Kommentare, jeder Platzhalter/Block deklariert und benutzt.
- G-Layout neutral: Kopfbalken in `{{farbe}}` mit `{{betrieb_name}}`, kein Logo; Fuß `{{betrieb_name}}`, `{{betrieb_adresse}}`, `{{betrieb_telefon}}`, `{{betrieb_email}}`, `{{impressum_url}}`, `{{datenschutz_url}}`, Satz „Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet.“; Blöcke `sie`/`du` für jeden Anredetext.
- Platzhalter: G01 `leistung, datum, zeit, link` + Block `zu_viele` (Text: höchstmögliche Zahl offener Termine, absagen oder anrufen `{{betrieb_telefon}}`) – **kein** `gast_name`; G02 `gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link, ort` + Blöcke `preis`, `person`, `storno` (`{{storno_text}}`), `widerruf` (`{{widerrufsbelehrung}}`, `{{widerruf_link}}`), `anfrage_angenommen`; G03 `gast_name, leistung, datum, zeit, link`; G04 `gast_name, leistung, datum, zeit` + Block `nachricht` (`{{nachricht}}`); G05 `gast_name, leistung, person, datum, zeit, link` + Block `person`; G06 `gast_name, leistung, datum, zeit, absage_von, grund` + Block `nachricht`; G07 `gast_name, leistung, alt_datum, alt_zeit, datum, zeit, link`; G08 `eingang, nummer` + Blöcke `zugeordnet`, `nach_frist` – **kein** `gast_name`. K20 (Du-Form, WEBklar-Layout mit Logo): `kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link` + Block `anfrage`; K21: wie K20 + `alt_datum, alt_zeit, eingang` + Blöcke `abgesagt`, `verschoben`, `widerrufen`, `nicht_zugeordnet`, `nach_frist`.
- [ ] Tests: Vorlagen-Suite grün inkl. neuer IDs (Titel = Betreff, ein Kommentar, keine Reste, Text ohne Tags); `renderMail('G02', …, { du: true })` enthält im Text kein „ Sie “/„Ihr “; G01/G08 haben keinen Platzhalter `gast_name`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mailvorlagen G01–G08, K20, K21“
### Task 10: Mail-Warteschlange
**Files:** Create `server/services/termine/outbox.js`, `test/termineOutbox.test.js`.
**Consumes:** Task 8. **Produces:** `einreihen({ featureId, bookingId, art, an, fromName, replyTo, template, values, blocks, ics, icsMethod }) ⇒ doc` (rendert sofort, speichert `wartet`, ruft `sendeEintrag(id)` ohne zu warten); `sendeEintrag(id)` (einziger Sendepfad: Pause/Budget prüfen, Sperre `termine-mail:<id>`, Fehlerklassen laut Global Constraints, nach Erfolg Inhalt leeren); `verarbeite(now)`; `warteAufVersand()` (für Tests: wartet auf laufende Sendeversuche); `zustellFehler(bookingIds) ⇒ Set`; `raeumeAuf(now)`; `pausiertBis()`.
- [ ] Tests: Budget 2/h ⇒ dritter wartet; 4xx ⇒ 1/5/30 min, dann `fehler`; 550 ⇒ sofort `fehler`; EAUTH ⇒ Pause, während der Pause **keine** Sendeversuche (Fake-Transport zählt), danach weiter; zwei `verarbeite` parallel ⇒ eine Mail; nach Erfolg sind `an/html/text/ics` leer; Aufräumen 7 Tage / 48 h.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mail-Warteschlange mit Stundenbudget“
## Portal: Ablauf
### Task 11: Kontext, Einstellungs-Speicher, Mail-Werte
**Files:** Create `server/services/termine/kontext.js`, `server/services/termine/mails.js`, `test/termineKontext.test.js`.
**Consumes:** Tasks 4, 6, 7, 9, 10. **Produces:**
- `kontext.js`: `kontextPerSiteKey(siteKey) ⇒ ctx|null` (Funktion `termine`, `active`, Modus ≠ off; eigene Abfrage bis Task 17, dann `findActiveBySiteKey(siteKey, 'termine')`; 30 s Speicher-Cache je siteKey); `kontextPerFeature(featureId) ⇒ ctx|null` (auch `disabled`); `ctx = { feature, project, einstellung, version }`; `ladeEinstellung(featureId)`; `speichereEinstellung(feature, einstellung, { version, by }) ⇒ { einstellung, version }` (Sperre `termine-cfg:<featureId>`, Dokument-ID = featureId, 409 bei Versionskonflikt, Cache leeren); `websiteUrl(ctx)` (`einstellung.betrieb.websiteUrl` oder `https://` + erster Host aus `analyseHosts(project)`).
- `mails.js`: `BETREFFE` (Tabelle je Vorlage × Anrede × Fall, Sie/Du, G05 „Erinnerung: Ihr Termin am {datum} um {zeit}“); `werteFuer(art, ctx, buchung, extra) ⇒ { values, blocks }` (Links `${config.publicBaseUrl}/termin.html#v=<token>` bzw. `#m=<verwaltungsToken>`; `widerruf_link` = Verwaltungslink); `anGast(art, ctx, buchung, extra)`, `anBetrieb(art, ctx, buchung, { block, extra })` über `einreihen` (ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH).
- [ ] Tests: Speichern/Laden mit Version, Konflikt ⇒ 409; Cache wird beim Speichern geleert; Betreff je Anrede (Du ohne „Sie“); G02-Werte mit Widerrufsblock nur bei Verbraucher; Links zeigen auf `config.publicBaseUrl`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte“
### Task 12: Gast: halten, Vorschau, bestätigen
**Files:** Create `server/services/termine/ablauf.js`, `test/termineAblaufGast.test.js`.
**Consumes:** Tasks 5, 6, 11. **Produces** (jede Zustandsänderung unter `withLocks(['termine-buchung:'+id])` mit frischem Lesen):
- `halte(ctx, eingabe, now) ⇒ { status: 'gehalten'|'zu_viele', email }` – Eingabe `{ leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen: { storno, vorzeitig } }`; Feldfehler (400, `err.fehler`); Slot neu berechnen; Ressource bei `egal` wählen; **erst** Buchung `gehalten` anlegen (`heldUntil = now + holdMinutes`, `nachweis` = Fassungen/Hashes der Texte, Zeitpunkte, Knopftext, Anrede; `verbraucher`, `preisCent`, `preisArt`, `buchungsart`, `zahlart 'vorort'`, `zahlStatus 'keine'`, `icsSequenz 0`, `verifyHash`), **dann** Zellen; `SLOT_VERGEBEN` ⇒ Buchung `verfallen`, Fehler 409 Feld `zeit`; G01; Grenze offene Termine ⇒ nichts anlegen, G01-Block `zu_viele`, Ergebnis `zu_viele`.
- `vorschau(token, now) ⇒ { art: 'bestaetigen'|'verwalten', status, termin: { nummer, leistung, person, datum, zeit, dauer, preis, zahlung, ort }, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl, websiteUrl }, gast: { name, email }, anrede, farbe, knopf, aktionen: { bestaetigen, absagen, verschieben, widerrufen }, hinweis }` (Token zuerst als Bestätigungs-, dann als Verwaltungstoken suchen; unbekannt/abgelaufen ⇒ 404).
- `bestaetige(token, now) ⇒ { status, schon? }` – `gehalten` + `heldUntil > now` ⇒ `sofort`: `bestaetigt` + `bestaetigtAt` + `manageHash` + G02 + K20; `anfrage`: `angefragt` + `manageHash` + G03 + K20-Block `anfrage`; schon bestätigt ⇒ `{ status, schon: true }`; abgelaufen ⇒ 410 `{ code: 'abgelaufen', betrieb: { name, telefon, websiteUrl } }`.
- [ ] Tests (Nachbau `defaultLimit 25`, `mailSink`, `warteAufVersand`): Halten ⇒ G01 ohne Gastname, Zellen; Bestätigen ⇒ G02 mit `icalEvent` und `#m=`-Link + K20 an den Betrieb, kein BCC; Anfrage ⇒ G03/K20-Block; zweiter Klick ⇒ `schon`; nach 31 min ⇒ 410; `email_normalisiert` (`' Max@GMail.com '` zählt gegen dieselbe Grenze); vierte offene Buchung ⇒ `zu_viele`, keine Buchung, G01-Block; Slot weg ⇒ 409, Buchung `verfallen`, keine Zellen; Zustimmung vorzeitig fehlt ⇒ 400 Feld `vorzeitig`; `vorschau` mit Verwaltungstoken liefert `gast`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Buchung halten und bestaetigen“
### Task 13: Gast: absagen, verschieben, widerrufen
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufVerwalten.test.js`.
**Produces:** `sageAbGast(token, now)` (bis `absageBisStd` vor Beginn, sonst 409 Feld `_` „Absage nur telefonisch: {telefon}“; Zellen frei, G06 CANCEL, K21 `abgesagt`); `verschiebeGast(token, { tag, zeit }, now)` (gleiche Frist; Belegung `ohneBuchung`; `belegeZellen({ …, ausser: id })`, dann `gibZellenFrei(id, { behalte: neueZellen })`; `icsSequenz+1`; G07 + K21 `verschoben`); `zeitenFuerToken(token, { tag | monat }, now)` (für die Gästeseite, `ohneBuchung`); `widerrufeGast(token, { name }, now) ⇒ { eingang: { datum, uhrzeit } }` und `widerrufeOhneLink(ctx, { name, email, nummer }, now) ⇒ { eingang }` (Treffer mit Widerrufsrecht ⇒ `widerrufenAt`, `abgesagt` von `gast`, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`; Treffer nach Frist ⇒ nur speichern + G08 `nach_frist` + K21 `nach_frist`; kein Treffer ⇒ G08 + K21 `nicht_zugeordnet`; Grenzen aus Global Constraints; Antwort immer gleich, Eingang in Berliner Zeit als Strings).
- [ ] Tests: Absage vor/nach Frist; Verschieben 10:00→10:30 (überlappend) gelingt, ICS Sequenz 1; Verschieben auf belegte Zeit ⇒ 409, alte Zellen unverändert; Widerruf 2 h vor Termin trotz abgelaufener Stornofrist; Widerruf ohne Treffer = gleiche Antwort + K21 `nicht_zugeordnet`; vierter G08 an dieselbe Adresse am Tag wird nicht gesendet; nicht angenommene Anfrage ⇒ kein Widerruf, nur Absage.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste“
### Task 14: Betrieb: Aktionen, manuelle Termine, Sperren
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufBetrieb.test.js`.
**Produces:** `aktion(ctx, bookingId, { aktion: 'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen'|'anonymisieren', grund, nachricht, tag, zeit }, now)` (annehmen ⇒ `bestaetigt`, `angenommenAt`, G02 Block `anfrage_angenommen`; ablehnen ⇒ `abgelehnt`, G04, Zellen frei; absagen ⇒ G06 mit `absage_von` = Betriebsname; verschieben ⇒ wie Gast, ohne Frist, `ohneRegeln`; nichterschienen nur nach Beginn; anonymisieren ⇒ Gastfelder leeren, Status bleibt); `manuell(ctx, eingabe, now)` (`quelle 'manuell'`, `bestaetigt`, `verbraucher false`, Länge ≤ 500 min, G02 nur mit `bestaetigungSenden`); `sperre(ctx, { ressource, tag, von, bis, titel }, now)` (`quelle 'sperre'`, `bestaetigt`, `leistungName` = Titel, Länge ≤ 500 min sonst 400 Feld `bis` „Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.“); `entsperre(ctx, bookingId)`.
- [ ] Tests: annehmen ⇒ G02 mit Verwaltungslink (gleicher Link wie in G03); ablehnen ⇒ Zellen frei; manuell ohne Haken ⇒ keine Gastmail; Sperre 09:00–17:30 ⇒ 400; Sperre 09:00–13:00 blockiert Slots; anonymisieren; `nichterschienen` vor Beginn ⇒ 409.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren“
### Task 15: Hintergrund-Jobs
**Files:** Create `server/services/termine/jobs.js`, `test/termineJobs.test.js`; Modify `server/index.js`.
**Produces:** jeder Job kehrt bei `termineAus()` sofort zurück (keine Abfrage); `collection_not_found` (404) ⇒ höchstens eine Warnung je Stunde. `minutenJob(now)` (abgelaufene Haltungen ⇒ `verfallen`, Zellen frei; `outbox.verarbeite`); `fuenfMinutenJob(now)` (Erinnerungen nach Spec 4: Marker zuerst, dann G05; `erledigt`; vergangene Anfragen ⇒ `abgelehnt`); `taeglicherJob(now)` (Löschfristen, Outbox aufräumen, verwaiste Zellen, `disabled`-Funktionen > 30 Tage: Buchungen, Zellen, Einstellung). In `server/index.js` wie die vorhandenen Jobs (`setTimeout(…).unref()` + `setInterval(…).unref()`, eigene Fehlerbehandlung): 60 s, 5 min, täglich (+180 s).
- [ ] Tests: Modus off ⇒ keine Appwrite-Anfrage; Verfall; Erinnerung genau einmal (zwei Läufe), nicht bei Buchung kürzer als `stundenVorher` vor Beginn, nachgeholt nach 20 min Jobausfall; `erledigt`; vergangene Anfrage ⇒ `abgelehnt`, Zellen frei; Löschfristen je Status; `verfall_und_bestaetigung_gleichzeitig` (Job und `bestaetige` parallel ⇒ ein Endzustand, keine G02 nach `verfallen`); fehlende Collection ⇒ eine Warnung.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Hintergrund-Jobs“
## Portal: Schnittstellen
### Task 16: Öffentliche Schnittstelle
**Files:** Create `server/services/termine/limits.js`, `server/routes/publicTermine.js`, `test/publicTermine.test.js`, `test/publicTermineRohling.test.js`; Modify `server/index.js` (nach `kundenansichtGuard`, vor dem globalen `express.json`), `server/middleware/kundenansichtGuard.js` (`PASS_PREFIXES` + `'/api/public/'`), `test/kundenansichtGuard.test.js`.
**Consumes:** Tasks 11–14, `analyseDomains(project)` aus `server/services/analyse.js`.
**Routen** (`/api/public/termine/v1`; feste Pfade vor `/:siteKey`):
- `GET /:siteKey/config` ⇒ `{ aktiv: true, heute, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl }, anrede, farbe, leistungen: [{ id, name, beschreibung, dauer, preis, preisArt, buchungsart, knopf, verbraucher, ressourcen }], ressourcen: [{ id, name, typ }], personWaehlbar, regeln: { horizontTage, absageBisStd }, zahlung: { modus: 'vorort', text }, preisHinweis, storno: { text, version } | null, widerruf, erinnerung: { anbieten, stundenVorher }, gastFelder, hinweis, texte: { storno, erinnerung, vorzeitig, gesundheit }, formToken }` (`public, max-age=60`, ohne ETag).
- `GET /:siteKey/tage?leistung&ressource&monat` ⇒ `{ tage, monat: monatInfo }` (`max-age=15`); `GET /:siteKey/zeiten?leistung&ressource&tag` ⇒ `{ zeiten: ['09:00'], datumLang, vorzeitigNoetig }` (`no-store`; `vorzeitigNoetig` serverseitig für diesen Tag).
- `POST /:siteKey/buchungen` (Eingabe von `halte` + `formToken`, `kontakt_url`) ⇒ 201 `{ ok: true, email: 'm***@gmail.com', minuten: 30 }`.
- `POST /:siteKey/widerruf` `{ name, email, nummer }` ⇒ `{ ok: true, eingang: { datum, uhrzeit } }`.
- `POST /termin/vorschau|bestaetigen|absagen` `{ token }`, `POST /termin/verschieben` `{ token, tag, zeit }`, `POST /termin/widerruf` `{ token, name }`, `POST /termin/tage` `{ token, monat }`, `POST /termin/zeiten` `{ token, tag }`.
- `GET /kalender/:siteKey/:feedToken.ics` (konstantzeitiger Vergleich mit den `feedToken` der Ressourcen; falsch ⇒ 404 ohne Hinweis; `text/calendar; charset=utf-8`, `max-age=300`; bestätigte Termine + Sperren der Ressource, −7 bis +90 Tage).
- CORS: GET `*`; POST/OPTIONS erlaubt, wenn Origin-Host ∈ `analyseDomains(project)` ∪ {Host von `config.publicBaseUrl`} oder `new URL(origin).host === req.get('host')` (gleiche Herkunft); für `/termin/*` nur die beiden letzten; dann `Access-Control-Allow-Origin: <origin>`, `Vary: Origin`; sonst 403 `{ fehler: { _: 'Nicht erlaubt' } }`; Preflight 204 (`GET, POST, OPTIONS`, `Content-Type`, `Max-Age 86400`). Router-Parser + Fehlermiddleware laut Global Constraints; `/tage` und `/zeiten` 10 s Speicher-Cache je (siteKey, Parameter).
- [ ] `publicTermine.test.js` (startTestApp + Nachbau + `mailSink`; Origin/`X-Forwarded-For` je Test, `resetTermineLimitsForTests()`, `config.termine.formMinSekunden = 0`): rekursiv keine verbotenen IDs; unbekannt/off/disabled ⇒ `{aktiv:false}` (GET und POST); fremde Origin ⇒ 403, gleiche Herkunft und `liveDomain`-Origin erlaubt; Preflight (`send('OPTIONS', …)`); Honeypot und `zu_schnell` (mit `formMinSekunden = 3`) ⇒ 201 ohne Buchung; `abgelaufen` ⇒ 400; 11. Buchung je IP ⇒ 429, IPv6 /64 geteilt; Knopf je Fall in `config`; Ablauf halten → vorschau → bestätigen → verschieben → absagen über HTTP; Widerruf mit/ohne Treffer gleiche Antwort; Kalender mit falschem Token 404, richtigem enthält den bestätigten, nicht den gehaltenen Termin; `failNext` ⇒ 503 ohne Rohtext; Kundenansicht-Cookie stört öffentliche POSTs nicht.
- [ ] `publicTermineRohling.test.js` (nacktes `express()` nur mit dem Router): 20 KB ohne `content-length` (chunked) ⇒ 413; kaputtes JSON ⇒ 400; Antwort ohne Parser-Rohtext. Reihenfolge-Test: in `server/index.js` steht `app.use(kundenansichtGuard)` vor `'/api/public/termine/v1'` vor `app.use(express.json(`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz“
### Task 17: Funktionen verallgemeinern, Team-Route, Kundenansicht
**Files:** Modify `server/services/siteFeatures.js`, `server/routes/admin/funktionenAdmin.js`, `server/routes/funktionen.js`, `server/middleware/kundenansichtGuard.js`, `server/services/customerViewLog.js`, `server/services/customerAdmin.js`, `server/services/termine/kontext.js`; Tests `test/funktionenRoutes.test.js`, `test/siteFeatures.test.js`, `test/kundenansichtGuard.test.js` (Erlaubnisliste exakt; `MOUNTS` + `/api/termine` erst in Task 18), `test/customerViewLog.test.js` (zehn Aktionen), neu `test/funktionenTermine.test.js`.
**Produces:** `FEATURE_KEYS = ['news','termine']`; `FEATURE_LABELS = { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' } }`; `findActiveBySiteKey(siteKey, featureKey = 'news')`; `activateClosedTickets` unverändert (TP2). Team: `POST /api/team/funktionen/:customerId/:key` (`key` ∉ FEATURE_KEYS ⇒ 404; Meldung „Für dieses Projekt gibt es noch {keine}“); `adminView` lädt die Funktionsdokumente des Kunden einmal. `GET /api/funktionen` + `termine: { verfuegbar: termineFuerKunde(req.session.email) }`. `KUNDENANSICHT_ALLOWED` + `['POST', /^\/api\/termine\/.+$/]`, `['PATCH', /^\/api\/termine\/einstellungen\/?$/]`, `['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/]`; `VIEW_ACTIONS` + `termin_einstellungen`, `termin_aktion`, `termin_manuell`. `deleteCustomerWithPortalAccess` schaltet Terminplaner-Funktionen des Kunden ab (Notiz „Kunde gelöscht“). `kontext.js` nutzt nun `findActiveBySiteKey(…, 'termine')`.
- [ ] Tests: Team aktiviert `termine` für Projekt von c1, News unverändert; `/:customerId/kalender` ⇒ 404; Meldungen grammatisch; `GET /api/funktionen` mit/ohne Pilot-E-Mail; Erlaubnisliste exakt (inkl. Negativfall `POST /api/termineX`); Kunde löschen ⇒ `disabled`; bisherige Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)“
### Task 18: Kunden-Schnittstelle `/api/termine`
**Files:** Create `server/routes/termine.js`, `test/termineRoutes.test.js`; Modify `server/index.js` (`app.use('/api/termine', termineRoutes)`), `test/kundenansichtGuard.test.js` (`MOUNTS` + `['/api/termine', termineRoutes]`).
**Consumes:** Tasks 4, 6, 11–14, 17. Alle Routen: `requireSession`; `projectId` (Query/Body); `assertOwnProject`; `termineFuerKunde(session.email)` sonst 409 „Der Terminplaner ist noch nicht verfügbar.“; Funktion `off`/fehlt ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; `disabled` ⇒ nur Lesen + Export; Schreiblimit 120/h je Kunde; Protokoll: PATCH einstellungen, POST vorlage, POST feed ⇒ `termin_einstellungen`; POST aktion, DELETE ⇒ `termin_aktion`; POST buchungen ⇒ `termin_manuell`.
**Routen:** `GET /einstellungen?projectId` ⇒ `{ state, aktivSeit, einstellung | null, version, vorlagen, feeds: { [ressourceId]: url }, siteKey, einbau: { snippet }, anfragenOffen }`; `POST /einstellungen/vorlage` `{ projectId, vorlage }` (nur ohne Einstellung, sonst 409; Betrieb vorbefüllt aus `analysePrivacyData(customer)` (Name/Firma, Anschrift, E-Mail) + `phone`); `PATCH /einstellungen` `{ projectId, version, einstellung }` ⇒ `{ einstellung, version }` | 400 `{ fehler }` | 409; `GET /buchungen?projectId&von&bis&status=a,b` (Zeitraum ≤ 62 Tage) ⇒ `{ buchungen: [{ id, nummer, status, quelle, startsAt, endsAt, datum, zeit, leistungId, leistung, dauer, titel, person, ressourceId, gastName, gastEmail, gastTelefon, gastNachricht, erinnerung, zustellFehler, angenommenAt, widerrufenAt, abgesagtVon, absageGrund }] }`; `GET /zeiten?projectId&leistung&ressource&tag[&ohne=bookingId]` (`ohneRegeln`); `POST /buchungen` `{ projectId, art: 'manuell'|'sperre', … }` ⇒ 201; `POST /buchungen/:id/aktion`; `DELETE /buchungen/:id?projectId` (nur Sperren); `POST /ressourcen/:id/feed` ⇒ `{ url }`; `GET /export.csv?projectId` (`text/csv; charset=utf-8`, BOM, `;`, Kopf `Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht`, Zellen mit `= + - @` am Anfang ⇒ `'` voran).
- [ ] Tests (`send` für PATCH/DELETE): fremdes Projekt 403; ohne Pilot 409; Vorlage zweimal ⇒ 409; PATCH alte Version ⇒ 409; Löschen mit künftigem Termin ⇒ 400 mit Anzahl; Aktion annehmen ⇒ G02; Kundenansicht darf PATCH/DELETE (mit Header) und es wird protokolliert; Guard-Schleife „jede Schreib-Route außerhalb der Liste ⇒ 403“ grün mit dem neuen Router; `disabled` ⇒ PATCH 409, Export 200; CSV entschärft.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kunden-Schnittstelle“
## Portal: Oberflächen
**UI-Testserver (Tasks 19–21):** `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` + `startFakeStripe()` + `useTestEnv({ appwriteUrl, stripeUrl })`, dann dynamische Imports; `startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, '/api/funktionen': funktionenRoutes, '/': express.static('public') })`; danach `config.publicBaseUrl = app.url`, `config.termine.mode = 'pilot'`, `config.termine.pilotEmails = ['*']`, `config.termine.formMinSekunden = 0`; Seed: Projekt + aktive Funktion + Einstellung aus Vorlage `salon` mit Plan Mo–So 00:00–24:00, `feiertageGeschlossen false`, `vorlaufMin 0`, `maxOffeneJeEmail 10`; `mailSink`. Screenshots nach `/root/reports/terminplaner-ui/`.
### Task 19: Einbau-Skript, Demo, Einbauanleitung
**Files:** Create `public/embed/termine.js`, `public/embed/termine-demo.html`, `docs/funktionen/termine-einbau.md`, `test/ui/termine-widget.ui.mjs`, `test/termineEmbedStatic.test.js`.
**Consumes:** Task 16 (Antwortformen). **Produces:** Einbau nach Spec 7 (`data-webklar-termine`, `data-api`, `data-modus inline|knopf`, `data-knopf`, `data-leistung`); Klassen `wkt-*`; `<style id="wkt-style">` einmal, `@layer wkt`; CSS-Variablen `--wkt-akzent, --wkt-akzent-text, --wkt-hintergrund, --wkt-text, --wkt-rand, --wkt-radius, --wkt-schrift, --wkt-schrift-titel`; Schritte Spec 4; Monatsraster aus `monat` (APG: Pfeile, Pos1/Ende, Bild↑/↓, `aria-selected`, `aria-disabled`), Zeiten als Knöpfe; `aria-live="polite"`; Texte Sie/Du nach `anrede`, Haken-Texte aus `texte`, Knopf aus `leistungen[].knopf`; Honeypot `kontakt_url` (unsichtbar, `autocomplete="off"`, `tabindex="-1"`, `aria-hidden`); `config` neu laden, wenn älter als 100 min; Fuß „Vertrag widerrufen“ (Formular → `POST /widerruf`), „Datenschutz“, „Terminbuchung über WEBklar“; Knopfmodus lädt beim Klick; Fehler ⇒ Ausweichtext bzw. „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“. Einbauanleitung: Snippet, Variablen, drei Stilbeispiele, Datenschutz-Absatz (Task 7), Abnahme-Checkliste, API-Kurzreferenz.
- [ ] `termineEmbedStatic.test.js`: Quelltext ohne `localStorage`, `sessionStorage`, `document.cookie`, `Date`, `getDay`, `toLocale`, `Intl.`; `innerHTML` nur mit Konstanten.
- [ ] Playwright (UI-Testserver): inline bis „Fast geschafft“ + G01 im Sink; Knopfmodus mit `<dialog>`; nur Tastatur; 390 px; `timezoneId: 'America/New_York'` zeigt dieselben Uhrzeiten; Anrede Du; zwei Widgets; Server gestoppt ⇒ Ausweichtext.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einbau-Skript, Demo und Einbauanleitung“
### Task 20: Gästeseiten
**Files:** Create `public/termin.html`, `public/termin.js`, `public/termin.css`, `test/ui/termin-seiten.ui.mjs`.
**Produces:** liest `#v=`/`#m=`, entfernt den Hash per `history.replaceState`, ruft `/termin/vorschau`; Ansichten Bestätigen (Zusammenfassung + `knopf`), Bestätigt/Angefragt, Abgelaufen (Link `websiteUrl`, Telefon), Verwalten (Absagen mit Rückfrage in der Seite, Verschieben über `/termin/tage|zeiten`, „Vertrag widerrufen“ → Formular nach § 356a mit vorbefülltem Namen/Buchungsnummer/E-Mail, Knopf „Widerruf bestätigen“, Eingang mit `eingang.datum`/`uhrzeit`); Kopf in `farbe`; Fuß: Impressum/Datenschutz des Betriebs + „Impressum WEBklar“ (`/impressum.html`); `noindex`; keine Cookies; Sie/Du.
- [ ] Playwright: G01-Link aus dem Sink öffnen ⇒ bestätigen ⇒ G02 im Sink; zweiter Aufruf „bereits bestätigt“; abgelaufen; absagen; verschieben; widerrufen (`timezoneId: 'America/New_York'` zeigt Berliner Eingangszeit); 390 px.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen“
### Task 21a: Portal-Ansicht – Gerüst, Kachel, Termine
**Files:** Create `public/termine.js`, `public/termine.css`, `public/termine-view.js`, `test/termineView.test.js`, `test/ui/termine-portal.ui.mjs`; Modify `public/funktionen.js`, `public/app.js` (`VIEW_HASHES.termine = 'termine'`, `viewRefreshers.termine`, `APP_PARENT.termine = 'funktionen'`, `FEATURE_LABELS.termine = 'Terminplaner'`), `public/dashboard.html` (`<section class="kb-view" id="view-termine"><header class="kb-head"><h1>Terminplaner</h1><p>…</p></header><div id="termine-body">…`, Stylesheet), `test/funktionenTiles.test.js`.
**Produces:** Kachel (`termine?.verfuegbar` fehlt/false ⇒ „Kommt bald“; `project.features?.termine || { state: 'off' }`; *Aus* „Wir schalten den Terminplaner für dich frei.“ + Chat / *Aktiv* „Termine öffnen“ + „{n} Anfragen warten“ aus `anfragenOffen` / *Abgeschaltet*); `termine-view.js` (rein): `fensterParsen('09:00–12:30, 13:30–18:00')`, `fensterText`, `statusLabel`, `wocheVon(tag)`, `gruppiereNachTag(buchungen)`; `termine.js` `initTermine(root, { projectId })`: Projektauswahl, Vorlagenauswahl ohne Einstellung, Reiter-Gerüst, Reiter **Termine** (Tag/Woche/Monat mit `von/bis`, Filter → Statuslisten, Aktionen mit Rückfragen in der Seite, „+ Termin eintragen“, „Zeit sperren“ mit 500-Minuten-Hinweis, Hinweis „Bestätigung nicht zugestellt“), `disabled` ⇒ nur Lesen.
- [ ] Tests reine Helfer; Kachelzustände; Playwright (Fixture `page.route` und ein Lauf gegen den UI-Testserver): Vorlage wählen, Liste mit allen Status, Anfrage annehmen, App-Ansicht 390 px als Unterseite von Funktionen (Titel „Terminplaner“).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht, Kachel, Termine“
### Task 21b: Portal-Ansicht – Leistungen, Team & Plätze, Einstellungen
**Files:** Modify `public/termine.js`, `public/termine.css`, `test/ui/termine-portal.ui.mjs`.
**Produces:** Reiter **Leistungen** (Liste, Reihenfolge, Felder Task 4, Gesundheits-Hinweis), **Team & Plätze** (Wochenplan je Tag mit `fensterParsen`, Leistungszuordnung, Abwesenheiten, Bundesland, Sonderöffnungen, Kalender-Abo-Link kopieren/neu), **Einstellungen** (Spec 9 Punkt 4; Zahlung „online“ ausgegraut „kommt bald“; Hinweis Widerruf schlägt Stornofrist; Stornotext-Muster gekennzeichnet; Export-Link); Speichern mit `version`, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld.
- [ ] Playwright: Leistung anlegen, Wochenplan ändern, Speichern, Konflikt (zweiter Tab), Feldfehler (`javascript:`-URL), Feed-Link neu erzeugen.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen“
## WOMS
### Task 22: Funktionen-Reiter für mehrere Funktionen
**Repo:** `/home/webklar/apps/ticket.webklar.com/dev-termine` (aus V3; existiert er nicht, Schritte aus V3 zuerst ausführen).
**Files:** Create `src/lib/funktionenView.js`, `tests/unit/funktionenView.test.mjs`; Modify `src/lib/teamApi.js` (`setFeature(customerId, key, { projectId, action, note })`; `setNewsFeature` als Wrapper), `src/components/customer/CustomerFunktionen.jsx`, `src/lib/viewLog.js` (+ `termin_einstellungen: 'Terminplaner-Einstellungen geändert'`, `termin_aktion: 'Termin bearbeitet'`, `termin_manuell: 'Termin eingetragen'`), `tests/unit/viewLog.test.mjs`.
**Produces:** `FUNKTIONEN = [{ key: 'news', label: 'News', bestaetigen: { activate: 'News ohne Zahlung aktivieren?', disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.' } }, { key: 'termine', label: 'Terminplaner', bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.' } }]`, `ZUSTAND` (bisheriges `STATE`), `funktionOf(project, key)`, `aktionen(state)`, `mergeFunktion(projects, projectId, key, aktualisiert)`; Reiter zeigt je Projekt eine Zeile je Funktion, Ticketnummer als Link `/tickets?woid=`, Antwort übernimmt nur `features[key]`.
- [ ] Tests (Register, Aktionen, Merge überschreibt die andere Funktion nicht); `npm test`; `nice npx vite build` (vorher `free -m`).
- [ ] FAIL → implementieren → PASS → Commit „Funktionen-Reiter: News und Terminplaner je Projekt“
---
## Abschluss
- [ ] Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und Global Constraints; Critical/Important beheben.
- [ ] `npm test` (Node 24); reine Termine-Tests unter Node 20 (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js`).
- [ ] Ledger nach `/root/reports/terminplaner-tp1/`.
## Livegang (je Schritt Freigabe des Nutzers; der Nutzer pusht `main`)
1. **L1 Portal:** auf aktuelles `origin/main` rebasen, `npm test`, Push `feature/terminplaner:main` (Deploy installiert `feiertagejs`). Schalter `off` ⇒ keine Jobs, nichts sichtbar. Prüfen: `/api/public/termine/v1/aaaaaaaaaaaaaaaa/config` ⇒ `{"aktiv":false}`, `/api/termine/einstellungen` ohne Login 401, Log ohne Fehler.
2. **L2 Schema:** `docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs --check`, dann ohne `--check`; erneut `--check` ⇒ nichts fehlt.
3. **L3 Schalter:** Compose (`kundenbereich-server`): `TERMINE_ENABLED=${TERMINE_ENABLED:-off}`, `TERMINE_PILOT_EMAILS=${TERMINE_PILOT_EMAILS:-}`; `/home/webklar/apps/.env` (Sicherung `.bak-termine`): `TERMINE_ENABLED=pilot`, `TERMINE_PILOT_EMAILS=kenso.gri@gmail.com`; `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server` (nie `preview-portal`).
4. **L4 WOMS:** Push `feature/terminplaner:test`; Bundle enthält „Terminplaner“.
5. **L5 Pilot:** WOMS Kunde „test“ → Funktionen → Terminplaner für „Test-Website“ aktivieren; Portal (Login des Testkunden): Vorlage, Leistungen, Zeiten, Betriebs-E-Mail kenso.gri@gmail.com; `WEBklar/test-website`: Seite „Termin buchen“ mit Widget + Datenschutz-Absatz-Entwurf, Push ⇒ Deploy; Testbuchungen: bestätigen, Anfrage annehmen/ablehnen, Erinnerung (Termin knapp über der Erinnerungszeit), absagen, verschieben, widerrufen, Kalender-Abo in Google.
6. **L6:** Nutzertest; Testbuchungen nach Wunsch löschen.