diff --git a/docs/superpowers/plans/2026-10-02-terminplaner-tp1-kern.md b/docs/superpowers/plans/2026-10-02-terminplaner-tp1-kern.md new file mode 100644 index 0000000..bd3f64a --- /dev/null +++ b/docs/superpowers/plans/2026-10-02-terminplaner-tp1-kern.md @@ -0,0 +1,247 @@ +# 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 `"" ` (Gast) bzw. `"WEBklar" ` (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/` 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` – 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:`; 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` (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:`, 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=` bzw. `#m=`; 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: `, `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 (`