49 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), 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; SchalterTERMINE_ENABLED=off(Vorgabe) |pilot|on;TERMINE_PILOT_EMAILSKomma-Liste (klein, getrimmt,*= alle). In TP1 verhält sichonwiepilotohne E-Mail-Filter; Bestellen gibt es nicht. - Dateinamen: nie
server/services/appointments.js(TestadminPanelGone). Neue Dateien unterserver/services/termine/. - Zeiten: Rechnen in Wanduhr
Europe/Berlin, speichern als UTC (toISOString()); Appwrite liefert+00:00– Datumswerte immer mitnew Date()lesen. Nicht existierende Wanduhrzeiten (Umstellung März) werden ausgelassen, doppelte (Oktober) nehmen das erste Vorkommen.deadlines.berlinTimenicht verwenden. - Zellen à 5 Minuten; alle Zellen einer Buchung in einem Bulk-Create (max. 100 ⇒ Belegung bis 500 Minuten), Zell-ID
c_+ 34 Hex vonsha1(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 solangeheldUntil > 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, nichtrecht.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 nichtabgelehnt/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-Feldwebsite; 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+Funktionregeln.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 übertermineOutbox: höchstens 200 / h, Wiederholung nach 1, 5, 30 min, dannfehler; SMTP-Anmeldefehler (EAUTH/535) ⇒ 15 min Pause; Einträge 7 Tage nachgesendet/fehlerlöschen. - Löschfristen:
verfallen48 h nachheldUntil;abgesagt/abgelehnt30 Tage;erledigt/nichterschienenaufbewahrungTage(Vorgabe 90, 14–365) nach Terminende; Funktiondisabled⇒ alles 30 Tage nachdisabledAt. - Portaltexte Du-Form, kein
confirm(); Gästetexte Sie/Du nachanrede. Kundentext nur übertextContent/esc(). - Nichts pushen, nichts in Produktion schreiben (Ausnahme Vorab-Spike mit Freigabe). Nie
git add -A(node_modules).
Review Focus
- Gast-Browser in anderer Zeitzone (z. B. New York) sieht dieselben Uhrzeiten wie Berlin; Widget rechnet nie mit
Datedes Browsers. → Task 14 (Playwright mittimezoneId: 'America/New_York') - E-Mail mit Großbuchstaben/Leerzeichen (
Max@GMail.com) zählt als dieselbe Adresse für Grenzen und Widerruf. → Task 9 (email_normalisiert) - Betrieb löscht eine Leistung/Ressource mit künftigen Terminen ⇒ abgelehnt mit Anzahl; Deaktivieren erlaubt, bestehende Termine bleiben gültig. → Task 4 + 13
- 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) - 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-CollectiontermineSpike(AttributebookingId64), 50 parallelePOST …/collections/termineSpike/documentsmit{ documents: [{ $id, bookingId }, …] }(je 20 Zellen, Überlappungen) ⇒ erwartet genau 1× 201 je überlappende Gruppe, Rest 409document_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}, 409transaction_conflict) hinter derselben FunktioncreateDocuments.
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(EnvAPPWRITE_COLLECTION_TERMINE_CONFIG|…_BOOKINGS|…_CLAIMS|…_OUTBOX, Vorgaben = Namen).config.termine = { mode, pilotEmails: string[], holdMinutes: 30, zelleMin: 5, mailsProStunde: 200 }(modenuroff|pilot|on, sonstoff).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 vonconfig.js, damit Tests es voruseTestEnv()laden dürfen):TERMINE_COLLECTIONS = { termineConfig: { attributes, indexes }, termineBookings: …, termineClaims: …, termineOutbox: … }exakt nach Spec 2 (Typenstringmit Größe,integer,boolean,datetime; Text-Felder alsstringmit großer Größe; Indizes{ key, type:'key', attributes:[…] }).scripts/termine-setup.mjsim Muster vonanalyse-setup.mjs:main({ check, client, log })legt fehlende Collections/Attribute an (Collection-ID ausconfig.collections), Indizes nur bei neuen Collections,--check⇒ Exit 1 bei Fehlendem;createAppwriteClientausanalyse-setup.mjswiederverwenden (createAttributegeht über/attributes/<type>für alle Typen; Bodyinteger⇒{ key, required:false },datetime⇒{ key, required:false }).test/support/schema.js:schemaKeys(collection)findet auch Collections ausTERMINE_COLLECTIONS(statischer Import vonserver/services/termine/schema.js).- Tests: Modus-Auswertung (leer/unbekannt ⇒ off; pilot mit
KENSO.gri@gmail.com⇒ true fürkenso.gri@gmail.com;*); Setup mit Fake-Client legt 4 Collections + alle Attribute + Indizes an, zweiter Lauf legt nichts an,--checklegt nichts an und meldet Fehlendes;schemaKeys('termineBookings')enthältstartsAt,manageHash,nachweis. - FAIL → implementieren → PASS (
node --test test/termineSetup.test.js test/termineSchalter.test.js, danachnpm 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 …/documentsmit rohem Body{ documents }(überadminFetchmit neuer Optionraw: true; heute istrawnur für/users), wirft wieadminFetch(409 bei Duplikat). Ergebnis des Spikes beachten.listAllDocuments(collectionId, queries, { pageSize = 500, max = 20000 }) ⇒ docs– lädt seitenweise mitcursorAfter(letzte$id), bis eine Seite < pageSize; übermax⇒ Fehler „zu viele Einträge“.- Nachbau:
POSTmitdocuments-Array = alles oder nichts (eine vorhandene ID ⇒ 409, nichts geschrieben; ungültige ID ⇒ 400); Abfragenbetween,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;
betweeninklusiv 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(nurIntl, Vorlagespike-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; AnzeigedatumLang(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);ausUtcrü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) undbetrieb.email(gültig) Pflicht;farbe^#[0-9a-f]{6}$;anredesie|du;bundesland∈ BUNDESLAENDER; Pläne: FensterHH:MMaufsteigend, nicht überlappend, Ende > Beginn (24:00 erlaubt);dauer5–480 und Vielfaches von 5,pufferNach0–120 Vielfaches von 5,dauer + pufferNach ≤ 500;preisArtfest|ab|keiner,preisCent0–1 000 000;buchungsartsofort|anfrage; jede aktive Leistung hat ≥ 1 aktive Ressource;regeln.raster∈ {5,10,15,20,30,60},vorlaufMin0–20160,horizontTage1–365,absageBisStd0–336,maxOffeneJeEmail1–10;gastFelderjeaus|optional|pflicht;erinnerung.stundenVorher∈ {2,12,24,48};zahlung.modusnurvorortin TP1 (online/beides ⇒ Fehler „kommt bald“);aufbewahrungTage14–365; Abwesenheitenvon ≤ 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.versionwird bei geändertem Text neu gesetzt (YYYY-MM-DD-n);feedTokenje Ressource beibehalten bzw. neu (32 Byte base64url).oeffentlicheEinstellung(data) ⇒ objektmit allen Feldern vonGET /configaußeraktiv,hinweis,zustimmungVorzeitigText,formToken(Form siehe Task 11; nur aktive Leistungen mit aktiver Ressource,preisals Text,widerruf= irgendeine aktive verbindliche Leistung für Verbraucher, keinefeedToken).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)“ überpreisHinweis(data));zahlungText(data) ⇒ 'Bezahlung vor Ort: bar, EC-Karte, Kreditkarte'(Artenbar|ec|kreditkarte|ueberweisung|paypal).vorlagen.js:VORLAGENmit Schlüsselnsalon, kosmetik, beratung, werkstatt, handwerk, tattoo, fotograf, hundesalon, sonstiges(jelabel, 2–5 Leistungen mit realistischen Dauern/Puffern, Plan Di–Sa bzw. Mo–Fr,buchungsartanfrage bei tattoo/handwerk/fotograf,ressourcen1 Person bzw. 2 Plätze bei werkstatt, Stornotext-Muster Variante 1 ausrecherche-recht-zahlung.md7.3 F);vorlagenListe() ⇒ [{ key, label }];ausVorlage(key, { name, email, telefon, adresse }) ⇒ data(valide nachnormalisiereEinstellung).- 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;oeffentlicheEinstellungenthält keinfeedToken. - 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 imrasterab Fensterbeginn,start + dauer + pufferNachganz im Fenster, nicht in Belegung,start ≥ now + vorlaufMin,tag ≤ heute + horizontTage;ohneRegeln(Portal) ignoriert Vorlauf/Horizont, nicht aber Vergangenheit; beiegalVereinigung,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 indata.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 mitheldUntilin der Vergangenheit blockiert nicht;egalmit zwei Personen; 2026-03-29 Plan 00:00–06:00 enthält kein02: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 ausconfig.sessionSecretabgeleitetem 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>– unterwithLocks(['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, Indexidx_booking).buchungen.js(Datenzugriff, keine Regeln):legeAn(data) ⇒ doc;hole(id);aendere(id, patch) ⇒ doc(setztupdatedAt);imZeitraum(featureId, von, bis) ⇒ docs(betweenaufstartsAt,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);zellenFuer10: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.htmlund…/anlage_2.html(Abrufdatum im Kopf), Platzhalter für Betrieb (Name, Anschrift, E-Mail, Telefon); Datenschutz-Absatz (Sie/Du) und Formular-Hinweis ausrecherche-recht.md11.1/11.2 (angepasst: Löschfristen aus den Global Constraints); 6c-Entwurf fürpublic/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(Vorlagespike-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öckesie/dufür jeden Anredetext. Gemeinsame Platzhalter:gast_name, leistung, person, datum, zeit, dauer, preis, zahlung, nummer, link; G02 zusätzlich Blöckepreis,person,storno({{storno_text}}),widerruf({{widerrufsbelehrung}}+{{widerruf_link}}),anfrage_angenommen; G06{{absage_von}}, Blocknachricht({{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, Blockanfrage; K21: Blöckeabgesagt,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 mitics; niebcc; Anführungszeichen im Namen entfernt).- Tests: ICS-Zeilen ≤ 75 Oktette,
UID/SEQUENCEkorrekt, Komma/Semikolon escaped, CANCEL hatSTATUS:CANCELLED; Vorlagen-Suite grün inkl. neuer IDs;sendGuestMailübermailSink: keinbcc,replyTogesetzt, From-Name = Betrieb, Anhangtext/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 intermineOutbox(status 'wartet',naechsterVersuch = now), stößtsendeEintrag(doc.$id)an, ohne zu warten (Fehler nur loggen).verarbeite(now) ⇒ { gesendet, fehler, pausiert }– alle fälligenwartet-Einträge (älteste zuerst), solange das Stundenbudget (config.termine.mailsProStunde, gleitendes Fenster im Speicher) reicht; je Eintrag Sperretermine-mail:<id>; Erfolg ⇒gesendet,gesendetAt; Fehler ⇒versuche+1,naechsterVersuch+1/+5/+30 min, nach 3 Wiederholungenfehler+fehler-Text;EAUTH/responseCode 535⇒ alles 15 min pausieren.zustellFehler(bookingIds) ⇒ Set<bookingId>(Einträge mitstatus 'fehler'), für die Portal-Liste.raeumeAuf(now)– löschtgesendet/fehlerälter als 7 Tage.- Tests: Budget 2/h ⇒ dritter wartet; Wiederholungsabstände; EAUTH ⇒ Pause, danach weiter; doppelte Verarbeitung desselben Eintrags (zwei
verarbeiteparallel) ⇒ 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(nuractive, Modus ≠ off);kontextPerFeature(featureId) ⇒ ctx|null(auchdisabled);ctx = { feature, project, data, version };ladeEinstellung(featureId) ⇒ { data, version } | null;speichereEinstellung(feature, data, { version, by }) ⇒ { data, version }(Sperretermine-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 })– übereinreihen; Links${publicBaseUrl}/termin.html#v=<token>bzw.#m=<token>; ICS: G02/G07 REQUEST, G06 CANCEL, K20 PUBLISH.ablauf.js(alle Funktionen nehmennow; jede Zustandsänderung unterwithLocks(['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, GrenzemaxOffeneJeEmail(429 „Sie haben bereits {n} offene Termine.“), Slot neu berechnen (sonst 409SLOT_VERGEBEN), Ressource beiegalwählen, Buchunggehalten(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? }– nurgehaltenundheldUntil > now(sonst 410abgelaufen);sofort⇒bestaetigt+ Verwaltungstoken + G02 + K20;anfrage⇒angefragt+ Verwaltungstoken + G03 + K20 (Blockanfrage); schon bestätigt ⇒{ status, schon:true }.sageAbGast(token, now)(bisabsageBisStdvor Beginn, sonst 409 „Absage nur telefonisch“),verschiebeGast(token, { tag, zeit }, now)(gleiche Frist; neue Zellen vor Freigabe der alten;icsSequenz+1; G07 + K21verschoben),widerrufeGast(token, { name }, now)undwiderrufeOhneLink(siteCtx, { name, email, nummer }, now) ⇒ { eingang }(Treffer ⇒widerrufenAt,abgesagtvongast, Zellen frei, G08, K21widerrufen; kein Treffer ⇒ G08 an die angegebene Adresse + K21nicht_zugeordnet; Antwort immer gleich).- Betrieb:
aktion(ctx, bookingId, { aktion:'annehmen'|'ablehnen'|'absagen'|'verschieben'|'nichterschienen', grund, nachricht, tag, zeit }, now)(G02anfrage_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)(abgelaufenegehalten⇒verfallen+ Zellen frei;outbox.verarbeite),fuenfMinutenJob(now)(Erinnerung:bestaetigt,erinnerung, ohneerinnerungGesendetAt,now + 1h < startsAt ≤ now + stundenVorher,bestaetigtAt < startsAt − stundenVorher– erst Marker, dann G05;erledigt1 h nach Ende),taeglicherJob(now)(Löschfristen laut Global Constraints inkl. Zellen und Outbox; Funktionendisabledseit > 30 Tagen: Buchungen, Zellen, Einstellung löschen; verwaiste Zellen). Inserver/index.jsnach 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 + K21nicht_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 undbestaetigeparallel ⇒ genau ein Endzustand, keine G02 nachverfallen). - 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 ausoeffentlicheEinstellung(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 wiehalte-Eingabe +formToken,website) ⇒ 201{ ok:true, email:'m***@gmail.com', minuten:30 }; 400/409/429{ fehler:{ feld:text } }(Slot vergeben ⇒ Feldzeit).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 vonconfig.publicBaseUrl(für/termin/*nur letzterer) ⇒Access-Control-Allow-Origin: <origin>,Vary: Origin; sonst 403{ fehler:{ _:'Nicht erlaubt' } }; Preflight 204 mitAllow-Methods: GET, POST, OPTIONS,Allow-Headers: Content-Type,Max-Age: 86400. Fehler aus Appwrite ⇒ 503{ fehler:{ _:'Gerade nicht erreichbar' } }, nie Rohtext. Modusoff⇒ jede/:siteKey/*-Route{ aktiv:false }bzw. 404 für POST. - Tests (startTestApp + Nachbau + mailSink; Origin-Header gesetzt): kein Feld
customerId|projectId|featureId|$idin 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; IPv62001:db8::1und2001:db8::2teilen 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');activateClosedTicketsbleibt bei News (TP2).- Team:
POST /api/team/funktionen/:customerId/:key(key∈ FEATURE_KEYS, sonst 404;/newsverhält sich wie bisher); Meldung ohne Dokument: „Für dieses Projekt gibt es noch keine {Label}“;adminViewlädt die Funktionsdokumente des Kunden einmal (stattgetFeatureDocje Projekt) und setztadminNote/ticketMissingje Schlüssel. GET /api/funktionenergänzttermine: { 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 Kundendisabled(Notiz „Kunde gelöscht“).- Tests: Team aktiviert
terminefür Projekt von c1 ⇒ Zustand active, News unverändert;/:customerId/kalender⇒ 404;GET /api/funktionenmit Pilot-E-Mail ⇒termine.verfuegbar true, sonst false, Projekte habenfeatures.termine; Erlaubnisliste exakt (Test anpassen); Kunde löschen ⇒ terminedisabled; 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 (mitkuenftigeTermineauszaehleKuenftige).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?projectIdnur für Sperren.POST /ressourcen/:id/feed{ projectId }⇒{ url }(neuesfeedToken, Version +1).GET /export.csv?projectId⇒text/csv; charset=utf-8mit BOM,;-getrennt, KopfNummer;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 keinlocalStorage,sessionStorage,document.cookie,innerHTML =mit Variablen aus Antworten (nur Konstanten), keinnew Date((Zeitzonen-Unabhängigkeit).- Playwright gegen echten Testserver (
startAppwriteMock+startTestApp({ '/api/public/termine/v1': publicTermineRoutes, '/': express.static('public') }), Seed einer aktiven Funktion mit Vorlagesalon,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.verfuegbarfalse ⇒ 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“ ausGET /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),leerEinstellungnicht nötig.termine.jsinitTermine(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 mitfensterParsen, 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 mitversion, Konflikt ⇒ „Die Einstellungen wurden inzwischen geändert. Bitte neu laden.“; Feldfehler am Feld;disabled⇒ nur Lesen + Hinweis.test/termineView.test.jsfür die reinen Helfer;funktionenTiles.test.jsum die drei Kachelzustände.- Playwright (Fixture:
page.route('**/api/**')wiefunktionen.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)
- L1 Portal: Branch auf aktuelles
origin/mainrebasen,npm test, Pushfeature/terminplaner:main(Deploy installiertfeiertagejs). Schalter bleibtoff(Vorgabe) ⇒ 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: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(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 (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. - L6: Nutzertest; Testbuchungen nach Wunsch löschen.