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

248 lines
49 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), 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 (playwright-core aus dem npx-Cache), 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/` (Zeit-Helfer und Zellen als Vorlage: `spike-code/tz.mjs`, `spike-code/claims.mjs`, `spike-code/ics.mjs`).
**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: `git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/terminplaner ../dev-termine origin/test`, node_modules skip-worktree + eigenes `npm install`.
## Global Constraints
- Funktionsschlüssel `termine`; Schalter `TERMINE_ENABLED` = `off` (Vorgabe) | `pilot` | `on`; `TERMINE_PILOT_EMAILS` Komma-Liste (klein, getrimmt, `*` = alle). In TP1 verhält sich `on` wie `pilot` ohne E-Mail-Filter; Bestellen gibt es nicht.
- Dateinamen: nie `server/services/appointments.js` (Test `adminPanelGone`). Neue Dateien unter `server/services/termine/`.
- Zeiten: Rechnen in Wanduhr `Europe/Berlin`, speichern als UTC (`toISOString()`); Appwrite liefert `+00:00` – Datumswerte immer mit `new Date()` lesen. Nicht existierende Wanduhrzeiten (Umstellung März) werden ausgelassen, doppelte (Oktober) nehmen das erste Vorkommen. `deadlines.berlinTime` nicht verwenden.
- Zellen à **5 Minuten**; alle Zellen einer Buchung in **einem** Bulk-Create (max. 100 ⇒ Belegung bis 500 Minuten), Zell-ID `c_` + 34 Hex von `sha1(featureId|ressourceId|zelleIso)`.
- Haltezeit **30 Minuten**; Tokens 32 Zufallsbytes base64url, gespeichert nur als SHA-256-Hex; Bestätigungstoken einmal wirksam; Verwaltungstoken gültig bis Terminende + 1 Tag. Zustandsänderungen nur per POST.
- Status: `gehalten`, `angefragt`, `bestaetigt`, `erledigt`, `nichterschienen`, `abgesagt`, `abgelehnt`, `verfallen`. Belegend: `gehalten` (nur solange `heldUntil > now`), `angefragt`, `bestaetigt`.
- Knopftexte exakt: verbindlich + Preis ⇒ „Zahlungspflichtig buchen“ (Bestätigungsseite „Zahlungspflichtig bestätigen“); verbindlich ohne Preis ⇒ „Termin buchen“ („Termin bestätigen“); Anfrage ⇒ „Termin anfragen“ („Anfrage absenden“).
- Zustimmung vorzeitiger Beginn (Pflicht nur bei: verbindlich, Preis, `recht.gaesteSind` ≠ `unternehmer`, nicht `recht.freizeit`, Terminbeginn < Buchung + 14 Tage): „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.“
- Widerruf möglich ⇔ Buchung verbindlich mit `verbraucher === true` (gespeichert beim Halten: `recht.gaesteSind !== 'unternehmer' && !recht.freizeit`), Status nicht `abgelehnt`/`verfallen`, `now < bestaetigtAt + 14 Tage`, Termin noch nicht vorbei.
- Öffentliche Antworten enthalten nie `customerId`, `projectId`, `featureId`, Appwrite-/Ticket-/Stripe-IDs; nur Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen). Unbekannter/inaktiver siteKey ⇒ `200 { aktiv: false }`.
- Schutz: Body > 16 384 Byte ⇒ 413 (vor dem Parsen über `content-length`); Honeypot-Feld `website`; Formular-Token HMAC, Alter 3 s – 2 h; je IP (IPv6 je /64) 10 Buchungen / 10 min und 120 GET / min; je Funktion 60 Haltungen / h; je E-Mail+Funktion `regeln.maxOffeneJeEmail` (Vorgabe 3) offene Termine und 5 Bestätigungsmails / Tag; Widerruf 10 / h je IP. Speicher-Maps mit Obergrenze 10 000 Schlüssel. Treffer bei Honeypot/Zeitfalle ⇒ normale Erfolgsantwort, nichts gespeichert.
- Mails: Absender `"<betrieb.name>" <SMTP_FROM>` (Gast) bzw. `"WEBklar" <SMTP_FROM>` (K20/K21), `replyTo` = Betriebs-E-Mail (Gast) bzw. keiner, **nie BCC**, kein Tracking. Alles über `termineOutbox`: höchstens 200 / h, Wiederholung nach 1, 5, 30 min, dann `fehler`; SMTP-Anmeldefehler (`EAUTH`/535) ⇒ 15 min Pause; Einträge 7 Tage nach `gesendet`/`fehler` löschen.
- Löschfristen: `verfallen` 48 h nach `heldUntil`; `abgesagt`/`abgelehnt` 30 Tage; `erledigt`/`nichterschienen` `aufbewahrungTage` (Vorgabe 90, 14–365) nach Terminende; Funktion `disabled` ⇒ alles 30 Tage nach `disabledAt`.
- Portaltexte Du-Form, kein `confirm()`; Gästetexte Sie/Du nach `anrede`. Kundentext nur über `textContent`/`esc()`.
- Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie `git add -A` (node_modules).
## Review Focus
1. Gast-Browser in anderer Zeitzone (z. B. New York) sieht dieselben Uhrzeiten wie Berlin; Widget rechnet nie mit `Date` des Browsers. → Task 14 (Playwright mit `timezoneId: 'America/New_York'`)
2. E-Mail mit Großbuchstaben/Leerzeichen (` Max@GMail.com `) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 9 (`email_normalisiert`)
3. Betrieb löscht eine Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, bestehende Termine bleiben gültig. → Task 4 + 13
4. Gast klickt den Bestätigungslink genau, während der Minutenjob die Haltung verfallen lässt ⇒ genau ein Ergebnis (Sperre je Buchung). → Task 10 (`verfall_und_bestaetigung_gleichzeitig`)
5. Zwei Widgets auf einer Seite (zwei Leistungen per `data-leistung`) stören sich nicht (IDs, Stile einmal). → Task 14
---
## Vorab (Controller, kein Subagent): Spike Bulk-Create
- [ ] Nutzer um Freigabe bitten. Dann im Container (`docker exec -w /app webklar-kundenbereich-server node …`, Skript im Scratchpad, nicht im Repo): Wegwerf-Collection `termineSpike` (Attribute `bookingId` 64), 50 parallele `POST …/collections/termineSpike/documents` mit `{ documents: [{ $id, bookingId }, …] }` (je 20 Zellen, Überlappungen) ⇒ erwartet genau 1× 201 je überlappende Gruppe, Rest 409 `document_already_exists`, keine Teil-Schreibungen (Liste prüfen). Collection löschen.
- [ ] Ergebnis ins Ledger. Positiv ⇒ Task 2 wie beschrieben. Negativ ⇒ Task 2 nutzt die Transactions-API (`POST /databases/transactions`, Operationen, `PATCH {commit:true}`, 409 `transaction_conflict`) hinter derselben Funktion `createDocuments`.
---
## Portal: Grundlagen
### Task 1: Schalter, Konfiguration, Schema-Skript
**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`, Vorgaben = Namen).
- `config.termine = { mode, pilotEmails: string[], holdMinutes: 30, zelleMin: 5, mailsProStunde: 200 }` (`mode` nur `off|pilot|on`, sonst `off`).
- `schalter.js`: `termineModus() ⇒ 'off'|'pilot'|'on'`; `termineFuerKunde(email) ⇒ boolean` (off ⇒ false; on ⇒ true; pilot ⇒ E-Mail klein/getrimmt in Liste oder Liste enthält `*`); `termineOeffentlichAktiv() ⇒ mode !== 'off'`.
- `server/services/termine/schema.js` (reine Daten, **ohne** Import von `config.js`, damit Tests es vor `useTestEnv()` laden dürfen): `TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, termineBookings: …, termineClaims: …, termineOutbox: … }` exakt nach Spec 2 (Typen `string` mit Größe, `integer`, `boolean`, `datetime`; Text-Felder als `string` mit großer Größe; Indizes `{ key, type:'key', attributes:[…] }`).
- `scripts/termine-setup.mjs` im Muster von `analyse-setup.mjs`: `main({ check, client, log })` legt fehlende Collections/Attribute an (Collection-ID aus `config.collections`), Indizes nur bei neuen Collections, `--check` ⇒ Exit 1 bei Fehlendem; `createAppwriteClient` aus `analyse-setup.mjs` wiederverwenden (`createAttribute` geht über `/attributes/<type>` für alle Typen; Body `integer` ⇒ `{ key, required:false }`, `datetime` ⇒ `{ key, required:false }`).
- `test/support/schema.js`: `schemaKeys(collection)` findet auch Collections aus `TERMINE_COLLECTIONS` (statischer Import von `server/services/termine/schema.js`).
- [ ] Tests: Modus-Auswertung (leer/unbekannt ⇒ off; pilot mit `KENSO.gri@gmail.com ` ⇒ true für `kenso.gri@gmail.com`; `*`); Setup mit Fake-Client legt 4 Collections + alle Attribute + Indizes an, zweiter Lauf legt nichts an, `--check` legt nichts an und meldet Fehlendes; `schemaKeys('termineBookings')` enthält `startsAt`, `manageHash`, `nachweis`.
- [ ] FAIL → implementieren → PASS (`node --test test/termineSetup.test.js test/termineSchalter.test.js`, danach `npm test`) → Commit „Terminplaner: Schalter, Konfiguration, Schema-Skript“
### Task 2: Appwrite-Wrapper und Nachbau: Bulk-Create, Bereichsabfragen, Seiten
**Files:** Modify `server/services/appwriteAdmin.js`, `test/support/appwriteMock.js`; Test `test/appwriteBulk.test.js`.
**Produces:**
- `Query.between(attr, a, b)`, `Query.greaterThanEqual`, `Query.lessThanEqual`, `Query.cursorAfter(id)` (`{ method:'cursorAfter', values:[id] }`).
- `createDocuments(collectionId, documents: Array<{$id, ...fields}>) ⇒ docs` – `POST …/documents` mit rohem Body `{ documents }` (über `adminFetch` mit neuer Option `raw: true`; heute ist `raw` nur für `/users`), wirft wie `adminFetch` (409 bei Duplikat). Ergebnis des Spikes beachten.
- `listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs` – lädt seitenweise mit `cursorAfter` (letzte `$id`), bis eine Seite < pageSize; über `max` ⇒ Fehler „zu viele Einträge“.
- Nachbau: `POST` mit `documents`-Array = alles oder nichts (eine vorhandene ID ⇒ 409, nichts geschrieben; ungültige ID ⇒ 400); Abfragen `between`, `greaterThanEqual`, `lessThanEqual`, `cursorAfter` (nach Sortierung: alles nach dem Dokument mit dieser ID); Vergleich von Datumswerten als Zeitpunkte (`Date.parse`), sonst wie bisher.
- [ ] Tests: Bulk legt 3 an; Bulk mit einer vorhandenen ID ⇒ 409 und Liste unverändert; `between` inklusiv beidseitig; `listAllDocuments` über 3 Seiten (pageSize 2, 5 Docs) liefert 5 in Reihenfolge; bestehende Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden“
### 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`, Vorlage `spike-code/tz.mjs`): `TZ = 'Europe/Berlin'`; `zuUtc(tag:'YYYY-MM-DD', uhrzeit:'HH:MM') ⇒ Date | null` (null, wenn die Wanduhrzeit nicht existiert; doppelte ⇒ erstes Vorkommen; `'24:00'` = Folgetag 00:00); `ausUtc(date) ⇒ { tag, uhrzeit }`; `wochentag(tag) ⇒ 1..7` (Mo=1); `plusTage(tag, n) ⇒ tag`; `heute(now) ⇒ tag`; Anzeige `datumLang(date) ⇒ 'Montag, 12.10.2026'`, `datumKurz(date) ⇒ '12.10.2026'`, `uhrzeit(date) ⇒ '10:30'`.
- `feiertage.js`: `BUNDESLAENDER = ['BW','BY','BE','BB','HB','HH','HE','MV','NI','NW','RP','SL','SN','ST','SH','TH']`; `istFeiertag(tag, land) ⇒ boolean` (feiertagejs, Jahres-Cache).
- [ ] Tests: `zuUtc('2026-03-29','02:30') === null`; `zuUtc('2026-03-29','03:00')` = `01:00Z`; `zuUtc('2026-10-25','02:30')` = `00:30Z` (erstes Vorkommen, MESZ); `ausUtc` rückwärts für beide Tage; `wochentag('2026-10-05') === 1`; `plusTage('2026-10-25', 1) === '2026-10-26'`; Feiertage: RP 2026-06-04 (Fronleichnam) ja, BE 2026-06-04 nein, BE 2026-03-08 (Frauentag) ja, SN 2026-11-18 (Buß- und Bettag) ja, alle 2026-12-25 ja. Zusätzlich unter Node 20: `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 je Bundesland“
### 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.data`, ergänzt um `betrieb.kleinunternehmer: boolean`):**
- `neueId(prefix:'r'|'s'|'a') ⇒ 'r_' + 8 Zeichen [a-z0-9]`.
- `normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = {} }) ⇒ { data } | { fehler: { [feldpfad]: text } }` – füllt Vorgaben, kürzt Strings, prüft: `betrieb.name` (1–80) und `betrieb.email` (gültig) Pflicht; `farbe` `^#[0-9a-f]{6}$`; `anrede` sie|du; `bundesland` ∈ BUNDESLAENDER; Pläne: Fenster `HH:MM` aufsteigend, nicht überlappend, Ende > Beginn (24:00 erlaubt); `dauer` 5–480 und Vielfaches von 5, `pufferNach` 0–120 Vielfaches von 5, `dauer + pufferNach ≤ 500`; `preisArt` fest|ab|keiner, `preisCent` 0–1 000 000; `buchungsart` sofort|anfrage; jede aktive Leistung hat ≥ 1 aktive Ressource; `regeln.raster` ∈ {5,10,15,20,30,60}, `vorlaufMin` 0–20160, `horizontTage` 1–365, `absageBisStd` 0–336, `maxOffeneJeEmail` 1–10; `gastFelder` je `aus|optional|pflicht`; `erinnerung.stundenVorher` ∈ {2,12,24,48}; `zahlung.modus` nur `vorort` in TP1 (online/beides ⇒ Fehler „kommt bald“); `aufbewahrungTage` 14–365; Abwesenheiten `von ≤ bis`. `kuenftigeTermine` = `{ leistungen: {id: n}, ressourcen: {id: n} }`: Entfernen einer ID mit n > 0 ⇒ Fehler „Für diese Leistung gibt es noch {n} künftige Termine. Deaktiviere sie stattdessen.“; `storno.version` wird bei geändertem Text neu gesetzt (`YYYY-MM-DD-n`); `feedToken` je Ressource beibehalten bzw. neu (32 Byte base64url).
- `oeffentlicheEinstellung(data) ⇒ objekt` mit allen Feldern von `GET /config` außer `aktiv`, `hinweis`, `zustimmungVorzeitigText`, `formToken` (Form siehe Task 11; nur aktive Leistungen mit aktiver Ressource, `preis` als Text, `widerruf` = irgendeine aktive verbindliche Leistung für Verbraucher, keine `feedToken`).
- `knopfText(leistung, { bestaetigen = false }) ⇒ string` (Global Constraints); `brauchtZustimmungVorzeitig(data, leistung, startDate, now) ⇒ boolean`; `istVerbraucherBuchung(data, leistung) ⇒ boolean`; `preisText(leistung, data) ⇒ '45,00 €' | 'ab 45,00 €' | ''` (Zusatz „inkl. USt“ bzw. „(kein Ausweis der USt nach § 19 UStG)“ über `preisHinweis(data)`); `zahlungText(data) ⇒ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'` (Arten `bar|ec|kreditkarte|ueberweisung|paypal`).
- `vorlagen.js`: `VORLAGEN` mit Schlüsseln `salon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges` (je `label`, 2–5 Leistungen mit realistischen Dauern/Puffern, Plan Di–Sa bzw. Mo–Fr, `buchungsart` anfrage bei tattoo/handwerk/fotograf, `ressourcen` 1 Person bzw. 2 Plätze bei werkstatt, Stornotext-Muster Variante 1 aus `recherche-recht-zahlung.md` 7.3 F); `vorlagenListe() ⇒ [{ key, label }]`; `ausVorlage(key, { name, email, telefon, adresse }) ⇒ data` (valide nach `normalisiereEinstellung`).
- [ ] Tests: jede Vorlage ergibt valide Daten; Pflichtfelder; überlappende Fenster ⇒ Fehler am Pfad `ressourcen.0.plan.1`; Leistung ohne aktive Ressource ⇒ Fehler; Löschen mit künftigen Terminen ⇒ Fehler mit Anzahl; `zahlung.modus 'online'` ⇒ Fehler; Knopftexte aller drei Fälle; Zustimmung vorzeitig: Verbraucher + Preis + Termin in 3 Tagen ⇒ true, in 20 Tagen ⇒ false, `unternehmer` ⇒ false, `keiner`-Preis ⇒ false; `oeffentlicheEinstellung` enthält kein `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`.
**Consumes:** Task 3, 4. **Produces:**
- `belegungAus(buchungen, now) ⇒ [{ ressourceId, start: Date, ende: Date }]` (nur belegende Status; `ende` = `blockEndsAt`).
- `freieZeiten({ data, leistungId, ressourceId /* id | 'egal' */, tag, belegung, now, ohneRegeln = false }) ⇒ [{ uhrzeit:'HH:MM', start: Date, ressourceIds: string[] }]` – Fenster je Ressource (Sonderöffnung > Abwesenheit > Feiertag geschlossen > Plan), Startzeiten im `raster` ab Fensterbeginn, `start + dauer + pufferNach` ganz im Fenster, nicht in Belegung, `start ≥ now + vorlaufMin`, `tag ≤ heute + horizontTage`; `ohneRegeln` (Portal) ignoriert Vorlauf/Horizont, nicht aber Vergangenheit; bei `egal` Vereinigung, `ressourceIds` = alle freien.
- `freieTage({ …, monat:'YYYY-MM' }) ⇒ ['YYYY-MM-DD']` (max. 31 Tage, nur ab heute).
- `waehleRessource(ressourceIds, buchungenAmTag, data) ⇒ id` (wenigste belegende Buchungen am Tag, dann Reihenfolge in `data.ressourcen`).
- `blockEnde(start, leistung) ⇒ Date`.
- [ ] Tests: Pause teilt Fenster; Puffer verhindert Start 15 min vor Pausenbeginn bei 15+5; Vorlauf 120 ⇒ keine Zeiten vor now+2h; Horizont; Abwesenheit der Ressource und Betriebsurlaub (`ressourceId null`); Feiertag geschlossen vs. Sonderöffnung am Feiertag; Belegung durch Haltung mit `heldUntil` in der Vergangenheit blockiert nicht; `egal` mit zwei Personen; **2026-03-29** Plan 00:00–06:00 enthält kein `02:xx`; **2026-10-25** Plan 01:00–04:00, 60-min-Leistung: Termin 02:00 hat echte Dauer 60 min; letzter Termin endet genau am Fensterende.
- [ ] 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(token) ⇒ hex64`; `buchungsNummer(bookingId) ⇒ 8 Zeichen [A-Z0-9]` (aus der UUID ohne Bindestriche, groß); `formToken(siteKey, now) ⇒ string` / `pruefeFormToken(siteKey, token, now) ⇒ 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig'` (HMAC-SHA256 mit aus `config.sessionSecret` abgeleitetem Schlüssel `'termine-form'`).
- `zellen.js`: `zellenFuer(start, blockEnde) ⇒ Date[]` (5-min-Raster, Beginn abgerundet, Ende aufgerundet); `zellenId(featureId, ressourceId, zelle) ⇒ string`; `belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now }) ⇒ Promise<void>` – unter `withLocks(['termine-res:'+featureId+':'+ressourceId], fn, { waitMs: 5000 })`; Bulk-Create; bei 409: blockierende Zellen per ID lesen, deren Buchungen laden; sind alle nicht mehr belegend (Status oder abgelaufene Haltung), diese Zellen löschen und genau einmal neu versuchen, sonst Fehler `{ status: 409, code: 'SLOT_VERGEBEN' }`; `gibZellenFrei(bookingId)` (alle Zellen der Buchung löschen, Index `idx_booking`).
- `buchungen.js` (Datenzugriff, keine Regeln): `legeAn(data) ⇒ doc`; `hole(id)`; `aendere(id, patch) ⇒ doc` (setzt `updatedAt`); `imZeitraum(featureId, von, bis) ⇒ docs` (`between` auf `startsAt`, `listAllDocuments`); `perHash('verifyHash'|'manageHash', hash) ⇒ doc|null`; `offeneFuerEmail(featureId, email, now) ⇒ number` (belegend, Termin in der Zukunft); `zaehleKuenftige(featureId, now) ⇒ { leistungen, ressourcen }`.
- [ ] Tests (Nachbau): zwei Buchungen für dieselbe Zeit nacheinander ⇒ zweite `SLOT_VERGEBEN`; parallel (`Promise.all`, 10×) ⇒ genau eine erfolgreich; abgelaufene Haltung blockiert nicht (Aufräumen + zweiter Versuch); `zellenFuer` 10:00–10:50 ⇒ 10 Zellen; Token-Hash stabil; Formular-Token zu jung/alt/gefälscht.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher“
### Task 7: Rechtstext-Entwürfe 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`.
- Widerrufsbelehrung für Dienstleistungen + Muster-Widerrufsformular **wörtlich** nach Anlage 1/2 zu Art. 246a § 1 EGBGB in der aktuellen Fassung (inkl. Hinweis auf die Widerrufsfunktion), abgerufen von `https://www.gesetze-im-internet.de/bgbeg/anlage_1.html` und `…/anlage_2.html` (Abrufdatum im Kopf), Platzhalter für Betrieb (Name, Anschrift, E-Mail, Telefon); Datenschutz-Absatz (Sie/Du) und Formular-Hinweis aus `recherche-recht.md` 11.1/11.2 (angepasst: Löschfristen aus den Global Constraints); 6c-Entwurf für `public/datenschutz.html` (nicht einbauen). Alle Dateien Kopf „ENTWURF – vor Einsatz bei fremden Betrieben prüfen“.
- `rechtstexte.js`: `widerrufsbelehrung(betrieb, anrede) ⇒ { text, html }`, `formularHinweis(data) ⇒ string` (Kurzfassung 11.2 mit Link-Platzhalter), `ZUSTIMMUNG_VORZEITIG(betriebName)`, `ZUSTIMMUNG_STORNO`, `ZUSTIMMUNG_ERINNERUNG(stunden)` (exakte Texte aus den Global Constraints), `RECHTSTEXTE_VERSION = '2026-10-02'`.
- [ ] Tests: Belehrung enthält „Vertrag widerrufen“, die Betriebsanschrift und keine `{{`; Zustimmungstexte wortgleich zu den Global Constraints.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Rechtstext-Entwuerfe fuer Gaeste“
### Task 8: Kalenderdatei, Mailvorlagen, Gästeversand
**Files:** Create `server/services/termine/ics.js`, `server/mail-templates/G01 Termin bestaetigen.html` … `G08 Eingang Widerruf.html`, `K20 Neue Buchung.html`, `K21 Termin geaendert.html`, `test/termineIcs.test.js`; Modify `server/services/mailTemplates.js` (IDs `[GKT]\d{2}` in `templateIds` und Kopfkommentar-Regex), `server/services/mailer.js`, `test/mailTemplates.test.js` (`EXPECTED` + G01–G08, K20, K21; Logo-Prüfung nur für K/T; G-Vorlagen: kein WEBklar-Logo, enthalten `{{betrieb_name}}`).
**Produces:**
- `ics.js` (Vorlage `spike-code/ics.mjs`): `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`, UTC `…Z`, `RSVP=FALSE`); `icsKalender(termine[], { name, jetzt }) ⇒ string` (`METHOD:PUBLISH`, `X-WR-CALNAME`, `X-PUBLISHED-TTL:PT15M`).
- Vorlagen nach Spec 6 (Tabelle). G: neutrales Layout (Kopfbalken in `{{farbe}}`, Betriebsname, kein Logo), Fuß mit `{{betrieb_name}}`, `{{betrieb_adresse}}`, `{{betrieb_telefon}}`, `{{betrieb_email}}`, `{{impressum_url}}`, `{{datenschutz_url}}` und „Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet.“; Blöcke `sie`/`du` für jeden Anredetext. Gemeinsame Platzhalter: `gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link`; G02 zusätzlich Blöcke `preis`, `person`, `storno` (`{{storno_text}}`), `widerruf` (`{{widerrufsbelehrung}}` + `{{widerruf_link}}`), `anfrage_angenommen`; G06 `{{absage_von}}`, Block `nachricht` (`{{nachricht}}`); G07 `{{alt_datum}}`, `{{alt_zeit}}`; G08 `{{eingang}}`, `{{erklaerung}}`. K20 (Du-Form, WEBklar-Layout): `kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link`, Block `anfrage`; K21: Blöcke `abgesagt`, `verschoben`, `widerrufen`, `nicht_zugeordnet`, Platzhalter wie K20 + `alt_datum, alt_zeit, eingang`.
- `mailer.sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethode = 'REQUEST' }) ⇒ nodemailer-Info` (wirft; `icalEvent: { method, filename: 'termin.ics', content }` nur mit `ics`; nie `bcc`; Anführungszeichen im Namen entfernt).
- [ ] Tests: ICS-Zeilen ≤ 75 Oktette, `UID`/`SEQUENCE` korrekt, Komma/Semikolon escaped, CANCEL hat `STATUS:CANCELLED`; Vorlagen-Suite grün inkl. neuer IDs; `sendGuestMail` über `mailSink`: kein `bcc`, `replyTo` gesetzt, From-Name = Betrieb, Anhang `text/calendar`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kalenderdatei, Mailvorlagen G01–G08/K20/K21, Versand an Gaeste“
### Task 9: 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, icsMethode }) ⇒ doc` – rendert sofort (`renderMail`), speichert in `termineOutbox` (`status 'wartet'`, `naechsterVersuch = now`), stößt `sendeEintrag(doc.$id)` an, ohne zu warten (Fehler nur loggen).
- `verarbeite(now) ⇒ { gesendet, fehler, pausiert }` – alle fälligen `wartet`-Einträge (älteste zuerst), solange das Stundenbudget (`config.termine.mailsProStunde`, gleitendes Fenster im Speicher) reicht; je Eintrag Sperre `termine-mail:<id>`; Erfolg ⇒ `gesendet`, `gesendetAt`; Fehler ⇒ `versuche+1`, `naechsterVersuch` +1/+5/+30 min, nach 3 Wiederholungen `fehler` + `fehler`-Text; `EAUTH`/`responseCode 535` ⇒ alles 15 min pausieren.
- `zustellFehler(bookingIds) ⇒ Set<bookingId>` (Einträge mit `status 'fehler'`), für die Portal-Liste.
- `raeumeAuf(now)` – löscht `gesendet`/`fehler` älter als 7 Tage.
- [ ] Tests: Budget 2/h ⇒ dritter wartet; Wiederholungsabstände; EAUTH ⇒ Pause, danach weiter; doppelte Verarbeitung desselben Eintrags (zwei `verarbeite` parallel) ⇒ eine Mail; Aufräumen nach 7 Tagen.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mail-Warteschlange mit Stundenbudget“
### Task 10: Buchungsablauf und Hintergrund-Jobs
**Files:** Create `server/services/termine/kontext.js`, `server/services/termine/mails.js`, `server/services/termine/ablauf.js`, `server/services/termine/jobs.js`, `test/termineAblauf.test.js`, `test/termineJobs.test.js`; Modify `server/index.js` (Jobs).
**Consumes:** Tasks 4–9 (Task 7: `widerrufsbelehrung`, Zustimmungstexte), `siteFeatures.findActiveBySiteKey` (Task 12 erweitert die Signatur; bis dahin in `kontext.js` eigene Abfrage `featureKey 'termine'`, `state 'active'`).
**Produces:**
- `kontext.js`: `kontextPerSiteKey(siteKey) ⇒ ctx|null` (nur `active`, Modus ≠ off); `kontextPerFeature(featureId) ⇒ ctx|null` (auch `disabled`); `ctx = { feature, project, data, version }`; `ladeEinstellung(featureId) ⇒ { data, version } | null`; `speichereEinstellung(feature, data, { version, by }) ⇒ { data, version }` (Sperre `termine-cfg:<featureId>`, 409 bei Versionskonflikt, legt Dokument mit ID = featureId an).
- `mails.js`: `werteFuer(ctx, buchung, extra) ⇒ values`; `sendeAnGast(art:'G01'…'G08', ctx, buchung, extra)`, `sendeAnBetrieb(art:'K20'|'K21', ctx, buchung, { block, extra })` – über `einreihen`; Links `${publicBaseUrl}/termin.html#v=<token>` bzw. `#m=<token>`; ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH.
- `ablauf.js` (alle Funktionen nehmen `now`; jede Zustandsänderung unter `withLocks(['termine-buchung:'+id])` mit frischem Lesen):
- `halte(ctx, eingabe, now) ⇒ { buchung, email }` – Eingabe `{ leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen:{ storno, vorzeitig } }`; E-Mail getrimmt + klein; Feldfehler (400, `err.fehler`), Zustimmungen nach Task 4, Grenze `maxOffeneJeEmail` (429 „Sie haben bereits {n} offene Termine.“), Slot neu berechnen (sonst 409 `SLOT_VERGEBEN`), Ressource bei `egal` wählen, Buchung `gehalten` (`quelle 'online'`, `nachweis` = Fassungen + Hashes + Zeitpunkte + Knopftext, `verbraucher`, `preisCent`, `zahlart 'vorort'`, `zahlStatus 'keine'`, `icsSequenz 0`), Zellen, G01.
- `vorschau(token, now) ⇒ { art:'bestaetigen'|'verwalten', … }` (Form Task 11).
- `bestaetige(token, now) ⇒ { status, schon? }` – nur `gehalten` und `heldUntil > now` (sonst 410 `abgelaufen`); `sofort` ⇒ `bestaetigt` + Verwaltungstoken + G02 + K20; `anfrage` ⇒ `angefragt` + Verwaltungstoken + G03 + K20 (Block `anfrage`); schon bestätigt ⇒ `{ status, schon:true }`.
- `sageAbGast(token, now)` (bis `absageBisStd` vor Beginn, sonst 409 „Absage nur telefonisch“), `verschiebeGast(token, { tag, zeit }, now)` (gleiche Frist; neue Zellen vor Freigabe der alten; `icsSequenz+1`; G07 + K21 `verschoben`), `widerrufeGast(token, { name }, now)` und `widerrufeOhneLink(siteCtx, { name, email, nummer }, now) ⇒ { eingang }` (Treffer ⇒ `widerrufenAt`, `abgesagt` von `gast`, Zellen frei, G08, K21 `widerrufen`; kein Treffer ⇒ G08 an die angegebene Adresse + K21 `nicht_zugeordnet`; Antwort immer gleich).
- Betrieb: `aktion(ctx, bookingId, { aktion:'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen', grund, nachricht, tag, zeit }, now)` (G02 `anfrage_angenommen` / G04 / G06 / G07; „nichterschienen“ nur nach Beginn); `manuell(ctx, { leistung, ressource, tag, zeit, name, email, telefon, nachricht, bestaetigungSenden }, now)` (`quelle 'manuell'`, `bestaetigt`, keine Regeln außer Belegung, `verbraucher false`, G02 nur mit Haken); `sperre(ctx, { ressource, von, bis, titel }, now)` (`quelle 'sperre'`, `bestaetigt`, ohne Gast); `entsperre(ctx, bookingId)`.
- `jobs.js`: `minutenJob(now)` (abgelaufene `gehalten` ⇒ `verfallen` + Zellen frei; `outbox.verarbeite`), `fuenfMinutenJob(now)` (Erinnerung: `bestaetigt`, `erinnerung`, ohne `erinnerungGesendetAt`, `now + 1h < startsAt ≤ now + stundenVorher`, `bestaetigtAt < startsAt − stundenVorher` – erst Marker, dann G05; `erledigt` 1 h nach Ende), `taeglicherJob(now)` (Löschfristen laut Global Constraints inkl. Zellen und Outbox; Funktionen `disabled` seit > 30 Tagen: Buchungen, Zellen, Einstellung löschen; verwaiste Zellen). In `server/index.js` nach Muster der vorhandenen Jobs (`setTimeout(...).unref()` + `setInterval(...).unref()`, eigene Fehlerbehandlung): 60 s, 5 min, täglich (+180 s Versatz).
- [ ] Tests (Nachbau + mailSink + feste `now`): Halten ⇒ G01 an Gast, Zellen da; Bestätigen ⇒ G02 mit ICS und Verwaltungslink + K20 an Betrieb, kein BCC; Anfrage ⇒ G03/K20-Block; zweiter Klick ⇒ `schon`; nach 31 min ⇒ 410; `email_normalisiert` (`' Max@GMail.com '` zählt gegen dieselbe Grenze und findet die Buchung beim Widerruf); Absage nach Frist ⇒ 409; Verschieben belegt neu und gibt alt frei; Widerruf innerhalb 14 Tagen trotz abgelaufener Stornofrist; Widerruf ohne Treffer ⇒ gleiche Antwort + K21 `nicht_zugeordnet`; Betrieb lehnt ab ⇒ G04 + Zellen frei; manuell ohne Haken ⇒ keine Gastmail; Jobs: Verfall, Erinnerung genau einmal (zwei Läufe), nicht für Buchung 30 min vor Beginn, `erledigt`, Löschfristen (je Status), `verfall_und_bestaetigung_gleichzeitig` (Job und `bestaetige` parallel ⇒ genau ein Endzustand, keine G02 nach `verfallen`).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Buchungsablauf, Mails und Hintergrund-Jobs“
## Portal: Schnittstellen
### Task 11: Öffentliche Schnittstelle
**Files:** Create `server/routes/publicTermine.js`, `test/publicTermine.test.js`; Modify `server/index.js` (Einhängen **nach** `kundenansichtGuard`, **vor** dem globalen `express.json`), `server/middleware/kundenansichtGuard.js` (`PASS_PREFIXES` + `'/api/public/'`), `test/kundenansichtGuard.test.js`.
**Consumes:** Task 10, `analyseDomains(project)` aus `server/services/analyse.js`.
**Routen** (Basis `/api/public/termine/v1`; feste Pfade vor `/:siteKey`):
- `GET /:siteKey/config` ⇒ `{ aktiv:true, betrieb:{ name, telefon, adresse, impressumUrl, datenschutzUrl }, anrede, farbe, leistungen:[{ id, name, beschreibung, dauer, preis, buchungsart, ressourcen:[id] }], ressourcen:[{ id, name, typ }], personWaehlbar, regeln:{ horizontTage, absageBisStd }, zahlung:{ modus:'vorort', text }, storno:{ text, version }|null, widerruf:boolean, erinnerung:{ anbieten, stundenVorher }, gastFelder:{ telefon, nachricht, zusatz:[] }, hinweis:string, zustimmungVorzeitigText:string, formToken }` | `{ aktiv:false }`; `Cache-Control: public, max-age=60`, ohne ETag (das Formular-Token ändert sich je Abruf; ein bis 60 s altes Token ist gültig). Antwort zusammengesetzt aus `oeffentlicheEinstellung(data)` + `hinweis: formularHinweis(data)` + `zustimmungVorzeitigText: ZUSTIMMUNG_VORZEITIG(name)` + `formToken`.
- `GET /:siteKey/tage?leistung&ressource&monat` ⇒ `{ tage:[] }` (`max-age=15`); `GET /:siteKey/zeiten?leistung&ressource&tag` ⇒ `{ zeiten:['09:00',…] }` (`no-store`); ungültige Parameter ⇒ 400 `{ fehler:{ feld:text } }`.
- `POST /:siteKey/buchungen` (Body wie `halte`-Eingabe + `formToken`, `website`) ⇒ 201 `{ ok:true, email:'m***@gmail.com', minuten:30 }`; 400/409/429 `{ fehler:{ feld:text } }` (Slot vergeben ⇒ Feld `zeit`).
- `POST /:siteKey/widerruf` `{ name, email, nummer }` ⇒ `{ ok:true, eingang:ISO }`.
- `POST /termin/vorschau` `{ token }` ⇒ `{ art, status, termin:{ nummer, leistung, person, datum, zeit, dauer, preis, zahlung, ort }, betrieb:{…}, anrede, farbe, knopf, aktionen:{ bestaetigen, absagen, verschieben, widerrufen }, hinweis? }` | 404 `{ fehler:'Link ungültig oder abgelaufen' }`.
- `POST /termin/bestaetigen|absagen` `{ token }`; `POST /termin/verschieben` `{ token, tag, zeit }`; `POST /termin/widerruf` `{ token, name }`; `POST /termin/tage` `{ token, monat }`, `POST /termin/zeiten` `{ token, tag }` (für Verschieben).
- `GET /kalender/:feedToken.ics` ⇒ `text/calendar; charset=utf-8`, `max-age=300` (bestätigte Termine + Sperren der Ressource, nächste 90 Tage, auch vergangene 7 Tage).
- Schutz laut Global Constraints; CORS: GET `*`; POST/OPTIONS nur für Origin-Host ∈ `analyseDomains(project)` ∪ Host von `config.publicBaseUrl` (für `/termin/*` nur letzterer) ⇒ `Access-Control-Allow-Origin: <origin>`, `Vary: Origin`; sonst 403 `{ fehler:{ _:'Nicht erlaubt' } }`; Preflight 204 mit `Allow-Methods: GET, POST, OPTIONS`, `Allow-Headers: Content-Type`, `Max-Age: 86400`. Fehler aus Appwrite ⇒ 503 `{ fehler:{ _:'Gerade nicht erreichbar' } }`, nie Rohtext. Modus `off` ⇒ jede `/:siteKey/*`-Route `{ aktiv:false }` bzw. 404 für POST.
- [ ] Tests (startTestApp + Nachbau + mailSink; Origin-Header gesetzt): kein Feld `customerId|projectId|featureId|$id` in irgendeiner Antwort (rekursiv prüfen); unbekannter Schlüssel und Modus off ⇒ `{aktiv:false}`; GET mit beliebiger Origin ⇒ `*`; POST von fremder Origin ⇒ 403; Preflight; Honeypot und zu junges Formular-Token ⇒ 201 ohne Buchung/Mail; 11. Buchung je IP in 10 min ⇒ 429; IPv6 `2001:db8::1` und `2001:db8::2` teilen das Limit; Body 20 KB ⇒ 413; ganzer Ablauf halten → vorschau → bestaetigen → absagen über HTTP; Widerruf ohne Treffer = gleiche Antwort; Kalender-Feed enthält bestätigten Termin, nicht den gehaltenen; Appwrite-Fehler (`failNext`) ⇒ 503 ohne Rohtext; Kundenansicht-Cookie stört öffentliche POSTs nicht.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz“
### Task 12: 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` (auf `findActiveBySiteKey` umstellen), Tests `test/funktionenRoutes.test.js`, `test/siteFeatures.test.js`, `test/kundenansichtGuard.test.js`, neu `test/funktionenTermine.test.js`.
**Produces:**
- `FEATURE_KEYS = ['news', 'termine']`; `FEATURE_LABELS = { news:'News-Pinnwand', termine:'Terminplaner' }`; `findActiveBySiteKey(siteKey, featureKey = 'news')`; `activateClosedTickets` bleibt bei News (TP2).
- Team: `POST /api/team/funktionen/:customerId/:key` (`key` ∈ FEATURE_KEYS, sonst 404; `/news` verhält sich wie bisher); Meldung ohne Dokument: „Für dieses Projekt gibt es noch keine {Label}“; `adminView` lädt die Funktionsdokumente des Kunden einmal (statt `getFeatureDoc` je Projekt) und setzt `adminNote`/`ticketMissing` je Schlüssel.
- `GET /api/funktionen` ergänzt `termine: { verfuegbar: termineFuerKunde(req.session.email) }`.
- Kundenansicht: `KUNDENANSICHT_ALLOWED` + `['POST', /^\/api\/termine\/.+$/]`, `['PATCH', /^\/api\/termine\/einstellungen\/?$/]`; `VIEW_ACTIONS` + `termin_einstellungen`, `termin_aktion`, `termin_manuell`.
- `deleteCustomerWithPortalAccess`: Terminplaner-Funktionen des Kunden `disabled` (Notiz „Kunde gelöscht“).
- [ ] Tests: Team aktiviert `termine` für Projekt von c1 ⇒ Zustand active, News unverändert; `/:customerId/kalender` ⇒ 404; `GET /api/funktionen` mit Pilot-E-Mail ⇒ `termine.verfuegbar true`, sonst false, Projekte haben `features.termine`; Erlaubnisliste exakt (Test anpassen); Kunde löschen ⇒ termine `disabled`; alle bisherigen Funktionen-/News-Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)“
### Task 13: Kunden-Schnittstelle `/api/termine`
**Files:** Create `server/routes/termine.js`, `test/termineRoutes.test.js`; Modify `server/index.js` (`app.use('/api/termine', termineRoutes)`).
**Consumes:** Tasks 4, 6, 10, 12. Alle Routen `requireSession`, `projectId` (Query oder Body), `assertOwnProject`, `termineFuerKunde(session.email)` sonst 409 „Der Terminplaner ist noch nicht verfügbar.“; Funktion muss existieren (`off` ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“); `disabled` ⇒ nur Lesen + Export; Schreiblimit 120 / h je Kunde; jede Schreibaktion `logIfKundenansicht`.
**Routen:**
- `GET /einstellungen?projectId` ⇒ `{ state, einstellung: data|null, version, vorlagen, feeds:{ [ressourceId]: url }, siteKey, einbau:{ snippet } }`.
- `POST /einstellungen/vorlage` `{ projectId, vorlage }` ⇒ wie GET (nur wenn noch keine Einstellung; Betriebsdaten aus dem Kundendokument vorbefüllt: `companyName||name`, `email`, `phone`, `location`).
- `PATCH /einstellungen` `{ projectId, version, einstellung }` ⇒ `{ einstellung, version }` | 400 `{ fehler }` | 409 Versionskonflikt (mit `kuenftigeTermine` aus `zaehleKuenftige`).
- `GET /buchungen?projectId&von&bis&status` (max. 62 Tage) ⇒ `{ buchungen:[{ id, nummer, status, quelle, startsAt, endsAt, leistung, person, ressourceId, gastName, gastEmail, gastTelefon, gastNachricht, erinnerung, zustellFehler, widerrufenAt, abgesagtVon, absageGrund }] }`.
- `GET /zeiten?projectId&leistung&ressource&tag` ⇒ `{ zeiten }` (`ohneRegeln: true`).
- `POST /buchungen` `{ projectId, art:'manuell'|'sperre', … }` ⇒ 201 `{ buchung }`; `POST /buchungen/:id/aktion` `{ projectId, aktion, … }` ⇒ `{ buchung }`; `DELETE /buchungen/:id?projectId` nur für Sperren.
- `POST /ressourcen/:id/feed` `{ projectId }` ⇒ `{ url }` (neues `feedToken`, Version +1).
- `GET /export.csv?projectId` ⇒ `text/csv; charset=utf-8` mit BOM, `;`-getrennt, Kopf `Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht`, Formel-Injektion verhindert (Zellen mit `= + - @` am Anfang ⇒ `'` voran).
- [ ] Tests: fremdes Projekt ⇒ 403; ohne Pilot ⇒ 409; Vorlage anlegen, zweites Mal ⇒ 409; PATCH mit alter Version ⇒ 409; Löschen einer Leistung mit künftigem Termin ⇒ 400 mit Anzahl; Liste nur eigener Funktion; manuell/Sperre; Aktion annehmen ⇒ G02; Kundenansicht darf PATCH (mit Header) und es wird protokolliert; `disabled` ⇒ PATCH 409, Export 200; CSV-Injektion entschärft.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kunden-Schnittstelle“
## Portal: Oberflächen
### Task 14: 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 11 (Antwortformen). **Produces:** Einbau laut Spec 7 (`data-webklar-termine`, `data-api`, `data-modus inline|knopf`, `data-knopf`, `data-leistung`), Klassen `wkt-*`, Stile einmal je Seite (`<style id="wkt-style">`, `@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 (Leistung → Person → Tag/Zeit → Daten → Zusammenfassung → „Fast geschafft“); Monatsraster nach APG (Pfeile, Pos1/Ende, Bild↑/↓ Monat, `aria-selected`, `aria-disabled`), Zeiten als Knöpfe; Fehler über `aria-live="polite"`; alle Uhrzeiten/Daten als Strings vom Server; Fuß „Vertrag widerrufen“ (Formular im Widget, `POST /widerruf`) wenn `widerruf`, „Datenschutz“-Link, „Terminbuchung über WEBklar“; Knopfmodus lädt erst beim Klick; Fehler ⇒ Ausweichtext bzw. „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“. `docs/funktionen/termine-einbau.md`: Snippet, Variablentabelle, drei Stilbeispiele, Datenschutz-Absatz (Verweis Task 7), Abnahme-Checkliste (Testbuchung, Mails, Absage, Handy, Tastatur), öffentliche API-Kurzreferenz.
- [ ] `test/termineEmbedStatic.test.js`: Quelltext enthält kein `localStorage`, `sessionStorage`, `document.cookie`, `innerHTML =` mit Variablen aus Antworten (nur Konstanten), kein `new Date(` (Zeitzonen-Unabhängigkeit).
- [ ] Playwright gegen echten Testserver (`startAppwriteMock` + `startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/': express.static('public') })`, Seed einer aktiven Funktion mit Vorlage `salon`, `mailSink`): inline Buchung bis „Fast geschafft“, G01 im Sink; Knopfmodus mit `<dialog>`; nur Tastatur; Handy 390 px; `timezoneId: 'America/New_York'` zeigt dieselben Uhrzeiten; zwei Widgets auf einer Seite; Server aus ⇒ Ausweichtext; Screenshots `/root/reports/terminplaner-ui/widget-*.png`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einbau-Skript, Demo und Einbauanleitung“
### Task 15: Gästeseiten
**Files:** Create `public/termin.html`, `public/termin.js`, `public/termin.css`, `test/ui/termin-seiten.ui.mjs`.
**Produces:** Seite liest `#v=`/`#m=` aus `location.hash`, ruft `POST /termin/vorschau`; Ansichten Bestätigen (Zusammenfassung + Knopf `knopf`), Bestätigt/Angefragt, Abgelaufen, Verwalten (Absagen mit Rückfrage in der Seite, Verschieben mit Tage/Zeiten über Token, „Vertrag widerrufen“ mit Formular nach § 356a: Name, Buchungsnummer + E-Mail vorbefüllt, Knopf „Widerruf bestätigen“, danach Eingang mit Datum/Uhrzeit); Kopf in `farbe` mit Betriebsname; Fuß: Impressum/Datenschutz des Betriebs + „Impressum WEBklar“ (`/impressum.html`); `<meta name="robots" content="noindex">`; Hash nach dem Lesen per `history.replaceState` entfernen; keine Cookies.
- [ ] Playwright (gleicher Testserver): bestätigen (danach ist die Buchung `bestaetigt`, G02 im Sink), zweiter Aufruf „bereits bestätigt“, abgelaufen, absagen, verschieben, widerrufen, Handy-Breite; Screenshots.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen“
### Task 16: Portal-Ansicht und Kachel
**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` (Kachel), `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">` mit `#termine-body`, Stylesheet), `test/funktionenTiles.test.js`.
**Produces:**
- Kachel: `termine.verfuegbar` false ⇒ wie bisher „Kommt bald“; sonst Pille *Aus* (kein Projekt aktiv: Text „Wir schalten den Terminplaner für dich frei.“ + Chat-Knopf) / *Aktiv* („Termine öffnen“, `data-view="termine"`, Zähler „{n} Anfragen warten“ aus `GET /api/termine/buchungen?status=angefragt`) / *Abgeschaltet*.
- `termine-view.js` (rein, Node-testbar): `fensterParsen('09:00–12:30, 13:30–18:00') ⇒ [['09:00','12:30'],['13:30','18:00']] | { fehler }`, `fensterText(fenster)`, `statusLabel(status) ⇒ deutsch`, `tageDerWoche(tag)`, `gruppiereNachTag(buchungen)`, `leerEinstellung` nicht nötig.
- `termine.js` `initTermine(root, { projectId })`: Projektauswahl; ohne Einstellung ⇒ Vorlagenauswahl; Reiter **Termine** (Tag/Woche/Monat, Filter, Aktionen nach Spec 4 mit Rückfragen in der Seite, „+ Termin eintragen“, „Zeit sperren“, Hinweis „Bestätigung nicht zugestellt“), **Leistungen**, **Team & Plätze** (Wochenplan je Tag mit `fensterParsen`, 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 als Vorschlag, gekennzeichnet; Export-Link); Speichern mit `version`, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld; `disabled` ⇒ nur Lesen + Hinweis.
- [ ] `test/termineView.test.js` für die reinen Helfer; `funktionenTiles.test.js` um die drei Kachelzustände.
- [ ] Playwright (Fixture: `page.route('**/api/**')` wie `funktionen.ui.mjs`, dazu ein Durchlauf gegen den echten Testserver): Vorlage wählen, Leistung anlegen, Wochenplan ändern, Speichern, Konflikt, Termine-Liste mit allen Status, Anfrage annehmen, App-Ansicht (390 px) als Unterseite von Funktionen; Screenshots.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht und Kachel“
## WOMS
### Task 17: Funktionen-Reiter für mehrere Funktionen
**Repo:** `/home/webklar/apps/ticket.webklar.com/dev-termine` (Branch `feature/terminplaner`).
**Files:** Create `src/lib/funktionenView.js`, `tests/unit/funktionenView.test.mjs`; Modify `src/lib/teamApi.js` (`setFeature(customerId, key, { projectId, action, note })` ⇒ `POST /funktionen/:customerId/:key`; `setNewsFeature` als Wrapper), `src/components/customer/CustomerFunktionen.jsx` (je Projekt eine Zeile je Funktion aus dem Register; Ticketnummer als Link `/tickets?woid=`; Antwort nur `features[key]` des Projekts übernehmen), `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) ⇒ { aktivieren, abschalten }`, `mergeFunktion(projects, projectId, key, aktualisiert)`.
- [ ] Tests (Register, Aktionen, Merge überschreibt die andere Funktion nicht); `npm test`; `npx vite build`.
- [ ] FAIL → implementieren → PASS → Commit „Funktionen-Reiter: News und Terminplaner je Projekt“
---
## Abschluss
- [ ] Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und diese Global Constraints; Funde Critical/Important beheben.
- [ ] `npm test` (Host Node 24) und die Terminplaner-Tests unter Node 20 (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termine*.test.js test/publicTermine.test.js`).
- [ ] Ledger + Screenshots nach `/root/reports/terminplaner-tp1/`.
## Livegang (je Schritt Freigabe des Nutzers; der Nutzer pusht `main`)
1. **L1 Portal:** Branch auf aktuelles `origin/main` rebasen, `npm test`, Push `feature/terminplaner:main` (Deploy installiert `feiertagejs`). Schalter bleibt `off` (Vorgabe) ⇒ 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`: `TERMINE_ENABLED=pilot`, `TERMINE_PILOT_EMAILS=kenso.gri@gmail.com` (Sicherung `.bak-termine`); `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 (Snippet aus dem Portal) + 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.