- 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>
56 KiB
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; SchalterTERMINE_ENABLED=off(Vorgabe) |pilot|on;TERMINE_PILOT_EMAILSKomma-Liste (klein, getrimmt,*= alle). TP1:on=pilotohne E-Mail-Filter; kein Bestellen. Beiofflaufen keine Termine-Jobs und keine Termine-Abfragen. config.terminewird bei jedem Aufruf gelesen (Tests ändernconfig.termine.*zur Laufzeit). Werte:mode,pilotEmails,holdMinutes: 30,zelleMin: 5,mailsProStunde: 200,formMinSekunden: 3(EnvTERMINE_FORM_MIN_SECONDS).- Neue Dateien unter
server/services/termine/; nieserver/services/appointments.js(TestadminPanelGone). - Einstellungs-Attribut heißt
einstellung(niedata:appwriteAdmin.formatRequestBodyhält Bodies mitdatafür fertig verpackt). - Zeit: Wanduhr
Europe/Berlin, gespeichert UTC (toISOString()); Appwrite liefert+00:00⇒ Datumswerte immer mitnew Date()lesen. Nicht existierende Wanduhrzeiten (März) auslassen, doppelte (Oktober) = erstes Vorkommen. AlleHH:MMin Plänen, Sonderöffnungen, Abwesenheiten und Sperren sind Vielfache von 5 Minuten.deadlines.berlinTimenicht verwenden. - Zellen à 5 Minuten, Zell-ID
c_+ 34 Hex vonsha1(featureId|ressourceId|zelleIso), Feldbis=blockEndsAtder 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;gehaltenmitheldUntil ≤ now⇒ frei). - Status:
gehalten,angefragt,bestaetigt,erledigt,nichterschienen,abgesagt,abgelehnt,verfallen. Belegend:gehalten(solangeheldUntil > now),angefragt,bestaetigt. - Tokens: Bestätigungstoken = 32 Zufallsbytes base64url, gespeichert nur
verifyHash(SHA-256-Hex), wirksam einmal bisheldUntil. Verwaltungstoken = base64url(HMAC-SHA256(Schlüssel ausconfig.sessionSecret+'termine-manage', bookingId)), gespeichert nurmanageHash, jederzeit neu berechenbar, gültig bis Terminende + 1 Tag. Zustandsänderungen nur per POST. VertragsschlussvertragAt=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 inverbrauchergespeichert). Widerruf möglich ⇔ verbindlich,verbraucher, Statusbestaetigt,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 Modusoff⇒200 { aktiv: false }(GET und POST unter/:siteKey);/termin/*(Token) bleibt fürdisablednutzbar (Absagen, Widerruf). - Schutz: Router-eigener Parser 16 KB + Router-Fehlermiddleware (413/400/503, nie
err.message);content-length> 16 384 ⇒ 413 vorab; Honeypot-Feldkontakt_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 (Modullimits.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+Funktionregeln.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-Blockzu_vielean 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/K21from: { 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 übertermineOutbox: einziger SendepfadsendeEintragprüft Pause und Stundenbudget (200/h);EAUTH/535 ⇒ 15 min Pause für alle, Eintrag bleibtwartet; Verbindungsfehler/4xx ⇒ erneut nach 1, 5, 30 min, dannfehler; 5xx (außer 535) ⇒ sofortfehler; nachgesendetwerdenan,html,text,icsgeleert; Einträge 7 Tage nachgesendetbzw. 48 h nachfehlergelöscht. - Löschfristen:
verfallen48 h nachheldUntil;abgesagt/abgelehnt30 Tage;erledigt/nichterschienenaufbewahrungTage(Vorgabe 90, 14–365) nach Terminende;angefragtmit Beginn in der Vergangenheit ⇒abgelehnt(System); Funktiondisabled⇒ alles 30 Tage nachdisabledAt. - Tests: Termine-Tests starten den Nachbau mit
startAppwriteMock({ schema, datetimes, defaultLimit: 25 })(Task 2); festenowper Parameter; Mails mitmailSink()+warteAufVersand()prüfen; Kalenderdaten stehen im Sink alsicalEvent(nicht als Anhang). Schwere Läufe (Playwright,vite build, Docker-Node-20) nacheinander, vorherfree -m, mitnice. - Portaltexte Du-Form, kein
confirm(); Gästetexte Sie/Du nachanrede. Kundentext nur übertextContent/esc();hrefnur für geprüftehttps:-URLs, mitrel="noopener noreferrer". - Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie
git add -A.
Review Focus
- 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) - E-Mail mit Großbuchstaben/Leerzeichen (
Max@GMail.com) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 12 (email_normalisiert) - Betrieb löscht Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, Termine bleiben. → Task 4 + 18
- Bestätigungsklick genau während der Minutenjob die Haltung verfallen lässt ⇒ genau ein Endzustand. → Task 15 (
verfall_und_bestaetigung_gleichzeitig) - 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-CollectiontermineSpike(bookingIdstring 64), 50 parallelePOST …/collections/termineSpike/documentsmit{ documents: [{ $id, bookingId }, …] }(je 20 Zellen, Überlappungen) ⇒ je überlappender Gruppe genau ein 201, Rest 409document_already_exists, keine Teil-Schreibungen; Collection löschen. Negativ ⇒ Task 2 bautcreateDocumentsüber die Transactions-API (POST /databases/transactions, Operationen,PATCH {commit:true}, 409transaction_conflict). - V2 Gesetzestexte: Anlage 1 und 2 zu Art. 246a § 1 EGBGB von
https://www.gesetze-im-internet.de/bgbeg/anlage_1.htmlund…/anlage_2.htmlwörtlich nachdocs/rechtliches/quellen/anlage-1.txtundanlage-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, dortgit ls-files -z node_modules | xargs -0 git update-index --skip-worktreeundnpm install(niegit 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(EnvAPPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX);config.terminelaut Global Constraints.schalter.js:termineModus(),termineFuerKunde(email) ⇒ boolean,termineAus() ⇒ boolean.schema.js(reine Daten, kein Import vonconfig.js):TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, … }exakt nach Spec 2 (miteinstellungstattdata;termineOutbox.icsMethod); Attributtypenstring(Größe),integer,boolean,datetime; Indizes{ key, type: 'key', attributes };TERMINE_DATETIMES = { collection: [keys] }.scripts/termine-setup.mjs(Musteranalyse-setup.mjs,createAppwriteClientvon dort wiederverwenden;createAttributenutzt/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 auchTERMINE_COLLECTIONS(statischer Import ausserver/services/termine/schema.js).- Tests: Modus (leer/unbekannt ⇒ off; pilot +
KENSO.gri@gmail.com⇒ true;*; Laufzeitänderung vonconfig.termine.modewirkt); Setup mit Fake-Client: 4 Collections + Attribute + Indizes, zweiter Lauf legt nichts an,--checklegt nichts an;schemaKeys('termineConfig')enthälteinstellung, nichtdata; Rundreise: Nachbau mitschemaKeys('termineConfig'),createDocument+updateDocumentmiteinstellung/version/updatedByund Zurücklesen. - FAIL → implementieren → PASS (
node --test test/termineSetup.test.js test/termineSchalter.test.js, dannnpm 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 neueadminFetch-Optionraw: true; 409 bei Duplikat; Spike-Ergebnis V1).listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs(Query.limit(pageSize),orderAsc('$id'),cursorAfter, bis Seite < pageSize; übermax⇒ 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\dTsind;datetimes-Felder werden beim Schreiben auf…+00:00normalisiert,''⇒ 400;defaultLimit(Termine-Tests: 25) gilt ohnelimit-Abfrage wie Appwrite. - Tests: Bulk 3 Dokumente; Bulk mit vorhandener ID ⇒ 409 und unverändert;
betweeninklusiv;listAllDocumentsüber 3 Seiten;defaultLimit25 liefert 25 von 30; Datum…Zkommt als+00:00zurü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;ausUtcrü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ätzlichdocker 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; Pflichtbetrieb.name(1–80),betrieb.email(ein Postfach); URLs (impressumUrl,datenschutzUrl,agbUrl,websiteUrl) nurhttps:, ≤ 300, ohne Zugangsdaten;farbe^#[0-9a-f]{6}$;anredesie|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; FensterHH:MMauf 5 min, aufsteigend, ohne Überlappung, Ende > Beginn (24:00 erlaubt);dauer5–480,pufferNach0–120 (je Vielfache von 5),dauer + pufferNach ≤ 500;preisArtfest|ab|keiner,preisCent0–1 000 000;buchungsartsofort|anfrage; aktive Leistung braucht ≥ 1 aktive Ressource;regeln.raster∈ {5,10,15,20,30,60},vorlaufMin0–20160,horizontTage1–365,absageBisStd0–336,maxOffeneJeEmail1–10;gastFelderaus|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.modusnurvorort(sonst Fehler „Online-Zahlung kommt bald“),vorOrtArten⊆ bar|ec|kreditkarte|ueberweisung|paypal;aufbewahrungTage14–365; Abwesenheitenvon ≤ 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.versionbei geändertem TextYYYY-MM-DD-n(ausnow);feedTokennur ausbisherübernehmen, neue Ressourcen bekommen eines (32 Byte base64url), Eingabewerte werden ignoriert.oeffentlicheEinstellung(einstellung, now) ⇒Felder vonGET /configaußeraktiv,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,buchungsartanfrage bei tattoo/handwerk/fotograf, werkstatt mit 2 Plätzen, Stornotext Variante 1 ausrecherche-recht-zahlung.md7.3 F),vorlagenListe(),ausVorlage(key, betrieb, now) ⇒ einstellung(valide).- Tests: alle Vorlagen valide; Pflichtfelder;
javascript:alert(1)alsimpressumUrl⇒ Fehler am Pfadbetrieb.impressumUrl; Fenster 09:07 ⇒ Fehler; Überlappung ⇒ Fehler am Pfadressourcen.0.plan.1; Leistung ohne aktive Ressource; Löschen mit künftigen Terminen ⇒ Fehler mit Anzahl; Client-feedTokenwird ignoriert;online⇒ Fehler; Knopftexte; Zustimmung vorzeitig (3 Tage ⇒ true, 20 Tage ⇒ false, Unternehmer/kein Preis ⇒ false);oeffentlicheEinstellungohnefeedToken. - 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;
egalmit zwei Personen;ohneBuchunggibt die eigene Zeit frei; 2026-03-29 ohne02: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'(Mindestalterconfig.termine.formMinSekunden).zellen.js(spike-code/claims.mjsnur 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(Sperretermine-res:<featureId>:<ressourceId>,waitMs 5000; mitausserzä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 = [] })(überlistAllDocuments, behält die genannten Zellzeiten);verwaisteZellen(now) ⇒ ids(bis < now − 1 Tagoder Buchung nicht belegend).buchungen.js(nur Datenzugriff):legeAn,hole,aendere(setztupdatedAt),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 mitaussergelingt, Fremdbuchung im neuen Bereich ⇒SLOT_VERGEBENund alte Zellen unverändert;gibZellenFreilöscht 40 Zellen (über 25er-Seiten);imZeitraumfindet 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.txtwö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übermailSink: keinbcc,replyTo, From-Name = Betrieb,sent[0].icalEvent.method === 'REQUEST'undcontententhältBEGIN:VCALENDAR, ohneicskeinicalEvent; 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öckesie/dufür jeden Anredetext. - Platzhalter: G01
leistung, datum, zeit, link+ Blockzu_viele(Text: höchstmögliche Zahl offener Termine, absagen oder anrufen{{betrieb_telefon}}) – keingast_name; G02gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link, ort+ Blöckepreis,person,storno({{storno_text}}),widerruf({{widerrufsbelehrung}},{{widerruf_link}}),anfrage_angenommen; G03gast_name, leistung, datum, zeit, link; G04gast_name, leistung, datum, zeit+ Blocknachricht({{nachricht}}); G05gast_name, leistung, person, datum, zeit, link+ Blockperson; G06gast_name, leistung, datum, zeit, absage_von, grund+ Blocknachricht; G07gast_name, leistung, alt_datum, alt_zeit, datum, zeit, link; G08eingang, nummer+ Blöckezugeordnet,nach_frist– keingast_name. K20 (Du-Form, WEBklar-Layout mit Logo):kunde_name, leistung, person, datum, zeit, gast_name, gast_email, gast_telefon, gast_nachricht, portal_link+ Blockanfrage; K21: wie K20 +alt_datum, alt_zeit, eingang+ Blöckeabgesagt,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 Platzhaltergast_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 ⇒ sofortfehler; EAUTH ⇒ Pause, während der Pause keine Sendeversuche (Fake-Transport zählt), danach weiter; zweiverarbeiteparallel ⇒ eine Mail; nach Erfolg sindan/html/text/icsleer; 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(Funktiontermine,active, Modus ≠ off; eigene Abfrage bis Task 17, dannfindActiveBySiteKey(siteKey, 'termine'); 30 s Speicher-Cache je siteKey);kontextPerFeature(featureId) ⇒ ctx|null(auchdisabled);ctx = { feature, project, einstellung, version };ladeEinstellung(featureId);speichereEinstellung(feature, einstellung, { version, by }) ⇒ { einstellung, version }(Sperretermine-cfg:<featureId>, Dokument-ID = featureId, 409 bei Versionskonflikt, Cache leeren);websiteUrl(ctx)(einstellung.betrieb.websiteUrloderhttps://+ erster Host ausanalyseHosts(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 })übereinreihen(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 beiegalwählen; erst Buchunggehaltenanlegen (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⇒ Buchungverfallen, Fehler 409 Feldzeit; G01; Grenze offene Termine ⇒ nichts anlegen, G01-Blockzu_viele, Ergebniszu_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-Blockanfrage; 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 miticalEventund#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, Buchungverfallen, keine Zellen; Zustimmung vorzeitig fehlt ⇒ 400 Feldvorzeitig;vorschaumit Verwaltungstoken liefertgast. - 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;
nichterschienenvor 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
stundenVorhervor Beginn, nachgeholt nach 20 min Jobausfall;erledigt; vergangene Anfrage ⇒abgelehnt, Zellen frei; Löschfristen je Status;verfall_und_bestaetigung_gleichzeitig(Job undbestaetigeparallel ⇒ ein Endzustand, keine G02 nachverfallen); 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;vorzeitigNoetigserverseitig für diesen Tag).POST /:siteKey/buchungen(Eingabe vonhalte+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 denfeedTokender 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 vonconfig.publicBaseUrl} odernew URL(origin).host === req.get('host')(gleiche Herkunft); für/termin/*nur die beiden letzten; dannAccess-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;/tageund/zeiten10 s Speicher-Cache je (siteKey, Parameter). publicTermine.test.js(startTestApp + Nachbau +mailSink; Origin/X-Forwarded-Forje Test,resetTermineLimitsForTests(),config.termine.formMinSekunden = 0): rekursiv keine verbotenen IDs; unbekannt/off/disabled ⇒{aktiv:false}(GET und POST); fremde Origin ⇒ 403, gleiche Herkunft undliveDomain-Origin erlaubt; Preflight (send('OPTIONS', …)); Honeypot undzu_schnell(mitformMinSekunden = 3) ⇒ 201 ohne Buchung;abgelaufen⇒ 400; 11. Buchung je IP ⇒ 429, IPv6 /64 geteilt; Knopf je Fall inconfig; 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(nacktesexpress()nur mit dem Router): 20 KB ohnecontent-length(chunked) ⇒ 413; kaputtes JSON ⇒ 400; Antwort ohne Parser-Rohtext. Reihenfolge-Test: inserver/index.jsstehtapp.use(kundenansichtGuard)vor'/api/public/termine/v1'vorapp.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
terminefür Projekt von c1, News unverändert;/:customerId/kalender⇒ 404; Meldungen grammatisch;GET /api/funktionenmit/ohne Pilot-E-Mail; Erlaubnisliste exakt (inkl. NegativfallPOST /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 (
sendfü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 ohnelocalStorage,sessionStorage,document.cookie,Date,getDay,toLocale,Intl.;innerHTMLnur 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.routeund 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(vorherfree -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)
- L1 Portal: auf aktuelles
origin/mainrebasen,npm test, Pushfeature/terminplaner:main(Deploy installiertfeiertagejs). Schalteroff⇒ keine Jobs, nichts sichtbar. Prüfen:/api/public/termine/v1/aaaaaaaaaaaaaaaa/config⇒{"aktiv":false},/api/termine/einstellungenohne Login 401, Log ohne Fehler. - L2 Schema:
docker exec -w /app webklar-kundenbereich-server node scripts/termine-setup.mjs --check, dann ohne--check; erneut--check⇒ nichts fehlt. - 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(niepreview-portal). - L4 WOMS: Push
feature/terminplaner:test; Bundle enthält „Terminplaner“. - 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. - L6: Nutzertest; Testbuchungen nach Wunsch löschen.