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

49 KiB
Raw 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), dazu eine öffentliche, versionierte Schnittstelle /api/public/termine/v1, eine Kunden-Schnittstelle /api/termine, ein Einbau-Skript embed/termine.js, Gästeseiten termin.html und die Portal-Ansicht #termine. WOMS bekommt Aktivieren/Abschalten je Funktion.

Tech Stack: Node 20 (Produktion) / 24 (Host-Tests), Express 4, Appwrite 1.8.1 REST, nodemailer 9, feiertagejs 1.5.1 (neu), Playwright (playwright-core aus dem npx-Cache), React 18 (WOMS).

Spec: docs/superpowers/specs/2026-10-02-terminplaner-design.md (Abschnitte „TP1 Kern“ 1–14). Recherche: /root/reports/terminplaner-2026-10-02/ (Zeit-Helfer und Zellen als Vorlage: spike-code/tz.mjs, spike-code/claims.mjs, spike-code/ics.mjs).

Worktrees: Portal /home/webklar/apps/kundenbereich.webklar.com/dev-termine (Branch feature/terminplaner von origin/main ff7fe35, eigenes node_modules per npm ci, Basis 1463 Tests grün). WOMS: git -C /home/webklar/apps/ticket.webklar.com/build worktree add -b feature/terminplaner ../dev-termine origin/test, node_modules skip-worktree + eigenes npm install.

Global Constraints

  • Funktionsschlüssel termine; Schalter TERMINE_ENABLED = off (Vorgabe) | pilot | on; TERMINE_PILOT_EMAILS Komma-Liste (klein, getrimmt, * = alle). In TP1 verhält sich on wie pilot ohne E-Mail-Filter; Bestellen gibt es nicht.
  • Dateinamen: nie server/services/appointments.js (Test adminPanelGone). Neue Dateien unter server/services/termine/.
  • Zeiten: Rechnen in Wanduhr Europe/Berlin, speichern als UTC (toISOString()); Appwrite liefert +00:00 – Datumswerte immer mit new Date() lesen. Nicht existierende Wanduhrzeiten (Umstellung März) werden ausgelassen, doppelte (Oktober) nehmen das erste Vorkommen. deadlines.berlinTime nicht verwenden.
  • Zellen à 5 Minuten; alle Zellen einer Buchung in einem Bulk-Create (max. 100 ⇒ Belegung bis 500 Minuten), Zell-ID c_ + 34 Hex von sha1(featureId|ressourceId|zelleIso).
  • Haltezeit 30 Minuten; Tokens 32 Zufallsbytes base64url, gespeichert nur als SHA-256-Hex; Bestätigungstoken einmal wirksam; Verwaltungstoken gültig bis Terminende + 1 Tag. Zustandsänderungen nur per POST.
  • Status: gehalten, angefragt, bestaetigt, erledigt, nichterschienen, abgesagt, abgelehnt, verfallen. Belegend: gehalten (nur solange heldUntil > now), angefragt, bestaetigt.
  • Knopftexte exakt: verbindlich + Preis ⇒ „Zahlungspflichtig buchen“ (Bestätigungsseite „Zahlungspflichtig bestätigen“); verbindlich ohne Preis ⇒ „Termin buchen“ („Termin bestätigen“); Anfrage ⇒ „Termin anfragen“ („Anfrage absenden“).
  • Zustimmung vorzeitiger Beginn (Pflicht nur bei: verbindlich, Preis, recht.gaesteSind ≠ unternehmer, nicht recht.freizeit, Terminbeginn < Buchung + 14 Tage): „Ich verlange ausdrücklich, dass {Betrieb} mit der Leistung vor Ablauf der Widerrufsfrist beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Leistung erlischt (§ 356 Abs. 5 Nr. 2 BGB). Widerrufe ich vor der vollständigen Erbringung, schulde ich einen angemessenen Betrag für die bis dahin erbrachte Leistung (§ 357a Abs. 2 BGB).“ Storno: „Ich habe die Stornobedingungen gelesen.“ Erinnerung (nie vorangekreuzt): „Ich möchte {n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten.“ Über Freitext: „Bitte keine Gesundheitsangaben.“
  • Widerruf möglich ⇔ Buchung verbindlich mit verbraucher === true (gespeichert beim Halten: recht.gaesteSind !== 'unternehmer' && !recht.freizeit), Status nicht abgelehnt/verfallen, now < bestaetigtAt + 14 Tage, Termin noch nicht vorbei.
  • Öffentliche Antworten enthalten nie customerId, projectId, featureId, Appwrite-/Ticket-/Stripe-IDs; nur Leistungs-/Ressourcen-IDs der Einstellung und die Buchungsnummer (8 Zeichen). Unbekannter/inaktiver siteKey ⇒ 200 { aktiv: false }.
  • Schutz: Body > 16 384 Byte ⇒ 413 (vor dem Parsen über content-length); Honeypot-Feld website; Formular-Token HMAC, Alter 3 s – 2 h; je IP (IPv6 je /64) 10 Buchungen / 10 min und 120 GET / min; je Funktion 60 Haltungen / h; je E-Mail+Funktion regeln.maxOffeneJeEmail (Vorgabe 3) offene Termine und 5 Bestätigungsmails / Tag; Widerruf 10 / h je IP. Speicher-Maps mit Obergrenze 10 000 Schlüssel. Treffer bei Honeypot/Zeitfalle ⇒ normale Erfolgsantwort, nichts gespeichert.
  • Mails: Absender "<betrieb.name>" <SMTP_FROM> (Gast) bzw. "WEBklar" <SMTP_FROM> (K20/K21), replyTo = Betriebs-E-Mail (Gast) bzw. keiner, nie BCC, kein Tracking. Alles über termineOutbox: höchstens 200 / h, Wiederholung nach 1, 5, 30 min, dann fehler; SMTP-Anmeldefehler (EAUTH/535) ⇒ 15 min Pause; Einträge 7 Tage nach gesendet/fehler löschen.
  • Löschfristen: verfallen 48 h nach heldUntil; abgesagt/abgelehnt 30 Tage; erledigt/nichterschienen aufbewahrungTage (Vorgabe 90, 14–365) nach Terminende; Funktion disabled ⇒ alles 30 Tage nach disabledAt.
  • Portaltexte Du-Form, kein confirm(); Gästetexte Sie/Du nach anrede. Kundentext nur über textContent/esc().
  • Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie git add -A (node_modules).

Review Focus

  1. Gast-Browser in anderer Zeitzone (z. B. New York) sieht dieselben Uhrzeiten wie Berlin; Widget rechnet nie mit Date des Browsers. → Task 14 (Playwright mit timezoneId: 'America/New_York')
  2. E-Mail mit Großbuchstaben/Leerzeichen ( Max@GMail.com) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 9 (email_normalisiert)
  3. Betrieb löscht eine Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, bestehende Termine bleiben gültig. → Task 4 + 13
  4. Gast klickt den Bestätigungslink genau, während der Minutenjob die Haltung verfallen lässt ⇒ genau ein Ergebnis (Sperre je Buchung). → Task 10 (verfall_und_bestaetigung_gleichzeitig)
  5. Zwei Widgets auf einer Seite (zwei Leistungen per data-leistung) stören sich nicht (IDs, Stile einmal). → Task 14

Vorab (Controller, kein Subagent): Spike Bulk-Create

  • Nutzer um Freigabe bitten. Dann im Container (docker exec -w /app webklar-kundenbereich-server node …, Skript im Scratchpad, nicht im Repo): Wegwerf-Collection termineSpike (Attribute bookingId 64), 50 parallele POST …/collections/termineSpike/documents mit { documents: [{ $id, bookingId }, …] } (je 20 Zellen, Überlappungen) ⇒ erwartet genau 1× 201 je überlappende Gruppe, Rest 409 document_already_exists, keine Teil-Schreibungen (Liste prüfen). Collection löschen.
  • Ergebnis ins Ledger. Positiv ⇒ Task 2 wie beschrieben. Negativ ⇒ Task 2 nutzt die Transactions-API (POST /databases/transactions, Operationen, PATCH {commit:true}, 409 transaction_conflict) hinter derselben Funktion createDocuments.

Portal: Grundlagen

Task 1: Schalter, Konfiguration, Schema-Skript

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

  • config.collections.termineConfig|termineBookings|termineClaims|termineOutbox (Env APPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX, Vorgaben = Namen).
  • config.termine = { mode, pilotEmails: string[], holdMinutes: 30, zelleMin: 5, mailsProStunde: 200 } (mode nur off|pilot|on, sonst off).
  • schalter.js: termineModus() ⇒ 'off'|'pilot'|'on'; termineFuerKunde(email) ⇒ boolean (off ⇒ false; on ⇒ true; pilot ⇒ E-Mail klein/getrimmt in Liste oder Liste enthält *); termineOeffentlichAktiv() ⇒ mode !== 'off'.
  • server/services/termine/schema.js (reine Daten, ohne Import von config.js, damit Tests es vor useTestEnv() laden dürfen): TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, termineBookings: …, termineClaims: …, termineOutbox: … } exakt nach Spec 2 (Typen string mit Größe, integer, boolean, datetime; Text-Felder als string mit großer Größe; Indizes { key, type:'key', attributes:[…] }).
  • scripts/termine-setup.mjs im Muster von analyse-setup.mjs: main({ check, client, log }) legt fehlende Collections/Attribute an (Collection-ID aus config.collections), Indizes nur bei neuen Collections, --check ⇒ Exit 1 bei Fehlendem; createAppwriteClient aus analyse-setup.mjs wiederverwenden (createAttribute geht über /attributes/<type> für alle Typen; Body integer ⇒ { key, required:false }, datetime ⇒ { key, required:false }).
  • test/support/schema.js: schemaKeys(collection) findet auch Collections aus TERMINE_COLLECTIONS (statischer Import von server/services/termine/schema.js).
  • Tests: Modus-Auswertung (leer/unbekannt ⇒ off; pilot mit KENSO.gri@gmail.com ⇒ true für kenso.gri@gmail.com; *); Setup mit Fake-Client legt 4 Collections + alle Attribute + Indizes an, zweiter Lauf legt nichts an, --check legt nichts an und meldet Fehlendes; schemaKeys('termineBookings') enthält startsAt, manageHash, nachweis.
  • FAIL → implementieren → PASS (node --test test/termineSetup.test.js test/termineSchalter.test.js, danach npm test) → Commit „Terminplaner: Schalter, Konfiguration, Schema-Skript“

Task 2: Appwrite-Wrapper und Nachbau: Bulk-Create, Bereichsabfragen, Seiten

Files: Modify server/services/appwriteAdmin.js, test/support/appwriteMock.js; Test test/appwriteBulk.test.js. Produces:

  • Query.between(attr, a, b), Query.greaterThanEqual, Query.lessThanEqual, Query.cursorAfter(id) ({ method:'cursorAfter', values:[id] }).
  • createDocuments(collectionId, documents: Array<{$id, ...fields}>) ⇒ docs – POST …/documents mit rohem Body { documents } (über adminFetch mit neuer Option raw: true; heute ist raw nur für /users), wirft wie adminFetch (409 bei Duplikat). Ergebnis des Spikes beachten.
  • listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs – lädt seitenweise mit cursorAfter (letzte $id), bis eine Seite < pageSize; über max ⇒ Fehler „zu viele Einträge“.
  • Nachbau: POST mit documents-Array = alles oder nichts (eine vorhandene ID ⇒ 409, nichts geschrieben; ungültige ID ⇒ 400); Abfragen between, greaterThanEqual, lessThanEqual, cursorAfter (nach Sortierung: alles nach dem Dokument mit dieser ID); Vergleich von Datumswerten als Zeitpunkte (Date.parse), sonst wie bisher.
  • Tests: Bulk legt 3 an; Bulk mit einer vorhandenen ID ⇒ 409 und Liste unverändert; between inklusiv beidseitig; listAllDocuments über 3 Seiten (pageSize 2, 5 Docs) liefert 5 in Reihenfolge; bestehende Tests grün.
  • FAIL → implementieren → PASS → Commit „Appwrite: Bulk-Create, Bereichsabfragen, seitenweises Laden“

Task 3: Zeit und Feiertage

Files: Create server/services/termine/zeit.js, server/services/termine/feiertage.js, test/termineZeit.test.js; Modify package.json/package-lock.json (npm install feiertagejs@1.5.1 --save-exact). Produces:

  • zeit.js (nur Intl, Vorlage spike-code/tz.mjs): TZ = 'Europe/Berlin'; zuUtc(tag:'YYYY-MM-DD', uhrzeit:'HH:MM') ⇒ Date | null (null, wenn die Wanduhrzeit nicht existiert; doppelte ⇒ erstes Vorkommen; '24:00' = Folgetag 00:00); ausUtc(date) ⇒ { tag, uhrzeit }; wochentag(tag) ⇒ 1..7 (Mo=1); plusTage(tag, n) ⇒ tag; heute(now) ⇒ tag; Anzeige datumLang(date) ⇒ 'Montag, 12.10.2026', datumKurz(date) ⇒ '12.10.2026', uhrzeit(date) ⇒ '10:30'.
  • feiertage.js: BUNDESLAENDER = ['BW','BY','BE','BB','HB','HH','HE','MV','NI','NW','RP','SL','SN','ST','SH','TH']; istFeiertag(tag, land) ⇒ boolean (feiertagejs, Jahres-Cache).
  • Tests: zuUtc('2026-03-29','02:30') === null; zuUtc('2026-03-29','03:00') = 01:00Z; zuUtc('2026-10-25','02:30') = 00:30Z (erstes Vorkommen, MESZ); ausUtc rückwärts für beide Tage; wochentag('2026-10-05') === 1; plusTage('2026-10-25', 1) === '2026-10-26'; Feiertage: RP 2026-06-04 (Fronleichnam) ja, BE 2026-06-04 nein, BE 2026-03-08 (Frauentag) ja, SN 2026-11-18 (Buß- und Bettag) ja, alle 2026-12-25 ja. Zusätzlich unter Node 20: docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termineZeit.test.js.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Zeit (Berlin, Umstellung) und Feiertage je Bundesland“

Task 4: Einstellungen und Branchen-Vorlagen

Files: Create server/services/termine/einstellungen.js, server/services/termine/vorlagen.js, test/termineEinstellungen.test.js. Interfaces (Datenform = Spec 2 termineConfig.data, ergänzt um betrieb.kleinunternehmer: boolean):

  • neueId(prefix:'r'|'s'|'a') ⇒ 'r_' + 8 Zeichen [a-z0-9].
  • normalisiereEinstellung(eingabe, { bisher = null, kuenftigeTermine = {} }) ⇒ { data } | { fehler: { [feldpfad]: text } } – füllt Vorgaben, kürzt Strings, prüft: betrieb.name (1–80) und betrieb.email (gültig) Pflicht; farbe ^#[0-9a-f]{6}$; anrede sie|du; bundesland ∈ BUNDESLAENDER; Pläne: Fenster HH:MM aufsteigend, nicht überlappend, Ende > Beginn (24:00 erlaubt); dauer 5–480 und Vielfaches von 5, pufferNach 0–120 Vielfaches von 5, dauer + pufferNach ≤ 500; preisArt fest|ab|keiner, preisCent 0–1 000 000; buchungsart sofort|anfrage; jede aktive Leistung hat ≥ 1 aktive Ressource; regeln.raster ∈ {5,10,15,20,30,60}, vorlaufMin 0–20160, horizontTage 1–365, absageBisStd 0–336, maxOffeneJeEmail 1–10; gastFelder je aus|optional|pflicht; erinnerung.stundenVorher ∈ {2,12,24,48}; zahlung.modus nur vorort in TP1 (online/beides ⇒ Fehler „kommt bald“); aufbewahrungTage 14–365; Abwesenheiten von ≤ bis. kuenftigeTermine = { leistungen: {id: n}, ressourcen: {id: n} }: Entfernen einer ID mit n > 0 ⇒ Fehler „Für diese Leistung gibt es noch {n} künftige Termine. Deaktiviere sie stattdessen.“; storno.version wird bei geändertem Text neu gesetzt (YYYY-MM-DD-n); feedToken je Ressource beibehalten bzw. neu (32 Byte base64url).
  • oeffentlicheEinstellung(data) ⇒ objekt mit allen Feldern von GET /config außer aktiv, hinweis, zustimmungVorzeitigText, formToken (Form siehe Task 11; nur aktive Leistungen mit aktiver Ressource, preis als Text, widerruf = irgendeine aktive verbindliche Leistung für Verbraucher, keine feedToken).
  • knopfText(leistung, { bestaetigen = false }) ⇒ string (Global Constraints); brauchtZustimmungVorzeitig(data, leistung, startDate, now) ⇒ boolean; istVerbraucherBuchung(data, leistung) ⇒ boolean; preisText(leistung, data) ⇒ '45,00 €' | 'ab 45,00 €' | '' (Zusatz „inkl. USt“ bzw. „(kein Ausweis der USt nach § 19 UStG)“ über preisHinweis(data)); zahlungText(data) ⇒ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte' (Arten bar|ec|kreditkarte|ueberweisung|paypal).
  • vorlagen.js: VORLAGEN mit Schlüsseln salon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges (je label, 2–5 Leistungen mit realistischen Dauern/Puffern, Plan Di–Sa bzw. Mo–Fr, buchungsart anfrage bei tattoo/handwerk/fotograf, ressourcen 1 Person bzw. 2 Plätze bei werkstatt, Stornotext-Muster Variante 1 aus recherche-recht-zahlung.md 7.3 F); vorlagenListe() ⇒ [{ key, label }]; ausVorlage(key, { name, email, telefon, adresse }) ⇒ data (valide nach normalisiereEinstellung).
  • Tests: jede Vorlage ergibt valide Daten; Pflichtfelder; überlappende Fenster ⇒ Fehler am Pfad ressourcen.0.plan.1; Leistung ohne aktive Ressource ⇒ Fehler; Löschen mit künftigen Terminen ⇒ Fehler mit Anzahl; zahlung.modus 'online' ⇒ Fehler; Knopftexte aller drei Fälle; Zustimmung vorzeitig: Verbraucher + Preis + Termin in 3 Tagen ⇒ true, in 20 Tagen ⇒ false, unternehmer ⇒ false, keiner-Preis ⇒ false; oeffentlicheEinstellung enthält kein feedToken.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Einstellungen und Branchen-Vorlagen“

Task 5: Verfügbarkeit

Files: Create server/services/termine/slots.js, test/termineSlots.test.js. Consumes: Task 3, 4. Produces:

  • belegungAus(buchungen, now) ⇒ [{ ressourceId, start: Date, ende: Date }] (nur belegende Status; ende = blockEndsAt).
  • freieZeiten({ data, leistungId, ressourceId /* id | 'egal' */, tag, belegung, now, ohneRegeln = false }) ⇒ [{ uhrzeit:'HH:MM', start: Date, ressourceIds: string[] }] – Fenster je Ressource (Sonderöffnung > Abwesenheit > Feiertag geschlossen > Plan), Startzeiten im raster ab Fensterbeginn, start + dauer + pufferNach ganz im Fenster, nicht in Belegung, start ≥ now + vorlaufMin, tag ≤ heute + horizontTage; ohneRegeln (Portal) ignoriert Vorlauf/Horizont, nicht aber Vergangenheit; bei egal Vereinigung, ressourceIds = alle freien.
  • freieTage({ …, monat:'YYYY-MM' }) ⇒ ['YYYY-MM-DD'] (max. 31 Tage, nur ab heute).
  • waehleRessource(ressourceIds, buchungenAmTag, data) ⇒ id (wenigste belegende Buchungen am Tag, dann Reihenfolge in data.ressourcen).
  • blockEnde(start, leistung) ⇒ Date.
  • Tests: Pause teilt Fenster; Puffer verhindert Start 15 min vor Pausenbeginn bei 15+5; Vorlauf 120 ⇒ keine Zeiten vor now+2h; Horizont; Abwesenheit der Ressource und Betriebsurlaub (ressourceId null); Feiertag geschlossen vs. Sonderöffnung am Feiertag; Belegung durch Haltung mit heldUntil in der Vergangenheit blockiert nicht; egal mit zwei Personen; 2026-03-29 Plan 00:00–06:00 enthält kein 02:xx; 2026-10-25 Plan 01:00–04:00, 60-min-Leistung: Termin 02:00 hat echte Dauer 60 min; letzter Termin endet genau am Fensterende.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Verfuegbarkeit“

Task 6: Zellen, Tokens, Buchungsspeicher

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

  • token.js: neuesToken() ⇒ { token, hash }; hashToken(token) ⇒ hex64; buchungsNummer(bookingId) ⇒ 8 Zeichen [A-Z0-9] (aus der UUID ohne Bindestriche, groß); formToken(siteKey, now) ⇒ string / pruefeFormToken(siteKey, token, now) ⇒ 'ok'|'zu_schnell'|'abgelaufen'|'ungueltig' (HMAC-SHA256 mit aus config.sessionSecret abgeleitetem Schlüssel 'termine-form').
  • zellen.js: zellenFuer(start, blockEnde) ⇒ Date[] (5-min-Raster, Beginn abgerundet, Ende aufgerundet); zellenId(featureId, ressourceId, zelle) ⇒ string; belegeZellen({ featureId, ressourceId, bookingId, start, blockEnde, now }) ⇒ Promise<void> – unter withLocks(['termine-res:'+featureId+':'+ressourceId], fn, { waitMs: 5000 }); Bulk-Create; bei 409: blockierende Zellen per ID lesen, deren Buchungen laden; sind alle nicht mehr belegend (Status oder abgelaufene Haltung), diese Zellen löschen und genau einmal neu versuchen, sonst Fehler { status: 409, code: 'SLOT_VERGEBEN' }; gibZellenFrei(bookingId) (alle Zellen der Buchung löschen, Index idx_booking).
  • buchungen.js (Datenzugriff, keine Regeln): legeAn(data) ⇒ doc; hole(id); aendere(id, patch) ⇒ doc (setzt updatedAt); imZeitraum(featureId, von, bis) ⇒ docs (between auf startsAt, listAllDocuments); perHash('verifyHash'|'manageHash', hash) ⇒ doc|null; offeneFuerEmail(featureId, email, now) ⇒ number (belegend, Termin in der Zukunft); zaehleKuenftige(featureId, now) ⇒ { leistungen, ressourcen }.
  • Tests (Nachbau): zwei Buchungen für dieselbe Zeit nacheinander ⇒ zweite SLOT_VERGEBEN; parallel (Promise.all, 10×) ⇒ genau eine erfolgreich; abgelaufene Haltung blockiert nicht (Aufräumen + zweiter Versuch); zellenFuer 10:00–10:50 ⇒ 10 Zellen; Token-Hash stabil; Formular-Token zu jung/alt/gefälscht.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Zellen gegen Doppelbuchung, Tokens, Buchungsspeicher“

Task 7: Rechtstext-Entwürfe für Gäste

Files: Create docs/rechtliches/termine-widerrufsbelehrung-muster.md, docs/rechtliches/termine-datenschutz-absatz.md, docs/rechtliches/termine-hinweise.md, docs/rechtliches/termine-datenschutz-6c-entwurf.md, server/services/termine/rechtstexte.js, test/termineRechtstexte.test.js.

  • Widerrufsbelehrung für Dienstleistungen + Muster-Widerrufsformular wörtlich nach Anlage 1/2 zu Art. 246a § 1 EGBGB in der aktuellen Fassung (inkl. Hinweis auf die Widerrufsfunktion), abgerufen von https://www.gesetze-im-internet.de/bgbeg/anlage_1.html und …/anlage_2.html (Abrufdatum im Kopf), Platzhalter für Betrieb (Name, Anschrift, E-Mail, Telefon); Datenschutz-Absatz (Sie/Du) und Formular-Hinweis aus recherche-recht.md 11.1/11.2 (angepasst: Löschfristen aus den Global Constraints); 6c-Entwurf für public/datenschutz.html (nicht einbauen). Alle Dateien Kopf „ENTWURF – vor Einsatz bei fremden Betrieben prüfen“.
  • rechtstexte.js: widerrufsbelehrung(betrieb, anrede) ⇒ { text, html }, formularHinweis(data) ⇒ string (Kurzfassung 11.2 mit Link-Platzhalter), ZUSTIMMUNG_VORZEITIG(betriebName), ZUSTIMMUNG_STORNO, ZUSTIMMUNG_ERINNERUNG(stunden) (exakte Texte aus den Global Constraints), RECHTSTEXTE_VERSION = '2026-10-02'.
  • Tests: Belehrung enthält „Vertrag widerrufen“, die Betriebsanschrift und keine {{; Zustimmungstexte wortgleich zu den Global Constraints.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Rechtstext-Entwuerfe fuer Gaeste“

Task 8: Kalenderdatei, Mailvorlagen, Gästeversand

Files: Create server/services/termine/ics.js, server/mail-templates/G01 Termin bestaetigen.html … G08 Eingang Widerruf.html, K20 Neue Buchung.html, K21 Termin geaendert.html, test/termineIcs.test.js; Modify server/services/mailTemplates.js (IDs [GKT]\d{2} in templateIds und Kopfkommentar-Regex), server/services/mailer.js, test/mailTemplates.test.js (EXPECTED + G01–G08, K20, K21; Logo-Prüfung nur für K/T; G-Vorlagen: kein WEBklar-Logo, enthalten {{betrieb_name}}). Produces:

  • ics.js (Vorlage spike-code/ics.mjs): icsTermin({ uid, sequenz, methode:'REQUEST'|'CANCEL'|'PUBLISH', start, ende, titel, ort, beschreibung, organisator:{name,email}, teilnehmer:{name,email}|null, jetzt }) ⇒ string (CRLF, Faltung 75 Oktette, Escaping \ ; , \n, UTC …Z, RSVP=FALSE); icsKalender(termine[], { name, jetzt }) ⇒ string (METHOD:PUBLISH, X-WR-CALNAME, X-PUBLISHED-TTL:PT15M).
  • Vorlagen nach Spec 6 (Tabelle). G: neutrales Layout (Kopfbalken in {{farbe}}, Betriebsname, kein Logo), Fuß mit {{betrieb_name}}, {{betrieb_adresse}}, {{betrieb_telefon}}, {{betrieb_email}}, {{impressum_url}}, {{datenschutz_url}} und „Diese E-Mail wurde im Auftrag von {{betrieb_name}} über den Terminplaner von WEBklar versendet.“; Blöcke sie/du für jeden Anredetext. Gemeinsame Platzhalter: gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link; G02 zusätzlich Blöcke preis, person, storno ({{storno_text}}), widerruf ({{widerrufsbelehrung}} + {{widerruf_link}}), anfrage_angenommen; G06 {{absage_von}}, Block nachricht ({{nachricht}}); G07 {{alt_datum}}, {{alt_zeit}}; G08 {{eingang}}, {{erklaerung}}. K20 (Du-Form, WEBklar-Layout): kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link, Block anfrage; K21: Blöcke abgesagt, verschoben, widerrufen, nicht_zugeordnet, Platzhalter wie K20 + alt_datum, alt_zeit, eingang.
  • mailer.sendGuestMail({ to, fromName, replyTo = null, subject, html, text, ics = null, icsMethode = 'REQUEST' }) ⇒ nodemailer-Info (wirft; icalEvent: { method, filename: 'termin.ics', content } nur mit ics; nie bcc; Anführungszeichen im Namen entfernt).
  • Tests: ICS-Zeilen ≤ 75 Oktette, UID/SEQUENCE korrekt, Komma/Semikolon escaped, CANCEL hat STATUS:CANCELLED; Vorlagen-Suite grün inkl. neuer IDs; sendGuestMail über mailSink: kein bcc, replyTo gesetzt, From-Name = Betrieb, Anhang text/calendar.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Kalenderdatei, Mailvorlagen G01–G08/K20/K21, Versand an Gaeste“

Task 9: Mail-Warteschlange

Files: Create server/services/termine/outbox.js, test/termineOutbox.test.js. Consumes: Task 8. Produces:

  • einreihen({ featureId, bookingId, art, an, fromName, replyTo, template, values, blocks, ics, icsMethode }) ⇒ doc – rendert sofort (renderMail), speichert in termineOutbox (status 'wartet', naechsterVersuch = now), stößt sendeEintrag(doc.$id) an, ohne zu warten (Fehler nur loggen).
  • verarbeite(now) ⇒ { gesendet, fehler, pausiert } – alle fälligen wartet-Einträge (älteste zuerst), solange das Stundenbudget (config.termine.mailsProStunde, gleitendes Fenster im Speicher) reicht; je Eintrag Sperre termine-mail:<id>; Erfolg ⇒ gesendet, gesendetAt; Fehler ⇒ versuche+1, naechsterVersuch +1/+5/+30 min, nach 3 Wiederholungen fehler + fehler-Text; EAUTH/responseCode 535 ⇒ alles 15 min pausieren.
  • zustellFehler(bookingIds) ⇒ Set<bookingId> (Einträge mit status 'fehler'), für die Portal-Liste.
  • raeumeAuf(now) – löscht gesendet/fehler älter als 7 Tage.
  • Tests: Budget 2/h ⇒ dritter wartet; Wiederholungsabstände; EAUTH ⇒ Pause, danach weiter; doppelte Verarbeitung desselben Eintrags (zwei verarbeite parallel) ⇒ eine Mail; Aufräumen nach 7 Tagen.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Mail-Warteschlange mit Stundenbudget“

Task 10: Buchungsablauf und Hintergrund-Jobs

Files: Create server/services/termine/kontext.js, server/services/termine/mails.js, server/services/termine/ablauf.js, server/services/termine/jobs.js, test/termineAblauf.test.js, test/termineJobs.test.js; Modify server/index.js (Jobs). Consumes: Tasks 4–9 (Task 7: widerrufsbelehrung, Zustimmungstexte), siteFeatures.findActiveBySiteKey (Task 12 erweitert die Signatur; bis dahin in kontext.js eigene Abfrage featureKey 'termine', state 'active'). Produces:

  • kontext.js: kontextPerSiteKey(siteKey) ⇒ ctx|null (nur active, Modus ≠ off); kontextPerFeature(featureId) ⇒ ctx|null (auch disabled); ctx = { feature, project, data, version }; ladeEinstellung(featureId) ⇒ { data, version } | null; speichereEinstellung(feature, data, { version, by }) ⇒ { data, version } (Sperre termine-cfg:<featureId>, 409 bei Versionskonflikt, legt Dokument mit ID = featureId an).
  • mails.js: werteFuer(ctx, buchung, extra) ⇒ values; sendeAnGast(art:'G01'…'G08', ctx, buchung, extra), sendeAnBetrieb(art:'K20'|'K21', ctx, buchung, { block, extra }) – über einreihen; Links ${publicBaseUrl}/termin.html#v=<token> bzw. #m=<token>; ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH.
  • ablauf.js (alle Funktionen nehmen now; jede Zustandsänderung unter withLocks(['termine-buchung:'+id]) mit frischem Lesen):
    • halte(ctx, eingabe, now) ⇒ { buchung, email } – Eingabe { leistung, ressource, tag, zeit, name, email, telefon, nachricht, zusatz, erinnerung, zustimmungen:{ storno, vorzeitig } }; E-Mail getrimmt + klein; Feldfehler (400, err.fehler), Zustimmungen nach Task 4, Grenze maxOffeneJeEmail (429 „Sie haben bereits {n} offene Termine.“), Slot neu berechnen (sonst 409 SLOT_VERGEBEN), Ressource bei egal wählen, Buchung gehalten (quelle 'online', nachweis = Fassungen + Hashes + Zeitpunkte + Knopftext, verbraucher, preisCent, zahlart 'vorort', zahlStatus 'keine', icsSequenz 0), Zellen, G01.
    • vorschau(token, now) ⇒ { art:'bestaetigen'|'verwalten', … } (Form Task 11).
    • bestaetige(token, now) ⇒ { status, schon? } – nur gehalten und heldUntil > now (sonst 410 abgelaufen); sofort ⇒ bestaetigt + Verwaltungstoken + G02 + K20; anfrage ⇒ angefragt + Verwaltungstoken + G03 + K20 (Block anfrage); schon bestätigt ⇒ { status, schon:true }.
    • sageAbGast(token, now) (bis absageBisStd vor Beginn, sonst 409 „Absage nur telefonisch“), verschiebeGast(token, { tag, zeit }, now) (gleiche Frist; neue Zellen vor Freigabe der alten; icsSequenz+1; G07 + K21 verschoben), widerrufeGast(token, { name }, now) und widerrufeOhneLink(siteCtx, { name, email, nummer }, now) ⇒ { eingang } (Treffer ⇒ widerrufenAt, abgesagt von gast, Zellen frei, G08, K21 widerrufen; kein Treffer ⇒ G08 an die angegebene Adresse + K21 nicht_zugeordnet; Antwort immer gleich).
    • Betrieb: aktion(ctx, bookingId, { aktion:'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen', grund, nachricht, tag, zeit }, now) (G02 anfrage_angenommen / G04 / G06 / G07; „nichterschienen“ nur nach Beginn); manuell(ctx, { leistung, ressource, tag, zeit, name, email, telefon, nachricht, bestaetigungSenden }, now) (quelle 'manuell', bestaetigt, keine Regeln außer Belegung, verbraucher false, G02 nur mit Haken); sperre(ctx, { ressource, von, bis, titel }, now) (quelle 'sperre', bestaetigt, ohne Gast); entsperre(ctx, bookingId).
  • jobs.js: minutenJob(now) (abgelaufene gehalten ⇒ verfallen + Zellen frei; outbox.verarbeite), fuenfMinutenJob(now) (Erinnerung: bestaetigt, erinnerung, ohne erinnerungGesendetAt, now + 1h < startsAt ≤ now + stundenVorher, bestaetigtAt < startsAt − stundenVorher – erst Marker, dann G05; erledigt 1 h nach Ende), taeglicherJob(now) (Löschfristen laut Global Constraints inkl. Zellen und Outbox; Funktionen disabled seit > 30 Tagen: Buchungen, Zellen, Einstellung löschen; verwaiste Zellen). In server/index.js nach Muster der vorhandenen Jobs (setTimeout(...).unref() + setInterval(...).unref(), eigene Fehlerbehandlung): 60 s, 5 min, täglich (+180 s Versatz).
  • Tests (Nachbau + mailSink + feste now): Halten ⇒ G01 an Gast, Zellen da; Bestätigen ⇒ G02 mit ICS und Verwaltungslink + K20 an Betrieb, kein BCC; Anfrage ⇒ G03/K20-Block; zweiter Klick ⇒ schon; nach 31 min ⇒ 410; email_normalisiert (' Max@GMail.com ' zählt gegen dieselbe Grenze und findet die Buchung beim Widerruf); Absage nach Frist ⇒ 409; Verschieben belegt neu und gibt alt frei; Widerruf innerhalb 14 Tagen trotz abgelaufener Stornofrist; Widerruf ohne Treffer ⇒ gleiche Antwort + K21 nicht_zugeordnet; Betrieb lehnt ab ⇒ G04 + Zellen frei; manuell ohne Haken ⇒ keine Gastmail; Jobs: Verfall, Erinnerung genau einmal (zwei Läufe), nicht für Buchung 30 min vor Beginn, erledigt, Löschfristen (je Status), verfall_und_bestaetigung_gleichzeitig (Job und bestaetige parallel ⇒ genau ein Endzustand, keine G02 nach verfallen).
  • FAIL → implementieren → PASS → Commit „Terminplaner: Buchungsablauf, Mails und Hintergrund-Jobs“

Portal: Schnittstellen

Task 11: Öffentliche Schnittstelle

Files: Create server/routes/publicTermine.js, test/publicTermine.test.js; Modify server/index.js (Einhängen nach kundenansichtGuard, vor dem globalen express.json), server/middleware/kundenansichtGuard.js (PASS_PREFIXES + '/api/public/'), test/kundenansichtGuard.test.js. Consumes: Task 10, analyseDomains(project) aus server/services/analyse.js. Routen (Basis /api/public/termine/v1; feste Pfade vor /:siteKey):

  • GET /:siteKey/config ⇒ { aktiv:true, betrieb:{ name, telefon, adresse, impressumUrl, datenschutzUrl }, anrede, farbe, leistungen:[{ id, name, beschreibung, dauer, preis, buchungsart, ressourcen:[id] }], ressourcen:[{ id, name, typ }], personWaehlbar, regeln:{ horizontTage, absageBisStd }, zahlung:{ modus:'vorort', text }, storno:{ text, version }|null, widerruf:boolean, erinnerung:{ anbieten, stundenVorher }, gastFelder:{ telefon, nachricht, zusatz:[] }, hinweis:string, zustimmungVorzeitigText:string, formToken } | { aktiv:false }; Cache-Control: public, max-age=60, ohne ETag (das Formular-Token ändert sich je Abruf; ein bis 60 s altes Token ist gültig). Antwort zusammengesetzt aus oeffentlicheEinstellung(data) + hinweis: formularHinweis(data) + zustimmungVorzeitigText: ZUSTIMMUNG_VORZEITIG(name) + formToken.
  • GET /:siteKey/tage?leistung&ressource&monat ⇒ { tage:[] } (max-age=15); GET /:siteKey/zeiten?leistung&ressource&tag ⇒ { zeiten:['09:00',…] } (no-store); ungültige Parameter ⇒ 400 { fehler:{ feld:text } }.
  • POST /:siteKey/buchungen (Body wie halte-Eingabe + formToken, website) ⇒ 201 { ok:true, email:'m***@gmail.com', minuten:30 }; 400/409/429 { fehler:{ feld:text } } (Slot vergeben ⇒ Feld zeit).
  • POST /:siteKey/widerruf { name, email, nummer } ⇒ { ok:true, eingang:ISO }.
  • POST /termin/vorschau { token } ⇒ { art, status, termin:{ nummer, leistung, person, datum, zeit, dauer, preis, zahlung, ort }, betrieb:{…}, anrede, farbe, knopf, aktionen:{ bestaetigen, absagen, verschieben, widerrufen }, hinweis? } | 404 { fehler:'Link ungültig oder abgelaufen' }.
  • POST /termin/bestaetigen|absagen { token }; POST /termin/verschieben { token, tag, zeit }; POST /termin/widerruf { token, name }; POST /termin/tage { token, monat }, POST /termin/zeiten { token, tag } (für Verschieben).
  • GET /kalender/:feedToken.ics ⇒ text/calendar; charset=utf-8, max-age=300 (bestätigte Termine + Sperren der Ressource, nächste 90 Tage, auch vergangene 7 Tage).
  • Schutz laut Global Constraints; CORS: GET *; POST/OPTIONS nur für Origin-Host ∈ analyseDomains(project) ∪ Host von config.publicBaseUrl (für /termin/* nur letzterer) ⇒ Access-Control-Allow-Origin: <origin>, Vary: Origin; sonst 403 { fehler:{ _:'Nicht erlaubt' } }; Preflight 204 mit Allow-Methods: GET, POST, OPTIONS, Allow-Headers: Content-Type, Max-Age: 86400. Fehler aus Appwrite ⇒ 503 { fehler:{ _:'Gerade nicht erreichbar' } }, nie Rohtext. Modus off ⇒ jede /:siteKey/*-Route { aktiv:false } bzw. 404 für POST.
  • Tests (startTestApp + Nachbau + mailSink; Origin-Header gesetzt): kein Feld customerId|projectId|featureId|$id in irgendeiner Antwort (rekursiv prüfen); unbekannter Schlüssel und Modus off ⇒ {aktiv:false}; GET mit beliebiger Origin ⇒ *; POST von fremder Origin ⇒ 403; Preflight; Honeypot und zu junges Formular-Token ⇒ 201 ohne Buchung/Mail; 11. Buchung je IP in 10 min ⇒ 429; IPv6 2001:db8::1 und 2001:db8::2 teilen das Limit; Body 20 KB ⇒ 413; ganzer Ablauf halten → vorschau → bestaetigen → absagen über HTTP; Widerruf ohne Treffer = gleiche Antwort; Kalender-Feed enthält bestätigten Termin, nicht den gehaltenen; Appwrite-Fehler (failNext) ⇒ 503 ohne Rohtext; Kundenansicht-Cookie stört öffentliche POSTs nicht.
  • FAIL → implementieren → PASS → Commit „Terminplaner: oeffentliche Schnittstelle v1 mit Missbrauchsschutz“

Task 12: Funktionen verallgemeinern, Team-Route, Kundenansicht

Files: Modify server/services/siteFeatures.js, server/routes/admin/funktionenAdmin.js, server/routes/funktionen.js, server/middleware/kundenansichtGuard.js, server/services/customerViewLog.js, server/services/customerAdmin.js, server/services/termine/kontext.js (auf findActiveBySiteKey umstellen), Tests test/funktionenRoutes.test.js, test/siteFeatures.test.js, test/kundenansichtGuard.test.js, neu test/funktionenTermine.test.js. Produces:

  • FEATURE_KEYS = ['news', 'termine']; FEATURE_LABELS = { news:'News-Pinnwand', termine:'Terminplaner' }; findActiveBySiteKey(siteKey, featureKey = 'news'); activateClosedTickets bleibt bei News (TP2).
  • Team: POST /api/team/funktionen/:customerId/:key (key ∈ FEATURE_KEYS, sonst 404; /news verhält sich wie bisher); Meldung ohne Dokument: „Für dieses Projekt gibt es noch keine {Label}“; adminView lädt die Funktionsdokumente des Kunden einmal (statt getFeatureDoc je Projekt) und setzt adminNote/ticketMissing je Schlüssel.
  • GET /api/funktionen ergänzt termine: { verfuegbar: termineFuerKunde(req.session.email) }.
  • Kundenansicht: KUNDENANSICHT_ALLOWED + ['POST', /^\/api\/termine\/.+$/], ['PATCH', /^\/api\/termine\/einstellungen\/?$/]; VIEW_ACTIONS + termin_einstellungen, termin_aktion, termin_manuell.
  • deleteCustomerWithPortalAccess: Terminplaner-Funktionen des Kunden disabled (Notiz „Kunde gelöscht“).
  • Tests: Team aktiviert termine für Projekt von c1 ⇒ Zustand active, News unverändert; /:customerId/kalender ⇒ 404; GET /api/funktionen mit Pilot-E-Mail ⇒ termine.verfuegbar true, sonst false, Projekte haben features.termine; Erlaubnisliste exakt (Test anpassen); Kunde löschen ⇒ termine disabled; alle bisherigen Funktionen-/News-Tests grün.
  • FAIL → implementieren → PASS → Commit „Funktionen: Terminplaner als zweite Funktion (Team-Route, Kundenansicht)“

Task 13: Kunden-Schnittstelle /api/termine

Files: Create server/routes/termine.js, test/termineRoutes.test.js; Modify server/index.js (app.use('/api/termine', termineRoutes)). Consumes: Tasks 4, 6, 10, 12. Alle Routen requireSession, projectId (Query oder Body), assertOwnProject, termineFuerKunde(session.email) sonst 409 „Der Terminplaner ist noch nicht verfügbar.“; Funktion muss existieren (off ⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“); disabled ⇒ nur Lesen + Export; Schreiblimit 120 / h je Kunde; jede Schreibaktion logIfKundenansicht. Routen:

  • GET /einstellungen?projectId ⇒ { state, einstellung: data|null, version, vorlagen, feeds:{ [ressourceId]: url }, siteKey, einbau:{ snippet } }.
  • POST /einstellungen/vorlage { projectId, vorlage } ⇒ wie GET (nur wenn noch keine Einstellung; Betriebsdaten aus dem Kundendokument vorbefüllt: companyName||name, email, phone, location).
  • PATCH /einstellungen { projectId, version, einstellung } ⇒ { einstellung, version } | 400 { fehler } | 409 Versionskonflikt (mit kuenftigeTermine aus zaehleKuenftige).
  • GET /buchungen?projectId&von&bis&status (max. 62 Tage) ⇒ { buchungen:[{ id, nummer, status, quelle, startsAt, endsAt, leistung, person, ressourceId, gastName, gastEmail, gastTelefon, gastNachricht, erinnerung, zustellFehler, widerrufenAt, abgesagtVon, absageGrund }] }.
  • GET /zeiten?projectId&leistung&ressource&tag ⇒ { zeiten } (ohneRegeln: true).
  • POST /buchungen { projectId, art:'manuell'|'sperre', … } ⇒ 201 { buchung }; POST /buchungen/:id/aktion { projectId, aktion, … } ⇒ { buchung }; DELETE /buchungen/:id?projectId nur für Sperren.
  • POST /ressourcen/:id/feed { projectId } ⇒ { url } (neues feedToken, Version +1).
  • GET /export.csv?projectId ⇒ text/csv; charset=utf-8 mit BOM, ;-getrennt, Kopf Nummer;Status;Datum;Uhrzeit;Leistung;Person/Platz;Name;E-Mail;Telefon;Nachricht, Formel-Injektion verhindert (Zellen mit = + - @ am Anfang ⇒ ' voran).
  • Tests: fremdes Projekt ⇒ 403; ohne Pilot ⇒ 409; Vorlage anlegen, zweites Mal ⇒ 409; PATCH mit alter Version ⇒ 409; Löschen einer Leistung mit künftigem Termin ⇒ 400 mit Anzahl; Liste nur eigener Funktion; manuell/Sperre; Aktion annehmen ⇒ G02; Kundenansicht darf PATCH (mit Header) und es wird protokolliert; disabled ⇒ PATCH 409, Export 200; CSV-Injektion entschärft.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Kunden-Schnittstelle“

Portal: Oberflächen

Task 14: Einbau-Skript, Demo, Einbauanleitung

Files: Create public/embed/termine.js, public/embed/termine-demo.html, docs/funktionen/termine-einbau.md, test/ui/termine-widget.ui.mjs, test/termineEmbedStatic.test.js. Consumes: Task 11 (Antwortformen). Produces: Einbau laut Spec 7 (data-webklar-termine, data-api, data-modus inline|knopf, data-knopf, data-leistung), Klassen wkt-*, Stile einmal je Seite (<style id="wkt-style">, @layer wkt), CSS-Variablen --wkt-akzent, --wkt-akzent-text, --wkt-hintergrund, --wkt-text, --wkt-rand, --wkt-radius, --wkt-schrift, --wkt-schrift-titel; Schritte Spec 4 (Leistung → Person → Tag/Zeit → Daten → Zusammenfassung → „Fast geschafft“); Monatsraster nach APG (Pfeile, Pos1/Ende, Bild↑/↓ Monat, aria-selected, aria-disabled), Zeiten als Knöpfe; Fehler über aria-live="polite"; alle Uhrzeiten/Daten als Strings vom Server; Fuß „Vertrag widerrufen“ (Formular im Widget, POST /widerruf) wenn widerruf, „Datenschutz“-Link, „Terminbuchung über WEBklar“; Knopfmodus lädt erst beim Klick; Fehler ⇒ Ausweichtext bzw. „Gerade nicht erreichbar. Bitte rufen Sie an: {telefon}“. docs/funktionen/termine-einbau.md: Snippet, Variablentabelle, drei Stilbeispiele, Datenschutz-Absatz (Verweis Task 7), Abnahme-Checkliste (Testbuchung, Mails, Absage, Handy, Tastatur), öffentliche API-Kurzreferenz.

  • test/termineEmbedStatic.test.js: Quelltext enthält kein localStorage, sessionStorage, document.cookie, innerHTML = mit Variablen aus Antworten (nur Konstanten), kein new Date( (Zeitzonen-Unabhängigkeit).
  • Playwright gegen echten Testserver (startAppwriteMock + startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/': express.static('public') }), Seed einer aktiven Funktion mit Vorlage salon, mailSink): inline Buchung bis „Fast geschafft“, G01 im Sink; Knopfmodus mit <dialog>; nur Tastatur; Handy 390 px; timezoneId: 'America/New_York' zeigt dieselben Uhrzeiten; zwei Widgets auf einer Seite; Server aus ⇒ Ausweichtext; Screenshots /root/reports/terminplaner-ui/widget-*.png.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Einbau-Skript, Demo und Einbauanleitung“

Task 15: Gästeseiten

Files: Create public/termin.html, public/termin.js, public/termin.css, test/ui/termin-seiten.ui.mjs. Produces: Seite liest #v=/#m= aus location.hash, ruft POST /termin/vorschau; Ansichten Bestätigen (Zusammenfassung + Knopf knopf), Bestätigt/Angefragt, Abgelaufen, Verwalten (Absagen mit Rückfrage in der Seite, Verschieben mit Tage/Zeiten über Token, „Vertrag widerrufen“ mit Formular nach § 356a: Name, Buchungsnummer + E-Mail vorbefüllt, Knopf „Widerruf bestätigen“, danach Eingang mit Datum/Uhrzeit); Kopf in farbe mit Betriebsname; Fuß: Impressum/Datenschutz des Betriebs + „Impressum WEBklar“ (/impressum.html); <meta name="robots" content="noindex">; Hash nach dem Lesen per history.replaceState entfernen; keine Cookies.

  • Playwright (gleicher Testserver): bestätigen (danach ist die Buchung bestaetigt, G02 im Sink), zweiter Aufruf „bereits bestätigt“, abgelaufen, absagen, verschieben, widerrufen, Handy-Breite; Screenshots.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Gaesteseiten Bestaetigen, Verwalten, Widerrufen“

Task 16: Portal-Ansicht und Kachel

Files: Create public/termine.js, public/termine.css, public/termine-view.js, test/termineView.test.js, test/ui/termine-portal.ui.mjs; Modify public/funktionen.js (Kachel), public/app.js (VIEW_HASHES.termine = 'termine', viewRefreshers.termine, APP_PARENT.termine = 'funktionen', FEATURE_LABELS.termine = 'Terminplaner'), public/dashboard.html (<section class="kb-view" id="view-termine"> mit #termine-body, Stylesheet), test/funktionenTiles.test.js. Produces:

  • Kachel: termine.verfuegbar false ⇒ wie bisher „Kommt bald“; sonst Pille Aus (kein Projekt aktiv: Text „Wir schalten den Terminplaner für dich frei.“ + Chat-Knopf) / Aktiv („Termine öffnen“, data-view="termine", Zähler „{n} Anfragen warten“ aus GET /api/termine/buchungen?status=angefragt) / Abgeschaltet.
  • termine-view.js (rein, Node-testbar): fensterParsen('09:00–12:30, 13:30–18:00') ⇒ [['09:00','12:30'],['13:30','18:00']] | { fehler }, fensterText(fenster), statusLabel(status) ⇒ deutsch, tageDerWoche(tag), gruppiereNachTag(buchungen), leerEinstellung nicht nötig.
  • termine.js initTermine(root, { projectId }): Projektauswahl; ohne Einstellung ⇒ Vorlagenauswahl; Reiter Termine (Tag/Woche/Monat, Filter, Aktionen nach Spec 4 mit Rückfragen in der Seite, „+ Termin eintragen“, „Zeit sperren“, Hinweis „Bestätigung nicht zugestellt“), Leistungen, Team & Plätze (Wochenplan je Tag mit fensterParsen, Abwesenheiten, Bundesland, Sonderöffnungen, Kalender-Abo-Link kopieren/neu), Einstellungen (Spec 9 Punkt 4; Zahlung „online“ ausgegraut „kommt bald“; Hinweis Widerruf schlägt Stornofrist; Stornotext-Muster als Vorschlag, gekennzeichnet; Export-Link); Speichern mit version, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld; disabled ⇒ nur Lesen + Hinweis.
  • test/termineView.test.js für die reinen Helfer; funktionenTiles.test.js um die drei Kachelzustände.
  • Playwright (Fixture: page.route('**/api/**') wie funktionen.ui.mjs, dazu ein Durchlauf gegen den echten Testserver): Vorlage wählen, Leistung anlegen, Wochenplan ändern, Speichern, Konflikt, Termine-Liste mit allen Status, Anfrage annehmen, App-Ansicht (390 px) als Unterseite von Funktionen; Screenshots.
  • FAIL → implementieren → PASS → Commit „Terminplaner: Portal-Ansicht und Kachel“

WOMS

Task 17: Funktionen-Reiter für mehrere Funktionen

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

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

Abschluss

  • Gesamt-Review des Branches (Portal + WOMS) gegen Spec TP1 und diese Global Constraints; Funde Critical/Important beheben.
  • npm test (Host Node 24) und die Terminplaner-Tests unter Node 20 (docker run --rm -v "$PWD":/w:ro -w /w node:20-alpine node --test test/termine*.test.js test/publicTermine.test.js).
  • Ledger + Screenshots nach /root/reports/terminplaner-tp1/.

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

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