Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-02-terminplaner-tp1-kern.md
ClaudeandClaude Sonnet 5.5 f134ed0032 Terminplaner: Abschluss – Spezifikation und Rechtshinweise an den Stand des Codes angeglichen
- Spezifikation: Abweichungen aus den Abschlussreviews nachgezogen (kontakt_url, kein ETag und formMinSekunden,
  Token-Endpunkte unter /termin/*, Zellen bis blockEndsAt, Outbox-Fristen, Reihenfolge Buchung vor Zellen,
  Widerrufsfrist nach §§ 187/188/193 BGB, Abwesenheit schlägt Sonderöffnung, Kunden-API vollständig,
  Impressum-Adresse) sowie R53 (Wartezeit vor dem Absenden), R54 (Widerruf als Knopf, im Knopfmodus
  dauerhaft) und R55 (Widerruf bei jeder Verbraucher-Buchung, auch Anfragen).
- Neuer Abschnitt "Betrieb und Livegang – Hinweise" in 14 (SESSION_SECRET nie rotieren, Modus off stoppt die
  Löschjobs, Zellbereinigung vor TP2 begrenzen, Warteschlange listet jede Minute alles Fällige).
- Plan: Impressum-Link auf https://webklar.com/impressum.
- Rechtshinweise: "offen 1" durch R14 erledigt, Haken für vorzeitigen Beginn nach R29/R31, Widerrufshinweis
  nach R55 und G02; offene Punkte zu Abo-Ende und Knopfmodus in den Datenschutz-Entwürfen.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-03 08:17:44 +00:00

56 KiB
Raw Permalink Blame History

Terminplaner TP1 Kern – Umsetzungsplan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Gäste buchen auf der Website eines Betriebs Termine (Person oder Platz, sofort oder als Anfrage, Bestätigung per E-Mail-Link, Absagen/Verschieben/Widerrufen per Link); der Betrieb verwaltet alles im Portal; Pilot mit dem Kunden „test“.

Architecture: Neue Funktion termine im Kundenportal (Express, Appwrite, vanilla JS). Reine Bausteine in server/services/termine/ (Zeit, Feiertage, Einstellungen, Slots, Zellen gegen Doppelbuchung, ICS, Mails, Warteschlange, Ablauf, Jobs), dazu eine öffentliche, versionierte Schnittstelle /api/public/termine/v1, eine Kunden-Schnittstelle /api/termine, ein Einbau-Skript embed/termine.js, Gästeseiten termin.html und die Portal-Ansicht #termine. WOMS bekommt Aktivieren/Abschalten je Funktion.

Tech Stack: Node 20 (Produktion) / 24 (Host-Tests), Express 4, Appwrite 1.8.1 REST, nodemailer 9, feiertagejs 1.5.1 (neu), Playwright (/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs, Chromium ~/.cache/ms-playwright/chromium-1234), React 18 (WOMS).

Spec: docs/superpowers/specs/2026-10-02-terminplaner-design.md (Abschnitte „TP1 Kern“ 1–14). Recherche: /root/reports/terminplaner-2026-10-02/.

Worktrees: Portal /home/webklar/apps/kundenbereich.webklar.com/dev-termine (Branch feature/terminplaner von origin/main ff7fe35, eigenes node_modules per npm ci, Basis 1463 Tests grün). WOMS siehe V3 und Task 22.

Global Constraints

  • Funktionsschlüssel termine; Schalter TERMINE_ENABLED = off (Vorgabe) | pilot | on; TERMINE_PILOT_EMAILS Komma-Liste (klein, getrimmt, * = alle). TP1: on = pilot ohne E-Mail-Filter; kein Bestellen. Bei off laufen keine Termine-Jobs und keine Termine-Abfragen.
  • config.termine wird bei jedem Aufruf gelesen (Tests ändern config.termine.* zur Laufzeit). Werte: mode, pilotEmails, holdMinutes: 30, zelleMin: 5, mailsProStunde: 200, formMinSekunden: 3 (Env TERMINE_FORM_MIN_SECONDS).
  • Neue Dateien unter server/services/termine/; nie server/services/appointments.js (Test adminPanelGone).
  • Einstellungs-Attribut heißt einstellung (nie data: appwriteAdmin.formatRequestBody hält Bodies mit data für fertig verpackt).
  • Zeit: Wanduhr Europe/Berlin, gespeichert UTC (toISOString()); Appwrite liefert +00:00 ⇒ Datumswerte immer mit new Date() lesen. Nicht existierende Wanduhrzeiten (März) auslassen, doppelte (Oktober) = erstes Vorkommen. Alle HH:MM in Plänen, Sonderöffnungen, Abwesenheiten und Sperren sind Vielfache von 5 Minuten. deadlines.berlinTime nicht verwenden.
  • Zellen à 5 Minuten, Zell-ID c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso), Feld bis = blockEndsAt der Buchung (nie Haltezeit). Alle neuen Zellen einer Buchung in einem Bulk-Create (≤ 100 ⇒ jede Belegung ≤ 500 Minuten, auch Sperren und manuelle Termine). Ob eine Zelle noch gilt, entscheidet allein ihre Buchung (fehlt ⇒ frei; Status nicht belegend ⇒ frei; gehalten mit heldUntil ≤ now ⇒ frei).
  • Status: gehalten, angefragt, bestaetigt, erledigt, nichterschienen, abgesagt, abgelehnt, verfallen. Belegend: gehalten (solange heldUntil > now), angefragt, bestaetigt.
  • Tokens: Bestätigungstoken = 32 Zufallsbytes base64url, gespeichert nur verifyHash (SHA-256-Hex), wirksam einmal bis heldUntil. Verwaltungstoken = base64url(HMAC-SHA256(Schlüssel aus config.sessionSecret + 'termine-manage', bookingId)), gespeichert nur manageHash, jederzeit neu berechenbar, gültig bis Terminende + 1 Tag. Zustandsänderungen nur per POST. Vertragsschluss vertragAt = bestaetigtAt (sofort) bzw. angenommenAt (Anfrage).
  • Knopftexte exakt: verbindlich + Preis ⇒ „Zahlungspflichtig buchen“ (Bestätigungsseite „Zahlungspflichtig bestätigen“); verbindlich ohne Preis ⇒ „Termin buchen“ („Termin bestätigen“); Anfrage ⇒ „Termin anfragen“ („Anfrage absenden“).
  • Zustimmungstexte exakt (Sie-Form, auch bei Du-Betrieben, weil Rechtstext): vorzeitiger Beginn (Pflicht bei verbindlich + Preis + Verbraucher + Terminbeginn ≤ Ende der Widerrufsfrist = deadlines.withdrawalDeadline(jetzt + Haltezeit).endsAt; bei bepreisten Verbraucher-Anfragen immer — Ruling R31): „Ich verlange ausdrücklich, dass {Betrieb} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ · Storno: „Ich habe die Stornobedingungen gelesen.“ · Erinnerung (nie vorangekreuzt): „Ich möchte {n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.“ · über Freitext: „Bitte keine Gesundheitsangaben.“
  • Verbraucher-Buchung ⇔ recht.gaesteSind !== 'unternehmer' && !recht.freizeit (beim Halten in verbraucher gespeichert). Widerruf möglich ⇔ verbindlich, verbraucher, Status bestaetigt, now <= deadlines.withdrawalDeadline(vertragAt).endsAt (§§ 187/188/193 BGB, Ruling R29), Termin noch nicht begonnen.
  • Öffentliche Antworten: nie customerId, projectId, featureId, $id, Ticket-/Stripe-IDs; nur Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen). Fehlerform immer { fehler: { <feld|_>: text }, code? }. Unbekannter, inaktiver, abgeschalteter siteKey oder Modus off ⇒ 200 { aktiv: false } (GET und POST unter /:siteKey); /termin/* (Token) bleibt für disabled nutzbar (Absagen, Widerruf).
  • Schutz: Router-eigener Parser 16 KB + Router-Fehlermiddleware (413/400/503, nie err.message); content-length > 16 384 ⇒ 413 vorab; Honeypot-Feld kontakt_url; Formular-Token HMAC, zu_schnell (< formMinSekunden) oder Honeypot ⇒ gleiche Erfolgsantwort ohne Speichern/Mail, abgelaufen (> 2 h) ⇒ 400 „Bitte laden Sie die Seite neu.“; Grenzen (Modul limits.js, Speicher-Maps mit Obergrenze 10 000 Schlüssel, resetTermineLimitsForTests()): je IP (IPv6 je /64) 120 GET/min und 10 Buchungen/10 min; je siteKey 600 GET/min und 60 Haltungen/h; je E-Mail+Funktion regeln.maxOffeneJeEmail (Vorgabe 3) offene Termine und 5 G01/Tag; Widerruf 10/h je IP, 3 G08/Tag je Empfängeradresse, 20 G08/h je Funktion, 10 K21 „nicht zugeordnet“/h je Funktion. Grenze „offene Termine“ ⇒ dieselbe 201-Antwort, statt der Bestätigung geht G01-Block zu_viele an die Adresse (kein Orakel für Dritte). E-Mail-Adressen: genau ein Postfach (kein ,;<>", ≤ 254), getrimmt und klein.
  • Mails: Gast from: { name: betrieb.name, address: SMTP_FROM }, replyTo = Betriebs-E-Mail; K20/K21 from: { name: 'WEBklar', address } an die Betriebs-E-Mail; nie BCC, kein Tracking. Betreff = Platzhalter {{betreff}} (je Anrede/Fall vom Server). G01 und G08 ohne Text, den der Gast eingegeben hat. Alles über termineOutbox: einziger Sendepfad sendeEintrag prüft Pause und Stundenbudget (200/h); EAUTH/535 ⇒ 15 min Pause für alle, Eintrag bleibt wartet; Verbindungsfehler/4xx ⇒ erneut nach 1, 5, 30 min, dann fehler; 5xx (außer 535) ⇒ sofort fehler; nach gesendet werden an, html, text, ics geleert; Einträge 7 Tage nach gesendet bzw. 48 h nach fehler gelöscht.
  • Löschfristen: verfallen 48 h nach heldUntil; abgesagt/abgelehnt 30 Tage; erledigt/nichterschienen aufbewahrungTage (Vorgabe 90, 14–365) nach Terminende; angefragt mit Beginn in der Vergangenheit ⇒ abgelehnt (System); Funktion disabled ⇒ alles 30 Tage nach disabledAt.
  • Tests: Termine-Tests starten den Nachbau mit startAppwriteMock({ schema, datetimes, defaultLimit: 25 }) (Task 2); feste now per Parameter; Mails mit mailSink() + warteAufVersand() prüfen; Kalenderdaten stehen im Sink als icalEvent (nicht als Anhang). Schwere Läufe (Playwright, vite build, Docker-Node-20) nacheinander, vorher free -m, mit nice.
  • Portaltexte Du-Form, kein confirm(); Gästetexte Sie/Du nach anrede. Kundentext nur über textContent/esc(); href nur für geprüfte https:-URLs, mit rel="noopener noreferrer".
  • Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie git add -A.

Review Focus

  1. Gast-Browser in anderer Zeitzone (New York) sieht dieselben Daten und Uhrzeiten wie in Berlin; das Widget rechnet nie mit Browser-Datum. → Task 19 (Playwright timezoneId, statischer Test)
  2. E-Mail mit Großbuchstaben/Leerzeichen ( Max@GMail.com) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 12 (email_normalisiert)
  3. Betrieb löscht Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, Termine bleiben. → Task 4 + 18
  4. Bestätigungsklick genau während der Minutenjob die Haltung verfallen lässt ⇒ genau ein Endzustand. → Task 15 (verfall_und_bestaetigung_gleichzeitig)
  5. Zwei Widgets auf einer Seite (data-leistung) stören sich nicht (IDs, Stile einmal). → Task 19

Vorab (Controller, kein Subagent)

  • V1 Spike Bulk-Create (Nutzer-Freigabe einholen): im Container (docker exec -w /app webklar-kundenbereich-server node …, Skript im Scratchpad) Wegwerf-Collection termineSpike (bookingId string 64), 50 parallele POST …/collections/termineSpike/documents mit { documents: [{ $id, bookingId }, …] } (je 20 Zellen, Überlappungen) ⇒ je überlappender Gruppe genau ein 201, Rest 409 document_already_exists, keine Teil-Schreibungen; Collection löschen. Negativ ⇒ Task 2 baut createDocuments über die Transactions-API (POST /databases/transactions, Operationen, PATCH {commit:true}, 409 transaction_conflict).
  • V2 Gesetzestexte: Anlage 1 und 2 zu Art. 246a § 1 EGBGB von https://www.gesetze-im-internet.de/bgbeg/anlage_1.html und …/anlage_2.html wörtlich nach docs/rechtliches/quellen/anlage-1.txt und anlage-2.txt (Kopfzeile mit Abrufdatum).
  • V3 WOMS-Worktree: git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/terminplaner ../dev-termine origin/test, dort git ls-files -z node_modules | xargs -0 git update-index --skip-worktree und npm install (nie git add -A).

Portal: Grundlagen

Task 1: Schalter, Konfiguration, Schema

Files: Modify server/config.js, test/support/schema.js; Create server/services/termine/schalter.js, server/services/termine/schema.js, scripts/termine-setup.mjs, test/termineSetup.test.js, test/termineSchalter.test.js. Produces:

  • config.collections.termineConfig|termineBookings|termineClaims|termineOutbox (Env APPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX); config.termine laut Global Constraints.
  • schalter.js: termineModus(), termineFuerKunde(email) ⇒ boolean, termineAus() ⇒ boolean.
  • schema.js (reine Daten, kein Import von config.js): TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, … } exakt nach Spec 2 (mit einstellung statt data; termineOutbox.icsMethod); Attributtypen string (Größe), integer, boolean, datetime; Indizes { key, type: 'key', attributes }; TERMINE_DATETIMES = { collection: [keys] }.
  • scripts/termine-setup.mjs (Muster analyse-setup.mjs, createAppwriteClient von dort wiederverwenden; createAttribute nutzt /attributes/<type> für alle Typen): main({ check, client, log }), legt Fehlendes an, Indizes nur bei neuen Collections, --check ⇒ Exit 1 bei Fehlendem.
  • test/support/schema.js: schemaKeys(collection) kennt auch TERMINE_COLLECTIONS (statischer Import aus server/services/termine/schema.js).
  • Tests: Modus (leer/unbekannt ⇒ off; pilot + KENSO.gri@gmail.com ⇒ true; *; Laufzeitänderung von config.termine.mode wirkt); Setup mit Fake-Client: 4 Collections + Attribute + Indizes, zweiter Lauf legt nichts an, --check legt nichts an; schemaKeys('termineConfig') enthält einstellung, nicht data; Rundreise: Nachbau mit schemaKeys('termineConfig'), createDocument + updateDocument mit einstellung/version/updatedBy und Zurücklesen.
  • FAIL → implementieren → PASS (node --test test/termineSetup.test.js test/termineSchalter.test.js, dann npm test) → Commit „Terminplaner: Schalter, Konfiguration, Schema“

Task 2: Appwrite-Wrapper und Nachbau

Files: Modify server/services/appwriteAdmin.js, test/support/appwriteMock.js, test/support/testApp.js (exportiert zusätzlich send(method, path, opts) = bisheriges privates request); Test test/appwriteBulk.test.js. Produces:

  • Query.between, Query.greaterThanEqual, Query.lessThanEqual, Query.cursorAfter(id).
  • createDocuments(collectionId, documents: Array<{ $id, ...felder }>) ⇒ docs (roher Body { documents } über neue adminFetch-Option raw: true; 409 bei Duplikat; Spike-Ergebnis V1).
  • listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs (Query.limit(pageSize), orderAsc('$id'), cursorAfter, bis Seite < pageSize; über max ⇒ Fehler).
  • Nachbau: startAppwriteMock({ schema, datetimes = {}, defaultLimit = null }) – Bulk alles-oder-nichts (Duplikat ⇒ 409, unbekanntes Attribut je Dokument ⇒ 400, nichts geschrieben); neue Abfragen inkl. cursorAfter; Vergleich als Zeitpunkt nur, wenn beide Werte ^\d{4}-\d\d-\d\dT sind; datetimes-Felder werden beim Schreiben auf …+00:00 normalisiert, '' ⇒ 400; defaultLimit (Termine-Tests: 25) gilt ohne limit-Abfrage wie Appwrite.
  • Tests: Bulk 3 Dokumente; Bulk mit vorhandener ID ⇒ 409 und unverändert; between inklusiv; listAllDocuments über 3 Seiten; defaultLimit 25 liefert 25 von 30; Datum …Z kommt als +00:00 zurück; alle bisherigen Tests grün.
  • FAIL → implementieren → PASS → Commit „Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden, genauerer Nachbau“

Task 3: Zeit und Feiertage

Files: Create server/services/termine/zeit.js, server/services/termine/feiertage.js, test/termineZeit.test.js; Modify package.json/package-lock.json (npm install feiertagejs@1.5.1 --save-exact). Produces: zeit.js (nur Intl; /root/reports/terminplaner-2026-10-02/spike-code/tz.mjs nur als Vorlage für offsetMs): TZ; zuUtc(tag, 'HH:MM') ⇒ Date | null (null, wenn ausUtc(Ergebnis) nicht dieselbe Wanduhrzeit ergibt; '24:00' = Folgetag 00:00); ausUtc(date) ⇒ { tag, uhrzeit }; wochentag(tag) ⇒ 1..7; plusTage(tag, n); heute(now) ⇒ tag; monatInfo('YYYY-MM') ⇒ { label: 'Oktober 2026', ersterWochentag, anzahlTage, vorher, nachher }; datumLang(date) ⇒ 'Montag, 12.10.2026', datumKurz(date) ⇒ '12.10.2026', uhrzeit(date) ⇒ '10:30'. feiertage.js: BUNDESLAENDER (16 Kürzel), istFeiertag(tag, land).

  • Tests: zuUtc('2026-03-29','02:30') === null; zuUtc('2026-03-29','03:00') = 01:00Z; zuUtc('2026-10-25','02:30') = 00:30Z; ausUtc rückwärts; wochentag('2026-10-05') === 1; plusTage('2026-10-25', 1); monatInfo('2026-02') (28 Tage, So=7 als erster); Feiertage RP 2026-06-04 ja, BE 2026-06-04 nein, BE 2026-03-08 ja, SN 2026-11-18 ja, alle 2026-12-25 ja. Zusätzlich docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Zeit (Berlin, Umstellung) und Feiertage“

Task 4: Einstellungen und Branchen-Vorlagen

Files: Create server/services/termine/einstellungen.js, server/services/termine/vorlagen.js, test/termineEinstellungen.test.js. Interfaces (Datenform = Spec 2 termineConfig.einstellung + betrieb.kleinunternehmer: boolean, betrieb.websiteUrl):

  • neueId('r'|'s'|'a') ⇒ 'r_' + 8 [a-z0-9].
  • normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = { leistungen: {}, ressourcen: {} }, now }) ⇒ { einstellung } | { fehler: { [pfad]: text } }: Vorgaben auffüllen; Steuerzeichen entfernen; Längen kürzen; Pflicht betrieb.name (1–80), betrieb.email (ein Postfach); URLs (impressumUrl, datenschutzUrl, agbUrl, websiteUrl) nur https:, ≤ 300, ohne Zugangsdaten; farbe ^#[0-9a-f]{6}$; anrede sie|du; bundesland ∈ BUNDESLAENDER; IDs ^[rsa]_[a-z0-9]{8}$, je Liste eindeutig, neue Einträge ohne ID ⇒ neueId; Obergrenzen 20 Ressourcen, 50 Leistungen, 100 Abwesenheiten, 200 Sonderöffnungen, JSON.stringify ≤ 200 000; Fenster HH:MM auf 5 min, aufsteigend, ohne Überlappung, Ende > Beginn (24:00 erlaubt); dauer 5–480, pufferNach 0–120 (je Vielfache von 5), dauer + pufferNach ≤ 500; preisArt fest|ab|keiner, preisCent 0–1 000 000; buchungsart sofort|anfrage; aktive Leistung braucht ≥ 1 aktive Ressource; regeln.raster ∈ {5,10,15,20,30,60}, vorlaufMin 0–20160, horizontTage 1–365, absageBisStd 0–336, maxOffeneJeEmail 1–10; gastFelder aus|optional|pflicht, Zusatzfelder max. 5 ({ key: ^[a-z0-9_]{1,20}$, label, typ: text|auswahl, optionen ≤ 10, pflicht }); erinnerung.stundenVorher ∈ {2,12,24,48}; zahlung.modus nur vorort (sonst Fehler „Online-Zahlung kommt bald“), vorOrtArten ⊆ bar|ec|kreditkarte|ueberweisung|paypal; aufbewahrungTage 14–365; Abwesenheiten von ≤ bis. Entfernen einer ID mit künftigen Terminen ⇒ Fehler „Für diese Leistung gibt es noch {n} künftige Termine. Deaktiviere sie stattdessen.“ (Ressource entsprechend); storno.version bei geändertem Text YYYY-MM-DD-n (aus now); feedToken nur aus bisher übernehmen, neue Ressourcen bekommen eines (32 Byte base64url), Eingabewerte werden ignoriert.
  • oeffentlicheEinstellung(einstellung, now) ⇒ Felder von GET /config außer aktiv, hinweis, texte, formToken (Form Task 16).
  • knopfText(leistung, { bestaetigen }); istVerbraucher(einstellung); brauchtZustimmungVorzeitig(einstellung, leistung, start, now); preisText(leistung) ⇒ '45,00 €' | 'ab 45,00 €' | ''; preisHinweis(einstellung) ⇒ 'inkl. USt' | 'kein Ausweis der USt nach § 19 UStG'; zahlungText(einstellung) ⇒ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'.
  • vorlagen.js: VORLAGEN (salon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges; je 2–5 Leistungen, Plan, buchungsart anfrage bei tattoo/handwerk/fotograf, werkstatt mit 2 Plätzen, Stornotext Variante 1 aus recherche-recht-zahlung.md 7.3 F), vorlagenListe(), ausVorlage(key, betrieb, now) ⇒ einstellung (valide).
  • Tests: alle Vorlagen valide; Pflichtfelder; javascript:alert(1) als impressumUrl ⇒ Fehler am Pfad betrieb.impressumUrl; Fenster 09:07 ⇒ Fehler; Überlappung ⇒ Fehler am Pfad ressourcen.0.plan.1; Leistung ohne aktive Ressource; Löschen mit künftigen Terminen ⇒ Fehler mit Anzahl; Client-feedToken wird ignoriert; online ⇒ Fehler; Knopftexte; Zustimmung vorzeitig (3 Tage ⇒ true, 20 Tage ⇒ false, Unternehmer/kein Preis ⇒ false); oeffentlicheEinstellung ohne feedToken.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Einstellungen und Branchen-Vorlagen“

Task 5: Verfügbarkeit

Files: Create server/services/termine/slots.js, test/termineSlots.test.js. Produces: belegungAus(buchungen, now, { ohneBuchung = null }) ⇒ [{ ressourceId, start, ende }] (belegende Status, ende = blockEndsAt, ohne die genannte Buchung); freieZeiten({ einstellung, leistungId, ressourceId /* id|'egal' */, tag, belegung, now, ohneRegeln = false }) ⇒ [{ uhrzeit, start, ressourceIds }] (Sonderöffnung > Abwesenheit > Feiertag > Plan; Raster ab Fensterbeginn; start + dauer + pufferNach im Fenster; frei; start ≥ now + vorlaufMin und tag ≤ heute + horizontTage außer ohneRegeln; nie Vergangenheit); freieTage({ …, monat }) ⇒ ['YYYY-MM-DD']; waehleRessource(ids, buchungenAmTag, einstellung); blockEnde(start, leistung).

  • Tests: Pause; Puffer am Pausenrand; Vorlauf; Horizont; Abwesenheit Ressource und Betrieb; Feiertag vs. Sonderöffnung; abgelaufene Haltung blockiert nicht; egal mit zwei Personen; ohneBuchung gibt die eigene Zeit frei; 2026-03-29 ohne 02:xx; 2026-10-25 60-min-Termin um 02:00 dauert real 60 min; letzter Termin endet genau am Fensterende; Belegung, die am Vortag 22:00 beginnt und bis 06:00 läuft, blockiert 05:30.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Verfuegbarkeit“

Task 6: Zellen, Tokens, Buchungsspeicher

Files: Create server/services/termine/zellen.js, server/services/termine/token.js, server/services/termine/buchungen.js, test/termineZellen.test.js. Consumes: Task 2. Produces:

  • token.js: neuesToken() ⇒ { token, hash }; hashToken(t); verwaltungsToken(bookingId) ⇒ token; buchungsNummer(bookingId) ⇒ 8 [A-Z0-9]; formToken(siteKey, now), pruefeFormToken(siteKey, token, now) ⇒ 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig' (Mindestalter config.termine.formMinSekunden).
  • zellen.js (spike-code/claims.mjs nur als Vorlage für den Hash-Ansatz; Zellgröße/ID/Puffer wie hier): zellenFuer(start, blockEnde) ⇒ Date[]; zellenId(featureId, ressourceId, zelle); belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now, ausser = null }) ⇒ void (Sperre termine-res:<featureId>:<ressourceId>, waitMs 5000; mit ausser zählen Zellen dieser Buchung als eigene: angelegt wird nur Neu − Eigen; bei 409 blockierende Zellen per ID lesen, Gültigkeit nach Global Constraints prüfen, ungültige löschen, genau ein zweiter Versuch, sonst Fehler { status: 409, code: 'SLOT_VERGEBEN' }); gibZellenFrei(bookingId, { behalte = [] }) (über listAllDocuments, behält die genannten Zellzeiten); verwaisteZellen(now) ⇒ ids (bis < now − 1 Tag oder Buchung nicht belegend).
  • buchungen.js (nur Datenzugriff): legeAn, hole, aendere (setzt updatedAt), imZeitraum(featureId, von, bis) (lessThan(startsAt, bis) + greaterThan(blockEndsAt, von) + featureId, listAllDocuments), perHash(feld, hash), offeneFuerEmail(featureId, email, now), perEmailUndNummer(featureId, email, nummer), zaehleKuenftige(featureId, now) ⇒ { leistungen, ressourcen }, abgelaufeneHaltungen(now), erinnerungsKandidaten(now), zuErledigen(now), vergangeneAnfragen(now), loeschKandidaten(status, vor), zaehleAnfragen(featureId).
  • Tests (Nachbau, defaultLimit 25): zweite Buchung derselben Zeit ⇒ SLOT_VERGEBEN; 10 parallel ⇒ genau eine; abgelaufene Haltung und fehlende Buchung blockieren nicht; Verschieben 10:00→10:30 mit ausser gelingt, Fremdbuchung im neuen Bereich ⇒ SLOT_VERGEBEN und alte Zellen unverändert; gibZellenFrei löscht 40 Zellen (über 25er-Seiten); imZeitraum findet die Belegung vom Vortag; Verwaltungstoken stabil; Formular-Token zu jung/alt/gefälscht.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher“

Task 7: Rechtstext-Bausteine für Gäste

Files: Create docs/rechtliches/termine-widerrufsbelehrung-muster.md, docs/rechtliches/termine-datenschutz-absatz.md, docs/rechtliches/termine-hinweise.md, docs/rechtliches/termine-datenschutz-6c-entwurf.md, server/services/termine/rechtstexte.js, test/termineRechtstexte.test.js. Consumes: V2. Produces: Belehrung + Muster-Widerrufsformular für Dienstleistungen aus docs/rechtliches/quellen/anlage-1.txt/anlage-2.txt (Gestaltungshinweise angewendet: Dienstleistung, Betriebsdaten, Hinweis auf die Widerrufsfunktion), immer Sie-Form; Datenschutz-Absatz (Sie/Du) und Formular-Hinweis nach recherche-recht.md 11.1/11.2 mit den Löschfristen der Global Constraints; 6c-Entwurf (nicht einbauen). Alle Dateien mit Kopf „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“. rechtstexte.js: RECHTSTEXTE_VERSION = '2026-10-02', widerrufsbelehrung(betrieb) ⇒ string (Text mit Zeilenumbrüchen), formularHinweis(einstellung) ⇒ string, ZUSTIMMUNG_VORZEITIG(name), ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG(stunden), GESUNDHEIT_HINWEIS.

  • Tests: Belehrung enthält „Vertrag widerrufen“, die Betriebsanschrift, keinen offenen Platzhalter und die unveränderten Kernsätze der Quelle (Absätze aus anlage-1.txt wörtlich enthalten); Zustimmungstexte wortgleich zu den Global Constraints.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Rechtstext-Bausteine fuer Gaeste (Entwurf)“

Task 8: Kalenderdatei, Gästeversand, Vorlagen-Engine

Files: Create server/services/termine/ics.js, test/termineIcs.test.js; Modify server/services/mailTemplates.js ([GKT]\d{2} in templateIds und im Kopfkommentar-Regex), server/services/mailer.js, test/mailTemplates.test.js (Ordner-Test-Regex [GKT]; Logo-Prüfung nur K/T; G-Vorlagen: kein webklar-logo.png). Produces: ics.js: icsTermin({ uid, sequenz, methode: 'REQUEST'|'CANCEL'|'PUBLISH', start, ende, titel, ort, beschreibung, organisator: { name, email }, teilnehmer: { name, email } | null, jetzt }) ⇒ string (CRLF, Faltung 75 Oktette, Escaping \ ; , \n, CR/LF in Texten entfernt, CN="…" ohne " und Steuerzeichen, UTC, RSVP=FALSE, CANCEL mit STATUS:CANCELLED); icsKalender(termine, { name, jetzt }) (METHOD:PUBLISH, X-WR-CALNAME, X-PUBLISHED-TTL:PT15M). mailer.sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethod = 'REQUEST' }) ⇒ info (wirft; from: { name, address }; icalEvent: { method, filename: 'termin.ics', content } nur mit ics; nie bcc).

  • Tests: ICS-Zeilenlänge, UID/SEQUENCE, Escaping, CANCEL; sendGuestMail über mailSink: kein bcc, replyTo, From-Name = Betrieb, sent[0].icalEvent.method === 'REQUEST' und content enthält BEGIN:VCALENDAR, ohne ics kein icalEvent; bestehende Vorlagen-Tests grün (noch ohne neue Vorlagen).
  • FAIL → implementieren → PASS → Commit „Terminplaner: Kalenderdatei, Gaesteversand, Vorlagen-Engine fuer G-Vorlagen“

Task 9: Mailvorlagen G01–G08, K20, K21

Files: Create server/mail-templates/G01 Termin bestaetigen.html, G02 Termin bestaetigt.html, G03 Anfrage eingegangen.html, G04 Anfrage abgelehnt.html, G05 Erinnerung.html, G06 Termin abgesagt.html, G07 Termin verschoben.html, G08 Eingang Widerruf.html, K20 Neue Buchung.html, K21 Termin geaendert.html; Modify test/mailTemplates.test.js (EXPECTED sortiert: G01–G08, K01–K21, T01–T18; Kommentar).

  • Gerüst wie die vorhandenen Vorlagen: Kopfkommentar (G01 · …, Betreff: {{betreff}}, Platzhalter-, Block-Liste), <title>{{betreff}}</title>, <!--[if mso]>-Block, sonst keine Kommentare, jeder Platzhalter/Block deklariert und benutzt.
  • G-Layout neutral: Kopfbalken in {{farbe}} mit {{betrieb_name}}, kein Logo; Fuß {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}}, Satz „Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet.“; Blöcke sie/du für jeden Anredetext.
  • Platzhalter: G01 leistung, datum, zeit, link + Block zu_viele (Text: höchstmögliche Zahl offener Termine, absagen oder anrufen {{betrieb_telefon}}) – kein gast_name; G02 gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link, ort + Blöcke preis, person, storno ({{storno_text}}), widerruf ({{widerrufsbelehrung}}, {{widerruf_link}}), anfrage_angenommen; G03 gast_name, leistung, datum, zeit, link; G04 gast_name, leistung, datum, zeit + Block nachricht ({{nachricht}}); G05 gast_name, leistung, person, datum, zeit, link + Block person; G06 gast_name, leistung, datum, zeit, absage_von, grund + Block nachricht; G07 gast_name, leistung, alt_datum, alt_zeit, datum, zeit, link; G08 eingang, nummer + Blöcke zugeordnet, nach_frist – kein gast_name. K20 (Du-Form, WEBklar-Layout mit Logo): kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link + Block anfrage; K21: wie K20 + alt_datum, alt_zeit, eingang + Blöcke abgesagt, verschoben, widerrufen, nicht_zugeordnet, nach_frist.
  • Tests: Vorlagen-Suite grün inkl. neuer IDs (Titel = Betreff, ein Kommentar, keine Reste, Text ohne Tags); renderMail('G02', …, { du: true }) enthält im Text kein „ Sie “/„Ihr “; G01/G08 haben keinen Platzhalter gast_name.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Mailvorlagen G01–G08, K20, K21“

Task 10: Mail-Warteschlange

Files: Create server/services/termine/outbox.js, test/termineOutbox.test.js. Consumes: Task 8. Produces: einreihen({ featureId, bookingId, art, an, fromName, replyTo, template, values, blocks, ics, icsMethod }) ⇒ doc (rendert sofort, speichert wartet, ruft sendeEintrag(id) ohne zu warten); sendeEintrag(id) (einziger Sendepfad: Pause/Budget prüfen, Sperre termine-mail:<id>, Fehlerklassen laut Global Constraints, nach Erfolg Inhalt leeren); verarbeite(now); warteAufVersand() (für Tests: wartet auf laufende Sendeversuche); zustellFehler(bookingIds) ⇒ Set; raeumeAuf(now); pausiertBis().

  • Tests: Budget 2/h ⇒ dritter wartet; 4xx ⇒ 1/5/30 min, dann fehler; 550 ⇒ sofort fehler; EAUTH ⇒ Pause, während der Pause keine Sendeversuche (Fake-Transport zählt), danach weiter; zwei verarbeite parallel ⇒ eine Mail; nach Erfolg sind an/html/text/ics leer; Aufräumen 7 Tage / 48 h.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Mail-Warteschlange mit Stundenbudget“

Portal: Ablauf

Task 11: Kontext, Einstellungs-Speicher, Mail-Werte

Files: Create server/services/termine/kontext.js, server/services/termine/mails.js, test/termineKontext.test.js. Consumes: Tasks 4, 6, 7, 9, 10. Produces:

  • kontext.js: kontextPerSiteKey(siteKey) ⇒ ctx|null (Funktion termine, active, Modus ≠ off; eigene Abfrage bis Task 17, dann findActiveBySiteKey(siteKey, 'termine'); 30 s Speicher-Cache je siteKey); kontextPerFeature(featureId) ⇒ ctx|null (auch disabled); ctx = { feature, project, einstellung, version }; ladeEinstellung(featureId); speichereEinstellung(feature, einstellung, { version, by }) ⇒ { einstellung, version } (Sperre termine-cfg:<featureId>, Dokument-ID = featureId, 409 bei Versionskonflikt, Cache leeren); websiteUrl(ctx) (einstellung.betrieb.websiteUrl oder https:// + erster Host aus analyseHosts(project)).
  • mails.js: BETREFFE (Tabelle je Vorlage × Anrede × Fall, Sie/Du, G05 „Erinnerung: Ihr Termin am {datum} um {zeit}“); werteFuer(art, ctx, buchung, extra) ⇒ { values, blocks } (Links ${config.publicBaseUrl}/termin.html#v=<token> bzw. #m=<verwaltungsToken>; widerruf_link = Verwaltungslink); anGast(art, ctx, buchung, extra), anBetrieb(art, ctx, buchung, { block, extra }) über einreihen (ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH).
  • Tests: Speichern/Laden mit Version, Konflikt ⇒ 409; Cache wird beim Speichern geleert; Betreff je Anrede (Du ohne „Sie“); G02-Werte mit Widerrufsblock nur bei Verbraucher; Links zeigen auf config.publicBaseUrl.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Kontext, Einstellungs-Speicher, Mail-Werte“

Task 12: Gast: halten, Vorschau, bestätigen

Files: Create server/services/termine/ablauf.js, test/termineAblaufGast.test.js. Consumes: Tasks 5, 6, 11. Produces (jede Zustandsänderung unter withLocks(['termine-buchung:'+id]) mit frischem Lesen):

  • halte(ctx, eingabe, now) ⇒ { status: 'gehalten'|'zu_viele', email } – Eingabe { leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen: { storno, vorzeitig } }; Feldfehler (400, err.fehler); Slot neu berechnen; Ressource bei egal wählen; erst Buchung gehalten anlegen (heldUntil = now + holdMinutes, nachweis = Fassungen/Hashes der Texte, Zeitpunkte, Knopftext, Anrede; verbraucher, preisCent, preisArt, buchungsart, zahlart 'vorort', zahlStatus 'keine', icsSequenz 0, verifyHash), dann Zellen; SLOT_VERGEBEN ⇒ Buchung verfallen, Fehler 409 Feld zeit; G01; Grenze offene Termine ⇒ nichts anlegen, G01-Block zu_viele, Ergebnis zu_viele.
  • vorschau(token, now) ⇒ { art: 'bestaetigen'|'verwalten', status, termin: { nummer, leistung, person, datum, zeit, dauer, preis, zahlung, ort }, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl, websiteUrl }, gast: { name, email }, anrede, farbe, knopf, aktionen: { bestaetigen, absagen, verschieben, widerrufen }, hinweis } (Token zuerst als Bestätigungs-, dann als Verwaltungstoken suchen; unbekannt/abgelaufen ⇒ 404).
  • bestaetige(token, now) ⇒ { status, schon? } – gehalten + heldUntil > now ⇒ sofort: bestaetigt + bestaetigtAt + manageHash + G02 + K20; anfrage: angefragt + manageHash + G03 + K20-Block anfrage; schon bestätigt ⇒ { status, schon: true }; abgelaufen ⇒ 410 { code: 'abgelaufen', betrieb: { name, telefon, websiteUrl } }.
  • Tests (Nachbau defaultLimit 25, mailSink, warteAufVersand): Halten ⇒ G01 ohne Gastname, Zellen; Bestätigen ⇒ G02 mit icalEvent und #m=-Link + K20 an den Betrieb, kein BCC; Anfrage ⇒ G03/K20-Block; zweiter Klick ⇒ schon; nach 31 min ⇒ 410; email_normalisiert (' Max@GMail.com ' zählt gegen dieselbe Grenze); vierte offene Buchung ⇒ zu_viele, keine Buchung, G01-Block; Slot weg ⇒ 409, Buchung verfallen, keine Zellen; Zustimmung vorzeitig fehlt ⇒ 400 Feld vorzeitig; vorschau mit Verwaltungstoken liefert gast.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Buchung halten und bestaetigen“

Task 13: Gast: absagen, verschieben, widerrufen

Files: Modify server/services/termine/ablauf.js; Test test/termineAblaufVerwalten.test.js. Produces: sageAbGast(token, now) (bis absageBisStd vor Beginn, sonst 409 Feld _ „Absage nur telefonisch: {telefon}“; Zellen frei, G06 CANCEL, K21 abgesagt); verschiebeGast(token, { tag, zeit }, now) (gleiche Frist; Belegung ohneBuchung; belegeZellen({ …, ausser: id }), dann gibZellenFrei(id, { behalte: neueZellen }); icsSequenz+1; G07 + K21 verschoben); zeitenFuerToken(token, { tag | monat }, now) (für die Gästeseite, ohneBuchung); widerrufeGast(token, { name }, now) ⇒ { eingang: { datum, uhrzeit } } und widerrufeOhneLink(ctx, { name, email, nummer }, now) ⇒ { eingang } (Treffer mit Widerrufsrecht ⇒ widerrufenAt, abgesagt von gast, Zellen frei, G08 zugeordnet, K21 widerrufen; Treffer nach Frist ⇒ nur speichern + G08 nach_frist + K21 nach_frist; kein Treffer ⇒ G08 + K21 nicht_zugeordnet; Grenzen aus Global Constraints; Antwort immer gleich, Eingang in Berliner Zeit als Strings).

  • Tests: Absage vor/nach Frist; Verschieben 10:00→10:30 (überlappend) gelingt, ICS Sequenz 1; Verschieben auf belegte Zeit ⇒ 409, alte Zellen unverändert; Widerruf 2 h vor Termin trotz abgelaufener Stornofrist; Widerruf ohne Treffer = gleiche Antwort + K21 nicht_zugeordnet; vierter G08 an dieselbe Adresse am Tag wird nicht gesendet; nicht angenommene Anfrage ⇒ kein Widerruf, nur Absage.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Absagen, Verschieben, Widerruf durch Gaeste“

Task 14: Betrieb: Aktionen, manuelle Termine, Sperren

Files: Modify server/services/termine/ablauf.js; Test test/termineAblaufBetrieb.test.js. Produces: aktion(ctx, bookingId, { aktion: 'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen'|'anonymisieren', grund, nachricht, tag, zeit }, now) (annehmen ⇒ bestaetigt, angenommenAt, G02 Block anfrage_angenommen; ablehnen ⇒ abgelehnt, G04, Zellen frei; absagen ⇒ G06 mit absage_von = Betriebsname; verschieben ⇒ wie Gast, ohne Frist, ohneRegeln; nichterschienen nur nach Beginn; anonymisieren ⇒ Gastfelder leeren, Status bleibt); manuell(ctx, eingabe, now) (quelle 'manuell', bestaetigt, verbraucher false, Länge ≤ 500 min, G02 nur mit bestaetigungSenden); sperre(ctx, { ressource, tag, von, bis, titel }, now) (quelle 'sperre', bestaetigt, leistungName = Titel, Länge ≤ 500 min sonst 400 Feld bis „Eine Sperre kann höchstens 8 Stunden 20 Minuten dauern. Für ganze Tage nutze Abwesenheiten.“); entsperre(ctx, bookingId).

  • Tests: annehmen ⇒ G02 mit Verwaltungslink (gleicher Link wie in G03); ablehnen ⇒ Zellen frei; manuell ohne Haken ⇒ keine Gastmail; Sperre 09:00–17:30 ⇒ 400; Sperre 09:00–13:00 blockiert Slots; anonymisieren; nichterschienen vor Beginn ⇒ 409.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Aktionen des Betriebs, manuelle Termine, Sperren“

Task 15: Hintergrund-Jobs

Files: Create server/services/termine/jobs.js, test/termineJobs.test.js; Modify server/index.js. Produces: jeder Job kehrt bei termineAus() sofort zurück (keine Abfrage); collection_not_found (404) ⇒ höchstens eine Warnung je Stunde. minutenJob(now) (abgelaufene Haltungen ⇒ verfallen, Zellen frei; outbox.verarbeite); fuenfMinutenJob(now) (Erinnerungen nach Spec 4: Marker zuerst, dann G05; erledigt; vergangene Anfragen ⇒ abgelehnt); taeglicherJob(now) (Löschfristen, Outbox aufräumen, verwaiste Zellen, disabled-Funktionen > 30 Tage: Buchungen, Zellen, Einstellung). In server/index.js wie die vorhandenen Jobs (setTimeout(…).unref() + setInterval(…).unref(), eigene Fehlerbehandlung): 60 s, 5 min, täglich (+180 s).

  • Tests: Modus off ⇒ keine Appwrite-Anfrage; Verfall; Erinnerung genau einmal (zwei Läufe), nicht bei Buchung kürzer als stundenVorher vor Beginn, nachgeholt nach 20 min Jobausfall; erledigt; vergangene Anfrage ⇒ abgelehnt, Zellen frei; Löschfristen je Status; verfall_und_bestaetigung_gleichzeitig (Job und bestaetige parallel ⇒ ein Endzustand, keine G02 nach verfallen); fehlende Collection ⇒ eine Warnung.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Hintergrund-Jobs“

Portal: Schnittstellen

Task 16: Öffentliche Schnittstelle

Files: Create server/services/termine/limits.js, server/routes/publicTermine.js, test/publicTermine.test.js, test/publicTermineRohling.test.js; Modify server/index.js (nach kundenansichtGuard, vor dem globalen express.json), server/middleware/kundenansichtGuard.js (PASS_PREFIXES + '/api/public/'), test/kundenansichtGuard.test.js. Consumes: Tasks 11–14, analyseDomains(project) aus server/services/analyse.js. Routen (/api/public/termine/v1; feste Pfade vor /:siteKey):

  • GET /:siteKey/config ⇒ { aktiv: true, heute, betrieb: { name, telefon, adresse, impressumUrl, datenschutzUrl }, anrede, farbe, leistungen: [{ id, name, beschreibung, dauer, preis, preisArt, buchungsart, knopf, verbraucher, ressourcen }], ressourcen: [{ id, name, typ }], personWaehlbar, regeln: { horizontTage, absageBisStd }, zahlung: { modus: 'vorort', text }, preisHinweis, storno: { text, version } | null, widerruf, erinnerung: { anbieten, stundenVorher }, gastFelder, hinweis, texte: { storno, erinnerung, vorzeitig, gesundheit }, formToken } (public, max-age=60, ohne ETag).
  • GET /:siteKey/tage?leistung&ressource&monat ⇒ { tage, monat: monatInfo } (max-age=15); GET /:siteKey/zeiten?leistung&ressource&tag ⇒ { zeiten: ['09:00'], datumLang, vorzeitigNoetig } (no-store; vorzeitigNoetig serverseitig für diesen Tag).
  • POST /:siteKey/buchungen (Eingabe von halte + formToken, kontakt_url) ⇒ 201 { ok: true, email: 'm***@gmail.com', minuten: 30 }.
  • POST /:siteKey/widerruf { name, email, nummer } ⇒ { ok: true, eingang: { datum, uhrzeit } }.
  • POST /termin/vorschau|bestaetigen|absagen { token }, POST /termin/verschieben { token, tag, zeit }, POST /termin/widerruf { token, name }, POST /termin/tage { token, monat }, POST /termin/zeiten { token, tag }.
  • GET /kalender/:siteKey/:feedToken.ics (konstantzeitiger Vergleich mit den feedToken der Ressourcen; falsch ⇒ 404 ohne Hinweis; text/calendar; charset=utf-8, max-age=300; bestätigte Termine + Sperren der Ressource, −7 bis +90 Tage).
  • CORS: GET *; POST/OPTIONS erlaubt, wenn Origin-Host ∈ analyseDomains(project) ∪ {Host von config.publicBaseUrl} oder new URL(origin).host === req.get('host') (gleiche Herkunft); für /termin/* nur die beiden letzten; dann Access-Control-Allow-Origin: <origin>, Vary: Origin; sonst 403 { fehler: { _: 'Nicht erlaubt' } }; Preflight 204 (GET, POST, OPTIONS, Content-Type, Max-Age 86400). Router-Parser + Fehlermiddleware laut Global Constraints; /tage und /zeiten 10 s Speicher-Cache je (siteKey, Parameter).
  • publicTermine.test.js (startTestApp + Nachbau + mailSink; Origin/X-Forwarded-For je Test, resetTermineLimitsForTests(), config.termine.formMinSekunden = 0): rekursiv keine verbotenen IDs; unbekannt/off/disabled ⇒ {aktiv:false} (GET und POST); fremde Origin ⇒ 403, gleiche Herkunft und liveDomain-Origin erlaubt; Preflight (send('OPTIONS', …)); Honeypot und zu_schnell (mit formMinSekunden = 3) ⇒ 201 ohne Buchung; abgelaufen ⇒ 400; 11. Buchung je IP ⇒ 429, IPv6 /64 geteilt; Knopf je Fall in config; Ablauf halten → vorschau → bestätigen → verschieben → absagen über HTTP; Widerruf mit/ohne Treffer gleiche Antwort; Kalender mit falschem Token 404, richtigem enthält den bestätigten, nicht den gehaltenen Termin; failNext ⇒ 503 ohne Rohtext; Kundenansicht-Cookie stört öffentliche POSTs nicht.
  • publicTermineRohling.test.js (nacktes express() nur mit dem Router): 20 KB ohne content-length (chunked) ⇒ 413; kaputtes JSON ⇒ 400; Antwort ohne Parser-Rohtext. Reihenfolge-Test: in server/index.js steht app.use(kundenansichtGuard) vor '/api/public/termine/v1' vor app.use(express.json(.
  • FAIL → implementieren → PASS → Commit „Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz“

Task 17: Funktionen verallgemeinern, Team-Route, Kundenansicht

Files: Modify server/services/siteFeatures.js, server/routes/admin/funktionenAdmin.js, server/routes/funktionen.js, server/middleware/kundenansichtGuard.js, server/services/customerViewLog.js, server/services/customerAdmin.js, server/services/termine/kontext.js; Tests test/funktionenRoutes.test.js, test/siteFeatures.test.js, test/kundenansichtGuard.test.js (Erlaubnisliste exakt; MOUNTS + /api/termine erst in Task 18), test/customerViewLog.test.js (zehn Aktionen), neu test/funktionenTermine.test.js. Produces: FEATURE_KEYS = ['news','termine']; FEATURE_LABELS = { news: { name: 'News-Pinnwand', keine: 'keine News-Pinnwand' }, termine: { name: 'Terminplaner', keine: 'keinen Terminplaner' } }; findActiveBySiteKey(siteKey, featureKey = 'news'); activateClosedTickets unverändert (TP2). Team: POST /api/team/funktionen/:customerId/:key (key ∉ FEATURE_KEYS ⇒ 404; Meldung „Für dieses Projekt gibt es noch {keine}“); adminView lädt die Funktionsdokumente des Kunden einmal. GET /api/funktionen + termine: { verfuegbar: termineFuerKunde(req.session.email) }. KUNDENANSICHT_ALLOWED + ['POST', /^\/api\/termine\/.+$/], ['PATCH', /^\/api\/termine\/einstellungen\/?$/], ['DELETE', /^\/api\/termine\/buchungen\/[^/]+\/?$/]; VIEW_ACTIONS + termin_einstellungen, termin_aktion, termin_manuell. deleteCustomerWithPortalAccess schaltet Terminplaner-Funktionen des Kunden ab (Notiz „Kunde gelöscht“). kontext.js nutzt nun findActiveBySiteKey(…, 'termine').

  • Tests: Team aktiviert termine für Projekt von c1, News unverändert; /:customerId/kalender ⇒ 404; Meldungen grammatisch; GET /api/funktionen mit/ohne Pilot-E-Mail; Erlaubnisliste exakt (inkl. Negativfall POST /api/termineX); Kunde löschen ⇒ disabled; bisherige Tests grün.
  • FAIL → implementieren → PASS → Commit „Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)“

Task 18: Kunden-Schnittstelle /api/termine

Files: Create server/routes/termine.js, test/termineRoutes.test.js; Modify server/index.js (app.use('/api/termine', termineRoutes)), test/kundenansichtGuard.test.js (MOUNTS + ['/api/termine', termineRoutes]). Consumes: Tasks 4, 6, 11–14, 17. Alle Routen: requireSession; projectId (Query/Body); assertOwnProject; termineFuerKunde(session.email) sonst 409 „Der Terminplaner ist noch nicht verfügbar.“; Funktion off/fehlt ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; disabled ⇒ nur Lesen + Export; Schreiblimit 120/h je Kunde; Protokoll: PATCH einstellungen, POST vorlage, POST feed ⇒ termin_einstellungen; POST aktion, DELETE ⇒ termin_aktion; POST buchungen ⇒ termin_manuell. Routen: GET /einstellungen?projectId ⇒ { state, aktivSeit, einstellung | null, version, vorlagen, feeds: { [ressourceId]: url }, siteKey, einbau: { snippet }, anfragenOffen }; POST /einstellungen/vorlage { projectId, vorlage } (nur ohne Einstellung, sonst 409; Betrieb vorbefüllt aus analysePrivacyData(customer) (Name/Firma, Anschrift, E-Mail) + phone); PATCH /einstellungen { projectId, version, einstellung } ⇒ { einstellung, version } | 400 { fehler } | 409; GET /buchungen?projectId&von&bis&status=a,b (Zeitraum ≤ 62 Tage) ⇒ { buchungen: [{ id, nummer, status, quelle, startsAt, endsAt, datum, zeit, leistungId, leistung, dauer, titel, person, ressourceId, gastName, gastEmail, gastTelefon, gastNachricht, erinnerung, zustellFehler, angenommenAt, widerrufenAt, abgesagtVon, absageGrund }] }; GET /zeiten?projectId&leistung&ressource&tag[&ohne=bookingId] (ohneRegeln); POST /buchungen { projectId, art: 'manuell'|'sperre', … } ⇒ 201; POST /buchungen/:id/aktion; DELETE /buchungen/:id?projectId (nur Sperren); POST /ressourcen/:id/feed ⇒ { url }; GET /export.csv?projectId (text/csv; charset=utf-8, BOM, ;, Kopf Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht, Zellen mit = + - @ am Anfang ⇒ ' voran).

  • Tests (send für PATCH/DELETE): fremdes Projekt 403; ohne Pilot 409; Vorlage zweimal ⇒ 409; PATCH alte Version ⇒ 409; Löschen mit künftigem Termin ⇒ 400 mit Anzahl; Aktion annehmen ⇒ G02; Kundenansicht darf PATCH/DELETE (mit Header) und es wird protokolliert; Guard-Schleife „jede Schreib-Route außerhalb der Liste ⇒ 403“ grün mit dem neuen Router; disabled ⇒ PATCH 409, Export 200; CSV entschärft.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Kunden-Schnittstelle“

Portal: Oberflächen

UI-Testserver (Tasks 19–21): startAppwriteMock({ schema, datetimes, defaultLimit: 25 }) + startFakeStripe() + useTestEnv({ appwriteUrl, stripeUrl }), dann dynamische Imports; startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/api/termine': termineRoutes, '/api/funktionen': funktionenRoutes, '/': express.static('public') }); danach config.publicBaseUrl = app.url, config.termine.mode = 'pilot', config.termine.pilotEmails = ['*'], config.termine.formMinSekunden = 0; Seed: Projekt + aktive Funktion + Einstellung aus Vorlage salon mit Plan Mo–So 00:00–24:00, feiertageGeschlossen false, vorlaufMin 0, maxOffeneJeEmail 10; mailSink. Screenshots nach /root/reports/terminplaner-ui/.

Task 19: Einbau-Skript, Demo, Einbauanleitung

Files: Create public/embed/termine.js, public/embed/termine-demo.html, docs/funktionen/termine-einbau.md, test/ui/termine-widget.ui.mjs, test/termineEmbedStatic.test.js. Consumes: Task 16 (Antwortformen). Produces: Einbau nach Spec 7 (data-webklar-termine, data-api, data-modus inline|knopf, data-knopf, data-leistung); Klassen wkt-*; <style id="wkt-style"> einmal, @layer wkt; CSS-Variablen --wkt-akzent, --wkt-akzent-text, --wkt-hintergrund, --wkt-text, --wkt-rand, --wkt-radius, --wkt-schrift, --wkt-schrift-titel; Schritte Spec 4; Monatsraster aus monat (APG: Pfeile, Pos1/Ende, Bild↑/↓, aria-selected, aria-disabled), Zeiten als Knöpfe; aria-live="polite"; Texte Sie/Du nach anrede, Haken-Texte aus texte, Knopf aus leistungen[].knopf; Honeypot kontakt_url (unsichtbar, autocomplete="off", tabindex="-1", aria-hidden); config neu laden, wenn älter als 100 min; Fuß „Vertrag widerrufen“ (Formular → POST /widerruf), „Datenschutz“, „Terminbuchung über WEBklar“; Knopfmodus lädt beim Klick; Fehler ⇒ Ausweichtext bzw. „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“. Einbauanleitung: Snippet, Variablen, drei Stilbeispiele, Datenschutz-Absatz (Task 7), Abnahme-Checkliste, API-Kurzreferenz.

  • termineEmbedStatic.test.js: Quelltext ohne localStorage, sessionStorage, document.cookie, Date, getDay, toLocale, Intl.; innerHTML nur mit Konstanten.
  • Playwright (UI-Testserver): inline bis „Fast geschafft“ + G01 im Sink; Knopfmodus mit <dialog>; nur Tastatur; 390 px; timezoneId: 'America/New_York' zeigt dieselben Uhrzeiten; Anrede Du; zwei Widgets; Server gestoppt ⇒ Ausweichtext.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Einbau-Skript, Demo und Einbauanleitung“

Task 20: Gästeseiten

Files: Create public/termin.html, public/termin.js, public/termin.css, test/ui/termin-seiten.ui.mjs. Produces: liest #v=/#m=, entfernt den Hash per history.replaceState, ruft /termin/vorschau; Ansichten Bestätigen (Zusammenfassung + knopf), Bestätigt/Angefragt, Abgelaufen (Link websiteUrl, Telefon), Verwalten (Absagen mit Rückfrage in der Seite, Verschieben über /termin/tage|zeiten, „Vertrag widerrufen“ → Formular nach § 356a mit vorbefülltem Namen/Buchungsnummer/E-Mail, Knopf „Widerruf bestätigen“, Eingang mit eingang.datum/uhrzeit); Kopf in farbe; Fuß: Impressum/Datenschutz des Betriebs + „Impressum WEBklar“ (https://webklar.com/impressum); noindex; keine Cookies; Sie/Du.

  • Playwright: G01-Link aus dem Sink öffnen ⇒ bestätigen ⇒ G02 im Sink; zweiter Aufruf „bereits bestätigt“; abgelaufen; absagen; verschieben; widerrufen (timezoneId: 'America/New_York' zeigt Berliner Eingangszeit); 390 px.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen“

Task 21a: Portal-Ansicht – Gerüst, Kachel, Termine

Files: Create public/termine.js, public/termine.css, public/termine-view.js, test/termineView.test.js, test/ui/termine-portal.ui.mjs; Modify public/funktionen.js, public/app.js (VIEW_HASHES.termine = 'termine', viewRefreshers.termine, APP_PARENT.termine = 'funktionen', FEATURE_LABELS.termine = 'Terminplaner'), public/dashboard.html (<section class="kb-view" id="view-termine"><header class="kb-head"><h1>Terminplaner</h1><p>…</p></header><div id="termine-body">…, Stylesheet), test/funktionenTiles.test.js. Produces: Kachel (termine?.verfuegbar fehlt/false ⇒ „Kommt bald“; project.features?.termine || { state: 'off' }; Aus „Wir schalten den Terminplaner für dich frei.“ + Chat / Aktiv „Termine öffnen“ + „{n} Anfragen warten“ aus anfragenOffen / Abgeschaltet); termine-view.js (rein): fensterParsen('09:00–12:30, 13:30–18:00'), fensterText, statusLabel, wocheVon(tag), gruppiereNachTag(buchungen); termine.js initTermine(root, { projectId }): Projektauswahl, Vorlagenauswahl ohne Einstellung, Reiter-Gerüst, Reiter Termine (Tag/Woche/Monat mit von/bis, Filter → Statuslisten, Aktionen mit Rückfragen in der Seite, „+ Termin eintragen“, „Zeit sperren“ mit 500-Minuten-Hinweis, Hinweis „Bestätigung nicht zugestellt“), disabled ⇒ nur Lesen.

  • Tests reine Helfer; Kachelzustände; Playwright (Fixture page.route und ein Lauf gegen den UI-Testserver): Vorlage wählen, Liste mit allen Status, Anfrage annehmen, App-Ansicht 390 px als Unterseite von Funktionen (Titel „Terminplaner“).
  • FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht, Kachel, Termine“

Task 21b: Portal-Ansicht – Leistungen, Team & Plätze, Einstellungen

Files: Modify public/termine.js, public/termine.css, test/ui/termine-portal.ui.mjs. Produces: Reiter Leistungen (Liste, Reihenfolge, Felder Task 4, Gesundheits-Hinweis), Team & Plätze (Wochenplan je Tag mit fensterParsen, Leistungszuordnung, Abwesenheiten, Bundesland, Sonderöffnungen, Kalender-Abo-Link kopieren/neu), Einstellungen (Spec 9 Punkt 4; Zahlung „online“ ausgegraut „kommt bald“; Hinweis Widerruf schlägt Stornofrist; Stornotext-Muster gekennzeichnet; Export-Link); Speichern mit version, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld.

  • Playwright: Leistung anlegen, Wochenplan ändern, Speichern, Konflikt (zweiter Tab), Feldfehler (javascript:-URL), Feed-Link neu erzeugen.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht Leistungen, Team, Einstellungen“

WOMS

Task 22: Funktionen-Reiter für mehrere Funktionen

Repo: /home/webklar/apps/ticket.webklar.com/dev-termine (aus V3; existiert er nicht, Schritte aus V3 zuerst ausführen). Files: Create src/lib/funktionenView.js, tests/unit/funktionenView.test.mjs; Modify src/lib/teamApi.js (setFeature(customerId, key, { projectId, action, note }); setNewsFeature als Wrapper), src/components/customer/CustomerFunktionen.jsx, src/lib/viewLog.js (+ termin_einstellungen: 'Terminplaner-Einstellungen geändert', termin_aktion: 'Termin bearbeitet', termin_manuell: 'Termin eingetragen'), tests/unit/viewLog.test.mjs. Produces: FUNKTIONEN = [{ key: 'news', label: 'News', bestaetigen: { activate: 'News ohne Zahlung aktivieren?', disable: 'News abschalten? Die Website zeigt dann keine Beiträge mehr.' } }, { key: 'termine', label: 'Terminplaner', bestaetigen: { activate: 'Terminplaner ohne Zahlung aktivieren?', disable: 'Terminplaner abschalten? Auf der Website sind dann keine Buchungen mehr möglich.' } }], ZUSTAND (bisheriges STATE), funktionOf(project, key), aktionen(state), mergeFunktion(projects, projectId, key, aktualisiert); Reiter zeigt je Projekt eine Zeile je Funktion, Ticketnummer als Link /tickets?woid=, Antwort übernimmt nur features[key].

  • Tests (Register, Aktionen, Merge überschreibt die andere Funktion nicht); npm test; nice npx vite build (vorher free -m).
  • FAIL → implementieren → PASS → Commit „Funktionen-Reiter: News und Terminplaner je Projekt“

Abschluss

  • Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und Global Constraints; Critical/Important beheben.
  • npm test (Node 24); reine Termine-Tests unter Node 20 (docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js test/termineSlots.test.js test/termineEinstellungen.test.js test/termineIcs.test.js).
  • Ledger nach /root/reports/terminplaner-tp1/.

Livegang (je Schritt Freigabe des Nutzers; der Nutzer pusht main)

  1. L1 Portal: auf aktuelles origin/main rebasen, npm test, Push feature/terminplaner:main (Deploy installiert feiertagejs). Schalter off ⇒ keine Jobs, nichts sichtbar. Prüfen: /api/public/termine/v1/aaaaaaaaaaaaaaaa/config ⇒ {"aktiv":false}, /api/termine/einstellungen ohne Login 401, Log ohne Fehler.
  2. L2 Schema: docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs --check, dann ohne --check; erneut --check ⇒ nichts fehlt.
  3. L3 Schalter: Compose (kundenbereich-server): TERMINE_ENABLED=${TERMINE_ENABLED:-off}, TERMINE_PILOT_EMAILS=${TERMINE_PILOT_EMAILS:-}; /home/webklar/apps/.env (Sicherung .bak-termine): TERMINE_ENABLED=pilot, TERMINE_PILOT_EMAILS=kenso.gri@gmail.com; docker compose -f /home/webklar/apps/docker-compose.yml up -d --no-deps kundenbereich-server (nie preview-portal).
  4. L4 WOMS: Push feature/terminplaner:test; Bundle enthält „Terminplaner“.
  5. L5 Pilot: WOMS Kunde „test“ → Funktionen → Terminplaner für „Test-Website“ aktivieren; Portal (Login des Testkunden): Vorlage, Leistungen, Zeiten, Betriebs-E-Mail kenso.gri@gmail.com; WEBklar/test-website: Seite „Termin buchen“ mit Widget + Datenschutz-Absatz-Entwurf, Push ⇒ Deploy; Testbuchungen: bestätigen, Anfrage annehmen/ablehnen, Erinnerung (Termin knapp über der Erinnerungszeit), absagen, verschieben, widerrufen, Kalender-Abo in Google.
  6. L6: Nutzertest; Testbuchungen nach Wunsch löschen.