Plan TP1: Funde der Planpruefung eingearbeitet (Einstellungsfeld, Verwaltungstoken, Verschieben, Testbarkeit, Missbrauchsschutz)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Opus 5.5 committed 2026-10-03 08:17:43 +00:00
1 parent e3ff05c804
commit f28060198c
2 files changed
+190 -173

No files matched your search

@@ -4,244 +4,252 @@
**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.
**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 (playwright-core aus dem npx-Cache), React 18 (WOMS).
**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/` (Zeit-Helfer und Zellen als Vorlage: `spike-code/tz.mjs`, `spike-code/claims.mjs`, `spike-code/ics.mjs`).
**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: `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`.
**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). 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`.
- 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“).
- 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).
- Zustimmungstexte exakt (Sie-Form, auch bei Du-Betrieben, weil Rechtstext): vorzeitiger Beginn (Pflicht nur bei verbindlich + Preis + Verbraucher + Terminbeginn < jetzt + 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.“
- Verbraucher-Buchung ⇔ `recht.gaesteSind !== 'unternehmer' && !recht.freizeit` (beim Halten in `verbraucher` gespeichert). Widerruf möglich ⇔ verbindlich, `verbraucher`, Status `bestaetigt`, `now < vertragAt + 14 Tage`, Termin noch nicht begonnen.
- Öffentliche Antworten: nie `customerId`, `projectId`, `featureId`, `$id`, Ticket-/Stripe-IDs; nur Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen). Fehlerform immer `{ fehler: { <feld|_>: text }, code? }`. Unbekannter, inaktiver, abgeschalteter siteKey oder Modus `off` ⇒ `200 { aktiv: false }` (GET und POST unter `/:siteKey`); `/termin/*` (Token) bleibt für `disabled` nutzbar (Absagen, Widerruf).
- Schutz: Router-eigener Parser 16 KB + Router-Fehlermiddleware (413/400/503, nie `err.message`); `content-length` > 16 384 ⇒ 413 vorab; Honeypot-Feld `kontakt_url`; Formular-Token HMAC, `zu_schnell` (< `formMinSekunden`) oder Honeypot ⇒ gleiche Erfolgsantwort ohne Speichern/Mail, `abgelaufen` (> 2 h) ⇒ 400 „Bitte laden Sie die Seite neu.“; Grenzen (Modul `limits.js`, Speicher-Maps mit Obergrenze 10 000 Schlüssel, `resetTermineLimitsForTests()`): je IP (IPv6 je /64) 120 GET/min und 10 Buchungen/10 min; je siteKey 600 GET/min und 60 Haltungen/h; je E-Mail+Funktion `regeln.maxOffeneJeEmail` (Vorgabe 3) offene Termine und 5 G01/Tag; Widerruf 10/h je IP, 3 G08/Tag je Empfängeradresse, 20 G08/h je Funktion, 10 K21 „nicht zugeordnet“/h je Funktion. Grenze „offene Termine“ ⇒ dieselbe 201-Antwort, statt der Bestätigung geht G01-Block `zu_viele` an die Adresse (kein Orakel für Dritte). E-Mail-Adressen: genau ein Postfach (kein `,;<>"`, ≤ 254), getrimmt und klein.
- Mails: Gast `from: { name: betrieb.name, address: SMTP_FROM }`, `replyTo` = Betriebs-E-Mail; K20/K21 `from: { name: 'WEBklar', address }` an die Betriebs-E-Mail; **nie BCC**, kein Tracking. Betreff = Platzhalter `{{betreff}}` (je Anrede/Fall vom Server). G01 und G08 ohne Text, den der Gast eingegeben hat. Alles über `termineOutbox`: einziger Sendepfad `sendeEintrag` prüft Pause und Stundenbudget (200/h); `EAUTH`/535 ⇒ 15 min Pause für alle, Eintrag bleibt `wartet`; Verbindungsfehler/4xx ⇒ erneut nach 1, 5, 30 min, dann `fehler`; 5xx (außer 535) ⇒ sofort `fehler`; nach `gesendet` werden `an`, `html`, `text`, `ics` geleert; Einträge 7 Tage nach `gesendet` bzw. 48 h nach `fehler` gelöscht.
- Löschfristen: `verfallen` 48 h nach `heldUntil`; `abgesagt`/`abgelehnt` 30 Tage; `erledigt`/`nichterschienen` `aufbewahrungTage` (Vorgabe 90, 14–365) nach Terminende; `angefragt` mit Beginn in der Vergangenheit ⇒ `abgelehnt` (System); Funktion `disabled` ⇒ alles 30 Tage nach `disabledAt`.
- Tests: Termine-Tests starten den Nachbau mit `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` (Task 2); feste `now` per Parameter; Mails mit `mailSink()` + `warteAufVersand()` prüfen; Kalenderdaten stehen im Sink als `icalEvent` (nicht als Anhang). Schwere Läufe (Playwright, `vite build`, Docker-Node-20) nacheinander, vorher `free -m`, mit `nice`.
- Portaltexte Du-Form, kein `confirm()`; Gästetexte Sie/Du nach `anrede`. Kundentext nur über `textContent`/`esc()`; `href` nur für geprüfte `https:`-URLs, mit `rel="noopener noreferrer"`.
- Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie `git add -A`.
## Review Focus
1. Gast-Browser in anderer Zeitzone (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
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): Spike Bulk-Create
## Vorab (Controller, kein Subagent)
- [ ] 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`.
- [ ] **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-Skript
### 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`, 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“
- `config.collections.termineConfig|termineBookings|termineClaims|termineOutbox` (Env `APPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX`); `config.termine` laut Global Constraints.
- `schalter.js`: `termineModus()`, `termineFuerKunde(email) ⇒ boolean`, `termineAus() ⇒ boolean`.
- `schema.js` (reine Daten, **kein** Import von `config.js`): `TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, … }` exakt nach Spec 2 (mit `einstellung` statt `data`; `termineOutbox.icsMethod`); Attributtypen `string` (Größe), `integer`, `boolean`, `datetime`; Indizes `{ key, type: 'key', attributes }`; `TERMINE_DATETIMES = { collection: [keys] }`.
- `scripts/termine-setup.mjs` (Muster `analyse-setup.mjs`, `createAppwriteClient` von dort wiederverwenden; `createAttribute` nutzt `/attributes/<type>` für alle Typen): `main({ check, client, log })`, legt Fehlendes an, Indizes nur bei neuen Collections, `--check` ⇒ Exit 1 bei Fehlendem.
- `test/support/schema.js`: `schemaKeys(collection)` kennt auch `TERMINE_COLLECTIONS` (statischer Import aus `server/services/termine/schema.js`).
- [ ] Tests: Modus (leer/unbekannt ⇒ off; pilot + `KENSO.gri@gmail.com ` ⇒ true; `*`; Laufzeitänderung von `config.termine.mode` wirkt); Setup mit Fake-Client: 4 Collections + Attribute + Indizes, zweiter Lauf legt nichts an, `--check` legt nichts an; `schemaKeys('termineConfig')` enthält `einstellung`, nicht `data`; **Rundreise**: Nachbau mit `schemaKeys('termineConfig')`, `createDocument` + `updateDocument` mit `einstellung`/`version`/`updatedBy` und Zurücklesen.
- [ ] FAIL → implementieren → PASS (`node --test test/termineSetup.test.js test/termineSchalter.test.js`, dann `npm test`) → Commit „Terminplaner: Schalter, Konfiguration, Schema“
### Task 2: Appwrite-Wrapper und Nachbau: Bulk-Create, Bereichsabfragen, Seiten
**Files:** Modify `server/services/appwriteAdmin.js`, `test/support/appwriteMock.js`; Test `test/appwriteBulk.test.js`.
### 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(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“
- `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`, 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“
**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.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`.
**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`.
**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.
**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(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.
- `token.js`: `neuesToken() ⇒ { token, hash }`; `hashToken(t)`; `verwaltungsToken(bookingId) ⇒ token`; `buchungsNummer(bookingId) ⇒ 8 [A-Z0-9]`; `formToken(siteKey, now)`, `pruefeFormToken(siteKey, token, now) ⇒ 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig'` (Mindestalter `config.termine.formMinSekunden`).
- `zellen.js` (`spike-code/claims.mjs` nur als Vorlage für den Hash-Ansatz; Zellgröße/ID/Puffer wie hier): `zellenFuer(start, blockEnde) ⇒ Date[]`; `zellenId(featureId, ressourceId, zelle)`; `belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null }) ⇒ void` (Sperre `termine-res:<featureId>:<ressourceId>`, `waitMs 5000`; mit `ausser` zählen Zellen dieser Buchung als eigene: angelegt wird nur Neu − Eigen; bei 409 blockierende Zellen per ID lesen, Gültigkeit nach Global Constraints prüfen, ungültige löschen, genau ein zweiter Versuch, sonst Fehler `{ status: 409, code: 'SLOT_VERGEBEN' }`); `gibZellenFrei(bookingId, { behalte = [] })` (über `listAllDocuments`, behält die genannten Zellzeiten); `verwaisteZellen(now) ⇒ ids` (`bis < now − 1 Tag` oder Buchung nicht belegend).
- `buchungen.js` (nur Datenzugriff): `legeAn`, `hole`, `aendere` (setzt `updatedAt`), `imZeitraum(featureId, von, bis)` (`lessThan(startsAt, bis)` + `greaterThan(blockEndsAt, von)` + `featureId`, `listAllDocuments`), `perHash(feld, hash)`, `offeneFuerEmail(featureId, email, now)`, `perEmailUndNummer(featureId, email, nummer)`, `zaehleKuenftige(featureId, now) ⇒ { leistungen, ressourcen }`, `abgelaufeneHaltungen(now)`, `erinnerungsKandidaten(now)`, `zuErledigen(now)`, `vergangeneAnfragen(now)`, `loeschKandidaten(status, vor)`, `zaehleAnfragen(featureId)`.
- [ ] Tests (Nachbau, `defaultLimit 25`): zweite Buchung derselben Zeit ⇒ `SLOT_VERGEBEN`; 10 parallel ⇒ genau eine; abgelaufene Haltung und fehlende Buchung blockieren nicht; Verschieben 10:00→10:30 mit `ausser` gelingt, Fremdbuchung im neuen Bereich ⇒ `SLOT_VERGEBEN` und alte Zellen unverändert; `gibZellenFrei` löscht 40 Zellen (über 25er-Seiten); `imZeitraum` findet die Belegung vom Vortag; Verwaltungstoken stabil; Formular-Token zu jung/alt/gefälscht.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher“
### Task 7: Rechtstext-Entwürfe für Gäste
### 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`.
- 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“
**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, 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 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: Mail-Warteschlange
### Task 9: Mailvorlagen G01–G08, K20, K21
**Files:** Create `server/mail-templates/G01 Termin bestaetigen.html`, `G02 Termin bestaetigt.html`, `G03 Anfrage eingegangen.html`, `G04 Anfrage abgelehnt.html`, `G05 Erinnerung.html`, `G06 Termin abgesagt.html`, `G07 Termin verschoben.html`, `G08 Eingang Widerruf.html`, `K20 Neue Buchung.html`, `K21 Termin geaendert.html`; Modify `test/mailTemplates.test.js` (`EXPECTED` sortiert: G01–G08, K01–K21, T01–T18; Kommentar).
- Gerüst wie die vorhandenen Vorlagen: Kopfkommentar (`G01 · …`, `Betreff: {{betreff}}`, Platzhalter-, Block-Liste), `<title>{{betreff}}</title>`, `<!--[if mso]>`-Block, sonst keine Kommentare, jeder Platzhalter/Block deklariert und benutzt.
- G-Layout neutral: Kopfbalken in `{{farbe}}` mit `{{betrieb_name}}`, kein Logo; Fuß `{{betrieb_name}}`, `{{betrieb_adresse}}`, `{{betrieb_telefon}}`, `{{betrieb_email}}`, `{{impressum_url}}`, `{{datenschutz_url}}`, Satz „Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet.“; Blöcke `sie`/`du` für jeden Anredetext.
- Platzhalter: G01 `leistung, datum, zeit, link` + Block `zu_viele` (Text: höchstmögliche Zahl offener Termine, absagen oder anrufen `{{betrieb_telefon}}`) – **kein** `gast_name`; G02 `gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link, ort` + Blöcke `preis`, `person`, `storno` (`{{storno_text}}`), `widerruf` (`{{widerrufsbelehrung}}`, `{{widerruf_link}}`), `anfrage_angenommen`; G03 `gast_name, leistung, datum, zeit, link`; G04 `gast_name, leistung, datum, zeit` + Block `nachricht` (`{{nachricht}}`); G05 `gast_name, leistung, person, datum, zeit, link` + Block `person`; G06 `gast_name, leistung, datum, zeit, absage_von, grund` + Block `nachricht`; G07 `gast_name, leistung, alt_datum, alt_zeit, datum, zeit, link`; G08 `eingang, nummer` + Blöcke `zugeordnet`, `nach_frist` – **kein** `gast_name`. K20 (Du-Form, WEBklar-Layout mit Logo): `kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link` + Block `anfrage`; K21: wie K20 + `alt_datum, alt_zeit, eingang` + Blöcke `abgesagt`, `verschoben`, `widerrufen`, `nicht_zugeordnet`, `nach_frist`.
- [ ] Tests: Vorlagen-Suite grün inkl. neuer IDs (Titel = Betreff, ein Kommentar, keine Reste, Text ohne Tags); `renderMail('G02', …, { du: true })` enthält im Text kein „ Sie “/„Ihr “; G01/G08 haben keinen Platzhalter `gast_name`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mailvorlagen G01–G08, K20, K21“
### Task 10: Mail-Warteschlange
**Files:** Create `server/services/termine/outbox.js`, `test/termineOutbox.test.js`.
**Consumes:** Task 8. **Produces:**
- `einreihen({ featureId, bookingId, art, an, fromName, replyTo, template, values, blocks, ics, 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.
**Consumes:** Task 8. **Produces:** `einreihen({ featureId, bookingId, art, an, fromName, replyTo, template, values, blocks, ics, icsMethod }) ⇒ doc` (rendert sofort, speichert `wartet`, ruft `sendeEintrag(id)` ohne zu warten); `sendeEintrag(id)` (einziger Sendepfad: Pause/Budget prüfen, Sperre `termine-mail:<id>`, Fehlerklassen laut Global Constraints, nach Erfolg Inhalt leeren); `verarbeite(now)`; `warteAufVersand()` (für Tests: wartet auf laufende Sendeversuche); `zustellFehler(bookingIds) ⇒ Set`; `raeumeAuf(now)`; `pausiertBis()`.
- [ ] Tests: Budget 2/h ⇒ dritter wartet; 4xx ⇒ 1/5/30 min, dann `fehler`; 550 ⇒ sofort `fehler`; EAUTH ⇒ Pause, während der Pause **keine** Sendeversuche (Fake-Transport zählt), danach weiter; zwei `verarbeite` parallel ⇒ eine Mail; nach Erfolg sind `an/html/text/ics` leer; Aufräumen 7 Tage / 48 h.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Mail-Warteschlange mit Stundenbudget“
### 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: Ablauf
### Task 11: Kontext, Einstellungs-Speicher, Mail-Werte
**Files:** Create `server/services/termine/kontext.js`, `server/services/termine/mails.js`, `test/termineKontext.test.js`.
**Consumes:** Tasks 4, 6, 7, 9, 10. **Produces:**
- `kontext.js`: `kontextPerSiteKey(siteKey) ⇒ ctx|null` (Funktion `termine`, `active`, Modus ≠ off; eigene Abfrage bis Task 17, dann `findActiveBySiteKey(siteKey, 'termine')`; 30 s Speicher-Cache je siteKey); `kontextPerFeature(featureId) ⇒ ctx|null` (auch `disabled`); `ctx = { feature, project, einstellung, version }`; `ladeEinstellung(featureId)`; `speichereEinstellung(feature, einstellung, { version, by }) ⇒ { einstellung, version }` (Sperre `termine-cfg:<featureId>`, Dokument-ID = featureId, 409 bei Versionskonflikt, Cache leeren); `websiteUrl(ctx)` (`einstellung.betrieb.websiteUrl` oder `https://` + erster Host aus `analyseHosts(project)`).
- `mails.js`: `BETREFFE` (Tabelle je Vorlage × Anrede × Fall, Sie/Du, G05 „Erinnerung: Ihr Termin am {datum} um {zeit}“); `werteFuer(art, ctx, buchung, extra) ⇒ { values, blocks }` (Links `${config.publicBaseUrl}/termin.html#v=<token>` bzw. `#m=<verwaltungsToken>`; `widerruf_link` = Verwaltungslink); `anGast(art, ctx, buchung, extra)`, `anBetrieb(art, ctx, buchung, { block, extra })` über `einreihen` (ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH).
- [ ] Tests: Speichern/Laden mit Version, Konflikt ⇒ 409; Cache wird beim Speichern geleert; Betreff je Anrede (Du ohne „Sie“); G02-Werte mit Widerrufsblock nur bei Verbraucher; Links zeigen auf `config.publicBaseUrl`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte“
### Task 12: Gast: halten, Vorschau, bestätigen
**Files:** Create `server/services/termine/ablauf.js`, `test/termineAblaufGast.test.js`.
**Consumes:** Tasks 5, 6, 11. **Produces** (jede Zustandsänderung unter `withLocks(['termine-buchung:'+id])` mit frischem Lesen):
- `halte(ctx, eingabe, now) ⇒ { status: 'gehalten'|'zu_viele', email }` – Eingabe `{ leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen: { storno, vorzeitig } }`; Feldfehler (400, `err.fehler`); Slot neu berechnen; Ressource bei `egal` wählen; **erst** Buchung `gehalten` anlegen (`heldUntil = now + holdMinutes`, `nachweis` = Fassungen/Hashes der Texte, Zeitpunkte, Knopftext, Anrede; `verbraucher`, `preisCent`, `preisArt`, `buchungsart`, `zahlart 'vorort'`, `zahlStatus 'keine'`, `icsSequenz 0`, `verifyHash`), **dann** Zellen; `SLOT_VERGEBEN` ⇒ Buchung `verfallen`, Fehler 409 Feld `zeit`; G01; Grenze offene Termine ⇒ nichts anlegen, G01-Block `zu_viele`, Ergebnis `zu_viele`.
- `vorschau(token, now) ⇒ { art: 'bestaetigen'|'verwalten', status, termin: { nummer, leistung, person, datum, zeit, dauer, preis, zahlung, ort }, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl, websiteUrl }, gast: { name, email }, anrede, farbe, knopf, aktionen: { bestaetigen, absagen, verschieben, widerrufen }, hinweis }` (Token zuerst als Bestätigungs-, dann als Verwaltungstoken suchen; unbekannt/abgelaufen ⇒ 404).
- `bestaetige(token, now) ⇒ { status, schon? }` – `gehalten` + `heldUntil > now` ⇒ `sofort`: `bestaetigt` + `bestaetigtAt` + `manageHash` + G02 + K20; `anfrage`: `angefragt` + `manageHash` + G03 + K20-Block `anfrage`; schon bestätigt ⇒ `{ status, schon: true }`; abgelaufen ⇒ 410 `{ code: 'abgelaufen', betrieb: { name, telefon, websiteUrl } }`.
- [ ] Tests (Nachbau `defaultLimit 25`, `mailSink`, `warteAufVersand`): Halten ⇒ G01 ohne Gastname, Zellen; Bestätigen ⇒ G02 mit `icalEvent` und `#m=`-Link + K20 an den Betrieb, kein BCC; Anfrage ⇒ G03/K20-Block; zweiter Klick ⇒ `schon`; nach 31 min ⇒ 410; `email_normalisiert` (`' Max@GMail.com '` zählt gegen dieselbe Grenze); vierte offene Buchung ⇒ `zu_viele`, keine Buchung, G01-Block; Slot weg ⇒ 409, Buchung `verfallen`, keine Zellen; Zustimmung vorzeitig fehlt ⇒ 400 Feld `vorzeitig`; `vorschau` mit Verwaltungstoken liefert `gast`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Buchung halten und bestaetigen“
### Task 13: Gast: absagen, verschieben, widerrufen
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufVerwalten.test.js`.
**Produces:** `sageAbGast(token, now)` (bis `absageBisStd` vor Beginn, sonst 409 Feld `_` „Absage nur telefonisch: {telefon}“; Zellen frei, G06 CANCEL, K21 `abgesagt`); `verschiebeGast(token, { tag, zeit }, now)` (gleiche Frist; Belegung `ohneBuchung`; `belegeZellen({ …, ausser: id })`, dann `gibZellenFrei(id, { behalte: neueZellen })`; `icsSequenz+1`; G07 + K21 `verschoben`); `zeitenFuerToken(token, { tag | monat }, now)` (für die Gästeseite, `ohneBuchung`); `widerrufeGast(token, { name }, now) ⇒ { eingang: { datum, uhrzeit } }` und `widerrufeOhneLink(ctx, { name, email, nummer }, now) ⇒ { eingang }` (Treffer mit Widerrufsrecht ⇒ `widerrufenAt`, `abgesagt` von `gast`, Zellen frei, G08 `zugeordnet`, K21 `widerrufen`; Treffer nach Frist ⇒ nur speichern + G08 `nach_frist` + K21 `nach_frist`; kein Treffer ⇒ G08 + K21 `nicht_zugeordnet`; Grenzen aus Global Constraints; Antwort immer gleich, Eingang in Berliner Zeit als Strings).
- [ ] Tests: Absage vor/nach Frist; Verschieben 10:00→10:30 (überlappend) gelingt, ICS Sequenz 1; Verschieben auf belegte Zeit ⇒ 409, alte Zellen unverändert; Widerruf 2 h vor Termin trotz abgelaufener Stornofrist; Widerruf ohne Treffer = gleiche Antwort + K21 `nicht_zugeordnet`; vierter G08 an dieselbe Adresse am Tag wird nicht gesendet; nicht angenommene Anfrage ⇒ kein Widerruf, nur Absage.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste“
### Task 14: Betrieb: Aktionen, manuelle Termine, Sperren
**Files:** Modify `server/services/termine/ablauf.js`; Test `test/termineAblaufBetrieb.test.js`.
**Produces:** `aktion(ctx, bookingId, { aktion: 'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen'|'anonymisieren', grund, nachricht, tag, zeit }, now)` (annehmen ⇒ `bestaetigt`, `angenommenAt`, G02 Block `anfrage_angenommen`; ablehnen ⇒ `abgelehnt`, G04, Zellen frei; absagen ⇒ G06 mit `absage_von` = Betriebsname; verschieben ⇒ wie Gast, ohne Frist, `ohneRegeln`; nichterschienen nur nach Beginn; anonymisieren ⇒ Gastfelder leeren, Status bleibt); `manuell(ctx, eingabe, now)` (`quelle 'manuell'`, `bestaetigt`, `verbraucher false`, Länge ≤ 500 min, G02 nur mit `bestaetigungSenden`); `sperre(ctx, { ressource, tag, von, bis, titel }, now)` (`quelle 'sperre'`, `bestaetigt`, `leistungName` = Titel, Länge ≤ 500 min sonst 400 Feld `bis` „Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.“); `entsperre(ctx, bookingId)`.
- [ ] Tests: annehmen ⇒ G02 mit Verwaltungslink (gleicher Link wie in G03); ablehnen ⇒ Zellen frei; manuell ohne Haken ⇒ keine Gastmail; Sperre 09:00–17:30 ⇒ 400; Sperre 09:00–13:00 blockiert Slots; anonymisieren; `nichterschienen` vor Beginn ⇒ 409.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren“
### Task 15: Hintergrund-Jobs
**Files:** Create `server/services/termine/jobs.js`, `test/termineJobs.test.js`; Modify `server/index.js`.
**Produces:** jeder Job kehrt bei `termineAus()` sofort zurück (keine Abfrage); `collection_not_found` (404) ⇒ höchstens eine Warnung je Stunde. `minutenJob(now)` (abgelaufene Haltungen ⇒ `verfallen`, Zellen frei; `outbox.verarbeite`); `fuenfMinutenJob(now)` (Erinnerungen nach Spec 4: Marker zuerst, dann G05; `erledigt`; vergangene Anfragen ⇒ `abgelehnt`); `taeglicherJob(now)` (Löschfristen, Outbox aufräumen, verwaiste Zellen, `disabled`-Funktionen > 30 Tage: Buchungen, Zellen, Einstellung). In `server/index.js` wie die vorhandenen Jobs (`setTimeout(…).unref()` + `setInterval(…).unref()`, eigene Fehlerbehandlung): 60 s, 5 min, täglich (+180 s).
- [ ] Tests: Modus off ⇒ keine Appwrite-Anfrage; Verfall; Erinnerung genau einmal (zwei Läufe), nicht bei Buchung kürzer als `stundenVorher` vor Beginn, nachgeholt nach 20 min Jobausfall; `erledigt`; vergangene Anfrage ⇒ `abgelehnt`, Zellen frei; Löschfristen je Status; `verfall_und_bestaetigung_gleichzeitig` (Job und `bestaetige` parallel ⇒ ein Endzustand, keine G02 nach `verfallen`); fehlende Collection ⇒ eine Warnung.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Hintergrund-Jobs“
## Portal: Schnittstellen
### Task 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.
### Task 16: Öffentliche Schnittstelle
**Files:** Create `server/services/termine/limits.js`, `server/routes/publicTermine.js`, `test/publicTermine.test.js`, `test/publicTermineRohling.test.js`; Modify `server/index.js` (nach `kundenansichtGuard`, vor dem globalen `express.json`), `server/middleware/kundenansichtGuard.js` (`PASS_PREFIXES` + `'/api/public/'`), `test/kundenansichtGuard.test.js`.
**Consumes:** Tasks 11–14, `analyseDomains(project)` aus `server/services/analyse.js`.
**Routen** (`/api/public/termine/v1`; feste Pfade vor `/:siteKey`):
- `GET /:siteKey/config` ⇒ `{ aktiv: true, heute, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl }, anrede, farbe, leistungen: [{ id, name, beschreibung, dauer, preis, preisArt, buchungsart, knopf, verbraucher, ressourcen }], ressourcen: [{ id, name, typ }], personWaehlbar, regeln: { horizontTage, absageBisStd }, zahlung: { modus: 'vorort', text }, preisHinweis, storno: { text, version } | null, widerruf, erinnerung: { anbieten, stundenVorher }, gastFelder, hinweis, texte: { storno, erinnerung, vorzeitig, gesundheit }, formToken }` (`public, max-age=60`, ohne ETag).
- `GET /:siteKey/tage?leistung&ressource&monat` ⇒ `{ tage, monat: monatInfo }` (`max-age=15`); `GET /:siteKey/zeiten?leistung&ressource&tag` ⇒ `{ zeiten: ['09:00'], datumLang, vorzeitigNoetig }` (`no-store`; `vorzeitigNoetig` serverseitig für diesen Tag).
- `POST /:siteKey/buchungen` (Eingabe von `halte` + `formToken`, `kontakt_url`) ⇒ 201 `{ ok: true, email: 'm***@gmail.com', minuten: 30 }`.
- `POST /:siteKey/widerruf` `{ name, email, nummer }` ⇒ `{ ok: true, eingang: { datum, uhrzeit } }`.
- `POST /termin/vorschau|bestaetigen|absagen` `{ token }`, `POST /termin/verschieben` `{ token, tag, zeit }`, `POST /termin/widerruf` `{ token, name }`, `POST /termin/tage` `{ token, monat }`, `POST /termin/zeiten` `{ token, tag }`.
- `GET /kalender/:siteKey/:feedToken.ics` (konstantzeitiger Vergleich mit den `feedToken` der Ressourcen; falsch ⇒ 404 ohne Hinweis; `text/calendar; charset=utf-8`, `max-age=300`; bestätigte Termine + Sperren der Ressource, −7 bis +90 Tage).
- CORS: GET `*`; POST/OPTIONS erlaubt, wenn Origin-Host ∈ `analyseDomains(project)` ∪ {Host von `config.publicBaseUrl`} oder `new URL(origin).host === req.get('host')` (gleiche Herkunft); für `/termin/*` nur die beiden letzten; dann `Access-Control-Allow-Origin: <origin>`, `Vary: Origin`; sonst 403 `{ fehler: { _: 'Nicht erlaubt' } }`; Preflight 204 (`GET, POST, OPTIONS`, `Content-Type`, `Max-Age 86400`). Router-Parser + Fehlermiddleware laut Global Constraints; `/tage` und `/zeiten` 10 s Speicher-Cache je (siteKey, Parameter).
- [ ] `publicTermine.test.js` (startTestApp + Nachbau + `mailSink`; Origin/`X-Forwarded-For` je Test, `resetTermineLimitsForTests()`, `config.termine.formMinSekunden = 0`): rekursiv keine verbotenen IDs; unbekannt/off/disabled ⇒ `{aktiv:false}` (GET und POST); fremde Origin ⇒ 403, gleiche Herkunft und `liveDomain`-Origin erlaubt; Preflight (`send('OPTIONS', …)`); Honeypot und `zu_schnell` (mit `formMinSekunden = 3`) ⇒ 201 ohne Buchung; `abgelaufen` ⇒ 400; 11. Buchung je IP ⇒ 429, IPv6 /64 geteilt; Knopf je Fall in `config`; Ablauf halten → vorschau → bestätigen → verschieben → absagen über HTTP; Widerruf mit/ohne Treffer gleiche Antwort; Kalender mit falschem Token 404, richtigem enthält den bestätigten, nicht den gehaltenen Termin; `failNext` ⇒ 503 ohne Rohtext; Kundenansicht-Cookie stört öffentliche POSTs nicht.
- [ ] `publicTermineRohling.test.js` (nacktes `express()` nur mit dem Router): 20 KB ohne `content-length` (chunked) ⇒ 413; kaputtes JSON ⇒ 400; Antwort ohne Parser-Rohtext. Reihenfolge-Test: in `server/index.js` steht `app.use(kundenansichtGuard)` vor `'/api/public/termine/v1'` vor `app.use(express.json(`.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz“
### Task 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.
### Task 17: Funktionen verallgemeinern, Team-Route, Kundenansicht
**Files:** Modify `server/services/siteFeatures.js`, `server/routes/admin/funktionenAdmin.js`, `server/routes/funktionen.js`, `server/middleware/kundenansichtGuard.js`, `server/services/customerViewLog.js`, `server/services/customerAdmin.js`, `server/services/termine/kontext.js`; Tests `test/funktionenRoutes.test.js`, `test/siteFeatures.test.js`, `test/kundenansichtGuard.test.js` (Erlaubnisliste exakt; `MOUNTS` + `/api/termine` erst in Task 18), `test/customerViewLog.test.js` (zehn Aktionen), neu `test/funktionenTermine.test.js`.
**Produces:** `FEATURE_KEYS = ['news','termine']`; `FEATURE_LABELS = { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' } }`; `findActiveBySiteKey(siteKey, featureKey = 'news')`; `activateClosedTickets` unverändert (TP2). Team: `POST /api/team/funktionen/:customerId/:key` (`key` ∉ FEATURE_KEYS ⇒ 404; Meldung „Für dieses Projekt gibt es noch {keine}“); `adminView` lädt die Funktionsdokumente des Kunden einmal. `GET /api/funktionen` + `termine: { verfuegbar: termineFuerKunde(req.session.email) }`. `KUNDENANSICHT_ALLOWED` + `['POST', /^\/api\/termine\/.+$/]`, `['PATCH', /^\/api\/termine\/einstellungen\/?$/]`, `['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/]`; `VIEW_ACTIONS` + `termin_einstellungen`, `termin_aktion`, `termin_manuell`. `deleteCustomerWithPortalAccess` schaltet Terminplaner-Funktionen des Kunden ab (Notiz „Kunde gelöscht“). `kontext.js` nutzt nun `findActiveBySiteKey(…, 'termine')`.
- [ ] Tests: Team aktiviert `termine` für Projekt von c1, News unverändert; `/:customerId/kalender` ⇒ 404; Meldungen grammatisch; `GET /api/funktionen` mit/ohne Pilot-E-Mail; Erlaubnisliste exakt (inkl. Negativfall `POST /api/termineX`); Kunde löschen ⇒ `disabled`; bisherige Tests grün.
- [ ] FAIL → implementieren → PASS → Commit „Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)“
### Task 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.
### Task 18: Kunden-Schnittstelle `/api/termine`
**Files:** Create `server/routes/termine.js`, `test/termineRoutes.test.js`; Modify `server/index.js` (`app.use('/api/termine', termineRoutes)`), `test/kundenansichtGuard.test.js` (`MOUNTS` + `['/api/termine', termineRoutes]`).
**Consumes:** Tasks 4, 6, 11–14, 17. Alle Routen: `requireSession`; `projectId` (Query/Body); `assertOwnProject`; `termineFuerKunde(session.email)` sonst 409 „Der Terminplaner ist noch nicht verfügbar.“; Funktion `off`/fehlt ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; `disabled` ⇒ nur Lesen + Export; Schreiblimit 120/h je Kunde; Protokoll: PATCH einstellungen, POST vorlage, POST feed ⇒ `termin_einstellungen`; POST aktion, DELETE ⇒ `termin_aktion`; POST buchungen ⇒ `termin_manuell`.
**Routen:** `GET /einstellungen?projectId` ⇒ `{ state, aktivSeit, einstellung | null, version, vorlagen, feeds: { [ressourceId]: url }, siteKey, einbau: { snippet }, anfragenOffen }`; `POST /einstellungen/vorlage` `{ projectId, vorlage }` (nur ohne Einstellung, sonst 409; Betrieb vorbefüllt aus `analysePrivacyData(customer)` (Name/Firma, Anschrift, E-Mail) + `phone`); `PATCH /einstellungen` `{ projectId, version, einstellung }` ⇒ `{ einstellung, version }` | 400 `{ fehler }` | 409; `GET /buchungen?projectId&von&bis&status=a,b` (Zeitraum ≤ 62 Tage) ⇒ `{ buchungen: [{ id, nummer, status, quelle, startsAt, endsAt, datum, zeit, leistungId, leistung, dauer, titel, person, ressourceId, gastName, gastEmail, gastTelefon, gastNachricht, erinnerung, zustellFehler, angenommenAt, widerrufenAt, abgesagtVon, absageGrund }] }`; `GET /zeiten?projectId&leistung&ressource&tag[&ohne=bookingId]` (`ohneRegeln`); `POST /buchungen` `{ projectId, art: 'manuell'|'sperre', … }` ⇒ 201; `POST /buchungen/:id/aktion`; `DELETE /buchungen/:id?projectId` (nur Sperren); `POST /ressourcen/:id/feed` ⇒ `{ url }`; `GET /export.csv?projectId` (`text/csv; charset=utf-8`, BOM, `;`, Kopf `Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht`, Zellen mit `= + - @` am Anfang ⇒ `'` voran).
- [ ] Tests (`send` für PATCH/DELETE): fremdes Projekt 403; ohne Pilot 409; Vorlage zweimal ⇒ 409; PATCH alte Version ⇒ 409; Löschen mit künftigem Termin ⇒ 400 mit Anzahl; Aktion annehmen ⇒ G02; Kundenansicht darf PATCH/DELETE (mit Header) und es wird protokolliert; Guard-Schleife „jede Schreib-Route außerhalb der Liste ⇒ 403“ grün mit dem neuen Router; `disabled` ⇒ PATCH 409, Export 200; CSV entschärft.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Kunden-Schnittstelle“
## Portal: Oberflächen
### Task 14: Einbau-Skript, Demo, Einbauanleitung
**UI-Testserver (Tasks 19–21):** `startAppwriteMock({ schema, datetimes, defaultLimit: 25 })` + `startFakeStripe()` + `useTestEnv({ appwriteUrl, stripeUrl })`, dann dynamische Imports; `startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, '/api/funktionen': funktionenRoutes, '/': express.static('public') })`; danach `config.publicBaseUrl = app.url`, `config.termine.mode = 'pilot'`, `config.termine.pilotEmails = ['*']`, `config.termine.formMinSekunden = 0`; Seed: Projekt + aktive Funktion + Einstellung aus Vorlage `salon` mit Plan Mo–So 00:00–24:00, `feiertageGeschlossen false`, `vorlaufMin 0`, `maxOffeneJeEmail 10`; `mailSink`. Screenshots nach `/root/reports/terminplaner-ui/`.
### Task 19: Einbau-Skript, Demo, Einbauanleitung
**Files:** Create `public/embed/termine.js`, `public/embed/termine-demo.html`, `docs/funktionen/termine-einbau.md`, `test/ui/termine-widget.ui.mjs`, `test/termineEmbedStatic.test.js`.
**Consumes:** Task 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`.
**Consumes:** Task 16 (Antwortformen). **Produces:** Einbau nach Spec 7 (`data-webklar-termine`, `data-api`, `data-modus inline|knopf`, `data-knopf`, `data-leistung`); Klassen `wkt-*`; `<style id="wkt-style">` einmal, `@layer wkt`; CSS-Variablen `--wkt-akzent, --wkt-akzent-text, --wkt-hintergrund, --wkt-text, --wkt-rand, --wkt-radius, --wkt-schrift, --wkt-schrift-titel`; Schritte Spec 4; Monatsraster aus `monat` (APG: Pfeile, Pos1/Ende, Bild↑/↓, `aria-selected`, `aria-disabled`), Zeiten als Knöpfe; `aria-live="polite"`; Texte Sie/Du nach `anrede`, Haken-Texte aus `texte`, Knopf aus `leistungen[].knopf`; Honeypot `kontakt_url` (unsichtbar, `autocomplete="off"`, `tabindex="-1"`, `aria-hidden`); `config` neu laden, wenn älter als 100 min; Fuß „Vertrag widerrufen“ (Formular → `POST /widerruf`), „Datenschutz“, „Terminbuchung über WEBklar“; Knopfmodus lädt beim Klick; Fehler ⇒ Ausweichtext bzw. „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“. Einbauanleitung: Snippet, Variablen, drei Stilbeispiele, Datenschutz-Absatz (Task 7), Abnahme-Checkliste, API-Kurzreferenz.
- [ ] `termineEmbedStatic.test.js`: Quelltext ohne `localStorage`, `sessionStorage`, `document.cookie`, `Date`, `getDay`, `toLocale`, `Intl.`; `innerHTML` nur mit Konstanten.
- [ ] Playwright (UI-Testserver): inline bis „Fast geschafft“ + G01 im Sink; Knopfmodus mit `<dialog>`; nur Tastatur; 390 px; `timezoneId: 'America/New_York'` zeigt dieselben Uhrzeiten; Anrede Du; zwei Widgets; Server gestoppt ⇒ Ausweichtext.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Einbau-Skript, Demo und Einbauanleitung“
### Task 15: Gästeseiten
### Task 20: 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.
**Produces:** liest `#v=`/`#m=`, entfernt den Hash per `history.replaceState`, ruft `/termin/vorschau`; Ansichten Bestätigen (Zusammenfassung + `knopf`), Bestätigt/Angefragt, Abgelaufen (Link `websiteUrl`, Telefon), Verwalten (Absagen mit Rückfrage in der Seite, Verschieben über `/termin/tage|zeiten`, „Vertrag widerrufen“ → Formular nach § 356a mit vorbefülltem Namen/Buchungsnummer/E-Mail, Knopf „Widerruf bestätigen“, Eingang mit `eingang.datum`/`uhrzeit`); Kopf in `farbe`; Fuß: Impressum/Datenschutz des Betriebs + „Impressum WEBklar“ (`/impressum.html`); `noindex`; keine Cookies; Sie/Du.
- [ ] Playwright: G01-Link aus dem Sink öffnen ⇒ bestätigen ⇒ G02 im Sink; zweiter Aufruf „bereits bestätigt“; abgelaufen; absagen; verschieben; widerrufen (`timezoneId: 'America/New_York'` zeigt Berliner Eingangszeit); 390 px.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen“
### Task 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“
### Task 21a: Portal-Ansicht – Gerüst, Kachel, Termine
**Files:** Create `public/termine.js`, `public/termine.css`, `public/termine-view.js`, `test/termineView.test.js`, `test/ui/termine-portal.ui.mjs`; Modify `public/funktionen.js`, `public/app.js` (`VIEW_HASHES.termine = 'termine'`, `viewRefreshers.termine`, `APP_PARENT.termine = 'funktionen'`, `FEATURE_LABELS.termine = 'Terminplaner'`), `public/dashboard.html` (`<section class="kb-view" id="view-termine"><header class="kb-head"><h1>Terminplaner</h1><p>…</p></header><div id="termine-body">…`, Stylesheet), `test/funktionenTiles.test.js`.
**Produces:** Kachel (`termine?.verfuegbar` fehlt/false ⇒ „Kommt bald“; `project.features?.termine || { state: 'off' }`; *Aus* „Wir schalten den Terminplaner für dich frei.“ + Chat / *Aktiv* „Termine öffnen“ + „{n} Anfragen warten“ aus `anfragenOffen` / *Abgeschaltet*); `termine-view.js` (rein): `fensterParsen('09:00–12:30, 13:30–18:00')`, `fensterText`, `statusLabel`, `wocheVon(tag)`, `gruppiereNachTag(buchungen)`; `termine.js` `initTermine(root, { projectId })`: Projektauswahl, Vorlagenauswahl ohne Einstellung, Reiter-Gerüst, Reiter **Termine** (Tag/Woche/Monat mit `von/bis`, Filter → Statuslisten, Aktionen mit Rückfragen in der Seite, „+ Termin eintragen“, „Zeit sperren“ mit 500-Minuten-Hinweis, Hinweis „Bestätigung nicht zugestellt“), `disabled` ⇒ nur Lesen.
- [ ] Tests reine Helfer; Kachelzustände; Playwright (Fixture `page.route` und ein Lauf gegen den UI-Testserver): Vorlage wählen, Liste mit allen Status, Anfrage annehmen, App-Ansicht 390 px als Unterseite von Funktionen (Titel „Terminplaner“).
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht, Kachel, Termine“
### Task 21b: Portal-Ansicht – Leistungen, Team & Plätze, Einstellungen
**Files:** Modify `public/termine.js`, `public/termine.css`, `test/ui/termine-portal.ui.mjs`.
**Produces:** Reiter **Leistungen** (Liste, Reihenfolge, Felder Task 4, Gesundheits-Hinweis), **Team & Plätze** (Wochenplan je Tag mit `fensterParsen`, Leistungszuordnung, Abwesenheiten, Bundesland, Sonderöffnungen, Kalender-Abo-Link kopieren/neu), **Einstellungen** (Spec 9 Punkt 4; Zahlung „online“ ausgegraut „kommt bald“; Hinweis Widerruf schlägt Stornofrist; Stornotext-Muster gekennzeichnet; Export-Link); Speichern mit `version`, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld.
- [ ] Playwright: Leistung anlegen, Wochenplan ändern, Speichern, Konflikt (zweiter Tab), Feldfehler (`javascript:`-URL), Feed-Link neu erzeugen.
- [ ] FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen“
## WOMS
### Task 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`.
### Task 22: Funktionen-Reiter für mehrere Funktionen
**Repo:** `/home/webklar/apps/ticket.webklar.com/dev-termine` (aus V3; existiert er nicht, Schritte aus V3 zuerst ausführen).
**Files:** Create `src/lib/funktionenView.js`, `tests/unit/funktionenView.test.mjs`; Modify `src/lib/teamApi.js` (`setFeature(customerId, key, { projectId, action, note })`; `setNewsFeature` als Wrapper), `src/components/customer/CustomerFunktionen.jsx`, `src/lib/viewLog.js` (+ `termin_einstellungen: 'Terminplaner-Einstellungen geändert'`, `termin_aktion: 'Termin bearbeitet'`, `termin_manuell: 'Termin eingetragen'`), `tests/unit/viewLog.test.mjs`.
**Produces:** `FUNKTIONEN = [{ key: 'news', label: 'News', bestaetigen: { activate: 'News ohne Zahlung aktivieren?', disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.' } }, { key: 'termine', label: 'Terminplaner', bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.' } }]`, `ZUSTAND` (bisheriges `STATE`), `funktionOf(project, key)`, `aktionen(state)`, `mergeFunktion(projects, projectId, key, aktualisiert)`; Reiter zeigt je Projekt eine Zeile je Funktion, Ticketnummer als Link `/tickets?woid=`, Antwort übernimmt nur `features[key]`.
- [ ] Tests (Register, Aktionen, Merge überschreibt die andere Funktion nicht); `npm test`; `nice npx vite build` (vorher `free -m`).
- [ ] FAIL → implementieren → PASS → Commit „Funktionen-Reiter: News und Terminplaner je Projekt“
---
## Abschluss
- [ ] Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und 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/`.
- [ ] Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und Global Constraints; Critical/Important beheben.
- [ ] `npm test` (Node 24); reine Termine-Tests unter Node 20 (`docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js`).
- [ ] Ledger nach `/root/reports/terminplaner-tp1/`.
## Livegang (je Schritt Freigabe des Nutzers; der Nutzer pusht `main`)
1. **L1 Portal:** 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.
1. **L1 Portal:** auf aktuelles `origin/main` rebasen, `npm test`, Push `feature/terminplaner:main` (Deploy installiert `feiertagejs`). Schalter `off` ⇒ keine Jobs, nichts sichtbar. Prüfen: `/api/public/termine/v1/aaaaaaaaaaaaaaaa/config` ⇒ `{"aktiv":false}`, `/api/termine/einstellungen` ohne Login 401, Log ohne Fehler.
2. **L2 Schema:** `docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs --check`, dann ohne `--check`; erneut `--check` ⇒ nichts fehlt.
3. **L3 Schalter:** Compose (`kundenbereich-server`): `TERMINE_ENABLED=${TERMINE_ENABLED:-off}`, `TERMINE_PILOT_EMAILS=${TERMINE_PILOT_EMAILS:-}`; `/home/webklar/apps/.env`: `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`).
3. **L3 Schalter:** Compose (`kundenbereich-server`): `TERMINE_ENABLED=${TERMINE_ENABLED:-off}`, `TERMINE_PILOT_EMAILS=${TERMINE_PILOT_EMAILS:-}`; `/home/webklar/apps/.env` (Sicherung `.bak-termine`): `TERMINE_ENABLED=pilot`, `TERMINE_PILOT_EMAILS=kenso.gri@gmail.com`; `docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server` (nie `preview-portal`).
4. **L4 WOMS:** Push `feature/terminplaner:test`; Bundle enthält „Terminplaner“.
5. **L5 Pilot:** WOMS Kunde „test“ → Funktionen → Terminplaner für „Test-Website“ aktivieren; Portal (Login des Testkunden): Vorlage, Leistungen, Zeiten, Betriebs-E-Mail kenso.gri@gmail.com; `WEBklar/test-website`: Seite „Termin buchen“ mit Widget (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.
5. **L5 Pilot:** WOMS Kunde „test“ → Funktionen → Terminplaner für „Test-Website“ aktivieren; Portal (Login des Testkunden): Vorlage, Leistungen, Zeiten, Betriebs-E-Mail kenso.gri@gmail.com; `WEBklar/test-website`: Seite „Termin buchen“ mit Widget + Datenschutz-Absatz-Entwurf, Push ⇒ Deploy; Testbuchungen: bestätigen, Anfrage annehmen/ablehnen, Erinnerung (Termin knapp über der Erinnerungszeit), absagen, verschieben, widerrufen, Kalender-Abo in Google.
6. **L6:** Nutzertest; Testbuchungen nach Wunsch löschen.
@@ -74,8 +74,9 @@ legt nur Fehlendes an, Indizes nur bei neuen Collections). Alle Collections `per
`documentSecurity: false` (nur Server-Schlüssel). `test/support/schema.js` liest das neue Skript mit.
**`termineConfig`** – eine Einstellung je Funktion; Dokument-ID = ID des `portalFeatures`-Dokuments.
`customerId`, `projectId` (64), `data` (Text, JSON bis 200 000 Zeichen), `version` (Ganzzahl),
`updatedAt`, `updatedBy` (100). Inhalt von `data` (validiert in `services/termine/einstellungen.js`):
`customerId`, `projectId` (64), `einstellung` (Text, JSON bis 200 000 Zeichen; nicht `data` – das Wort
behandelt `appwriteAdmin.formatRequestBody` als fertig verpackten Body), `version` (Ganzzahl),
`updatedAt`, `updatedBy` (100). Inhalt von `einstellung` (validiert in `services/termine/einstellungen.js`):
```jsonc
{
@@ -202,7 +203,9 @@ Verwaltungslink und ggf. Widerrufsbelehrung; K20 an den Betrieb mit .ics) bzw.
K20-Block „Anfrage“ an den Betrieb). Abgelaufen ⇒ „Diese Reservierung ist abgelaufen“ + Link zur Website.
Ein Token gilt genau einmal.
**Verwaltungslink** `termin.html#m=<token>` (gültig bis Terminende + 1 Tag): zeigt den Termin und je nach
**Verwaltungslink** `termin.html#m=<token>` (gültig bis Terminende + 1 Tag; Token = HMAC über die
Buchungs-ID, damit spätere Mails wie die Erinnerung denselben Link enthalten; gespeichert nur sein Hash):
zeigt den Termin und je nach
Lage
- „Termin absagen“ bis `absageBisStd` vor Beginn (danach Text „Absage nur telefonisch: …“) ⇒ `abgesagt`,
Zellen frei, G06 an Gast (ICS CANCEL), K21 an den Betrieb;
@@ -223,15 +226,17 @@ Betrieb weiter – auch wenn keine Buchung passt (dann Block „nicht zugeordnet
**Betrieb im Portal**: Anfrage annehmen (`angefragt` → `bestaetigt`, G02) oder ablehnen (`abgelehnt`, G04,
Zellen frei); bestätigten Termin absagen (Pflicht-Grund-Auswahl + optionale Nachricht, G06) oder verschieben
(G07); „nicht erschienen“; Termin von Hand eintragen (`quelle: manuell`, Gast-E-Mail optional, G02 nur wenn
(G07); „nicht erschienen“; Gastdaten auf Wunsch löschen (anonymisieren); Termin von Hand eintragen (`quelle: manuell`, Gast-E-Mail optional, G02 nur wenn
der Haken „Bestätigung an den Gast senden“ gesetzt ist; ohne Widerrufsbelehrung durch das System, die ist
bei telefonischen Buchungen Sache des Betriebs); Zeit sperren (`quelle: sperre`).
bei telefonischen Buchungen Sache des Betriebs); Zeit sperren (`quelle: sperre`, höchstens 500 Minuten am Stück; längeres über Abwesenheiten).
**Hintergrund-Jobs** (`server/index.js`, Muster der vorhandenen `setInterval`-Jobs, je Job eigene Fehlerbehandlung):
- jede Minute: abgelaufene `gehalten` ⇒ `verfallen` + Zellen löschen; Warteschlange senden (6).
- alle 5 Minuten: Erinnerungen (`bestaetigt`, `erinnerung`, kein `erinnerungGesendetAt`,
`startsAt` in `[now + stundenVorher − 5 min, now + stundenVorher]`; erst Marker setzen, dann einreihen);
`bestaetigt` mit `endsAt < now − 1 h` ⇒ `erledigt`.
`now + 1 h < startsAt ≤ now + stundenVorher` und Vertragsschluss vor `startsAt − stundenVorher`, damit
ein Ausfall des Jobs keine Erinnerung verschluckt; erst Marker setzen, dann einreihen);
`bestaetigt` mit `endsAt < now − 1 h` ⇒ `erledigt`; `angefragt` mit `startsAt < now` ⇒ `abgelehnt`
(vom System, Zellen frei).
- täglich: Löschfristen (11), verwaiste Zellen (Buchung fehlt oder nicht mehr belegend).
### 5. Doppelbuchung verhindern
@@ -268,13 +273,17 @@ in `farbe`, Fuß mit Betrieb (Name, Adresse, Telefon, E-Mail, Links Impressum/Da
„Diese E-Mail wurde im Auftrag von {Betrieb} über den Terminplaner von WEBklar versendet.“ Jede Vorlage hat
Blöcke `sie`/`du`.
Der Betreff ist je Vorlage der Platzhalter `{{betreff}}`; der Server setzt ihn je Anrede und Fall (Du-Form
sinngemäß: „Bitte bestätige deinen Termin …“). G01 und G08 enthalten keinen vom Gast eingegebenen Text
(kein Missbrauch als Mail-Relais).
| ID | an | Betreff (Sie-Form) | Inhalt |
|---|---|---|---|
| G01 | Gast | Bitte bestätigen Sie Ihren Termin bei {betrieb} | Datum, Uhrzeit, Leistung, Knopf „Termin bestätigen“, „Das waren Sie nicht? Dann ignorieren Sie diese E-Mail.“ Nichts Werbliches. |
| G02 | Gast | Ihr Termin bei {betrieb} am {datum} | Zusammenfassung, Buchungsnummer, Zahlungsart, Stornoregel (Wortlaut), Verwaltungslink, .ics (REQUEST); bei Widerrufsrecht: Widerrufsbelehrung + Muster-Widerrufsformular + Hinweis auf die Widerrufsfunktion (§ 312f Abs. 2 BGB) |
| G03 | Gast | Ihre Terminanfrage bei {betrieb} | „Der Betrieb meldet sich“, Verwaltungslink (nur Absage) |
| G04 | Gast | Ihre Terminanfrage bei {betrieb} | abgelehnt, optionale Nachricht des Betriebs |
| G05 | Gast | Erinnerung: Ihr Termin morgen um {zeit} | Termin, Verwaltungslink; sonst nichts |
| G05 | Gast | Erinnerung: Ihr Termin am {datum} um {zeit} | Termin, Verwaltungslink; sonst nichts |
| G06 | Gast | Ihr Termin am {datum} wurde abgesagt | wer abgesagt hat, Grund/Nachricht, .ics (CANCEL) |
| G07 | Gast | Ihr Termin wurde verschoben | alte/neue Zeit, .ics (REQUEST, Sequenz + 1) |
| G08 | Gast | Eingangsbestätigung Ihres Widerrufs | Inhalt der Erklärung, Datum und Uhrzeit des Eingangs |
@@ -289,7 +298,7 @@ Gästedaten).
„{Leistung} – {Betrieb}“ (an den Betrieb: „{Leistung}: {Gastname}“), `LOCATION` = Adresse, `ORGANIZER` =
Betrieb, `ATTENDEE` = Gast (`RSVP=FALSE`), `METHOD` REQUEST/CANCEL. Keine „In Google eintragen“-Links.
**Kalender-Abo für den Betrieb**: je Ressource geheimer Link `…/v1/kalender/<feedToken>.ics` (nur
**Kalender-Abo für den Betrieb**: je Ressource geheimer Link `…/v1/kalender/<siteKey>/<feedToken>.ics` (nur
bestätigte Termine und Sperren der nächsten 90 Tage, `X-PUBLISHED-TTL: PT15M`; Google aktualisiert nur
alle paar Stunden – steht so im Portal). Link neu erzeugen macht den alten ungültig.
@@ -348,7 +357,7 @@ abgeleitet, nur zur Anzeige).
| `POST /bestaetigen` `{token}` | E-Mail bestätigt | – |
| `POST /absagen`, `POST /verschieben` `{token, …}` | Verwaltung durch den Gast | – |
| `POST /widerruf` `{name, email, nummer, token?}` | Widerrufsfunktion | – |
| `GET /kalender/:feedToken.ics` | Kalender-Abo des Betriebs (Schlüssel im Pfad statt siteKey) | `max-age=300` |
| `GET /kalender/:siteKey/:feedToken.ics` | Kalender-Abo des Betriebs (Token je Ressource, konstantzeitig verglichen) | `max-age=300` |
Schutz (ohne Captcha-Dienst):
- **CORS**: GETs `Access-Control-Allow-Origin: *`; POSTs nur von erlaubten Origins = Hosts des Projekts