# 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: { : 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/` 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::`, `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), `{{betreff}}`, `