351 KiB
Terminplaner TP2 Verkauf – 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: Kunden mit laufendem Wartungs-Abo bestellen den Terminplaner selbst (Fragebogen → Zustimmungen → 149 € über Stripe → Ticket mit Einbauanleitung → Einbau → Freischaltung); endet das Abo, endet der Terminplaner (Löschung nach 30 Tagen); dazu Rechtstexte + Kanzlei-Paket; am Ende TERMINE_ENABLED=on.
Architecture: Der News-Bestellweg wird zu einem Register config.features verallgemeinert (Checkout, Mails, Ticket, Freischaltung je Funktion). Der Fragebogen schreibt in dasselbe termineConfig-Dokument wie TP1 (Entwurf = spätere Live-Einstellung) plus ein neues Attribut fragebogen. Ein gemeinsamer Abo-Baustein aboStatus.js dient Analyse und Terminplaner; ein täglicher Abgleich koppelt den Terminplaner an das Abo. Einbauanleitung als reine Funktion, im Ticket (kurz) und im WOMS (voll).
Tech Stack: wie TP1 (Node 20 Produktion / 24 Host, Express 4, Appwrite 1.8.1 REST, nodemailer 9, Stripe über stripeBilling.js, Playwright aus /root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs), WOMS React/Vite.
Spec: docs/superpowers/specs/2026-10-03-terminplaner-tp2-verkauf-design.md (Abschnitte 1–11, Entscheidungen D1–D8). TP1-Spec docs/superpowers/specs/2026-10-02-terminplaner-design.md, TP1-Ledger /root/reports/terminplaner-tp1/progress.md (Rulings R1–R55), Erkundung /root/reports/terminplaner-tp2/erkundung-2026-10-03.json.
Worktrees: Portal /home/webklar/apps/kundenbereich.webklar.com/dev-termine, Branch feature/terminplaner-tp2 (von origin/main 3a0b008; Spec-Commit 6ed0e3b; eigenes node_modules). WOMS /home/webklar/apps/ticket.webklar.com/dev-termine, Branch feature/terminplaner-tp2 von origin/test 78bc99a (V1).
Global Constraints
- Nichts pushen, nichts in Produktion schreiben, keine Live-Stripe-Aufrufe; das macht der Inhaber bzw. der Livegang mit Freigabe. Nie
git add -A; Dateien explizit stagen. WOMS: jeder gepushte Branch wird ausgeliefert. - Node-20-tauglich (keine APIs ab Node 21).
config.*bei jedem Aufruf lesen (Tests ändern Werte zur Laufzeit). - Tests: Portal
npm test(node --test test/*.test.js), Mocks austest/support/(startAppwriteMock,startFakeStripe,mailSink,useTestEnv,startTestApp,sessionCookie); nie echte Dienste.test/previewDevRouter.test.jsist ein bekannter, unabhängiger Wackel-Test (einzeln wiederholen). WOMSnpm test+ einmalnice npx vite build. Playwright nicht innpm test, schwere Läufe nacheinander, vorherfree -m, mitnice. - Schema nur über idempotente Skripte mit
--check:scripts/termine-setup.mjs(Termine-Collections) und das neuescripts/feature-setup.mjs(portalFeatures.disabledReason,aboEndeMailAt,zahlungMeldung, neue CollectionfeatureConsents);scripts/setup-abo-collections.mjsbleibt unverändert;test/support/schema.jszieht mit. - News bleibt im Verhalten unverändert (bestehende Tests grün), außer den Rechtstext-Korrekturen in Block F. Analyse ändert ihr Verhalten nicht (nur gemeinsamer Abo-Baustein).
- Funktionsschlüssel
news,termineim Register;analysebleibt eigener Weg. Preise in Cent; Terminplaner-Einrichtung14900, Stripe-KatalogschlüsseltermineSetup, Lookup-Keywebklar_termine_einrichtung_14900. Preisanzeige „149 €“ mit Hinweis „ohne Umsatzsteuer gemäß § 19 UStG“ (gilt nach Block F auch für News). - Zustände
portalFeatures.state:off|ordered|setup|active|disabled.disabledReason∈team|abo_ende|abo_ende_vor_einrichtung|kunde_geloescht.unlockedByPurchase === truenur bei bezahlter Bestellung; Staff-/Pilot-Dokumente sind vom Abo-Abgleich ausgenommen. - Mails neu: K22 „Terminplaner beendet“ (Blöcke
abo_ende,hosting,vor_einrichtung,daten), K23 „Terminplaner läuft wieder“, T19 „Erstattung fällig: Terminplaner-Einrichtung“ (Vorlage aus B9b; einziger SendermeldeErstattung, B10b), T20 „Terminplaner Betriebsmeldung“ (A10a1). K15/K16/T16 mit{{funktion}}; T16 neue Blöckekein_abo,bereits_aktiv,anleitung,gesundheit({{gesundheit_text}}) undanhang_fehlt(F8); K02/K03 Blockfunktionen; K04/T09 mit{{produkt}}und{{preis}}. K/T in Du-Form; Layout nach den bestehenden Vorlagen;test/mailTemplates.test.js: Liste endet…ids('K',23), …ids('T',19), 'T20', Kopfkommentar ergänzen. - Kundentexte Du-Form, Gästetexte Sie/Du nach
anrede, Rechtstexte für Verbraucher Sie-Form. Text nur übertextContent/esc();hrefnur geprüftehttps:-URLs mitrel="noopener noreferrer". Keine Gesundheitsangaben. - Kundenansicht:
POST /api/funktionen/:key/checkoutundPUT /api/termine/fragebogensind fürreq.session.viewAsgesperrt (403); die WildcardPOST /^\/api\/termine\/.+$/wird durch eine explizite Liste ersetzt (Block A). - Protokolle ohne Gast-PII; Tagesbericht nur mit Zählungen.
- Ein Portal-Prozess (Sperren, Grenzen, Mailbudget im Speicher) – festhalten, nicht ändern.
Review Focus
- Zahlung trifft ein, nachdem sich der Zustand geändert hat (Abo beendet, Staff hat aktiviert, Sitzung abgelaufen, Dokument
disabled): nie Geld ohne Spur – immer Ticket oder T16/T19 an das Team, nie doppelt. → Block B (onFeaturePaid-Fälle), Block E - Abo-Abgleich irrt: Lesefehler,
past_due, Kündigung vorgemerkt, Pilot-/Staff-Dokument, Abo kommt innerhalb von 30 Tagen zurück ⇒ nie Abschalten bei Fehler, Pilot nie, Rückkehr schaltet wieder ein, Löschung prüft vorher frisch. → Block E - Entwurf ohne aktive Funktion: nie öffentlich sichtbar, nur der eigene Kunde schreibt, ab
orderednur lesbar (außer Staff in Kundenansicht), Kundenansicht bestätigt keine Fragebogen-Schritte. → Block C - Löschjobs mit vielen Betrieben: seitenweise, nie früher als die Frist, nie still für immer übersprungen (Überlauf läuft an Folgetagen weiter und wird gezählt). → Block A
- Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt sind gespeichert, bevor der Kunde zu Stripe geht (die Stripe-Sitzung wird zuerst angelegt, weil der Nachweis ihre ID trägt; die URL geht erst nach gespeichertem Nachweis an den Browser); Widerrufs-Anker = Zeit des Klicks. → Block B, F
Schnittstellen über Blockgrenzen (verbindlich für alle Tasks)
- Register
config.features(Block B,server/config.js):{ news: {...}, termine: {...} }, je Eintrag:key,product(KatalogschlüsselnewsSetup|termineSetup),label: { name, kurz, keine },priceLabel(z. B. „149 €“),setupEstimate,consent: { version, knopf, items: [{ id, text, haken }] },leistungsbeschreibung,avv,widerruf(Fassungen + URLs),returnHash('news'|'termine'),ticket: { topic(project) ⇒ string, details(ctx) ⇒ Promise<string> }mitctx = { feature, doc, project, customer },mails: { funktion, knopf },requiresWartungsAbo: boolean,ticketFreischaltung: boolean,disabledMessage,orderedConflictMessage; dazuconfig.ustHinweis. Helfer inserver/services/featureRegistry.js:featureConfig(key) ⇒ Eintrag | null,featureHaken(key)mit den Hakenverfuegbar,bestellpruefung,unterlagenBereit,belehrung,freischaltbar, undanleitungsLink. - Checkout-Body (Block B):
POST /api/funktionen/:key/checkoutmit{ projectId, zustimmung: { version, haken } }oder{ projectId, fortsetzen: true }(offene Sitzung fortsetzen). - Abo-Baustein (
server/services/aboStatus.js; Kernversion in B5 mit endgültiger Signatur, ausgebaut in E1):laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>(Wartungs-Abo,productKey || 'wartung', Status ∈ACTIVE_STATUSES; wirft bei Lesefehler; B5 kennt nurlaeuft(customerId), E1 ergänztaltbestandund die Wechsel-Regel),bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>(Gründe in dieser Prüfreihenfolge),aboMemo() ⇒ { laeuft, bestellbar }(je Lauf zwischengespeichert; E1 ergänztlage) und erst ab E1aboLage(customerId) ⇒ Promise<{ laeuft, art, endedAt }>(gelesener Spiegel inkl. Wechsel-Regel; in B5 gibt es sie nicht).aboMeldunggehört nicht in diese Datei:aboMeldung(feature, grund) ⇒ string(Argumentreihenfolge: Register-Eintrag, dann Grund ∈kein_abo|gekuendigt|nicht_aktiv|unbekannt) liegt inserver/services/featureOrder.js(B6) und ist die einzige Quelle der Abo-Hinweistexte; C2, C4a, C4b, C10a und E importieren sie von dort. Andere Blöcke nutzen nur diese Funktionen. - siteFeatures:
disable(docId, { by, adminNote, reason })idempotent (ein bereitsdisabledDokument behältdisabledAt), setztdisabledReason(A11);activate()leertdisabledAt,disabledReason,aboEndeMailAt, nichtzahlungMeldung(A11);markPaid(...) ⇒ { doc, created, fall }und MarkerzahlungMeldung(B10a);meldeErstattung(doc, { customer, sitzung, nachAboEnde })ist der einzige T19-Sender (B10b);activateClosedTickets({ now, customerId })über alle Register-Schlüssel mitticketFreischaltung: trueund Hakenfreischaltbar(doc) ⇒ Promise<boolean>aus dem Register (Termine: Einstellung vorhanden undlaeuft) (B11). - Zustimmungsnachweis (Block B,
server/services/featureConsents.js, CollectionfeatureConsents):speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc>(Hash über die Texte),letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null>,zustimmungFuerSitzung(sessionId),nachweisTexte(feature, { belehrung }); Widerrufs-AnkervertragAtüberpaidFeatureOrders. - Fragebogen (Block C,
server/services/termine/fragebogen.js; Stub in B5):bestellungAnsicht(...)(async),BRANCHEN(Liste{ key, name, vorlage | null, gesundheit: boolean }),SCHRITTE(Schlüssel der 8 Schritte),normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler },bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>(leer = bestellbar; der Abo-Punkt (h) nur beimitAbo !== false: der Checkout (B6, Standard-Haken) ruft mitmitAbo: falseund ohneabo, weil dort die Abo-Stufe (7) die Codeskein_abo|gekuendigt|nicht_aktivliefert; nurGET /api/termine/einstellungen(C4a) übergibtabound zeigt (h) als offenen Punkt),gesundheitsWarnungen(einstellung) ⇒ string[]. AttributtermineConfig.fragebogen(JSON-String, ≤ 16 000 Zeichen). RoutePUT /api/termine/fragebogen { projectId, fragebogen }. - Einbauanleitung (Block D,
server/services/termine/einbauanleitung.js):einbauanleitung({ feature /* portalFeatures-Dokument */, project, einstellung, fragebogen, version, now }) ⇒ { stand: { version, updatedAt }, warnungen: string[], abschnitte: Array<{ id, titel, text, kopierbar }>, text, kurz }(rein, niefeedToken);einbauSnippet({ siteKey, apiBase, telefon, modus, knopfText })(ausroutes/termine.jshierher verschoben). Team-RouteGET /api/team/funktionen/:customerId/termine/anleitung?projectId=. - Rechtstexte (Blöcke D/F,
server/services/termine/rechtstexte.js):datenschutzAbsatz(einstellung, anrede) ⇒ string(Quelle für Anleitung und Doku). - Status-Route (Block A):
GET /api/team/termine/status ⇒ { budget, queue: { wartet, aeltesteMin }, pause, letzterTagesjob, aktiveBetriebe }.
Grundlagen der Blöcke (aus den Block-Einleitungen, verbindlich für alle Tasks)
Jeder Task bekommt Kopf und eigenen Text, nicht die Einleitung seines Blocks. Was ein Task aus einer Block-Einleitung braucht, steht deshalb hier.
- Zeilenangaben: Alle
Z. nund:nim Plan gelten für Portal3a0b008(WOMS78bc99a) und verschieben sich durch frühere Tasks an denselben Dateien (besonderstest/termineJobs.test.js, die A3a, A3b, A4, A6, A10a2, A10a3, A12, E7, E8 und C6 ändern; ebensoserver/services/termine/jobs.js,outbox.js,server/config.js,server/services/siteFeatures.js,server/routes/termine.js): per Test- oder Funktionsname suchen, nicht nach Zeilennummer. - Neue Werte in
config.termine(Standardwerte, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit; angelegt in A3a, A3b, A6, A7 und A9a):mailsProFunktion: 40,mailsReserve: 0.25,mailListe: 100,loeschDeckel: 2000,loeschDeckelGesamt: 20000,zellenDeckel: 5000(vorhanden:mailsProStunde: 200,holdMinutes: 30). - Testkonventionen für Register-, Checkout-, Abo- und Zustimmungstests (Block B; gelten ebenso für die Tests in C, D, E und F, die diese Teile berühren):
startAppwriteMock({ schema: { portalFeatures: schemaKeys('portalFeatures'), subscriptions: schemaKeys('subscriptions'), featureConsents: schemaKeys('featureConsents'), termineConfig: schemaKeys('termineConfig') } })(featureConsentsab B3),useTestEnvvor den dynamischen Importen,config.termine.mode = 'pilot'undconfig.termine.pilotEmails = ['*']für Termine-Fälle, Mails übersetMailTransportForTests(mailSink()). Der Nachbau prüft Schlüssel nur beim Anlegen/Ändern, nicht inseed: Felder, die erst Block A/E ins Schema bringen (disabledReason), dürfen Tests schon seeden (schemaKeys('portalFeatures')enthält seit A11disabledReason,aboEndeMailAt,zahlungMeldung). Pins auf Werte, die Block F ändert (News-consent.version/-Text,mails.preisHinweis, Termine-Items und -Fassung,leistungsbeschreibung/avv) lesenconfig.features.*statt Literale; nur der Einfrier-TestfeatureMails(B9a, B9b) und die dort genannten Wortlaute sind Literale, die Block F2 gezielt anpasst. - Falle
seedFixtures:seedFixtures(aw)legt fürc1nur ein Hosting-Abosub_han,c2hat ebenfalls nur Hosting,c3gar kein Abo. Jeder Test, der fürc1ein Wartungs-Abo braucht (Termine-Bestellung, Abo-Baustein, Freischaltung, Abgleich), überschreibt es mitaw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))(Hilfen intest/support/fixtures.js); sonst scheitert er ankein_abo. - Im Code geprüft (Block B): Das Schema läuft über
scripts/feature-setup.mjs(A11 legt es nach dem Musterscripts/analyse-setup.mjsan, B3 ergänztfeatureConsents);scripts/setup-abo-collections.mjskennt kein--check, ist ein Top-Level-Skript ohne Exporte und bleibt unangetastet.scripts/stripe-products.mjsexportiert nurparseArgs(Task B2 exportiertKEYSundcatalogEntry).aboStatus.js,featureRegistry.js,featureConsents.jsundtermine/fragebogen.jsgibt es noch nicht.markPaidübergibt wedertopicnochdetails(siteFeatures.js:261-266),createFeatureTicketkürzt nichts (ticketAdmin.js:85).teamNoticeundunmatchedNoticekennen keinfunktion(T16-Betreff „…: News-Pinnwand“ ist intest/funktionenRoutes.test.jsZ. 150, 339, 371 gepinnt und bleibt für News so).seedFixtures(aw)legt fürc1ein Hosting-Abosub_han; Termine-Tests brauchenaw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))(überschreibt es),c2hat nur Hosting,c3gar kein Abo. - Festlegungen des Blocks E (gelten für E4–E10):
- Schreiben nur unter der Sperre
feature-admin:<projectId>(wieroutes/admin/funktionenAdmin.js:87) und nach frischem Lesen von Dokument und Abo (aboLagedirekt, nie der Memo). Der Memo (aboMemo()) dient nur der Vorauswahl je Kunde im Listenlauf. - Lesefehler (Appwrite, Abo, Ereignisse) ⇒ Kunde/Dokument überspringen,
fehler += 1, Log ohne Namen, Adressen und IDs von Gästen; nie abschalten, wenn etwas nicht lesbar war. - Marker erst nach Erfolg:
aboEndeMailAt(K22) undzahlungMeldung === 'gemeldet'(T19 übermeldeErstattung, B10b;purchaseStatusbleibtpaid). Schlägt der Versand fehl, wiederholt ihn der nächste Lauf;disabledAtändert sich dabei nie (sonst wüchse die Löschfrist). - Abgleich nur für
disabledReason-Werte aus dem Kopf (team | abo_ende | abo_ende_vor_einrichtung | kunde_geloescht); eindisabled-Dokument ohne Grund (Altbestand) wird wieteambehandelt und nie angefasst. Die Spec nennt in Abschnitt 6 zusätzlichfrist; maßgeblich ist die Liste aus dem Plankopf.
- Schreiben nur unter der Sperre
- Fassungsdatum
<F>(gilt für F1 bis F15b und überall, wo<F>oder<V>steht):<F>ist der Berliner Kalendertag des F1-Commits (JJJJ-MM-TT, muss nach2026-10-02liegen, der eingefrorenen Altfassung der Gästetexte in F10a). F1 schreibt ihn als Wert vonconfig.termine.avvVersion; alle späteren Tasks lesen die Konstante und tragen den Tag nie als Literal in Code oder Tests (Ausnahme: das LiteralRECHTSTEXTE_VERSIONintermine/rechtstexte.js, das F10b1 auf denselben Tag setzt;<V>=<F>, ein Test hält beide gleich). Drei Stände:config.termine.avvVersion(Leistungsbeschreibung, AVV, Belehrung, Bestelltexte, News-consent.version),RECHTSTEXTE_VERSION(Gästetexte) und „Stand“ der Datenschutzerklärung (Tag der Änderung). Eine ausgelieferte Fassung wird nie überschrieben: sobald die Texte an die Kanzlei gegangen oder live sind (Block G1/G7), heißt jede Textänderung neues Datum und neue Dateien; davor darf der Controller<F>mit einem eigenen Commit „Fassungsdatum anheben“ auf den Tag der Übergabe setzen. Beim Anheben entstehen neu: die F6-Dateien*-<F>.html/.pdf(alte, nie ausgelieferte Dateien werden gelöscht), das Archivtermine-gaestetexte-<V>.md(F10b1) samt Hash-Pin, die Fassungs- und Hash-Pins in den Tests von F2, F7, F10b1, F13a und F15a,RECHTSTEXTE_VERSION(F10b1) und News-consent.version(F2); die Datenschutzerklärung nur bei Textänderung.
Vorab (Controller, kein Subagent)
- V1 WOMS-Branch: im Worktree
/home/webklar/apps/ticket.webklar.com/dev-terminegit fetch origin && git checkout -b feature/terminplaner-tp2 origin/test;npm testgrün als Basis. - V2 Basis Portal: im Portal-Worktree
npm cifallsnode_modulesfehlt,npm testals Basis (Erkundung: 2235 Portal grün, WOMS 294); dazu der Node-20-Lauf der reinen Termine-Tests (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). - V3 Ledger: SDD-Workspace für diesen Plan anlegen; die Rulings R1–R55 aus TP1 gelten weiter, sofern dieser Plan nichts anderes sagt. Abweichungen aus der Planprüfung festhalten: der Entwurfszugriff (C4b) und der
kein_abo-Hinweis (B10b) nutzenlaeuft; der Tagesbericht zähltausgenommene Dokumente; der Abo-Abgleich hat keinen eigenen Beobachtungsschalter (er beobachtet über den Datenstand); der Checkout wendet den Abo-Punkt (h) des Bestell-Gates nicht an (mitAbo: false, B6/C2), die Abo-Stufe (7) in B6 liefert die Abo-Codes.
Reihenfolge der Blöcke: A → B → E → C → D → F, danach Block G (Livegang, am Ende von Block F). Was TP2 bewusst nicht enthält, steht in Spec Abschnitt 1 und 8 („SOLLTE“/„Kann warten“).
Block A: Technik-Voraussetzungen aus TP1
Schuld aus TP1 (Spec Abschnitt 8, MUSS #1–#6; die Verallgemeinerung von activateClosedTickets baut Block B, Task B11) plus die billigen SOLLTE-Punkte am selben Code (Frühausstieg in verarbeite, Index termineOutbox(bookingId), Minutenjob teilen). Löschjobs, Zellen-Sweep und CSV-Export lesen seitenweise (limit ≤ 100, Export 500), haben einen Deckel je Lauf und laufen an Folgetagen weiter; die Mail-Warteschlange bekommt Kontingent, Vorrang, Alarm und eine Status-Route. Neue Werte in config.termine (server/config.js Z. 378–394, bei jedem Aufruf gelesen, Tests setzen sie zur Laufzeit): mailsProFunktion: 40, mailsReserve: 0.25, mailListe: 100, loeschDeckel: 2000, loeschDeckelGesamt: 20000, zellenDeckel: 5000.
Reihenfolge: A2 vor A3a–A6 und A9a; A3a vor A3b, A4 und A6; A7 vor A9a; A9a vor A9b; A3a, A3b, A4, A6, A7 und A9b vor A10a1; A10a1 vor A10a2 vor A10a3; A10a3 vor A10b und A12; A11 steht für sich (vor Block B). Schema-Änderungen (Indizes, portalFeatures.disabledReason, aboEndeMailAt, zahlungMeldung) laufen vor dem Code-Deploy über die idempotenten Skripte scripts/termine-setup.mjs und das neue scripts/feature-setup.mjs (beide mit --check; Livegang Schritt 3): activate() schreibt die neuen Felder, ohne Schema käme Unknown attribute. scripts/setup-abo-collections.mjs bleibt unverändert (es hat kein --check und ist ein Top-Level-Skript ohne Exporte); feature-setup.mjs folgt dem Muster von scripts/analyse-setup.mjs (exportierte Attribut-Listen, main({ check, client, log }), Test mit Mock-Client).
Bewusst nicht in TP2 (Spec Abschnitt 8 „SOLLTE“/„Kann warten“, kein Task; im Ledger als offen führen): Transport pool: true und Begrenzung der Parallelität der einreihen-Sendungen (mailer.js:25); Mailbudget nach einem Neustart aus der Outbox rekonstruieren; /tage und /zeiten außerhalb des Horizonts vor imZeitraum kurzschließen (publicTermine.js); Hetzner-Überlimit-Antworten als Pause statt als fehler einstufen (outbox.js); mailSicher-Fehlermarker, damit das Portal „nicht zugestellt“ zeigt; Tagesjob zu fester Nachtzeit (heute 180 s nach jedem Start); TERMINE_TOKEN_SECRET; engines >=20; axe-/Screenreader-Lauf von Widget und Gästeseite (deshalb in der Leistungsbeschreibung keine Konformitätszusage, F4). Lock-Wartezeiten, halte ohne termine-cfg-Lock, Fetch-Timeouts und der Widget-Monolith bleiben liegen.
Task A1: Kundenansicht-Wildcard durch eine explizite Liste ersetzen
Files: Modify server/middleware/kundenansichtGuard.js (Z. 18–21), test/kundenansichtGuard.test.js (Z. 106–127 und neue Tests am Ende).
Consumes: –. Produces: KUNDENANSICHT_ALLOWED mit genau sechs Termine-Einträgen, jeweils mit \/?$: POST /api/termine/einstellungen/vorlage, POST /api/termine/ressourcen/[^/]+/feed, POST /api/termine/buchungen, POST /api/termine/buchungen/[^/]+/aktion, PATCH /api/termine/einstellungen, DELETE /api/termine/buchungen/[^/]+. Alles andere unter /api/termine (auch Block C PUT /fragebogen) bleibt in der Kundenansicht gesperrt, ohne dass es einen Eintrag braucht; Block B legt den Checkout unter /api/funktionen (dort nie erlaubt).
- Tests: (1) „Erlaubnisliste“ (Z. 106):
KUNDENANSICHT_ALLOWED.length === 8 + 6; die bisherigenok-Fälle bleiben erlaubt; neu nicht erlaubt:POST /api/termine/zukunft,POST|PUT /api/termine/fragebogen,POST /api/termine/export.csv,POST /api/termine/buchungen/abc(bisher durch die Wildcard erlaubt),POST …/buchungen/abc/aktion/x,POST …/einstellungen/vorlage/x,POST …/ressourcen/abc/feed/x,POST /api/funktionen/termine/checkout. (2) Neu „unbekanntes POST unter /api/termine ⇒ 403“: über HTTP mitkv()⇒ 403view_blockedfürPOST /api/termine/zukunft,PUT /api/termine/fragebogen,POST /api/termine/buchungen/abc/aktion/xundPOST /API/Termine/Zukunft(der Wächter antwortet vor dem Router, auch wenn es die Route nicht gibt); dieselben Pfade mitcustomerCookie('c1')⇒ nicht 403 (404, der Wächter fasst normale Kunden nicht an). (3) Neu „Router-Abgleich“:writeRoutes([['/api/termine', termineRouter]])gefiltert mitallowed()ergibt exakt die sechs Routen (:id⇒x); eine künftige Schreib-Route unter/api/terminefällt dadurch im Test „jede Schreib-Route ausserhalb der Erlaubnisliste ⇒ 403“ auf, nicht erst in Produktion. (4) Der Test „Kundenansicht darf PATCH, POST und DELETE (mit Header) und es wird protokolliert“ (termineRoutes.test.jsZ. 1028: Vorlage, Feed, Buchung, Aktion, PATCH, DELETE) bleibt grün. - FAIL → implementieren (die vier POST-Muster einzeln ausschreiben, kein
.+) → PASS →npm test→ Commit „Kundenansicht: Terminplaner-POSTs nur noch ueber eine explizite Liste“
Task A2: Abfrage-Bausteine und Nachbau-Erweiterung
Files: Modify server/services/appwriteAdmin.js (Query Z. 77–94, neu countDocuments), test/support/appwriteMock.js (matches Z. 124, supportedQueries Z. 145, list Z. 147–169, Rückgabeobjekt Z. 388); Test test/appwriteBulk.test.js (dort stehen die Query- und Nachbau-Tests, ab Z. 37).
Consumes: –. Produces: Query.select(attrs[]) ⇒ { method: 'select', values: attrs }; Query.notEqual(attr, wert | wert[]) ⇒ { method: 'notEqual', attribute, values: […] } (Liste = „keiner von“); countDocuments(collectionId, queries) ⇒ Promise<number | null> (ein Aufruf mit limit(1), liefert total der Antwort, null, wenn Appwrite keines liefert). Nachbau: select liefert nur die genannten Felder plus alle $…-Felder; notEqual (kein Wert gleich), total = Treffer vor limit/cursorAfter (heute: Länge nach limit), aw.listCalls(collection?) ⇒ [{ collection, queries: [{ method, attribute, values }] }] (nur Listen-GETs, geparst) und aw.resetListCalls(). Query.offset wird bewusst nicht gebaut (Löschseiten lesen ohne Cursor neu), ebenso Query.isNull (Block E filtert aboEndeMailAt im Code; sein Verhalten bei Datetime-Attributen auf Appwrite 1.8.1 ist ungemessen). Dass notEqual mit Liste und total auf der echten Appwrite 1.8.1 dasselbe bedeuten wie im Nachbau, ist ebenfalls ungemessen: Messprüfung in Block G (Livegang 5).
- Tests (
appwriteBulk.test.js):Query-Helfer liefern die Formen oben; Nachbau:select(['a'])⇒ Dokumente nur mitaund$id;notEqual('f', ['x','y'])schließt beide aus,notEqualmit einem Wert;totalbeilimit(2)über 5 Treffer ⇒ 5 undcountDocuments⇒ 5;listCalls('termineClaims')zeichnet Listenabfragen auf (keine POST/GET-by-ID),resetListCallsleert; „Nachbau ohne Optionen verhält sich wie bisher“ (Z. 196) bleibt grün. - FAIL → implementieren → PASS →
npm test→ Commit „Appwrite-Abfragen: select, notEqual, countDocuments und Nachbau dazu“
Task A3a: Aufbewahrungs-Löschung seitenweise – Grundlagen und Schritte verfallen/abgesagt/abgelehnt (Review Focus 4)
Files: Modify server/services/termine/buchungen.js (loeschKandidaten Z. 183–192), server/services/termine/jobs.js (loeschfristen Z. 288–315, nur die Schritte verfallen, abgesagt, abgelehnt; neu arbeiteSeiten; wendeLoeschfristenAn Z. 400, taeglicherJob Z. 420), server/config.js (loeschDeckel, Standard 2000), server/services/termine/schema.js (termineBookings: index('idx_status_ends', 'status', 'endsAt') und index('idx_status_updated', 'status', 'updatedAt'); die Spec nennt nur das erste, abgesagt/abgelehnt laufen über updatedAt und brauchen das zweite); Tests test/termineJobs.test.js (Z. 672–751 anpassen, neue), test/termineZellen.test.js (Z. 933–958), test/termineSetup.test.js (Z. 143–155).
Consumes: A2 (Query.select, aw.listCalls). Produces: loeschKandidaten(status, vor, { featureId = null, limit = 100 } = {}) ⇒ Promise<doc[]> (höchstens limit, älteste Bezugszeit zuerst, Query.select ohne Gastfelder: $id, featureId, status, endsAt, updatedAt, heldUntil; TypeError für nicht löschbare Status wie bisher); arbeiteSeiten(lauf, { holeSeite, bearbeite, deckel }) ⇒ { geloescht, gedeckelt, vollstaendig } (für A3b, A4); wendeLoeschfristenAn(now) ⇒ { verfallen, abgesagt, abgelehnt, erledigt, nichterschienen, gedeckelt } (gedeckelt = Zahl der Schritte, die am Deckel endeten; die Schritte erledigt/nichterschienen laufen bis A3b unverändert über den alten Weg); taeglicherJob liefert zusätzlich gedeckelt: { loeschfristen, abgeschaltet: 0, zellen: 0 } (A4/A6 füllen die anderen) und trennt gedeckelt vom Löschzähler ab, damit die Summe im tagesbericht stimmt.
Ansatz: Die Frist steht in der Datenbankabfrage (lessThan(Bezug, jetzt − Frist)) und wird nie im Code nachgeholt. Seiten ohne Cursor: Seite lesen, jede Buchung wie bisher unter ihrer Sperre frisch prüfen und samt Zellen löschen (loescheMitZellen), Liste neu lesen (Gelöschtes fehlt). arbeiteSeiten endet bei leerer Seite, beim Deckel (zählt lauf.gedeckelt, Log-Warnung ohne IDs) oder bei einer Seite ohne einen Erfolg (Fehler stehen im Log; bereits versuchte IDs merkt eine Menge, kein Endlosdurchlauf). Durchgang (1): global verfallen (48 h), abgesagt, abgelehnt (30 Tage), Deckel loeschDeckel je Status. Die Prüfung am Kandidaten (endsAt < Frist) bleibt als zweiter Boden.
- Tests: (1)
loeschKandidaten(termineZellen): 150 alteerledigt⇒ Standard 100,{ limit: 10 }⇒ 10, aufsteigend nachendsAt,{ featureId }filtert, Treffer ohnegastName/gastEmail, Bezugszeit je Status und TypeError fürbestaetigt/gehaltenwie bisher. (2) Seitenlauf: 250verfallen/abgesagt/abgelehntgemischt, davon 40 eine Millisekunde zu jung ⇒ ein Lauf löscht genau die 210 fälligen samt Zellen und keine junge; alle Listenabfragen antermineBookingshabenlimit ≤ 100und einselectohne Gastfelder (aw.listCalls), kein$id-Seitenlauf mit 500; der StandardloeschDeckel(2000, je Status) liegt über den 210 fälligen, ohne dass der Test ihn setzt. (3) Deckel:loeschDeckel = 7, 20 fälligeverfallen⇒ Lauf 1 löscht 7 und meldetgedeckelt: 1(Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mitgedeckelt: 0; nach jedem Lauf stimmen Rest und Zellen, an keinem wird eine junge Buchung gelöscht. (4) Fehler stoppt nicht für immer: alle DELETE auftermineBookingsscheitern (fetch-Wrapper) ⇒ der Lauf endet nach höchstens einer Seite je Schritt (Anfragenzahl begrenzt), Fehler gezählt, die anderen Schritte laufen; nächster Lauf ohne Fehler löscht alles. (5) Angepasste TP1-Tests: Z. 672 erwartetgedeckelt: 0; der Race-Test (Z. 742) bleibt. (6)termineSetup: Index-Liste vontermineBookingsumidx_status_ends(status, endsAt) undidx_status_updated(status, updatedAt) ergänzen,mainlegt beide an. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Aufbewahrungs-Loeschung seitenweise (Grundlagen, verfallen/abgesagt/abgelehnt)“
Task A3b: Aufbewahrungs-Löschung seitenweise – je Funktion, Sicherheitsnetz, Gesamtdeckel, Bericht (Review Focus 4)
Files: Modify server/services/termine/jobs.js (loeschfristen Z. 288–315, die Schritte erledigt/nichterschienen; wendeLoeschfristenAn Z. 400, taeglicherJob Z. 420, tagesbericht Z. 437), server/config.js (loeschDeckelGesamt, Standard 20000; loeschDeckel Standard 2000 kommt aus A3a); Tests test/termineJobs.test.js (Z. 672–751 anpassen, neue).
Consumes: A3a (loeschKandidaten, arbeiteSeiten, loeschDeckel, Indizes). Produces: wendeLoeschfristenAn und taeglicherJob behalten die Form aus A3a; tagesbericht bekommt das Segment „Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen“ (nur bei gedeckelt > 0, direkt nach Mails nicht zugestellt).
Ansatz: Durchgang (2): je Funktion (portalFeatures mit featureKey termine, state active|disabled, listAllDocuments mit Query.select(['$id'])) erledigt/nichterschienen nach aufbewahrungTage der Funktion (aufbewahrungTage() wie bisher, null ⇒ Funktion auslassen), gemeinsamer Deckel loeschDeckel je Funktion und loeschDeckelGesamt je Lauf. Durchgang (3), Sicherheitsnetz: erledigt/nichterschienen mit endsAt < jetzt − 365 Tage ohne Funktionsfilter (die größte erlaubte Aufbewahrung; fängt Buchungen ohne Funktionsdokument und Funktionen mit unlesbarer Einstellung spätestens nach 365 Tagen). Die Prüfung am Kandidaten (endsAt < Frist) bleibt als zweiter Boden.
- Tests: (1) „viele Betriebe“: 60 Funktionen (
active,aufbewahrungTage14/90/365 gemischt), je 6 Buchungen (3 fällig, 3 eine Millisekunde zu jung, Status gemischt) ⇒ ein Lauf löscht genau die 180 fälligen samt Zellen und keine junge (die StandardsloeschDeckel2000 je Funktion undloeschDeckelGesamt20000 je Lauf liegen darüber, der Test setzt sie nicht); alle Listenabfragen antermineBookingshabenlimit ≤ 100(aw.listCalls), kein$id-Seitenlauf mit 500. (2) Deckel je Funktion:loeschDeckel = 7, eine Funktion mit 20 fälligen ⇒ Lauf 1 löscht 7 und meldetgedeckelt: 1(Warnung im Log ohne ID/Name), Lauf 2 weitere 7, Lauf 3 die letzten 6 mitgedeckelt: 0; A 50 fällige, B 2 fällige,loeschDeckel = 5⇒ A 5, B 2 im ersten Lauf;loeschDeckelGesamt = 6⇒ insgesamt 6, Rest im nächsten Lauf; an keinem Lauf wird eine junge Buchung gelöscht. (3) Sicherheitsnetz: Buchung ohne Funktionsdokument, 366 Tage alt ⇒ gelöscht, 200 Tage alt ⇒ bleibt (dokumentierte Grenze, Waisen gibt es in Produktion nicht); Funktion mit unlesbarer Einstellung (failNexttermineConfig GET): 100 Tage bleibt (nie früher), 366 Tage weg. (4) Angepasste TP1-Tests: Z. 692 legt für F2 und F4portalFeatures-Dokumente an (F2 ohne Einstellung ⇒ 90, F4 kaputte Einstellung ⇒ 90). (5)tagesberichtunverändert ohne Deckel, mitgedeckelt> 0 zusätzlich das Segment oben (vollständige Zeile siehe E7). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Aufbewahrungs-Loeschung je Funktion mit Deckel, Sicherheitsnetz und Bericht“
Task A4: Löschung abgeschalteter Funktionen seitenweise (Review Focus 4)
Files: Modify server/services/termine/jobs.js (loescheAbgeschaltete Z. 347–391, nochAbgeschaltet Z. 325, loescheAbgeschalteteFunktionen Z. 410), server/services/termine/buchungen.js (neu ersteBuchungen); Tests test/termineJobs.test.js (Z. 753–870).
Consumes: A3a (arbeiteSeiten, loeschDeckel, gedeckelt im Tagesjob). Produces: ersteBuchungen(featureId, limit = 100) ⇒ doc[] (Query.equal('featureId'), limit, select(['$id'])); loescheAbgeschalteteFunktionen(now) ⇒ { funktionen, buchungen, gedeckelt }. Block E hängt die frische Abo-Prüfung an nochAbgeschaltet und setzt das Funktionsdokument nach dem Löschen der Einstellung auf off.
Ansatz: Je abgeschaltete Funktion (Frist disabledAt < jetzt − 30 Tage bleibt in der Funktionsliste) Seiten zu 100 lesen und löschen, bis die Seite leer ist; vor jeder Seite nach dem Lesen und alle 50 Buchungen nochAbgeschaltet (frisch, wie bisher); Deckel loeschDeckel je Funktion. Die Einstellung (termineConfig) wird nur nach leerer Seite ohne Fehler und mit letzter Prüfung unter termine-cfg:<id> gelöscht; sonst bleibt die Funktion disabled mit altem disabledAt und der nächste Tag macht weiter (gedeckelt + Log-Warnung).
- Tests: (1) Funktion seit 31 Tagen abgeschaltet mit 230 Buchungen (mit Zellen),
loeschDeckel = 100: Lauf 1 ⇒{ funktionen: 1, buchungen: 100, gedeckelt: 1 },termineConfigunddisabledAtunverändert; Lauf 2 ⇒ weitere 100; Lauf 3 ⇒ 30 und Einstellung weg,gedeckelt: 0; alle Listenabfragenlimit ≤ 100. (2) Deckel gilt je Funktion: A 150 Buchungen, B 10 ⇒ B im selben Lauf komplett samt Einstellung, A noch nicht. (3) Schaltet das Personal die Funktion nach der ersten Seite wieder ein (mitEinwurfauf die zweite Listenabfrage) ⇒ keine weitere Löschung, Einstellung bleibt, Log „inzwischen wieder aktiv“, kein Fehler. (4) Scheitert jedes Löschen (Wrapper) ⇒ Ende nach einer Seite, Einstellung bleibt, Anfragenzahl begrenzt, kein Wurf. (5) Die bestehenden Tests Z. 753–870 bleiben bis aufgedeckelt: 0in dendeepEqual-Pins ({ funktionen, buchungen, gedeckelt }; Block E pinnt dieselbe Form) grün (Wiedereinschalten nach 50 Buchungen ⇒ genau 50 gelöscht; Neulesen scheitert ⇒ nichts gelöscht, ein Fehler im Log). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Loeschung abgeschalteter Funktionen seitenweise, Rest an Folgetagen“
Task A5: CSV-Export seitenweise streamen (Review Focus 4)
Files: Modify server/routes/termine.js (GET /export.csv Z. 450–474, Import Z. 11), server/services/termine/buchungen.js (neu exportSeite); Tests test/termineRoutes.test.js (Z. 927–990).
Consumes: A2 (aw.listCalls). Produces: exportSeite(featureId, { nach = null, limit = 500 } = {}) ⇒ doc[] (Query.equal('featureId'), orderAsc('startsAt'), limit, cursorAfter(nach) ⇒ idx_feature_start).
Ansatz: Die erste Seite wird gelesen, bevor Header gesendet werden (Fehler ⇒ normales JSON-500). Dann BOM, Kopfzeile und je Seite die Zeilen per res.write (Sperren, quelle sperre, je Seite herausfiltern; Cursor = letzte Zeile der Rohseite). Ein Fehler nach dem ersten Byte ⇒ res.destroy(err): ein abgebrochener Download statt einer still gekürzten Datei. Wird das Cursor-Dokument zwischen zwei Seiten vom Tagesjob gelöscht, bricht der Export ab und wird neu geladen (selten, dokumentieren). imZeitraum bleibt für die übrigen Aufrufer.
- Tests: (1) 1 200 Buchungen + 40 Sperren + fremde Funktion ⇒ 1 200 Datenzeilen nach Beginn sortiert, Zeilen byte-gleich zum bisherigen Format; genau drei Listenabfragen an
termineBookings(500/500/200) mitlimit 500und ohne$id-Sortierung. (2) 20 100 Buchungen (über dem alten Deckel von 20 000 inlistAllDocuments) ⇒ 20 100 Zeilen, keinlist_limit_exceeded. (3)failNextGET 500 bei der ersten Seite ⇒ 500 JSON ohne CSV-Anfang; bei der zweiten (after: 1) ⇒ der Antwortkörper bricht ab (arrayBuffer()wirft), kein vollständig aussehendes Ende; im Log kein Gastname/keine Adresse. (4) Die Tests Z. 934 (BOM, Kopf, Sortierung, Sperren, fremde Funktionen) und Z. 957 (Hochkomma-Schutz) bleiben unverändert grün;Content-Disposition,no-store,nosniffwie bisher. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: CSV-Export seitenweise gestreamt“
Task A6: Zellen-Sweep seitenweise über idx_bis (Review Focus 4)
Files: Modify server/services/termine/zellen.js (ALLE_ZELLEN_MAX Z. 46, ungueltigeZellen Z. 92–99, verwaisteAus/alleZellen/verwaisteZellen/raeumeVerwaisteZellen Z. 215–270), server/config.js (zellenDeckel, Standard 5000), server/services/termine/jobs.js (taeglicherJob); Tests test/termineZellen.test.js (Z. 600–745), test/termineJobs.test.js (Tagesjob-Test Z. 867).
Consumes: A2 (Query.select, listCalls), A3a (gedeckelt im Tagesjob). Produces: raeumeVerwaisteZellen(now, { deckel = config.termine.zellenDeckel } = {}) ⇒ Promise<{ geloescht, gedeckelt }>; verwaisteZellen, alleZellen, ALLE_ZELLEN_MAX entfallen (nur in Tests benutzt); ungueltigeZellen(zellen, nowMs) prüft zuerst das Zeitfenster: eine Zelle mit bis < jetzt − 1 Tag ist ohne Buchungsabfrage ungültig, nur die übrigen werden gegen ihre Buchung geprüft (raeumeBlockierer in belegeZellen nutzt dieselbe Funktion).
Ansatz: Seiten über idx_bis: lessThan('bis', jetzt − 1 Tag), orderAsc('bis'), limit(100), select(['$id','featureId','ressourceId','bookingId','bis']), ohne Cursor neu lesen. Gruppierung je Ressourcensperre mit Map.get/push (statt [...alt, zelle], Z. 247, quadratisch); je Gruppe unter der Sperre erneut lesen (leseNachIds) und nur löschen, was dort noch dieselbe bookingId hat und alt ist; erster Fehler wird am Ende geworfen, eine Seite ohne Erfolg beendet den Lauf. Bewusste Änderung: Zellen mit künftigem bis, deren Buchung fehlt oder nichts mehr belegt (nur möglich, wenn gibFreiSicher scheiterte), findet der Sweep nicht mehr täglich; sie stören nicht (belegeZellen räumt Blockierer bei Bedarf, raeumeBlockierer) und fallen spätestens einen Tag nach bis in den Sweep.
- Tests: (1) Zellen mit
bisgenaujetzt − 1 Tagbleiben, eine Millisekunde älter ⇒ weg; der Sweep fragttermineBookingsnicht ab (kein GET). (2) 260 alte Zellen auf zwei Ressourcen (Test Z. 687 angepasst; der StandardzellenDeckel5000 liegt darüber) ⇒{ geloescht: 260, gedeckelt: false }, alle Listenabfragen antermineClaimsmitlimit ≤ 100. (3) Deckel: 600 alte Zellen,zellenDeckel = 250⇒ Lauf 1{ 250, true }, Lauf 2{ 250, true }, Lauf 3{ 100, false };taeglicherJobmeldetgedeckelt.zellenundzellenals Zahl. (4) 3 000 alte Zellen einer einzigen Ressource (ein Sperrschlüssel, Deckel 5 000) ⇒ alle gelöscht (Regression des quadratischen Aufbaus). (5) Race (Tests Z. 697/711 angepasst auf alte Zellen): wird eine gelistete Zelle vor der Sperre durch eine anderebookingIdund künftigesbisersetzt (mitEinwurf), bleibt sie. (6) Waise mit künftigembis(Buchungabgesagt) bleibt im Sweep stehen, blockiert aber keine Neubuchung (belegeZellenräumt sie, bestehender Test Z. 361/406 grün). (7) Ein DELETE-Fehler stoppt die übrigen Blöcke nicht und wird am Ende geworfen (Z. 736); danach kein Endlosdurchlauf. (8) Angepasste/entfallende TP1-Tests (einzeln): die beidenverwaisteZellen-Tests Z. 600 (Buchung fehlt, nicht mehr belegend, Zelle länger als einen Tag vorbei) und Z. 636 (liest alle Zellen, prüft in Blöcken) werden gelöscht, weil die Funktion entfällt; ihre Fälle gehen in (1), (2) und (6) auf. Der Test Z. 670 („löscht genau die verwaisten Zellen und zählt sie“) liest.geloeschtstatt der Zahl (Z. 681–683:equal(…, 4)undequal(…, 0), dazu müssen die Zellenbisälter als einen Tag haben; die künftigenc_ok/c_abgesagt/c_fehltbleiben bewusst liegen, vgl. (6)); Z. 687 (260 Zellen) laut (2); Z. 697 und Z. 711 laut (5); Z. 736 laut (7), dortequal(…, 1)(Z. 742) ebenfalls auf.geloescht. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Zellen-Sweep seitenweise ueber idx_bis mit Deckel“
Task A7: Mail-Fairness 1 – Kontingent je Funktion, Vorrang-Reserve, Haltungsgrenze
Files: Modify server/services/termine/outbox.js (Budgetzustand Z. 50–79, sende Z. 161–204), server/config.js (mailsProFunktion, Standard 40; mailsReserve, Standard 0.25), server/services/termine/limits.js (HALTUNGEN_JE_SITE Z. 44: 60 → 25); Tests test/termineOutbox.test.js (Z. 247–297, 466–493), test/termineLimits.test.js (Z. 139–155, 176–193), test/publicTermine.test.js (Z. 614–628).
Consumes: –. Produces: MAIL_VORRANG = ['G01','G02','G08'], MAIL_NACHRANGIG = ['G05','K20','K21'], mailKlasse(art) ⇒ 'vorrang' | 'normal' | 'nachrangig'; pruefePlatz(nowMs, featureId, art) ⇒ null | 'pause' | 'budget' | 'reserve' | 'kontingent'; mailBudget(now) ⇒ { stunde: { benutzt, max }, nachrangig: { benutzt, max }, jeFunktionMax, spitzen: [{ featureId, benutzt }] (höchstens 3) } (für A10a2 und A10b; nur Zahlen und Funktions-IDs, keine Adressen).
Ansatz: Das Stundenfenster hält Einträge { t, f, n } (Zeit, Funktion, nachrangig) statt Zahlen; gibPlatzFrei entfernt per Objekt. Regeln in dieser Reihenfolge: Pause; Gesamtzahl im Fenster ≥ mailsProStunde ⇒ budget; nachrangige im Fenster ≥ max(1, floor(mailsProStunde × (1 − mailsReserve))) ⇒ reserve (so bleibt ein Viertel der Stunde Vorrang- und Normal-Mails vorbehalten); Mails der Funktion im Fenster ≥ mailsProFunktion ⇒ kontingent. sende liest den Eintrag (kennt dann featureId und art), belegt den Platz erst danach und lässt einen gebremsten Eintrag unverändert wartet (kein Versuch, versuche bleibt); der Sofortversand aus einreihen unterliegt denselben Regeln. Mit 25 Haltungen je Stunde und siteKey löst eine Funktion höchstens 25 G01 aus, unter dem Kontingent 40.
- Tests (
config.termine.mailsProFunktion,mailsReserveje Test setzen,beforeEachstellt 200/40/0.25 zurück): (1) Kontingent:mailsProFunktion = 3, Funktion A reiht 5 G02 ein ⇒ 3 gesendet, 2wartet(versuche 0, Inhalt erhalten); Funktion B reiht 2 ein ⇒ beide gehen raus;verarbeite(+61 min)⇒ A's 2 gehen raus. (2) Reserve:mailsProStunde = 8(nachrangig höchstens 6): 10 G05 ⇒ 6 gesendet, danach ein G01 ⇒ geht raus (Plätze 7 und 8 gehören dem Vorrang), das siebte G05 bleibtwartet, obwohl noch ein Platz frei ist;mailKlassefür alle zehn Arten (G03/G04/G06/G07 ⇒normal). (3) Gleitendes Fenster je Funktion: Plätze laufen 60 Minuten nach dem Versand ab (59 Min nein, 60 Min ja); 4xx-Fehlversuche und Anmeldepause belegen weder Gesamt- noch Funktionsplatz (Tests Z. 281/386 grün). (4) Gleichzeitig:Promise.allüber 6 Einträge einer Funktion mit Kontingent 3 ⇒ genau 3 Transportaufrufe. (5)mailBudget: nach 5 Sendungen aus zwei Funktionen stimmenbenutzt,max,spitzen(sortiert, höchstens 3), kein@im Ergebnis. (6) TP1-Pins: Z. 466 (Stundenbudget gilt auch bei gleichzeitigen Versuchen) nimmtart: 'G02'; Z. 247–263 bleibt außer derfaellig-Zeile (A9a). (7) Limits:darfHaltung25 je siteKey und Stunde (Test Z. 139: Titel, 65 Aufrufe ⇒ 25 gezählte; Test Z. 176: Schleifei < 60⇒ 25),publicTermine.test.jsZ. 614: Titel und Schleifen 60/59 ⇒ 25/24, die 25. Haltung geht noch, die 26. ⇒ 429. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Mailkontingent je Funktion, Vorrang-Reserve und kleinere Haltungsgrenze“
Task A8: Empfänger-Kappe über Betriebe (G01, Freitext in G04/G06)
Files: Modify server/services/termine/limits.js (darfG01 Z. 196, neu darfEmpfaenger), server/services/termine/mails.js (anGast Z. 472–494); server/routes/publicTermine.js Z. 535 bleibt unverändert (ruft darfG01); Tests test/termineLimits.test.js (Z. 162–174 neu geschrieben, Z. 176–193), test/publicTermine.test.js (Z. 630–647), test/termineAblaufBetrieb.test.js (G04 Z. 281, G06 Z. 323).
Consumes: –. Produces: darfG01(featureId, adresse, now) zählt zusätzlich über alle Funktionen (zaehleAlle, alles oder nichts): empfaenger-h:<adresse> 3 je Stunde und empfaenger-d:<adresse> 10 je Tag, neben dem bisherigen g01:<funktion>:<adresse> 5 je Tag; darfEmpfaenger(adresse, now) ⇒ boolean (dieselben zwei Zähler, ohne Funktionsregel). Die Adresse ist die normalisierte (klein, getrimmt).
Ansatz: Das Gast-Formular bleibt ein stiller Erfolg wie heute (keine Buchung, keine Mail, gleiche Antwort), weil darfG01 vor halte prüft (kein verwaister Halte-Eintrag). Für G04 (extra.nachricht) und G06 vom Betrieb (extra.grund, extra.nachricht) mit Freitext prüft anGast darfEmpfaenger(an): ist die Kappe erreicht, geht die Mail ohne den Freitext raus (der Hinweis auf Ablehnung/Absage bleibt, der Missbrauchswert fällt weg), Log-Warnung ohne Adresse; ohne Freitext zählt nichts, G05/K20/K21 sind nicht betroffen.
- Tests: (1)
darfG01(Test Z. 162 wird neu geschrieben): dieselbe Adresse beif1⇒[true, true, true, false, false, false, false];f2mit derselben Adresse ⇒false(Kappe über Betriebe, bishertrue), andere Adresse ⇒true; nach 61 Minuten wieder drei; Tageskappe: 3 + 3 + 3 + 1 über vier Stunden verteilt ja, die 11. nein; alles oder nichts (ein abgelehnter Aufruf erhöht keinen Zähler, auch nicht den der Funktion);darfG08zählt weiter getrennt. Test Z. 176 (Speicher): drei statt fünf Durchläufe, der viertefalse. (2)darfEmpfaengerteilt die Zähler mitdarfG01(zweidarfEmpfaenger+ zweidarfG01⇒ der vierte Aufruffalse). (3)publicTermineTest Z. 630 wird „je Adresse 3 je Stunde“: sechs Anfragen derselben Adresse ⇒ alle 201 mit gleicher Antwort, drei Mails (statt 5), drei Buchungen, die 4. bis 6. ohne Haltung und ohne Mail; andere Adresse und „Fehler der Zeit kommen vor der Mailgrenze“ (409) wie bisher. (4)anGast: G04 mit Nachricht an eine Adresse über der Kappe ⇒ Mail geht raus und enthält den Text nicht, darunter mit Text; G06von: 'betrieb'mitgrundebenso, G06von: 'gast'zählt nicht; das Log enthält keine Adresse. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Empfaenger-Kappe ueber Betriebe (G01, Freitext in G04/G06)“
Task A9a: Mail-Fairness 2a – Frühausstieg, begrenzte und nach Klasse geordnete Warteschlange
Files: Modify server/services/termine/outbox.js (verarbeite Z. 260–275, sende Z. 161–204: nur die Platzprüfung), server/config.js (mailListe, Standard 100); Tests test/termineOutbox.test.js (Z. 218–263, neue).
Consumes: A2 (select, notEqual, listCalls), A7 (pruefePlatz, mailKlasse, MAIL_*). Produces: verarbeite(now) ⇒ { faellig, gesendet } (Form unverändert; faellig = gelesene Einträge).
Ansatz: (1) Frühausstieg ohne Abfrage bei Pause oder leerem Gesamtbudget. (2) Je Klasse vorrang ⇒ normal ⇒ nachrangig (entfällt bei reserve) eine Abfrage: status wartet, naechsterVersuch ≤ jetzt, art in/nicht in der Klasse (equal/notEqual), featureId notEqual blockierte (nur wenn nicht leer, höchstens 90 Werte), orderAsc('naechsterVersuch'), limit(mailListe), select(['$id','featureId','art','naechsterVersuch']) (keine Bodies). (3) Vor jedem Versand pruefePlatz: budget/pause ⇒ Ende; kontingent ⇒ Funktion in blockiert, Eintrag überspringen; ist die Seite voll und wurde etwas gesendet oder blockiert, höchstens 4 weitere Runden mit der neuen Ausschlussliste (keine Funktion verhungert hinter einer vollen).
- Tests: (1) Frühausstieg: Budget voll (
mailsProStunde = 2, zwei gesendet) ⇒verarbeiteliefert{ faellig: 0, gesendet: 0 }ohne eine Listenabfrage antermineOutbox(listCalls); ebenso während der Anmeldepause; der TP1-Test Z. 247–263 erwartet in der Zeileverarbeite(+59 min)jetzt{ faellig: 0, gesendet: 0 },+61 minweiter{ faellig: 1, gesendet: 1 }. (2) Begrenzt: 250 fällige G02 über 3 Funktionen ⇒ jede Abfragelimit ≤ 100, ohne$id-Seitenlauf, mitselect(keinhtmlim Abfragetext); gesendet wird höchstens bis zum Kontingent. (3) Reihenfolge: ein älteres G05, ein G03 und ein jüngeres G01 fällig, Budget 1 ⇒ G01; Budget 2 ⇒ G01 und G03; G05 zuletzt; innerhalb einer Klasse nachnaechsterVersuch(TP1-Test Z. 218 erwartet „fälligste zuerst“ stattcreatedAt). (4) Kein Verhungern: Funktion A mit 150 fälligen G02 (Kontingent 40) vor einem einzelnen G02 von Funktion B ⇒ ein Lauf sendet 40 von A und die Mail von B, höchstens 5 Abfragen je Klasse. (5)reserveerreicht ⇒ keine nachrangige Abfrage mehr. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Mail-Warteschlange begrenzt und nach Vorrang geordnet“
Task A9b: Mail-Fairness 2b – veraltete G01 verwerfen, Index bookingId
Files: Modify server/services/termine/outbox.js (sende Z. 161–204: G01-Prüfung, raeumeAuf Z. 304), server/services/termine/schema.js (termineOutbox: index('idx_booking', 'bookingId'); zustellFehler und verwerfeWartende fragen nach bookingId); Tests test/termineOutbox.test.js (neue), test/termineSetup.test.js (Z. 157).
Consumes: A9a. Produces: neuer Status verworfen (9 Zeichen, passt in status, kein Schema-Eingriff); sendeEintrag kann zusätzlich 'verworfen' liefern; verworfeneG01() ⇒ number (Zähler seit Prozessstart, für A10b).
Ansatz: Veraltetes G01: in sende nach dem Lesen und nach dem Check auf unverbucht, vor dem Budget: art 'G01', bookingId !== '-' (G01 zu_viele hat keinen Link), nowMs − createdAt > (config.termine.holdMinutes + 1) · 60 s ⇒ Status verworfen, an/html/text/ics geleert, fehler 'Verworfen: Bestätigungslink abgelaufen', naechsterVersuch = jetzt + 48 h; raeumeAuf löscht gesendet, fehler und verworfen; zustellFehler zählt verworfen nicht (der Betrieb sieht keinen Zustellfehler für einen verfallenen Halte-Eintrag).
- Tests: (1) Veraltetes G01:
createdAt= jetzt − 31 min − 1 s ⇒verworfenohne Transportaufruf, Felder geleert,zustellFehler([id])leer,raeumeAuf(+48 h)löscht ihn; genau 31 min ⇒ wird gesendet; G01 mitbookingId '-'(zu_viele) und ein gleich altes G02 werden gesendet; kein Budgetplatz belegt;holdMinuteswird bei jedem Aufruf gelesen. (2) Ein bereits gesendeter, nicht verbuchter Eintrag (unverbucht) wird nie verworfen und nie doppelt gesendet. (3) Schema:indexes('termineOutbox')=[['idx_status_next','status','naechsterVersuch'], ['idx_booking','bookingId']],mainlegt ihn an. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: veraltete G01 verworfen, Index bookingId“
Task A10a1: Sichtbarkeit 1a – Team-Mail-Vorlage T20
Files: Create server/mail-templates/T20 Terminplaner Betriebsmeldung.html (Layout von T14 Loeschfristen.html, Betreff [Terminplaner] {{vorfall}}, Platzhalter vorfall, details, zeit, ohne Blöcke); Modify test/mailTemplates.test.js (Z. 8 Kopfkommentar, Z. 11 Liste [...ids('G', 8), ...ids('K', 21), ...ids('T', 18), 'T20'], Z. 28 Präfix-Regel um Terminplaner). T19 „Erstattung fällig“ gehört Block B (B9b); dort wird die Liste zu [...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20'], in E3 zu ids('K', 23); 'T20' bleibt stehen.
Consumes: –. Produces: die Vorlage T20 (Versand ab A10a2 über sendAdminNotice).
- Tests (
mailTemplates.test.js): T20 rendert ohne Reste mit und ohne Blöcke, WEBklar-Logo vorhanden, Kopfkommentar passt zum<title>, Betreff beginnt mit[Terminplaner], die Platzhaltervorfall,details,zeitsind deklariert und verwendet; Liste und Präfix-Regel wie oben. - FAIL → Vorlage schreiben → PASS →
npm test→ Commit „Terminplaner: Team-Mail-Vorlage T20 Betriebsmeldung“
Task A10a2: Sichtbarkeit 1b – Betriebsprüfung mit Abkühlzeit (pruefeBetrieb)
Files: Create server/services/termine/betrieb.js, test/termineBetrieb.test.js; Modify server/services/termine/outbox.js (neu warteschlangeAlter), server/services/termine/jobs.js (Betriebsprüfung am Ende von minutenJob).
Consumes: A2 (countDocuments), A7 (mailBudget), A10a1 (T20), pausiertBis() (bisher ungenutzt). Produces: pruefeBetrieb(now) ⇒ Promise<string[]> (aktive Vorfälle, wirft nie); der gemeinsame Melder meldeVorfall(schluessel, text, now) (Warnlog stündlich, Team-Mail mit Abkühlzeit je Schlüssel; A10a3 meldet über ihn die Vorfälle tagesjob, deckel, betriebe); resetBetriebForTests().
Ansatz: Vorfälle dieses Tasks: budget (≥ 70 % von mailsProStunde im Fenster), queue (ältester fälliger wartet-Eintrag > 10 Min überfällig; warteschlangeAlter = aeltesteMin aus status wartet, naechsterVersuch ≤ jetzt, orderAsc, limit(1)), pause (pausiertBis() > now). Je Vorfall: Warnlog höchstens stündlich ([termine-betrieb] …, nur Zahlen), Team-Mail T20 über sendAdminNotice beim Beginn und danach frühestens nach 6 Stunden (Abkühlzeit je Schlüssel, auch wenn der Vorfall zwischendurch endete). Lesefehler ⇒ Werte null und ein Log, nie ein 500; Modus off ⇒ minutenJob ruft nichts auf. Fällt der SMTP aus, scheitert auch die Team-Mail; das Log bleibt der Rückfall.
- Tests (
termineBetrieb.test.js, feste Uhr,mailSink,config.chatNotify.emailsgesetzt): (1) Budget: 139 von 200 ⇒ kein Vorfall, 140 ⇒ Vorfall; Warnlog mit „Mailbudget 140/200“ genau einmal, 30 Min später kein zweites, 61 Min später wieder; T20 genau einmal, Betreff beginnt mit[Terminplaner], weder Adresse noch Name im Text. (2) Queue: Eintrag 10 Min überfällig ⇒ kein Vorfall, 11 Min ⇒ Vorfall; ein Eintrag mitnaechsterVersuchin der Zukunft (Wiederholung) zählt nicht. (3) Pause: EAUTH ⇒ Vorfallpausemit Endzeit im Log; endet nach Ablauf; zweite Pause innerhalb 6 Stunden ⇒ Log ja, Mail nein, nach 6 Stunden Mail ja. (4)pruefeBetriebwirft nie: Appwrite 500 ⇒ ein Log, keine Mail-Schleife; Team-Mail scheitert ⇒ Log, Vorfall bleibt gemerkt. (5)minutenJobim ModusoffruftpruefeBetriebnicht auf (0 Anfragen). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Betriebspruefung mit Abkuehlzeit fuer die Team-Mail“
Task A10a3: Sichtbarkeit 1c – Tagesjob-Meldung, Tagesjob-Felder und Bericht
Files: Modify server/services/termine/betrieb.js (neu meldeTagesjob), server/services/termine/jobs.js (meldeTagesjob am Ende von taeglicherJob mit aktiveBetriebe und geloescht im Ergebnis, tagesbericht), test/termineBetrieb.test.js, test/termineJobs.test.js (Z. 867, gepinnte tagesbericht-Zeile).
Consumes: A10a2 (betrieb.js, meldeVorfall), A2 (countDocuments), A3a/A3b/A4/A6 (gedeckelt). Produces: meldeTagesjob(ergebnis, now) (liest ergebnis.fehler, ergebnis.gedeckelt = { loeschfristen, abgeschaltet, zellen }, ergebnis.geloescht = Summe aller Löschzähler und ergebnis.aktiveBetriebe = Zahl oder null; merkt letzterTagesjob = { at, fehler, gedeckelt, geloescht } (nur Zählungen); prüft Fehler, Deckel, Betriebe; wirft nie); taeglicherJob liefert dafür zusätzlich geloescht und aktiveBetriebe (Zahl); der eingeschränkte Lauf aus E7 liefert dieselben Felder.
Ansatz: Vorfälle über den Melder aus A10a2: tagesjob (fehler > 0 oder Abbruch), deckel (Löschdeckel an 3 Tagesläufen in Folge), betriebe (≥ 20 aktive Betriebe, D8); aktiveBetriebe = countDocuments der aktiven Termine-Dokumente.
- Tests (
termineBetrieb.test.jsundtermineJobs.test.js, feste Uhr,mailSink,config.chatNotify.emailsgesetzt): (1)meldeTagesjob({ fehler: 2, … })⇒ Vorfalltagesjob; Deckel an 2 Tagen ⇒ nein, am 3. ⇒deckel;aktiveBetriebe19 ⇒ nein, 20 ⇒betriebe;letzterTagesjobträgt{ at, fehler, gedeckelt, geloescht }als Zahlen;meldeTagesjobwirft nie (Appwrite 500, Team-Mail scheitert). (2) Die exakt gepinntetagesbericht-Zeile (termineJobs.test.jsZ. 867) bekommt am Ende „, n aktive Betriebe“; das Ergebnis vontaeglicherJobträgtaktiveBetriebe(Zahl) undgeloescht, die Zeile bleibt frei von IDs, Adressen und Namen. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Tagesjob-Meldung, Betriebe-Zaehlung und Bericht“
Task A10b: Sichtbarkeit 2 – Status-Route
Files: Create server/routes/team/termine.js, test/termineStatus.test.js; Modify server/services/termine/betrieb.js (neu statusDaten), server/routes/team/index.js (router.use('/termine', termineTeamRoutes) hinter requireStaffJwt).
Consumes: A10a2 (betrieb.js, warteschlangeAlter), A10a3 (letzterTagesjob, aktiveBetriebe), A7 (mailBudget), A9b (verworfeneG01), pausiertBis(). Produces: statusDaten(now) ⇒ Promise<{ budget, queue: { wartet, aeltesteMin }, pause: { aktiv, bis }, letzterTagesjob, aktiveBetriebe }> (budget = mailBudget(now) plus verworfeneG01; letzterTagesjob = null oder { at, fehler, gedeckelt, geloescht }, nur Zählungen, nach einem Neustart null bis zum Tagesjob; aktiveBetriebe = countDocuments der aktiven Termine-Dokumente); GET /api/team/termine/status liefert genau das, Cache-Control: no-store. Lesefehler ⇒ Werte null und ein Log, nie ein 500.
- Tests (
termineStatus.test.js, Team-Eingang wieteamAdminRoutes.test.js): ohne JWT 401,jwt-kunde403,jwt-staff200; Form wie oben;letzterTagesjobvorhernull, nachtaeglicherJobgefüllt;aktiveBetriebe2 bei zwei aktiven und einem abgeschalteten Dokument;failNextauftermineOutbox⇒ 200 mitqueueausnull-Werten; rekursiv keine Adresse und kein Gastname im Body; ein Kunden-Cookie erreicht die Route nicht. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Status-Route fuer das Team“
Task A11: disable() idempotent, disabledReason, Schema-Skript feature-setup.mjs
Files: Modify server/services/siteFeatures.js (activate Z. 293–302, disable Z. 304–313, disableCustomerFeatures Z. 321–337), server/services/customerAdmin.js (Z. 289: reason: 'kunde_geloescht'), test/support/schema.js (schemaKeys('portalFeatures') hängt die Schlüssel aus PORTAL_FEATURE_ATTRIBUTES an, Import aus dem neuen Skript); Create scripts/feature-setup.mjs, test/featureSetup.test.js; Tests test/siteFeatures.test.js (Z. 216–265), test/funktionenTermine.test.js (Z. 74, 172–195). scripts/setup-abo-collections.mjs, retention.js und die WOMS-Route bleiben unverändert (Vorgabe-Grund team).
Consumes: –. Produces (verbindlich laut Kopf): DISABLED_REASONS = ['team', 'abo_ende', 'abo_ende_vor_einrichtung', 'kunde_geloescht']; disable(docId, { by, adminNote, reason = 'team' }) unter withLocks(['feature-disable:' + docId]): Dokument lesen (404 wie bisher), steht es schon auf disabled, bleibt alles unverändert (disabledAt, disabledReason, adminNote, kein Schreibzugriff) und das Dokument wird zurückgegeben; einzige Ausnahme Altbestand: fehlt einem disabled-Dokument disabledAt, wird nur dieses Feld auf jetzt gesetzt (sonst fiele es nie in die 30-Tage-Löschung), Grund und Notiz bleiben; sonst state disabled, disabledAt jetzt, disabledReason, Notiz wie bisher; ein unbekannter Grund ⇒ 400 ohne Schreibzugriff. Die Analyse-Nacharbeit (stopAnalyseCollecting) läuft für analyse-Dokumente unverändert bei jedem Aufruf. activate() setzt zusätzlich disabledReason: null und aboEndeMailAt: null (zahlungMeldung fasst activate() bewusst nicht an: ein noch offener Zahlungsfall darf durch eine Aktivierung nicht verloren gehen; nur E8 setzt es beim Zurücksetzen auf ''). disableCustomerFeatures(customerId, key, { by, adminNote, reason }) reicht den Grund durch. portalFeatures.consentText bleibt unberührt. Schema: scripts/feature-setup.mjs (Muster scripts/analyse-setup.mjs, importiert dessen createAppwriteClient; Hauptlauf nur bei direktem Aufruf) exportiert PORTAL_FEATURE_ATTRIBUTES = [{ type: 'string', key: 'disabledReason', size: 32 }, { type: 'datetime', key: 'aboEndeMailAt' }, { type: 'string', key: 'zahlungMeldung', size: 24 }] (alle optional; zahlungMeldung ist der Marker aus B10a/B10b: '' | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende' | 'gemeldet') und main({ check = false, client, log = console.log } = {}): legt Fehlendes idempotent an, --check liest nur und meldet je Lücke - portalFeatures.<key> FEHLT (Exit 1 bei Lücken). Block B (B3) ergänzt featureConsents im selben Skript.
- Tests: (1)
disableohne Grund ⇒disabledReason 'team'; mitreason: 'abo_ende'gespeichert; mit'frist'⇒ 400 und nichts geschrieben. (2) Idempotenz: zweitesdisable(anderer Grund, andere Notiz) lässtdisabledAt,disabledReason,adminNoteund$updatedAtunverändert und liefert das Dokument;Promise.all([disable, disable])⇒ ein Schreibzugriff, gleichesdisabledAt; AltbestanddisabledohnedisabledAt⇒disabledAtwird jetzt gesetzt, Grund und Notiz bleiben (zweiter Aufruf schreibt nichts mehr). (3) Die 30-Tage-Löschung wird nicht verschoben: Dokument mitdisabledAtvor 31 Tagen, erneutesdisable(Doppelklick im WOMS, Kundenlöschung) ⇒loescheAbgeschalteteFunktionenlöscht weiterhin. (4)activateleertdisabledAt,disabledReasonundaboEndeMailAt(Dokument vorher mit allen dreien gefüllt) und lässtzahlungMeldungunverändert. (5)disableCustomerFeaturesmitreason⇒ alle Dokumente tragen ihn, ein schon abgeschaltetes behält Grund unddisabledAt; Kundenlöschung (Test Z. 172) ⇒kunde_geloescht,schonAusbehältteam. (6) Analyse:disableeineranalyse-Funktion ruftanalyseDisabledwie vorher (auch beim zweiten Aufruf), kein Verhaltensunterschied. (7)featureSetup.test.js(Mock-Client wieanalyseSetup.test.js):PORTAL_FEATURE_ATTRIBUTESmit Typ und Größe (disabledReason≥ 24, längster Wertabo_ende_vor_einrichtung;aboEndeMailAtdatetime; alle optional);mainlegt nur Fehlendes an und überspringt Vorhandenes;main({ check: true })legt nichts an und meldet;schemaKeys('portalFeatures')enthält die drei Schlüssel (der Analyse-Aufbau[...schemaKeys('portalFeatures'), ...ANALYSE_KEYS]bleibt fehlerfrei). - FAIL → implementieren → PASS →
npm test→ Commit „Funktionen: disable() idempotent, disabledReason, Schema-Skript feature-setup“
Task A12: minutenJob in Verfall und Mailversand getrennt schützen
Files: Modify server/services/termine/jobs.js (fuehreAus Z. 91–108, minutenJob Z. 151–157); Tests test/termineJobs.test.js (Z. 300–361, 963–1005). server/index.js bleibt unverändert (der Pin-Test Z. 1007 prüft minutenJob in index.js).
Consumes: A10a2 (die Betriebsprüfung wandert in den Mail-Teil). Produces: fuehreAus(job, now, arbeit, { teil = null } = {}) führt die Überlappungssperre je job:teil (Block E7 erweitert dasselbe Optionsobjekt um trotzAus; die Signatur ist { teil = null, trotzAus = null } = {}); minutenJob(now) startet beide Teile mit Promise.all und liefert wie bisher { verfallen, faellig, gesendet, fehler } (Summe der Fehler), { aus: true } im Modus off, { uebersprungen: true }, wenn beide Teile übersprungen werden; ist nur der Verfall übersprungen, kommt zusätzlich verfallUebersprungen: true (analog mailUebersprungen). Die Warnung bei Überlappung nennt den Teil (minutenJob (Verfall)), das Fehlerlog behält minutenJob als Jobnamen.
- Tests: (1) Hängt der Verfall (erste Appwrite-Anfrage blockiert), versendet der nächste Takt trotzdem die Warteschlange (
gesendet: 1,verfallUebersprungen: true), die Überlappung steht einmal im Log; (2) hängt der Mailversand, verfällt im nächsten Takt eine abgelaufene Haltung trotzdem; (3) hängen beide ⇒{ uebersprungen: true }wie im TP1-Test Z. 963 (Pin wird um die Teilnamen im Log ergänzt); (4) scheitern beide Teile (Z. 991) ⇒fehler: 2, die Sperren sind danach frei; (5) Fehler im Verfall stoppt den Mailversand nicht (Z. 347 grün); (6) Modusoff⇒{ aus: true }ohne Abfrage (Z. 277 grün); (7) der Pinserver/index.js: die drei Jobs …(Z. 1007) bleibt unverändert grün. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Minutenjob in Verfall und Mailversand getrennt geschuetzt“
Abschlussprüfung Block A (Controller): npm test komplett grün (test/previewDevRouter.test.js bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)' (analyticsContract.test.js importiert ../analytics/src und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob' bleibt leer. Schema-Check vor jedem Deploy: node scripts/termine-setup.mjs --check und node scripts/feature-setup.mjs --check (Livegang Schritt 3).
Block B: Register, Checkout, Zustimmungsnachweis
Block B macht aus dem festverdrahteten News-Bestellweg ein Register config.features (Checkout, Mails, Ticket, Freischaltung je Funktion), legt den Stripe-Preis termineSetup an, baut POST /api/funktionen/:key/checkout mit der Prüfreihenfolge aus Spec 4.3 samt unveränderlichem Zustimmungsnachweis (featureConsents) und fängt jede Zahlung ab, die in einem anderen Zustand eintrifft (Review Focus #1 und #5). Reihenfolge im Gesamtplan A → B → E → C → D → F: Was Block C (bestellpruefung), E (aboStatus) und D (Ticket-Kurzfassung) liefern, steht hier als Stub mit endgültiger Signatur (Task B5, B8), den die genannten Tasks ersetzen. Alle Zeilenangaben beziehen sich auf Portal 3a0b008 plus Block A.
Im Code geprüft, Testkonventionen für B und die Falle seedFixtures: stehen im Kopf, Abschnitt „Grundlagen der Blöcke“ (jeder Task bekommt nur Kopf und eigenen Text).
Der Register-Eintrag (Tabelle der verbindlichen Form) steht in Task B1.
Task B1: Register config.features und featureRegistry.js
Files: Modify server/config.js (neuer Block features vor export const config, funktionen Z. 193–197 wird Alias mit Gettern auf features.news; neu config.ustHinweis = 'ohne Umsatzsteuer gemäß § 19 UStG', von Register und mails.preisHinweis genutzt), server/services/siteFeatures.js (FEATURE_LABELS Z. 11–14 und DISABLED_MESSAGE Z. 17–18 werden aus dem Register abgeleitet, Exporte und Werte bleiben). Create server/services/featureRegistry.js, test/featureRegistry.test.js.
Consumes: – (Block A fasst diese Stellen nicht an).
Produces: config.features (Tabelle unten in diesem Task); featureConfig(key) ⇒ Eintrag | null (nur eigene Schlüssel des Registers, Object.hasOwn; 'analyse', '__proto__', 'constructor', undefined ⇒ null); featureKeys() ⇒ ['news','termine']; featureHaken(key) ⇒ { verfuegbar?, bestellpruefung?, unterlagenBereit?, belehrung?, freischaltbar? } (B1 liefert die leere Hülle samt Überschreib-Regel, B6, B7 und B11 füllen die Standards; Standard-Haken laden Dienste per dynamischem import(), weil featureRegistry.js von siteFeatures.js importiert wird); oeffentlicheAnsicht(key) ⇒ null | { key, name, kurz, priceLabel, setupEstimate, consent: { version, knopf, items: [{ id, text, haken }] }, leistungsbeschreibung, avv, widerruf, returnHash, requiresWartungsAbo } (JSON-sicher, ohne Funktionen und ohne ticket/mails).
Register-Eintrag (verbindliche Form; der Plan-Kopf-Kern bleibt, alles Weitere ist additiv):
| Feld | news (heutige Werte, wörtlich) |
termine |
|---|---|---|
key, product |
'news', 'newsSetup' |
'termine', 'termineSetup' |
label |
{ name: 'News-Pinnwand', kurz: 'News', keine: 'keine News-Pinnwand' } |
{ name: 'Terminplaner', kurz: 'Termine', keine: 'keinen Terminplaner' } |
priceLabel, setupEstimate |
'30 €', 'meist innerhalb von 1–2 Wochen' |
'149 €', gleicher Text (Frage Q1) |
consent |
{ version: '2026-09-30', knopf: 'Zahlungspflichtig bestellen · 30 €', items: [{ id: 'beginn', haken: true, text: <heutiger Satz aus config.js:195-196, wörtlich inkl. „§ 356 Abs. 4 BGB“> }] } |
{ version: 'ENTWURF-2026-10-03', knopf: 'Zahlungspflichtig bestellen · 149 €', items } mit vier Haken unterlagen, gesundheit, beginn, information (alle haken: true, ohne url; die IDs sind die endgültigen aus F7, Block F ersetzt nur Texte und Fassung); Texte nur Platzhalter „ENTWURF (Block F): …“ nach Spec 4.3 (1)–(4). Die Widerrufsbelehrung ist kein Haken-Item, sondern wird als eigener Nachweiseintrag belehrung gespeichert (B4/B7/F7) |
leistungsbeschreibung, avv, widerruf |
{ version: '2026-09-30', url: '/vertragsunterlagen/funktionen-leistungsbeschreibung.html' }, null, null |
leistungsbeschreibung und avv je { version: 'ENTWURF', url: '' }, widerruf null (Block F1/F7 setzt config.termine.avvVersion-Fassung und versionierte Adressen) |
returnHash, returnProjekt |
'news', false |
'termine', true (Rücksprung #termine?bestellt=1&projekt=<id>) |
ticket |
topic(project) = `News-Pinnwand einbauen – ${projectName}`.trim(); details(ctx) = die 6 Zeilen aus ticketAdmin.js:95-102 |
topic = `Terminplaner einbauen – ${projectName}`; details(ctx) ruft lazy termine/ticketDetails.js (Stub aus B8, Block D4 ersetzt den Inhalt) |
mails |
funktion: 'News-Pinnwand'; formen: { dein: 'Deine News-Pinnwand', nom: 'die News-Pinnwand', akk: 'die News-Pinnwand', gen: 'der News-Pinnwand', pron: 'sie' }; knopf: 'Beitrag schreiben'; danach: 'Danach kannst Du Beiträge im Portal schreiben.'; bereit: 'Du kannst ab sofort Beiträge schreiben, sie erscheinen direkt bei Dir auf der Seite.'; preisHinweis: 'brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo'; anleitung: false |
funktion: 'Terminplaner'; formen: { dein: 'Dein Terminplaner', nom: 'der Terminplaner', akk: 'den Terminplaner', gen: 'des Terminplaners', pron: 'ihn' }; knopf: 'Termine öffnen'; danach, bereit als Platzhaltertexte für die Mail-Überarbeitung des Inhabers (Mailrunde des Inhabers, nicht Block F; vom todo-Test unten ausgenommen); preisHinweis = `einmalig, ${config.ustHinweis}; der Terminplaner läuft, solange Dein Wartungs-Abo läuft`; anleitung: true (die K15-Anhänge hängt F8 direkt in onFeaturePaid an, es gibt keinen Registerhaken dafür) |
| Schalter | requiresWartungsAbo: false, ticketFreischaltung: true |
requiresWartungsAbo: true, ticketFreischaltung: true |
| Meldungen | disabledMessage = heutiger DISABLED_MESSAGE; orderedConflictMessage: 'Die News-Pinnwand ist für dieses Projekt schon bestellt.' |
disabledMessage: 'Der Terminplaner ist für diese Website abgeschaltet. Schreib uns im Chat, wenn Du ihn wieder möchtest.'; orderedConflictMessage: 'Der Terminplaner ist für dieses Projekt schon bestellt.'; unavailableMessage: 'Der Terminplaner ist noch nicht verfügbar.' (= MELDUNG.nichtVerfuegbar, routes/termine.js:57) |
| Haken (optional) | – | nicht in config.js, sondern als Standard-Haken in featureRegistry.js (featureHaken(key)): verfuegbar(email) = termineFuerKunde (B6), bestellpruefung(ctx) = ladeEntwurf + Block-C-Funktion (B6), unterlagenBereit() (B6, Standard true; F8 hängt featureLegalReady ein), belehrung() (B7, Standard: der Platzhaltertext BELEHRUNG_PLATZHALTER = „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“, exportiert aus featureRegistry.js; F7 liefert widerrufsbelehrungFunktion(); news hat keinen belehrung-Haken), freischaltbar(doc) (B11); wer config.features.<key>.<haken> setzt (Tests), überschreibt den Standard |
- Tests (
test/featureRegistry.test.js, Testaufbau laut Kopf): „News-Eintrag entspricht den bisherigen Festwerten“ (product 'newsSetup',priceLabel '30 €',setupEstimate, Satz wörtlich,returnHash 'news',requiresWartungsAbo false,ticketFreischaltung true, beide Meldungen wortgleich zu den heutigen,topic({ projectName: 'Bäckerei' }) === 'News-Pinnwand einbauen – Bäckerei',details== die 6 Zeilen des heutigen Defaults mitsiteKeyund DomainliveDomain || previewUrl || subdomain); „Alias bleibt“ (config.funktionen.setupEstimate/consentText== News-Werte, Getter folgen einer Änderung anconfig.features.newszur Laufzeit); „FEATURE_LABELSunverändert“ ({ news: { name, keine }, termine: { name, keine } }exakt) undFEATURE_KEYS==featureKeys(); „Schlüsselschutz“ (featureConfigfüranalyse,__proto__,toString,undefined,''⇒null); „Haken überschreibbar“ (config.features.termine.bestellpruefung = fngewinnt gegen den Standard, danach zurücksetzen); „Ansicht ohne Funktionen“ (JSON.parse(JSON.stringify(ansicht))gleich, keinticket, keinmails); „Termine: Preis und Struktur“ (product 'termineSetup',priceLabel '149 €',requiresWartungsAbo true,returnProjekt true, Item-IDs['unterlagen','gesundheit','beginn','information']mithaken[true,true,true,true],config.ustHinweiswörtlich,mails.preisHinweisenthältconfig.ustHinweis);{ todo: 'Block F ersetzt die Platzhaltertexte' }-Test „Bestelltexte ohne ENTWURF“ (Version, Items,leistungsbeschreibung,avv,widerruffrei vonENTWURF, ab B7 auch der Text vonawait featureHaken('termine').belehrung(); ausgenommenmails.danach/mails.bereit, die der Inhaber in seiner Mailrunde schreibt; schlägt bis Block F1/F7 fehl, ohne die Suite zu brechen). - FAIL → implementieren (Einträge als reine Daten in
config.js;ticket.*des News-Eintrags ohne Importe,ticket.detailsdes Termine-Eintrags per dynamischemimport()innerhalb der Funktion, weilconfig.jskeine Dienste statisch importieren darf; das Zieltermine/ticketDetails.jsentsteht erst in B8, aufgerufen wird es vorher nirgends) → PASS →npm test→ Commit „Funktionen: Register config.features mit News- und Termine-Eintrag“
Task B2: Stripe – Katalog termineSetup und generisches createFeatureCheckout
Files: Modify server/config.js (termineSetup neben newsSetup Z. 100–107, im config neben newsSetup Z. 190), server/services/stripeBilling.js (PRODUCT_METADATA Z. 54 + termineSetup: 'termine-einrichtung', catalogFor Z. 57–62, createFeatureCheckout Z. 647–682), scripts/stripe-products.mjs (KEYS Z. 20 + 'termineSetup', KEYS und catalogEntry Z. 30–34 mit export), test/stripeBilling.test.js (neu neben news_einrichtung_einmalpreis, Z. 246–260). Create test/featureCheckout.test.js, test/stripeProductsScript.test.js.
Consumes: B1 featureConfig.
Produces: config.termineSetup = { key: 'termineSetup', name: 'Terminplaner – Einrichtung', amountCents: 14900, lookupKey: 'webklar_termine_einrichtung_14900', oneTime: true, priceNickname: 'Terminplaner-Einrichtung 149 €' }; createFeatureCheckout(customer, { projectId, key = 'news', consentAt }) ⇒ { id, url } (unbekannter key ⇒ Fehler status 400, vor jedem Stripe-Aufruf): Preis ensurePrice(feature.product), invoice_data: { description: <Katalogname>, footer, metadata: { featureKey: key, projectId } }, metadata unverändert (customerId, projectId, featureKey, consentAt), success_url = ${publicBaseUrl}/dashboard.html#${returnHash}?bestellt=1 (+ &projekt=<encodeURIComponent(projectId)> bei returnProjekt), cancel_url = …#${returnHash} (+ ?projekt=<id>); SEPA-Rückfall und 24-h-Ablauf unverändert. Skript: KEYS und catalogEntry(key) exportiert (Import startet main() nicht, import.meta.url-Wächter besteht).
- Tests:
stripeBilling.test.js„termine_einrichtung_einmalpreis“ nach Vorbild News: einPOST /v1/pricesmitunit_amount '14900',lookup_key 'webklar_termine_einrichtung_14900', ohnerecurring, Produkt-Metadatenwebklar = 'termine-einrichtung', zweiter Aufruf legt nichts an.featureCheckout.test.js(Stripe-Nachbau, direkter Aufruf): News: Preis 3000,success_url/cancel_urlidentisch zu heute (…#news?bestellt=1,…#news),invoice_creation[invoice_data][description] = 'News-Pinnwand – Einrichtung', Footer,metadata[featureKey]='news',expires_at< jetzt + 24 h,payment_method_typesKarte + SEPA, SEPA-Rückfall nur Karte; Termine: Preis 14900,invoice_data[description] = 'Terminplaner – Einrichtung',invoice_data[metadata][featureKey]='termine'und[projectId],success_url …#termine?bestellt=1&projekt=p%2F1(Sonderzeichen kodiert),cancel_url …#termine?projekt=p%2F1; unbekannter Schlüssel ('analyse') ⇒rejectsmitstatus 400undst.callsunverändert.stripeProductsScript.test.js:KEYSdeepEqual['wartung','hosting','mailStorage','newsSetup','termineSetup'],catalogEntry('termineSetup') === config.termineSetup, Import ohne Stripe-Aufruf und ohneprocess.exit(kein Live-Aufruf,--anlegenmacht der Inhaber im Livegang-Schritt 4). - FAIL → implementieren → PASS →
npm test→ Commit „Stripe: Katalog Terminplaner-Einrichtung (149 €) und Checkout je Funktion aus dem Register“
Task B3: Schema featureConsents in scripts/feature-setup.mjs
Files: Modify scripts/feature-setup.mjs (aus A11: neu FEATURE_CONSENT_ATTRIBUTES, FEATURE_CONSENT_INDEXES, CONSENT_COLLECTION_NAME; main legt die Collection samt Attributen und Indizes an bzw. meldet sie mit --check), server/config.js (collections.featureConsents, Env APPWRITE_COLLECTION_FEATURE_CONSENTS), test/support/schema.js (schemaKeys('featureConsents') liefert die Schlüssel aus FEATURE_CONSENT_ATTRIBUTES), test/featureSetup.test.js (aus A11, erweitert). scripts/setup-abo-collections.mjs bleibt unverändert (Top-Level-Skript ohne Exporte und ohne --check; ein nachträglich eingebauter Prüfmodus gefährdete das Live-Schema-Skript).
Consumes: A11 (feature-setup.mjs; createAppwriteClient aus analyse-setup.mjs). Produces: Collection featureConsents (nur Server-Key, wie analyseConsents; Anlegen über ensureCollection ohne Rechte): customerId 36, projectId 36, featureKey 16, stripeSessionId 128, acceptedAt datetime, fassungen 1000 (JSON), texte 20000 (JSON, Appwrite macht daraus eine TEXT-Spalte), textHash 64, ip 64, userAgent 300, konto 255; Indizes idx_session [stripeSessionId], idx_project_feature [projectId, featureKey, acceptedAt]. node scripts/feature-setup.mjs --check meldet je fehlende Collection/Attribut/Index eine Zeile - <id>[.<key>] FEHLT, schreibt nichts, Exit-Code 1 bei Lücken.
- Tests (
featureSetup.test.js, Mock-Client wieanalyseSetup.test.js): „Schema featureConsents“ (die 11 Schlüssel, Größen:texte≥ 20000,stripeSessionId≥ 128,acceptedAtdatetime, alle optional;schemaKeys('featureConsents')== dieselben Schlüssel; beide Indizes mit den Attributen oben);mainlegt bei fehlender Collection genaufeatureConsentsmit 11 Attributen und 2 Indizes an (und fürportalFeaturesnur Fehlendes, A11);mainüberspringt Vorhandenes (keine Anlege-Aufrufe);main({ check: true })schreibt nie (keinecreateAttribute/ensureCollection/createIndex-Aufrufe), meldetfeatureConsents FEHLTund liefert die Lücken im Ergebnis. Der Lauf gegen die echte Appwrite gehört in den Livegang-Schritt 3 (Block G), weil der Nachbau die Attribut-Wartezeiten nicht nachstellt. - FAIL → implementieren → PASS →
npm test→ Commit „Schema: Zustimmungsnachweis featureConsents im Skript feature-setup“
Task B4: Zustimmungsnachweis featureConsents.js und Widerrufs-Anker
Files: Create server/services/featureConsents.js, test/featureConsents.test.js. Modify server/services/siteFeatures.js (paidFeatureOrders Z. 190–206), server/routes/abo.js (withdrawFeature Z. 1091: o.vertragAt || o.orderedAt), test/publicForms.test.js (neuer Test neben „Widerruf Funktion…“ Z. 515).
Consumes: B3 Schema, B1 featureConfig.
Produces: speichereZustimmung({ customerId, projectId, featureKey, stripeSessionId, fassungen, texte, ip, userAgent, konto, at }) ⇒ Promise<doc> (Plan-Kopf-Signatur; fassungen Objekt Name ⇒ Fassung, texte Array { id, text, haken } genau in Anzeigereihenfolge; speichert beides als JSON-Text, textHash = SHA-256 (hex) über JSON.stringify(texte), acceptedAt = at); letzteZustimmung({ customerId, projectId, featureKey }) ⇒ Promise<doc | null> (neueste nach acceptedAt); zustimmungFuerSitzung(stripeSessionId) ⇒ Promise<doc | null>; leseZustimmung(doc) ⇒ { …doc, fassungen: object, texte: array }; texteHash(texte) ⇒ string; zustimmungsfassungen(feature) ⇒ { zustimmung, leistungsbeschreibung, avv?, widerruf? } (jeweils die version des Registerfelds, falls vorhanden) und nachweisTexte(feature, { belehrung = null } = {}) ⇒ [{ id, text, haken }] (alle consent.items in Anzeigereihenfolge, dann – falls übergeben – { id: 'belehrung', text: belehrung, haken: false } (der gezeigte Belehrungstext samt Muster-Formular, Review Focus 5; die Route holt ihn über featureHaken(key).belehrung), zuletzt { id: 'knopf', text: consent.knopf, haken: false }). speichereZustimmung erlaubt leeres stripeSessionId, ip und userAgent sowie fehlendes haken (Nachweis der Staff-Aktivierung aus F13a); zustimmungFuerSitzung('') (leer/undefined) liefert null ohne Abfrage (sonst träfe es die F13-Zeilen über idx_session). paidFeatureOrders liefert zusätzlich featureKey und vertragAt (= acceptedAt der Zustimmung zur bezahlten Sitzung, sonst doc.consentAt, sonst orderedAt; Lesefehler ⇒ Rückfall auf orderedAt mit Log, die Widerrufsprüfung darf an der Nachweis-Collection nie scheitern). Das Modul exportiert bewusst nur diese Lese-/Anfügefunktionen (keine Änderung, kein Löschen).
- Tests (
featureConsents.test.js): „speichert und liest“ (Rundlauf,fassungen/texteals Objekt/Array zurück,textHash== unabhängig berechneter SHA-256); „append-only“ (Object.keys(import)genau die sieben ExportespeichereZustimmung,letzteZustimmung,zustimmungFuerSitzung,leseZustimmung,texteHash,zustimmungsfassungen,nachweisTexte; keinupdate/delete/remove); „neueste Zustimmung“ (zwei Einträge,atverschieden,letzteZustimmungliefert den späteren, anderes Projekt/andere Funktion stört nicht); „zustimmungFuerSitzung“; „vollständig oder gar nicht“ (texte> 20 000 Zeichen ⇒ wirft 400, nichts gekürzt; fehlendes/ungültigesat⇒ wirft; leeretexte⇒ wirft); „IP/UA/Konto gekürzt auf 64/300/255“; „Nachweistexte aus dem Register“ (nachweisTexte(featureConfig('termine'))hat fünf Einträge (vier Haken +knopf), letzterknopf; mit{ belehrung: 'Text' }sechs,belehrungdirekt vorknopf,haken false); „Staff-Nachweis“ (stripeSessionId: '',ip: '',userAgent: '', Text ohnehaken⇒ gespeichert;zustimmungFuerSitzung('')⇒nullund keine Listenabfrage anfeatureConsents,zustimmungFuerSitzung(undefined)ebenso).publicForms.test.js: „Widerruf Funktion: Fristanker ist die Zeit des Klicks der bezahlten Sitzung“: DokumentorderedAt= vor 1 Tag (durch Neubestellung überschrieben), Zustimmung zur bezahlten Sitzung vor 13 Tagen ⇒ T09 nennt das Fristende vonwithdrawalDeadline(vor 13 Tagen)undRECHTZEITIG, mit Zustimmung vor 20 Tagen stattdessenNACH Fristende(je ein Testfall); eine spätere, unbezahlte Zustimmung desselben Kunden/Projekts (andere Sitzung) verschiebt den Anker nicht (Block F9 stützt sich aufvertragAt/featureKeyaus diesem Task, nicht aufletzteZustimmung); Nachweis-Collection kaputt (failNextauffeatureConsents) ⇒ Rückfall auforderedAt, Antwort 200. - FAIL → implementieren → PASS →
npm test→ Commit „Zustimmungsnachweis je Funktion (featureConsents) und Fristanker aus dem Klick“
Task B5: Schnittstellen-Stubs für Block E und C (aboStatus, fragebogen)
Files: Create server/services/aboStatus.js, server/services/termine/fragebogen.js (Stub), test/aboStatus.test.js, test/termineFragebogenStub.test.js.
Consumes: getCurrentSubscription, ACTIVE_STATUSES aus stripeBilling.js (Z. 24, 178).
Produces (Plan-Kopf-Signaturen; Block E1 erweitert die Datei aboStatus.js (Modify, die Marke KERNVERSION entfällt) und zieht hasWartungsAbo aus analyse.js:43-46 hinein; Block C ersetzt den Stub):
aboStatus.js(Kopfkommentar// KERNVERSION (Block B) – Block E baut aus; fail-closed, kein Stub im Sinn von „durchlassen“):laeuft(customerId) ⇒ Promise<boolean>(productKey || 'wartung'=='wartung'und Status ∈ACTIVE_STATUSES; wirft bei Lesefehler),bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>(Reihenfolge der Gründe, einmal für alle Blöcke: nichtlaeuft⇒kein_abo;cancelAtPeriodEnd || cancelAt⇒gekuendigt; Status ≠active⇒nicht_aktiv),aboMemo() ⇒ { laeuft, bestellbar }(Zwischenspeicher je Aufruf vonaboMemo()undcustomerId).termine/fragebogen.js(Stub, Kopfkommentar// STUB (Block B) – ersetzt Task C…):bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }>gibt[]zurück (Signatur wie im Kopf, endgültig).- Tests (
aboStatus.test.js; Testaufbau laut Kopf:seedFixturesgibtc1nur ein Hosting-Abo, für Wartungsfälle überschreibt der Test es mitaw.seed('subscriptions', subscriptionDoc('sub_h', { ...wartungDoc }))): Wartungs-Aboactive⇒laeufttrue,bestellbar { ok: true }; Hosting-Abo ⇒ false /kein_abo; Dokument ohneproductKeyzählt als Wartung (wieabo.js:68);canceled⇒ false /kein_abo;past_dueundtrialing⇒laeufttrue,bestellbar { ok: false, grund: 'nicht_aktiv' };cancelAtPeriodEndundcancelAt⇒gekuendigt, auch beitrialingundpast_due(Kündigung vor Status); kein Abo (c3) ⇒kein_abo; Lesefehler (aw.failNext({ collection: 'subscriptions', status: 500 })) ⇒ beide Funktionen werfen (niefalse/ok:false); Memo: zweiter Aufruf desselben Kunden aus demselbenaboMemo()kommt aus dem Speicher (ein danach eingeplantesfailNexttrifft ihn nicht, ein frischerlaeuft('c1')schon), andereraboMemo()liest neu.termineFragebogenStub.test.js: Stub liefert[]und trägt die Marke (Block C löscht diese Datei mit dem Stub). - FAIL → implementieren → PASS →
npm test→ Commit „Abo-Baustein (Kernversion) und Platzhalter fuer die Bestellpruefung“
Task B6: Checkout-Route, Teil 1 – Vorprüfungen in Spec-Reihenfolge
Files: Modify server/routes/funktionen.js (POST /news/checkout Z. 137–181 wird POST /:key/checkout; fail Z. 35–40 gibt zusätzlich err.body weiter), server/services/featureRegistry.js (featureSperre). Create server/services/featureOrder.js, test/funktionenCheckout.test.js; Modify test/kundenansichtGuard.test.js (Z. 118: ['POST','/api/funktionen/termine/checkout'] in die Negativliste).
Consumes: B1 featureConfig/oeffentlicheAnsicht/featureHaken, B5 aboMemo, bestellpruefung, ladeEinstellung (termine/kontext.js:75), termineFuerKunde (schalter.js:22), assertOwnProject.
Produces: Vertrag der Route POST /api/funktionen/:key/checkout, Body { projectId, zustimmung: { version, haken: [id, …] } } (News zusätzlich wie heute { projectId, consent: true }, gilt nur für Funktionen mit genau einem Haken-Item) oder { projectId, fortsetzen: true } („Zahlung fortsetzen“ aus der Statuskarte: die Zustimmungsprüfung (3) entfällt nur, wenn das Dokument nach (4) ordered ist, sonst 400 zustimmung_fehlt; Antwort und Nachweisregel in B7; enthält der Body beides, gilt zustimmung); featureOrder.js: pruefeZustimmung(feature, eingabe) ⇒ { akzeptiert: [id] } (wirft 400 zustimmung_fehlt, 409 fassung_veraltet mit err.body.consent = oeffentlicheAnsicht(key).consent), aboMeldung(feature, grund) ⇒ string (exportiert aus featureOrder.js; die Quelle der Abo-Hinweistexte, Du-Form, Akkusativ aus feature.mails.formen.akk; in den Wortlauten steht „den Terminplaner“ für formen.akk: kein_abo „Für den Terminplaner brauchst du ein laufendes Wartungs-Abo.“, gekuendigt „Dein Wartungs-Abo ist gekündigt. Für den Terminplaner brauchst du ein laufendes Abo.“, nicht_aktiv „Dein Wartungs-Abo ist gerade nicht aktiv (zum Beispiel wegen einer offenen Zahlung). Sobald es wieder aktiv ist, kannst du den Terminplaner bestellen.“, unbekannt „Das Abo konnte gerade nicht geprüft werden.“; Block C und E definieren keine eigenen Wortlaute), ladeEntwurf(featureId) ⇒ { einstellung, fragebogen } (ladeEinstellung + tolerantes Parsen von termineConfig.fragebogen, fehlt/kaputt ⇒ null), orderDeps = { aboMemo } (Test-Einhängepunkt), featureSperre(key, email) ⇒ string | null (in featureRegistry.js; featureHaken(key).verfuegbar, bei false die unavailableMessage; Standard-Haken verfuegbar, bestellpruefung und unterlagenBereit (Standard true) werden hier gefüllt). Prüfreihenfolge, jede Stufe ohne Seiteneffekt: (1) req.session.viewAs ⇒ 403 „In der Kundenansicht kann nicht bestellt werden.“; (2) featureConfig(key) fehlt ⇒ 404 „Unbekannte Funktion“; (3) Zustimmungen vollständig und Fassung == Serverfassung (400/409 wie oben); bei fortsetzen: true ohne zustimmung übersprungen; (4) projectId Text ⇒ sonst 400 „Bitte wähle ein Projekt.“; assertOwnProject ⇒ sonst 403; bei fortsetzen: true danach: getFeatureDoc nicht ordered ⇒ 400 zustimmung_fehlt; (5) featureSperre ⇒ 409 nicht_verfuegbar; (5b) featureHaken(key).unterlagenBereit() falsch ⇒ 503 unterlagen_fehlen „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“ (Standard immer bereit; F8 hängt featureLegalReady ein, vor jeder Stripe-Sitzung); (6) featureHaken(key).bestellpruefung({ customerId, projectId, feature }) (der Kontext enthält kein abo) ⇒ nicht leer ⇒ 409 bestellung_offen mit body.offen (Standard für termine: getFeatureDoc → ladeEntwurf(doc.$id), ohne Dokument einstellung/fragebogen = null, dann bestellpruefung({ einstellung, fragebogen, feature, mitAbo: false }) aus termine/fragebogen.js, ohne abo); (7) requiresWartungsAbo: aboMemo().bestellbar(customerId) ⇒ nicht ok ⇒ 409 mit code und body.grund = kein_abo | gekuendigt | nicht_aktiv (ohne Präfix, derselbe Code wie in Block C) und Text aus aboMeldung; Lesefehler ⇒ 500 mit generischem Text (fail-closed). Regel für den Abo-Punkt (h) des Gates (verbindlich für B6 und C2): der Checkout wendet (h) nicht an; der Standard-Haken ruft mit mitAbo: false und ohne abo, und auch der überschreibbare Haken config.features.termine.bestellpruefung bekommt kein abo. Die Abo-Stufe (7) ist damit die einzige Quelle der Checkout-Codes kein_abo | gekuendigt | nicht_aktiv und bleibt erreichbar: ein Kunde ohne Abo mit vollständigem Fragebogen bekommt 409 kein_abo, nie bestellung_offen mit Schritt bestellen; mit unvollständigem Fragebogen kommt zuerst bestellung_offen (ohne Eintrag bestellen, die Abo-Stufe wird nicht gelesen). Nur die Vorschau (GET /api/termine/einstellungen, C4a) übergibt abo und zeigt (h) als offenen Punkt.
- Tests (
funktionenCheckout.test.js, jede Ablehnung prüft zusätzlich:st.checkoutSessions.size === 0, keinfeatureConsents-Dokument, keinportalFeatures-Dokument): „Kundenansicht ⇒ 403“ (CookiekundenansichtCookie(), Testanwendung ohne Wächter, gültiger Body); „Wächter sperrt zusätzlich“ (kundenansichtGuard.test.js: die vorhandene SchleifewriteRoutes(MOUNTS)trifft/api/funktionen/x/checkoutautomatisch ⇒ 403view_blocked; Negativliste um den Termine-Pfad ergänzt); „unbekannter Schlüssel“ (analyse,__proto__,nope⇒ 404); „Zustimmung“: fehlt/leer ⇒ 400zustimmung_fehlt; ein Pflicht-Haken fehlt ⇒ 400; unbekannte Haken-ID ⇒ 400; falsche Fassung ⇒ 409fassung_veraltetmitconsent.version== Serverfassung und allen Item-Texten;consent: truebei Termine (vier Haken) ⇒ 400, bei News ⇒ weiter; „Projekt“: ohneprojectId400, fremd (p5vonc2) 403; „Schalter“:mode 'off'⇒ 409 „Der Terminplaner ist noch nicht verfügbar.“,pilotmit fremder Adresse ⇒ 409, News unabhängig vom Modus; „Reihenfolge“:config.features.termine.bestellpruefungaufasync () => [{ schritt: 'zeiten', text: 'Zeiten bestätigen' }]undorderDeps.aboMemoauf einen Zähler ⇒ 409bestellung_offenmitoffen[0].schritt === 'zeiten'und Zähler 0; danach Pruefung[]⇒ Zähler 1; der Haken erhält ein Objekt ohneabo-Eigenschaft ('abo' in arg === false); „Abo-Gründe“ (Testaufbau laut Kopf,seedFixtures-Falle):c3(kein Abo) undc2(Hosting) ⇒kein_abo, Kündigung vorgemerkt ⇒gekuendigt,past_dueohne Kündigung ⇒nicht_aktiv,past_duemit Kündigung ⇒gekuendigt, Lesefehler ⇒ 500 ohne Rohtext;nicht_aktiv-Text == der volle Wortlaut oben (endet auf „bestellen.“); mit leerer Prüfung ([], Stub oder echtes Gate) und Kunde ohne Abo ⇒ 409kein_abo, nichtbestellung_offen; „Unterlagen“:config.features.termine.unterlagenBereit = async () => false⇒ 503unterlagen_fehlen, Zähler 0, Reihenfolge (5) → (5b) → (6) im Reihenfolge-Test; „fortsetzen“:{ projectId, fortsetzen: true }beioffoder ohne Dokument ⇒ 400zustimmung_fehlt, kein Dokument entstanden; bei fremdem Projekt ⇒ 403; „News braucht kein Abo“ (c3bestellt News weiter nach Teil 2). - FAIL → implementieren → PASS →
npm test→ Commit „Checkout je Funktion: Vorpruefungen in der Reihenfolge der Spezifikation“
Task B7: Checkout-Route, Teil 2 – Sperre, Zustandsprüfung, Nachweis vor der Weiterleitung
Files: Modify server/routes/funktionen.js (Rest der Route, openOrderSession Z. 121–135 mit Meldung aus dem Register), server/services/siteFeatures.js (markOrdered Z. 208–226: Meldungen aus dem Register, consentText aus den Haken-Texten), server/services/featureRegistry.js (Standard-Haken belehrung, BELEHRUNG_PLATZHALTER), test/funktionenRoutes.test.js (Schema featureConsents, neue News-Fälle), test/funktionenCheckout.test.js.
Consumes: B2 createFeatureCheckout, B4 speichereZustimmung/zustimmungFuerSitzung/zustimmungsfassungen/nachweisTexte, B6.
Produces: Ablauf unter withLocks(['feature-order:<customer>:<project>']) (Eingangszeit klickAt = new Date().toISOString() vor der Sperre): (a) getFeatureDoc: setup/active ⇒ 409 feature.orderedConflictMessage, disabled ⇒ 409 feature.disabledMessage; (b) ordered ⇒ openOrderSession (offene Sitzung wiederverwenden, expired ⇒ neu, complete oder Stripe nicht erreichbar < 24 h ⇒ 409); bei Body fortsetzen: true (ohne zustimmung, „Zahlung fortsetzen“) wird eine offene Sitzung nur zurückgegeben, wenn zustimmungFuerSitzung(session.id) einen Nachweis kennt (dann weder zweite Stripe-Sitzung noch zweiter Nachweis); fehlt der Nachweis oder bräuchte es eine neue Sitzung (expired), antwortet die Route 409 zustimmung_fehlt ohne URL, und die Bestellseite zeigt das Formular, in dem der Kunde erneut zustimmt; (c) neue Sitzung: createFeatureCheckout(customer, { projectId, key, consentAt: klickAt }); (d) zustimmungFuerSitzung(session.id) leer ⇒ speichereZustimmung({ …, stripeSessionId: session.id, fassungen: zustimmungsfassungen(feature), texte: nachweisTexte(feature, { belehrung: (await featureHaken(key).belehrung?.()) ?? null }), ip: req.ip, userAgent: req.get('user-agent'), konto: 'Name <E-Mail>' (max. 255), at: klickAt }) — auch bei Wiederverwendung einer offenen Sitzung, falls für sie noch kein Nachweis liegt; (e) erst danach markOrdered({ customerId, projectId, key, sessionId, consent: { text, at: klickAt, ip, userAgent } }); (f) Antwort { url }. Gespeichert werden die Serverfassungen und -texte, nie Texte aus dem Client. Scheitert (d), gibt es keine URL und kein ordered (die offene, nicht erreichbare Stripe-Sitzung läuft nach 24 h ab; kein Stripe-Storno nötig). Belehrungs-Haken: B7 füllt den Standard-Haken featureHaken('termine').belehrung() in featureRegistry.js mit dem Platzhalter BELEHRUNG_PLATZHALTER = 'ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.' (exportiert; F7 ersetzt ihn durch widerrufsbelehrungFunktion()). Damit hat der Nachweis ab B7 immer einen Eintrag belehrung (haken false, direkt vor knopf) und die Bestellseite (C2, C8, C11) zeigt vor dem Knopf einen Belehrungstext; news hat keinen belehrung-Haken (kein solcher Eintrag, Verhalten unverändert).
- Tests, Review Focus #5 (
funktionenCheckout.test.js, Termine): „Nachweis steht vor der Weiterleitung“ – gültiger Aufruf ⇒ 200{ url }; genau einfeatureConsents-Dokument mitstripeSessionId== der Stripe-Sitzung,texte(geparst) deepEqualnachweisTexte(featureConfig('termine'), { belehrung: (await featureHaken('termine').belehrung?.()) ?? null })in Reihenfolge,fassungen==zustimmungsfassungen(…),textHash== SHA-256 über die gespeicherten Texte,ip/userAgent/kontogesetzt; ein Zeitpunkt:acceptedAt==metadata.consentAtder Stripe-Sitzung ==portalFeatures.consentAt, zwischen den vor/nach dem Aufruf genommenen Zeiten; „ohne Nachweis keine URL“ –aw.failNext({ collection: 'featureConsents', method: 'POST', status: 500 })⇒ 500, Antwort ohneurl, Dokument nichtordered(fehlt oderoff),orderedAtleer; „Belehrung im Nachweis“ –texteendet auf[…, { id: 'belehrung', text: BELEHRUNG_PLATZHALTER, haken: false }, { id: 'knopf', … }], der News-Nachweis hat keinenbelehrung-Eintrag; „Client-Texte zählen nicht“ – Body mit zusätzlichemtexte: ['manipuliert']⇒ gespeichert werden die Registertexte; „Fassungswechsel“ –config.features.termine.consent.versionzur Laufzeit ändern ⇒ alter Body 409, neuer Body 200 mit neuer Fassung im Nachweis; „Wiederverwendung“ – zweiter Aufruf bei offener Sitzung ⇒ gleiche URL, keine zweite Stripe-Sitzung, kein zweites Nachweis-Dokument; Nachweis der Sitzung per Test gelöscht ⇒ zweiter Aufruf legt einen neuen an, bevor die URL zurückkommt; „Zahlung fortsetzen“ ({ projectId, fortsetzen: true }, Review Focus 5) – Dokumentorderedmit Nachweis zur offenen Sitzung ⇒ 200 mit derselben URL, keine zweite Stripe-Sitzung, kein zweitesfeatureConsents-Dokument; Nachweis per Test gelöscht ⇒ 409zustimmung_fehlt, keine URL, danach derselbe Aufruf mitzustimmung⇒ 200, Nachweis wieder da (ein Dokument); abgelaufene Sitzung mitfortsetzen⇒ 409zustimmung_fehlt, mitzustimmung⇒ neue Sitzung; „abgelaufene Sitzung“ ⇒ neue Sitzung und neuer Nachweis (zwei Dokumente, zwei Sitzungs-IDs); „parallele Klicks“ (Promise.all×2) ⇒ eine Sitzung, ein Nachweis, beide 200 mit gleicher URL; Zustände:setup/active⇒ 409orderedConflictMessage,disabled⇒ 409disabledMessage,complete⇒ 409, nirgends neue Sitzung; Datenbankfehler beimmarkOrdered(failNextPATCHportalFeatures) ⇒ 500 mit „Bestellung konnte gerade nicht gestartet werden“, ohne Rohtext. News (funktionenRoutes.test.js, Schema umfeatureConsentsergänzt, alle bisherigen Tests unverändert grün): zusätzlich „News schreibt auch einen Nachweis“ (texte== dieconsent.itemsvonconfig.features.news+ Knopf,fassungen { zustimmung: config.features.news.consent.version, leistungsbeschreibung: config.features.news.leistungsbeschreibung.version }– gegenconfigstatt Literale, damit Block F2 die Fassung ohne Testbruch anhebt –,portalFeatures.consentTextweiter ==config.funktionen.consentText, Widerrufs-Anker überpaidFeatureOrders().vertragAt). - FAIL → implementieren → PASS →
npm test→ Commit „Checkout je Funktion: Zustimmungsnachweis vor der Weiterleitung, Zustaende aus dem Register“
Task B8: Ticket aus dem Register (Deckel, Notfall-Details) – alleinige Besitzerin von createFeatureTicket
Files: Modify server/services/siteFeatures.js (markPaid Z. 243–270), server/services/ticketAdmin.js (createFeatureTicket Z. 75–109), server/services/featureRegistry.js (ticketTexte, minimaleTicketDetails, anleitungsLink, kuerze, Konstanten TICKET_TOPIC_MAX = 128, TICKET_DETAILS_MAX = 7000, LANGE_DETAILS = 1000). Create server/services/termine/ticketDetails.js (Stub), test/featureTicket.test.js. Der bestehende Test webhook_retry_setzt_ticketlose_einrichtung_fort (test/funktionenRoutes.test.js:321-345) bleibt unverändert grün. Block D ändert createFeatureTicket nicht mehr (D5 entfällt).
Consumes: B1.
Produces: ticketTexte(ctx) ⇒ Promise<{ topic, details, fallbackDetails }> mit ctx = { feature, doc, project, customer } (feature = Register-Eintrag, doc = portalFeatures-Dokument, project = websiteProjects-Dokument, customer best effort oder null; now setzt Block D4 selbst im Rumpf): topic auf TICKET_TOPIC_MAX gekürzt, details auf TICKET_DETAILS_MAX (Schnitt an der letzten Zeilengrenze, eine einheitliche Schlusszeile „… gekürzt, vollständig im Ticketsystem unter Kunden → Funktionen“); wirft feature.ticket.details oder liefert es Leeres/Nicht-Text, gilt fallbackDetails. kuerze(text, max, { anZeile = false, endzeile = '' }) kürzt surrogatsicher (nie ein halbes Zeichenpaar). minimaleTicketDetails(ctx) = Kopfzeilen (Funktion, Projekt, Domain = liveDomain || previewUrl || subdomain, Kunde, siteKey) + Verweis: ${anleitungsLink(doc.customerId, doc.projectId)}. anleitungsLink(customerId, projectId) = ${config.womsBaseUrl}/customers/<id>?tab=funktionen&anleitung=<encodeURIComponent(projectId)> (der Reiter funktionen existiert in W/src/lib/customerTabs.js; das WOMS-Panel öffnet sich mit anleitung= automatisch, D10; B9b (T16 anleitung_url) und D3 nutzen dieselbe Funktion). createFeatureTicket({ …, topic, details, fallbackDetails }): topic und details bleiben optional mit den heutigen News-Festwerten als Rückfall (direkte Aufrufer und die Analyse bleiben wirkungsgleich; markPaid übergibt immer die Registertexte), kürzt beides selbst; Wiederholung nur, wenn createDocument mit status 400 scheitert und fallbackDetails übergeben wurde und details.length > LANGE_DETAILS (dann einmal mit fallbackDetails, gleiche woid); kurze Details (News, Standardtext) und jeder andere Fehler (5xx, Netz: unklar, ob das Ticket entstand, sonst Doppel-Tickets) werden ohne Wiederholung weitergereicht; scheitert auch der Fallback, wird der erste Fehler geworfen (markPaid bleibt dann setup ohne ticketId, Stripe wiederholt – unverändert). markPaid lädt den Kunden (best effort) und übergibt Register-topic/details/fallbackDetails. termine/ticketDetails.js: termineTicketDetails(ctx) ⇒ Promise<string> = minimaleTicketDetails + Zeile „Einrichtung bezahlt (149 €), Zeit nicht auf das Stundenkontingent anrechnen.“ (Kopfkommentar // STUB (Block B) – ersetzt Task D4; Block D4 ersetzt den Inhalt durch einbauanleitung(…).kurz, Signatur und ctx bleiben).
- Tests (
featureTicket.test.js; die bisherigen News-Tests insiteFeatures.test.js/funktionenRoutes.test.jsbleiben unverändert grün): „News-Ticket byte-gleich“ (topic === 'News-Pinnwand einbauen – Bäckerei',details== die 6 Zeilen des heutigen Defaults, einschließlichAnleitung: docs/funktionen/news-einbau.md (Repo Webklar-Kundenbereich)); „Termine-Ticket aus dem Register“ (topicbeginntTerminplaner einbauen –,detailsenthältsiteKeyund…?tab=funktionen&anleitung=<projectId>); „Deckel“ (Projektname 255 Zeichen ⇒topic.length <= 128; 100 × „😀“ als Projektname ⇒ gültige Zeichenkette ohne halbes Surrogat;details-Funktion liefert 20 000 Zeichen ⇒ gespeichert ≤ 7000, Schnitt an einer Zeilengrenze, endet auf der Schlusszeile; genau 7000 Zeichen unverändert); „Notfall“:details-Funktion wirft ⇒ Ticket entsteht mitminimaleTicketDetails(Geld ohne Spur ausgeschlossen); „Wiederholung nur bei langen Details“: Details mit 5000 Zeichen undaw.failNext({ collection: 'workorders', method: 'POST', status: 400 })einmal ⇒ zweiter Versuch mitfallbackDetails(Kopf + Link, ohne Snippet), genau ein Dokument inworkorders, gleichewoid; zweimal 400 ⇒markPaidwirft den ersten Fehler, DokumentsetupohneticketId, eine spätere Wiederholung legt genau ein Ticket an; dieselbe Lage mitstatus: 503⇒ wirft ohne Wiederholung,workordersunverändert (kein Doppel-Ticket); kurze Details (News/Standard) + einmal 400 ⇒ wirft, keine Wiederholung (das ist der bestehende Testwebhook_retry_setzt_ticketlose_einrichtung_fort: Webhook 500, danach 200); „Termine übermarkPaid“ (lange Kurzfassung + 400 ⇒ Ticket mit Minimal-Details,created: true, K15/T16 gehen raus); „Analyse unberührt“ (createFeatureTicketmit eigenemtopic/detailsohnefallbackDetails⇒ eincreateDocument-Aufruf, Text unverändert, Deckel nur bei Überlänge); „Defaults“ (ohnetopic/details⇒ der heutige News-Text). - FAIL → implementieren → PASS →
npm test→ Commit „Funktions-Ticket: Text aus dem Register, Laengendeckel, Notfall-Details und gezielte Wiederholung“
Task B9a: Mails K15 und K16 je Funktion (News-Wortlaut zuerst eingefroren)
Files: Modify server/mail-templates/K15 Bestellung Funktion.html, K16 Funktion bereit.html; Modify server/services/siteFeatures.js (K15-Versand Z. 530–546 in onFeaturePaid, runActivation Z. 578–593, portalUrl Z. 406); Modify test/mailTemplates.test.js (Platzhalter-/Blocklisten-Tests für K15 und K16). Create test/featureMails.test.js (K15/K16-Teil; B9b ergänzt T16 und T19).
Consumes: B1 (mails.*, formen, featureConfig).
Produces: Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): K15 kunde_name, projekt, funktion, funktion_gen, funktion_akk, funktion_nom, funktion_pron, preis, preis_hinweis, zeit, danach, portal_url, rechnung_url, Block rechnung; Betreff „Deine Bestellung: {{funktion}}“; die News-Texte werden 1:1 aus den Platzhaltern erzeugt (z. B. „Wir richten {{funktion_akk}} jetzt für Dein Projekt ein“, „Sobald {{funktion_nom}} auf Deiner Website eingebaut ist, schalten wir {{funktion_pron}} im Kundenportal frei … {{danach}}“, Preiszeile „{{preis}} ({{preis_hinweis}})“, Knopf „Zum Kundenportal“ ⇒ {{portal_url}}); der Spec-Block „kein Abo“ entfällt zugunsten des Registerwerts preisHinweis. K16 kunde_name, projekt, funktion, funktion_dein, funktion_nom, bereit, knopf, portal_url; Betreff „{{funktion_dein}} ist bereit“. Aufrufstellen: K15/K16 füllen alle Werte aus featureConfig(doc.featureKey), eur(config.newsSetup.amountCents) wird eur(catalog.amountCents) je feature.product, portal_url = ${publicBaseUrl}/dashboard.html#${returnHash}. Anhänge: K15 geht in B9a ohne Anhänge raus; die PDF-Anhänge der Termine-Bestellung hängt F8 direkt in onFeaturePaid an (featureLegalAttachments(doc.featureKey), lazy importiert; es gibt keinen Registerhaken mails.anhaenge).
- Zuerst Charakterisierung (vor dem Umbau der Vorlagen):
renderMailmit den heutigen News-Werten für K15 (mit/ohnerechnung) und K16 ausführen,subjectundtextals Literale intest/featureMails.test.jseinfrieren („News-Mails bleiben wörtlich“, u. a. „Wir richten die News-Pinnwand jetzt für Dein Projekt ein.“, „(brutto, einmalig; die Nutzung ist kostenlos, es läuft kein Abo)“, „Danach kannst Du Beiträge im Portal schreiben.“, „Deine News-Pinnwand ist bereit“, Knopf „Beitrag schreiben“, Link…/dashboard.html#news). Danach Vorlagen umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-Mails“ (K15/K16 mit Termine-Werten enthalten weder „News“ noch „Beitr“; Betreff „Deine Bestellung: Terminplaner“, „Dein Terminplaner ist bereit“; Preis „149,00 €“; K15-Link…#termine; Knopf „Termine öffnen“); „Platzhalter-/Blocklisten exakt“ (loadTemplate('K15')undloadTemplate('K16'):.placeholders/.blocksdeepEqual den Listen oben, die Schleifen inmailTemplates.test.jsdecken Rendern mit/ohne Blöcke ab); „K15 ohne Anhänge“ (Termine-Bestellung in B9a ⇒ K15 trägt keinen Anhang, der Versand ruft keinen Registerhaken). - FAIL → implementieren → PASS →
npm test(alle vorhandenen Mail-Tests infunktionenRoutes.test.jsZ. 150, 166, 199, 238, 339, 371 grün) → Commit „Mails K15/K16 je Funktion (News-Wortlaut eingefroren)“
Task B9b: Mail T16 je Funktion und neue Vorlage T19 Erstattung fällig
Files: Modify server/mail-templates/T16 Funktion bestellt.html; Create server/mail-templates/T19 Erstattung faellig.html (Layout von T16, Footer wie T16); Modify server/services/siteFeatures.js (teamNotice Z. 456, unmatchedNotice Z. 472), test/mailTemplates.test.js (Kopfkommentar Z. 8–9: „K15, K16, T16 Funktionen“ um „T19 Erstattung fällig“ ergänzen; EXPECTED Z. 11: [...ids('G', 8), ...ids('K', 21), ...ids('T', 19), 'T20'] (A10a1 hat 'T20' angehängt, nicht überschreiben; E3 hebt nur die K-Zahl); TEAM_FOOTER Z. 489 + 'T19'; Blockliste/Platzhalter-Tests für T16 und T19), test/featureMails.test.js (aus B9a: T16/T19-Teil dazu).
Consumes: B1 (mails.*, featureConfig), B8 (anleitungsLink), B9a (featureMails.test.js).
Produces: Platzhalter/Blöcke (Kopfkommentar der Vorlagen, Reihenfolge wie hier): T16 kunde_name, email, projekt, funktion, woid, sitekey, sitzung, zustand, anleitung_url, gesundheit_text; Blöcke zahlung_fehlgeschlagen, ungeklaert, kein_abo, bereits_aktiv, anleitung, gesundheit; Betreff „Neue Funktion bestellt: {{funktion}}“; Text kein_abo: Zahlung da und Ticket angelegt, aber kein laufendes Wartungs-Abo, Freischaltung bleibt gesperrt, endet das Abo vor Fertigstellung ⇒ volle Erstattung (T19); Text bereits_aktiv: „Zahlung eingegangen, aber die Funktion war schon {{zustand}}. Es wurde kein Ticket angelegt und nichts eingerichtet. Bitte prüfen: Einbau nachholen oder erstatten. Sitzung: {{sitzung}}“; anleitung = Knopf „Im Ticketsystem öffnen“ ⇒ {{anleitung_url}} (= anleitungsLink(doc.customerId, doc.projectId) aus B8, also …?tab=funktionen&anleitung=<projektId>, nur bei mails.anleitung); gesundheit = Warnzeile „Gesundheitsbereich prüfen: {{gesundheit_text}}“ (Spec 3.5; B deklariert Platzhalter und Block, sie bleiben bis D4 aus; Block D4 füllt sie in teamNotice aus gesundheitsWarnungen). Der T16-Betreff „…: News-Pinnwand“ ist in test/funktionenRoutes.test.js Z. 150, 339, 371 gepinnt und bleibt für News so. T19 (Betreff „[Abo] Erstattung fällig: {{funktion}}-Einrichtung“, passt zur Betreff-Regel für T-Vorlagen in mailTemplates.test.js Z. 28): kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url, Blöcke zahlung_nach_ende, ticket_offen, rechnung; B9b ist die einzige Besitzerin von T19 (Block E legt sie nicht noch einmal an; E5 und E10 senden sie über meldeErstattung, B10b). Inhalt: der Kunde hat gezahlt (Betrag, Sitzung, Rechnung), das Wartungs-Abo lief nicht mehr bzw. endete vor der Freischaltung ⇒ volle Erstattung von Hand in Stripe (keine automatische Rückzahlung); Ticket mit Vermerk schließen (die Freischaltung beim Schließen schaltet dann nicht frei, weil das Dokument disabled ist); zahlung_nach_ende: „Die Zahlung ist erst nach dem Abo-Ende eingegangen, es wurde kein Ticket angelegt“; ticket_offen: das Ticket {{woid}} ist noch offen. Aufrufstellen: T16 füllt alle Werte aus featureConfig(doc.featureKey) (unmatchedNotice ohne bekannten Schlüssel: funktion = 'Funktion', Rest leer); eur(...) und portal_url wie in B9a. Exportiert wird meldeErstattung erst in B10b.
- Zuerst Charakterisierung (vor dem Umbau der Vorlage):
renderMailmit den heutigen News-Werten für T16 (Normal,zahlung_fehlgeschlagen,ungeklaert) ausführen,subjectundtextals Literale intest/featureMails.test.jseinfrieren („News-Mails bleiben wörtlich“: Betreff „Neue Funktion bestellt: News-Pinnwand“ und die Texte der drei Fälle). Danach die Vorlage umbauen; derselbe Test bleibt grün. Weitere Tests: „Termine-T16“ (enthält weder „News“ noch „Beitr“; Betreff „Neue Funktion bestellt: Terminplaner“); „Platzhalter-/Blocklisten exakt“ (loadTemplate('T16')undloadTemplate('T19'):.placeholders/.blocksdeepEqual den Listen oben,mailTemplates.test.js-Schleifen decken Rendern mit/ohne Blöcke ab); „T19 rendert beide Fälle“ (nurzahlung_nach_ende, nurticket_offen, mit Rechnungsknopf;problemsleer; Footer „Ticketsystem“); „T16 gesundheit“ (Block aus ⇒ kein Warntext, Block an mitgesundheit_text⇒ der Text steht in der Mail;anleitung_urlträgt&anleitung=<projektId>). - FAIL → implementieren → PASS →
npm test(alle vorhandenen Mail-Tests infunktionenRoutes.test.jsZ. 150, 166, 199, 238, 339, 371 grün) → Commit „Mail T16 je Funktion, neue Vorlage T19 Erstattung faellig“
Task B10a: Zahlungseingang in jedem Zustand 1 – markPaid meldet den Fall, Meldemarker (Review Focus #1)
Files: Modify server/services/siteFeatures.js (markPaid Z. 243–270); Tests test/siteFeatures.test.js (neue fall-Tests).
Consumes: B1, B8 (Ticket), A11 (portalFeatures.zahlungMeldung im Schema; activate() lässt es unberührt, nur E8 setzt es beim Zurücksetzen zurück).
Produces: markPaid(…) ⇒ { doc, created, fall } mit fall ∈ null | 'bereits_aktiv' | 'abgeschaltet' | 'abo_ende'. Unter der vorhandenen Sperre feature-paid:<sessionId> gilt für ein per Sitzung gefundenes Dokument: ordered oder fortsetzbares setup ⇒ wie bisher (created: true, fall: null); off ⇒ unverändert created: false, fall: null (die Übernahme adoptPaidSession bleibt zuständig); jeder andere Zustand (active, disabled, setup mit Ticket): hat das Dokument purchaseStatus !== 'paid', wird die Zahlung einmalig verbucht (purchaseStatus: 'paid', unlockedByPurchase: true, stripeInvoiceId, invoiceUrl, adminNote neuester Eintrag <ISO> System: Zahlung eingegangen (Sitzung …), nichts eingerichtet, 300 Zeichen) und im eigenen Feld zahlungMeldung der noch zu meldende Fall vermerkt (active und setup mit Ticket ⇒ 'bereits_aktiv'; disabled mit disabledReason === 'abo_ende_vor_einrichtung' ⇒ 'abo_ende'; sonstiges disabled ⇒ 'abgeschaltet'). fall ist der Wert von zahlungMeldung, solange er einer dieser drei ist; nach der erfolgreichen Meldung steht dort 'gemeldet' (B10b) und fall ist null. Eine Wiederholung des Ereignisses (Stripe-Retry, completed + async_payment_succeeded bei SEPA) bucht deshalb nichts doppelt (purchaseStatus bleibt 'paid'), liefert den Fall aber erneut, solange die Meldung noch nicht raus ist. Eine normal bezahlte Bestellung (Übergang aus ordered) hat zahlungMeldung '': ihr Folgeereignis liefert fall: null, auch wenn das Dokument inzwischen active ist. purchaseStatus wird nie mit Meldezuständen überladen (andere Stellen, z. B. die Widerrufsprüfung, lesen es).
- Tests (
siteFeatures.test.js,markPaiddirekt):fallje Zustand (active⇒bereits_aktiv,disabled/team⇒abgeschaltet,disabled/abo_ende_vor_einrichtung⇒abo_ende,setupmit Ticket ⇒bereits_aktiv), jeweilscreated: false,purchaseStatus 'paid',unlockedByPurchase true, Rechnung gespeichert,zahlungMeldung== Fall; zweiter Aufruf ⇒ nichts doppelt verbucht (adminNoteunverändert),fallbleibt gesetzt, biszahlungMeldungauf'gemeldet'steht, danachnull; normal bezahltes Dokument (ordered⇒setup⇒active), Folgeereignis ⇒fall: nullundzahlungMeldung '';off⇒created: false,fall: null, Dokument unverändert; zwei parallele Aufrufe ⇒ einmal verbucht. - FAIL → implementieren → PASS →
npm test→ Commit „Zahlungseingang: markPaid meldet den Fall und merkt ihn im Dokument“
Task B10b: Zahlungseingang in jedem Zustand 2 – Meldungen T16/T19, meldeErstattung (Review Focus #1)
Files: Modify server/services/siteFeatures.js (onFeaturePaid Z. 515–552, teamNotice, neu meldeErstattung, meldeZahlungFall, zahlungsNotiz; customerOf und projectName werden exportiert). Create test/featurePaid.test.js.
Consumes: B1, B5 laeuft, B8 (Ticket), B9a (K15), B9b (T16-Vorlage, T19), B10a.
Produces: onFeaturePaid: created ⇒ K15 + T16 wie bisher; zusätzlich feature.requiresWartungsAbo und await laeuft(customerId) nach der Verbuchung (Lesefehler ⇒ Log, kein Block, nie 500) falsch ⇒ T16-Block kein_abo. fall 'bereits_aktiv'/'abgeschaltet' ⇒ meldeZahlungFall(doc, fall, { customer }) = nur T16-Block bereits_aktiv (zustand „freigeschaltet“ / „in Einrichtung“ bzw. „abgeschaltet“, woid leer), console.error mit Sitzungs-ID, keine K15. fall 'abo_ende' ⇒ nur meldeErstattung(doc, { customer, nachAboEnde: true }), kein Ticket, keine K15. meldeErstattung(doc, { customer = null, sitzung = doc.stripeSessionId, nachAboEnde = false } = {}) ⇒ Promise<{ sent: boolean, schonGemeldet?: boolean }> sendet T19 (betrag = priceLabel, woid, rechnung_url, Blöcke zahlung_nach_ende, wenn nachAboEnde gesetzt ist oder doc.zahlungMeldung === 'abo_ende' (Zahlung nach dem Abo-Ende, so auch wenn der Abgleich vor dem Webhook-Versand zuerst meldet), ticket_offen, wenn doc.ticketId gesetzt ist (kein Ticket und keine späte Zahlung ⇒ keiner der beiden Blöcke), rechnung) und ist die einzige Stelle für T19: Block E5 ruft sie aus dem Abgleich (nachAboEnde: false), erstattungMelden o. Ä. gibt es nicht. Idempotenz (meldeErstattung und meldeZahlungFall): unter withLocks(['feature-meldung:' + doc.$id]) (nie innerhalb einer feature-paid:*-Sperre aufrufen, withLocks ist nicht wiederbetretbar) das Dokument frisch lesen; zahlungMeldung === 'gemeldet' ⇒ { sent: false, schonGemeldet: true } ohne Versand; sonst senden und erst bei sent === true zahlungMeldung: 'gemeldet' schreiben (auch wenn dort vorher ein offener Fall stand: die Wiederholung sendet kein zweites T19). sendAdminNotice wirft nie; die Funktionen werfen deshalb nicht selbst. onFeaturePaid wirft bei den Fällen bereits_aktiv/abgeschaltet/abo_ende, wenn weder sent noch schonGemeldet zutrifft: der Webhook antwortet 500, Stripe wiederholt, markPaid liefert den noch offenen Fall erneut (B10a) und der nächste Versuch sendet genau einmal. Die Mailfehler der Normalpfade (K15, T16 beim created) bleiben wie bisher gefangen. Annahme für E: der Abgleich Schritt 1b meldet T19 nur für bezahlte (setup) Dokumente; ein unbezahltes ordered wird nur abgeschaltet, die spätere Zahlung meldet dieser Task.
- Tests, Review Focus #1 (Testaufbau laut Kopf; für „Abo läuft“ überschreibt der Test
sub_hvonc1mitwartungDoc;featurePaid.test.js, Termine, echter Bestellweg über die Route odermarkOrdered, Webhook überpostWebhook; Hilfsprüfungspur():Tickets + Team-Mails ≥ 1undTickets ≤ 1nach jedem Fall): (1)ordered+ Abo läuft ⇒setup, ein Ticket „Terminplaner einbauen – …“, K15 „Deine Bestellung: Terminplaner“ an den Kunden, T16 genau einmal ohnekein_abo-Text; (2)ordered+ Abo beendet (canceled) ⇒setup, Ticket, K15, T16 mitkein_abo-Text; (3)laeuftscheitert (failNextaufsubscriptions) ⇒ Webhook 200, Ticket, K15, T16 ohnekein_abo; (4) Staff aktiviert dieordered-Bestellung (adminActivate), dann Zahlung ⇒ Dokument bleibtactive, kein Ticket, keine K15, T16 mitbereits_aktiv/„freigeschaltet“ genau einmal,purchaseStatus 'paid',unlockedByPurchase true, Rechnung gespeichert,adminNotebeginnt mit der System-Zeile,console.errormit Sitzungs-ID,zahlungMeldung 'gemeldet'; zweite Zustellung desselben Ereignisses und zwei parallele Zustellungen (Promise.all) ⇒ weiterhin genau ein T16; (5) Team schaltet dieordered-Bestellung ab, dann Zahlung ⇒disabledbleibt (disabledAtunverändert), T16 „abgeschaltet“ einmal, Wiederholung stumm; (6)disabledmitdisabledReason: 'abo_ende_vor_einrichtung'(geseedet) + Zahlung ⇒ T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“ einmal mit Blockzahlung_nach_ende, kein T16, kein Ticket, keine K15, Wiederholung stumm (auchasync_payment_succeedednachcompleted); (7) Stripe-Sitzung läuft ab (off), Zahlung kommt doch ⇒ Übernahme,setup, ein Ticket, K15 + T16 je einmal; (8) Neubestellung nach Ablauf, die alte Sitzung zahlt ⇒ Übernahme in die laufende Bestellung, ein Ticket; die neue Sitzung zahlt danach ⇒ T16 „ungeklärt“ (bestehendes Verhalten), kein zweites Ticket; (9) SEPA:completedohnepaid⇒ nichts,async_payment_succeeded⇒ wie (1),async_payment_failed⇒ T16zahlung_fehlgeschlagenmit „Terminplaner“; (10) Versandfehler in den Fallpfaden: T16/T19 scheitert beim Versand (SMTP wirft) ⇒ Webhook 500 (Stripe wiederholt), die Zahlung ist gebucht (purchaseStatus 'paid',adminNote-Spur),zahlungMeldungnoch offen; die Wiederholung (Versand geht) ⇒ genau eine Mail,zahlungMeldung 'gemeldet', weitere Wiederholungen stumm; K15/T16 beim normalencreatedscheitern weiterhin gefangen (Webhook 200); (11) News unverändert (alle bisherigen Fälle infunktionenRoutes.test.jsgrün, zusätzlich News + Staff-Aktivierung + Zahlung ⇒ T16bereits_aktiv, die heute stille Lücke); (12)meldeErstattungdirekt (der Aufruf aus Block E5): bezahltessetup-Dokument ⇒ ein T19 mit Blockticket_offen,zahlungMeldung 'gemeldet', zweiter und paralleler Aufruf ⇒schonGemeldet, nie zwei Mails; offener Fallabo_ende+ Aufruf des Abgleichs + Webhook-Wiederholung ⇒ insgesamt genau ein T19; Mail scheitert ⇒{ sent: false }, kein Marker, kein Wurf. - FAIL → implementieren → PASS →
npm test→ Commit „Zahlungseingang in jedem Zustand: nie Geld ohne Spur, nie doppelt (T16/T19)“
Task B11: Freischaltung über das Register (activateClosedTickets, freischaltbar) – alleinige Besitzerin
Files: Modify server/services/siteFeatures.js (activateClosedTickets Z. 359–387; findActiveBySiteKey unverändert), server/services/featureRegistry.js (ticketFreischaltungKeys, Standard-Haken freischaltbar für termine, mit dynamischen Importen von kontext.js und aboStatus.js), test/siteFeatures.test.js (Z. 235, Test „activateClosedTickets bleibt bei News (TP2)…“: er pinnt heute, dass nur news geöffnet wird; er wird auf „Funktionen mit ticketFreischaltung; News verhält sich unverändert“ umgestellt). Create test/featureFreischaltung.test.js. Block D6 ändert activateClosedTickets nicht mehr.
Consumes: B1, B5 laeuft, B9a (K16 je Funktion), ladeEinstellung.
Produces: ticketFreischaltungKeys() ⇒ string[] (Register-Schlüssel mit ticketFreischaltung: true); activateClosedTickets({ now, customerId }) filtert Query.equal('featureKey', ticketFreischaltungKeys()) statt fest news und ruft unter der Sperre feature-activate:<id> nach dem frischen Lesen den optionalen Haken featureHaken(key).freischaltbar(doc) ⇒ Promise<boolean> (der Standard für termine darf einen zweiten Parameter { abo } annehmen, D6); false oder Fehler ⇒ Dokument bleibt setup (Log; einmal ein adminNote-Eintrag „Freischaltung wartet: “, kein Mailversand alle 5 Minuten – der T16-Hinweis kein_abo kam bei der Zahlung (B10b), T19 vom Abgleich (E5)). Termine-Standardhaken: Einstellung vorhanden (ladeEinstellung(doc.$id)) und laeuft(doc.customerId) (Spec 4.5: Aktivierung bleibt gesperrt, bis ein Abo läuft). Block D6 zieht diese Prüfung nach termine/freischaltung.js (freischaltHindernis(doc, { abo })) und der Standardhaken delegiert dorthin; die Tests dieses Tasks bleiben dabei unverändert grün. adminActivate (Staff-Knopf) bleibt Sache von Block D/E9/WOMS.
- Tests (Testaufbau laut Kopf,
seedFixtures-Falle: Wartungs-Abo fürc1durch Überschreiben vonsub_h): „News unverändert“ (bisherige Tests grün, Z. 235 wie oben angepasst); „Termine: Ticket geschlossen + Einstellung + Abo ⇒active, K16 „Dein Terminplaner ist bereit“ mit Knopf „Termine öffnen“ und Link…#termine“; „kein Abo ⇒ bleibtsetup,adminNoteenthält „Freischaltung wartet“, zweiter Lauf schreibt keinen zweiten Eintrag und sendet keine Mail; Abo kommt zurück ⇒ nächster Lauf aktiviert und sendet K16“; „keine Einstellung ⇒ bleibtsetup“; „Abo-Lesefehler ⇒ bleibtsetup,runActivationwirft nicht, andere Dokumente werden trotzdem bearbeitet“; „Abo endet zwischen Listenlesen und Sperre (der Haken meldet beim frischen Lesenfalse) ⇒ bleibtsetup“; „TicketCancelled⇒ bleibtsetup“; „customerId-Filter“; „Hook gilt nur für seine Funktion“ (News-Dokument im selben Lauf wird aktiviert); „parallele Läufe“ (runActivation()undrunActivation({ customerId })⇒ ein K16); „Dokument anderer Funktion mitsetup(z. B.analyse) bleibt unberührt“. - FAIL → implementieren → PASS →
npm test→ Commit „Freischaltung ueber das Register mit Pruefung von Einstellung und Abo“
Task B12: Register-Ansicht im Kundenportal (GET /api/funktionen, News-Seite)
Files: Modify server/routes/funktionen.js (GET / Z. 88–114), public/funktionen.js (PRICE Z. 9, newsTile Z. 81–85, initNews Z. 220, renderBooking Z. 261–297), test/funktionenRoutes.test.js (liste_zustaende Z. 60ff.), test/funktionenTiles.test.js.
Consumes: B1 oeffentlicheAnsicht, B7 (Checkout-Vertrag).
Produces: GET /api/funktionen ⇒ wie bisher plus features: { news: <Ansicht>, termine: <Ansicht> } (oeffentlicheAnsicht, auch wenn termine.verfuegbar falsch ist: nur öffentliche Bestelltexte); setupEstimate/consentText bleiben als News-Alias. Zusätzlich ustHinweis: config.ustHinweis (die einzige Stelle für den Preishinweis auf Bestellseiten; F2/F8 ergänzen ihn nicht noch einmal). Die Bestellseite (Block C8) bekommt dieselben Registerwerte über bestellungAnsicht() (aus oeffentlicheAnsicht, C2) in GET /api/termine/einstellungen → bestellung, nicht aus dieser Liste, und schickt zustimmung: { version, haken } an den Checkout; „Zahlung fortsetzen“ schickt { projectId, fortsetzen: true } (B6/B7: die Route liefert die offene Sitzung nur, wenn sie den Nachweis dazu kennt, sonst 409 zustimmung_fehlt und die Seite zeigt wieder das Formular). public/funktionen.js: Preis und Zustimmungstext der News-Seite aus features.news (priceLabel, consent.items[0].text, consent.knopf), Rückfall auf die bisherigen Konstanten, wenn features fehlt; News sendet weiter { projectId, consent: true } (Alias B6), das Verhalten bleibt gleich; der Wortlaut „inklusive Mehrwertsteuer“ (Z. 272) bleibt hier stehen (Korrektur in Block F).
- Tests:
liste_zustaende+ „features.news.priceLabel === '30 €',features.termine.priceLabel === '149 €',ustHinweis === config.ustHinweis, Items vollständig, kein Feld mit Funktion/ticket/mails,features.termineauch beiverfuegbar: false“;funktionenTiles.test.js: „Rückfall ohnefeatures“ (die bisherigenrender-Hilfen liefern nur{ projects }, Tests unverändert grün) und „Preis aus dem Register“ (features.news.priceLabel: '31 €'⇒ Kachel-Knopf „Einrichten lassen · 31 €“); statische Prüfung „News postet weiterconsent: truean/api/funktionen/news/checkout“. - FAIL → implementieren → PASS →
npm test→ Commit „Funktionen-Seite: Preis und Bestelltexte aus dem Register“
Abschlussprüfung Block B (Controller): npm test komplett grün (test/previewDevRouter.test.js bei Ausreißer einzeln wiederholen); command grep -rn "STUB (Block" server zeigt genau zwei Treffer (server/services/termine/fragebogen.js, server/services/termine/ticketDetails.js), aboStatus.js trägt die Marke // KERNVERSION (Block B) – Block E baut aus; Blöcke C, D und E entfernen ihre Marken. Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)' (analyticsContract.test.js importiert ../analytics/src und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob' bleibt leer. Festverdrahtetes News ist nur noch im Register: command grep -rn "dashboard.html#news\|config.newsSetup\|DISABLED_MESSAGE" server trifft ausschließlich die Definitionen in server/config.js, den Alias-Export in server/services/siteFeatures.js und den Katalog in stripeBilling.js (catalogFor); config.funktionen wird nur noch in server/routes/funktionen.js (GET-Alias) gelesen. Die Beschriftungen „News-Pinnwand“ in routes/abo.js (Widerruf, Block F/4.7), retention.js und routes/admin/funktionenAdmin.js sind nicht Teil von B.
Übergaben an andere Blöcke (verbindlich für deren Tasks):
- A:
disable()idempotent mitreason, das Schema (portalFeatures.disabledReason,aboEndeMailAt,zahlungMeldung) im neuen Skriptfeature-setup.mjs(A11; B3 ergänzt dortfeatureConsents) und die explizite Liste statt der Wildcard liefert A; B berührtactivateClosedTickets(B11), nichtdisable/activate. B-Tests seedendisabledReasonnur. - E: erweitert
aboStatus.js(Modify, MarkeKERNVERSIONentfällt; gleiche drei Funktionen,aboMemoje Lauf, dazuaboLageundaboMemo().lageaus E1; die Reihenfolge der Gründekein_abo→gekuendigt→nicht_aktivsteht hier fest);aboMeldungbleibt infeatureOrder.js(E definiert keine eigene); ruftmeldeErstattung(doc, { customer, nachAboEnde: false })(B10b, einzige T19-Stelle) aus dem Abgleich 1b (nur für bezahltesetup-Dokumente) und schickt K22/K23;unlockedByPurchase === trueist nach B10a auch bei „Zahlung nach Staff-Aktivierung“ gesetzt (diese Dokumente unterliegen dann dem Abgleich); E8 setzt beim ZurücksetzenzahlungMeldungauf''. - C: ersetzt den Stub
termine/fragebogen.js(bestellpruefung,BRANCHEN, …) und darfladeEntwurfinfeatureOrder.jsdurch seinen Lader ersetzen (Signatur(featureId) ⇒ { einstellung, fragebogen }bleibt); die Bestellseitefunktion-bestellen.jsliestbestellungausGET /api/termine/einstellungen(Registerwerte überoeffentlicheAnsicht), schickt{ projectId, zustimmung: { version, haken } }, zeigt bei 409fassung_veraltetdie neue Fassung ausbody.consentund beibestellung_offendie Punkte ausbody.offen; „Zahlung fortsetzen“ sendet{ projectId, fortsetzen: true }(409zustimmung_fehlt⇒ Formular); der Rücksprung liefert#termine?bestellt=1&projekt=<id>, die Abbruch-Adresse#termine?projekt=<id>; die Abo-Codes sindkein_abo|gekuendigt|nicht_aktiv. Der T16-Warnblockgesundheit(Spec 3.5) ist in B9b deklariert und wird von Block D4 gefüllt (nicht von C). - D: ersetzt den Inhalt von
termine/ticketDetails.js(termineTicketDetails(ctx),ctx = { feature, doc, project, customer },nowim Rumpf), füllt den T16-Blockgesundheit(D4), zieht die Freischalt-Prüfung nachtermine/freischaltung.js(D6; B11 bleibt Besitzerin vonactivateClosedTickets), blendet im WOMS „Aktivieren (ohne Zahlung)“ für Termine inordered/setupaus (der Fallbereits_aktivaus B10b bleibt als Netz);anleitungsLinkkommt ausfeatureRegistry.js(B8), T16 und Ticket verlinken also schon mit&anleitung=<projektId>;createFeatureTicketändert D nicht (D5 entfällt). - F: ersetzt alle
ENTWURF-Platzhalter im Termine-Eintrag (consent-Texte und -Fassung bei bereits festen IDs,leistungsbeschreibung,avv,widerruf,mails.danach/bereitausgenommen: das ist die Mailrunde des Inhabers;mails.preisHinweisbleibt ausconfig.ustHinweis; die K15-Anhänge hängt F8 direkt inonFeaturePaidan (featureLegalAttachments(doc.featureKey)), nicht über einen Registerhaken), korrigiert die News-Texte (§ 356 Abs. 5 Nr. 2, Wertersatz, § 19 UStG inconsent.items[0].text,mails.preisHinweis,public/funktionen.js:272) und hebt dabeiconsent.versionan (der Nachweis speichert die Fassung; die Pins in B1/B7/B9a/B9b lesenconfig, F2 passt nur den Einfrier-Test infeatureMails.test.jsan); Widerruf je Funktion (4.7:withdrawFeature-Frist je Funktion, K04/T09{{produkt}}/{{preis}},widerrufen.html) ist nicht in B, B4 liefert dafürfeatureKeyundvertragAtauspaidFeatureOrders(F9 nutzt genau diese); dertodo-Test aus B1 muss mit F1/F7 grün werden.
Block E: Abo-Kopplung
Ein gemeinsamer Abo-Baustein (aboStatus.js) trägt Analyse und Terminplaner. Der tägliche Abgleich gleicheAboAb (Spec 6 und 4.6) schaltet den Terminplaner ab, wenn das Wartungs-Abo endet, meldet fällige Erstattungen (T19), holt K22 nach und schaltet innerhalb der Frist wieder ein (K23); die Löschung nach 30 Tagen prüft vorher frisch und setzt das Dokument auf off zurück. Reihenfolge: A → B → E → C → D → F. B bringt aboStatus.js als Kernversion (B5), A11 disabledReason/disable(); E1 erweitert die Kernversion, E3–E12 folgen in dieser Reihenfolge. E11 und E12 liegen vor C und D: C4a/C4b/C10b/D2 bzw. D9/D10 setzen auf deren Stand auf und erhalten ihn. Der Abgleich liest nur den Appwrite-Spiegel subscriptions, nie Stripe; nichts läuft gegen Produktion.
Festlegungen des Blocks (gelten für E4–E10): stehen im Kopf, Abschnitt „Grundlagen der Blöcke“.
Task E1: Abo-Baustein aboStatus.js ausbauen, Analyse darauf umgestellt
Files: Modify server/services/aboStatus.js (B5-Kernversion; die Marke // KERNVERSION (Block B) – Block E baut aus entfällt), test/aboStatus.test.js (B5, erweitern statt neu anlegen); Modify server/services/analyse.js (Z. 11: Import ACTIVE_STATUSES, getCurrentSubscription entfällt, neu laeuft aus ./aboStatus.js; Z. 43–46 hasWartungsAbo wird (id) => laeuft(id, { altbestand: false })), test/analyse.test.js (+1 Test; bestehende Zeilen bleiben unberührt).
Consumes: getCurrentSubscription (stripeBilling.js:178), ACTIVE_STATUSES (:24), listContractEvents (contractEvents.js:120).
Produces (Namen und Signaturen aus dem Plankopf unverändert, Zusätze mit „+“; ein privater Lader lage(customerId, { altbestand }) liefert { laeuft, art, endedAt, abo }, alle Prädikate bauen darauf, damit Bestell-Gate, Freischaltung, T16 kein_abo und Abgleich nie verschieden urteilen):
laeuft(customerId, { altbestand = true } = {}) ⇒ Promise<boolean>: aktuelles Abo ist ein Wartungs-Abo mit Status ∈ACTIVE_STATUSES(active, trialing, past_due; vorgemerkte Kündigung zählt noch als laufend). Wirft bei jedem Lesefehler, gibt bei einem Fehler niefalse. Zwei Regeln:altbestand: true(Vorgabe, Terminplaner) ist die großzügige Regel: ProduktschlüsselproductKey || 'wartung'wieroutes/abo.js:68und die Wechsel-Regel unten;altbestand: false(Analyse) ist die heutige Regel unverändert:=== 'wartung'(ein Spiegel ohneproductKeyzählt nicht), keine Ereignisabfrage (Spec: „analyseEligibilityändert sein Verhalten nicht“). Der Unterschied betrifft nur Spiegel ohneproductKeyund den Wechsel-Widerruf;stripeBilling.js:246schreibtproductKeyimmer, nur Altbestand könnte es fehlen (Produktion ungeprüft ⇒ offene Frage).bestellbar(customerId) ⇒ Promise<{ ok: boolean, grund?: 'kein_abo' | 'gekuendigt' | 'nicht_aktiv' }>: Reihenfolge der Gründe (einmal für alle Blöcke, wie B5): nichtlaeuft(großzügige Regel inkl. Wechsel-Regel) ⇒kein_abo;cancelAtPeriodEnd || cancelAt(Musterroutes/abo.js:96) ⇒gekuendigt; Status ≠active(trialing, past_due) ⇒nicht_aktiv; sonst{ ok: true }. Wirft bei Lesefehler.aboMemo() ⇒ { laeuft(id), bestellbar(id), lage(id) }: je Aufruf vonaboMemo()ein eigener Speicher (Promise jecustomerId, eine Ablehnung bleibt für diesen Lauf stehen). Nie für Prüfungen unter einer Sperre.-
aboLage(customerId) ⇒ Promise<{ laeuft: boolean, art: 'laeuft' | 'wechsel_widerrufen' | 'hosting' | 'beendet', endedAt: string | null }>(= der private Lader mitaltbestand: true, kein eigener Zweig): Wechsel-Regel: sagt der Spiegel „Wartung läuft“ und hat das jüngstewechsel-Ereignis dieses Abos Statuswiderrufen⇒laeuft: false,art: 'wechsel_widerrufen'. Grund: Der Widerruf eines Wechsels (Hosting→Wartung) setzt den Spiegel nicht zurück, das tut das Team von Hand (Kommentarroutes/abo.js:992); ohne diese Regel schaltete der tägliche Abgleich einen per Sofortpfad (E9) beendeten Terminplaner am nächsten Tag wieder ein, und ein Kunde mit widerrufenem Wechsel dürfte bestellen (149 €) und freischalten, der Abgleich schaltete am Folgetag ab und löste T19 aus. Ereignisse werden nur gelesen, wenn der Spiegel „Wartung läuft“ sagt (und nur beialtbestand: true). Ohne laufendes Wartungs-Abo:art: 'hosting', wenn das aktuelle Abo ein laufendes Hosting-Abo ist, sonst'beendet';endedAt=endedAt || canceledAtdes Abos odernull.
aboMeldunggibt es hier nicht (die einzige Quelle der Abo-Hinweistexte istfeatureOrder.js, B6).- Tests
aboStatus.test.js(Testaufbau undseedFixtures-Falle laut Kopf; die B5-Fälle bleiben; Mock:subscriptionsundcontractEventsmitschemaKeys, Dokumente aussubscriptionDoc/wartungDoc/hosting2Docintest/support/fixtures.js):laeuft: Wartungactive/trialing/past_due⇒ true;canceled/unpaid/incomplete_expired⇒ false; Hosting aktiv ⇒ false; kein Dokument ⇒ false; aktives Abo mitcancelAtPeriodEnd: true⇒ true; zwei Dokumente (altescanceled, neuesactive) ⇒ true.- Altbestand-Unterschied: Dokument ohne
productKey⇒laeuft(id)true,laeuft(id, { altbestand: false })false. - Lesefehler:
aw.failNext({ collection: 'subscriptions', method: 'GET', status: 500 })⇒laeuft,bestellbar,aboLagelehnen ab (niefalse/{ ok: false }). bestellbar: Tabelle (active ohne Kündigung ⇒ ok;cancelAtPeriodEnd, nurcancelAtin der Zukunft ⇒gekuendigt; trialing, past_due ⇒nicht_aktiv; canceled, Hosting, kein Abo ⇒kein_abo; trialing und past_due mit Kündigung ⇒gekuendigt, Kündigung vor Status).aboLage:artje Fall (laeuft,hostingmit aktivem Hosting-Abo,beendetmitendedAt,wechsel_widerrufenmitwechsel-Ereigniswiderrufen; ein neuereswechsel-Ereigniserledigthebt es auf ⇒laeuft); beibeendetundhostingkeine Ereignis-Abfrage (Anfragen zählen per Wrapper umglobalThis.fetchwiemitEinwurfintermineJobs.test.js:234).- Wechsel-Regel in allen Prädikaten: Spiegel Wartung
active+ jüngsteswechsel-Ereigniswiderrufen⇒laeuft(id)false,bestellbar(id){ ok: false, grund: 'kein_abo' },aboLage.art 'wechsel_widerrufen';laeuft(id, { altbestand: false })bleibt true und fragt keine Ereignisse ab. Integration: Checkout (B6) ⇒ 409kein_abo, kein Dokument entstanden;activateClosedTickets(B11) mit TicketClosed⇒ Dokument bleibtsetup;adminActivate(E9) ⇒ 409. aboMemo: drei Aufrufe für denselben Kunden ⇒ eine Abfrage aufsubscriptions; zweiter Kunde ⇒ eine weitere; ein neueraboMemo()liest wieder; eine Ablehnung wird im selben Memo nicht neu gelesen (keine zusätzliche Anfrage).analyse.test.jsneuer Test „Altbestand ohne productKey: Analyse bleibt streng (kein_abo),aboStatus.laeuftgroßzügig“ (pinnt den Unterschied).
- Beweis „Analyse unverändert“:
node --test test/analyse.test.js test/analyseRoutes.test.js test/analyseView.test.js test/funktionenRoutes.test.js test/publicForms.test.jsvor der Umstellung grün, danach mit keiner geänderten bestehenden Testzeile grün (git diff test/analyse*.test.jszeigt nur den neuen Test);grep -n "ACTIVE_STATUSES\|getCurrentSubscription" server/services/analyse.jsleer. - FAIL →
aboStatus.jsausbauen (ein privater Laderlage(customerId, { altbestand })übergetCurrentSubscriptionundlistContractEvents; die Prädikate darauf; Memo je Aufruf in einerMapvon Promises) → PASS →npm test→ Commit „Abo-Baustein aboStatus.js: Wechsel-Regel in laeuft/bestellbar, aboLage; Analyse nutzt ihn“
Task E2: Anzeige von Abschaltgrund und Löschfrist (featureView, frist.js)
Files: Create server/services/termine/frist.js; Modify server/services/siteFeatures.js (featureView Z. 116); Tests test/siteFeatures.test.js, test/funktionenTermine.test.js (Z. 158). Nicht Teil von E2 (liegt in A11): idempotentes disable(), DISABLED_REASONS, activate()-Reset, disableCustomerFeatures(reason), customerAdmin.js kunde_geloescht, die Schema-Attribute (feature-setup.mjs) und deren Tests.
Consumes: A11 (disabledReason, disabledAt, aboEndeMailAt, reason).
Produces: featureView liefert zusätzlich disabledReason, disabledAt und loeschungAm (Berliner Tag 'JJJJ-MM-TT', nur bei state === 'disabled' und vorhandenem disabledAt, sonst null; bei anderen Zuständen alle drei null). frist.js: LOESCHFRIST_TAGE = 30, loeschzeit(disabledAt) ⇒ Date | null (disabledAt + 30 × 24 h, dieselbe Rechnung wie jobs.js:50), loeschTag(disabledAt) ⇒ 'JJJJ-MM-TT' | null (Berliner Kalendertag über ausUtc aus zeit.js); nur Importe aus zeit.js/util.js (kein Kreis mit siteFeatures.js). F4/F5 prüfen ihre Fristsätze gegen LOESCHFRIST_TAGE. Sperre und Mails kommen später (E4ff.).
- Tests:
featureView/GET /api/funktionen(funktionenTermine.test.js:158): disabled+abo_endemitdisabledAt: '2026-10-04T23:30:00.000Z'⇒loeschungAm === '2026-11-04'(Berliner Tag, nicht der UTC-Tag2026-11-03);team⇒ Grundteamund ebenfalls ein Datum; active ⇒ alle drei Feldernull; Altbestand ohne Grund ⇒disabledReason: null.frist.js:loeschTag('2026-10-04T23:30:00.000Z') === '2026-11-04';loeschzeit= exakt 30 × 24 h später; ungültig/leer ⇒null. - FAIL → implementieren → PASS →
npm test→ Commit „Abschaltgrund und Loeschfrist in der Funktionsansicht (featureView, frist.js)“
Task E3: Mailvorlagen K22, K23 und Block in K02/K03
Files: Create server/mail-templates/K22 Terminplaner beendet.html, K23 Terminplaner laeuft wieder.html (Layout wörtlich aus K19 Analyse beendet.html, Kopfkommentar im Format der vorhandenen); T19 legt B9b an, nicht E; Modify server/mail-templates/K02 Kuendigungsbestaetigung.html, K03 Eingang Kuendigung.html, server/routes/abo.js (K02-Aufruf Z. 469: Block funktionen: isWartung(sub); K03-Aufruf Z. 937: funktionen: !storage && isWartung(sub)), test/mailTemplates.test.js (Z. 8–11: nur ids('K', 23); die T-Liste bleibt ids('T', 19) plus 'T20' aus B9b/A10a1, nicht überschreiben; Kopfkommentar um „K22, K23 Terminplaner beendet/läuft wieder“ ergänzen).
Consumes: renderMail/loadTemplate (mailTemplates.js). Texte sind Entwurf für die Mail-Überarbeitung des Inhabers (Spec 2); nur Platzhalter und Blöcke sind verbindlich.
Produces:
- K22 Betreff
Dein Terminplaner wurde beendet – {{projekt}}; Platzhalterkunde_name, projekt, enddatum, loeschdatum, preis, portal_url; Blöckeabo_ende(Wartungs-Abo beendet),hosting(Hosting-Abo reicht nicht),vor_einrichtung(Abo endete vor der Einrichtung, Einrichtung wird voll erstattet, nennt{{preis}}),daten(Kasten „Was jetzt gilt“, nur für die ersten beiden: bis{{loeschdatum}}ansehen und CSV-Export im Portal, Gäste können ihre Termine weiter absagen, bestehende Termine bleiben eingetragen und Erinnerungen an Gäste laufen bis zur Löschung weiter, bitte Gäste selbst informieren, ab{{loeschdatum}}Löschung, Rückkehr: läuft bis dahin wieder ein Wartungs-Abo, schalten wir ihn automatisch und ohne Gebühr wieder ein; nach der Löschung braucht es neuen Fragebogen und neue{{preis}}). Du-Form. - K23 Betreff
Dein Terminplaner läuft wieder – {{projekt}}; Platzhalterkunde_name, projekt, portal_url; keine Blöcke. - T19 gehört B9b (eine Vorlage: Platzhalter
kunde_name, email, projekt, funktion, betrag, sitzung, woid, rechnung_url, Blöckezahlung_nach_ende, ticket_offen, rechnung); E4/E5/E10 senden sie ausschließlich übermeldeErstattung(B10b). - K02/K03: neuer Block
funktionen(im Kopfkommentar „Optionale Blöcke“ ergänzen; in K03 innerhalb vonabo): „Die Website-Analyse und der Terminplaner gehören zum Wartungs-Abo und enden mit ihm. Die Termindaten löschen wir 30 Tage nach dem Abschalten; vorher können Sie Ihre Termine im Kundenportal ansehen und exportieren.“ Sie-Form, weil K02/K03 selbst Sie-Form sind (Abweichung vom Du-Grundsatz, offene Frage). - Tests (
mailTemplates.test.js):templateIds()= G01–G08, K01–K23, T01–T20 (T19/T20 aus B9b/A10a1); die schleifenweisen Tests (Kopfkommentar =<title>, deklarierte = verwendete Platzhalter/Blöcke, Rendern ohne Reste mit allen Blöcken an/aus, WEBklar-Logo) laufen automatisch für die zwei neuen IDs K22 und K23. Neu:K22je Variante mit nur diesem Block an (abo_ende: „Wartungs-Abo“ und kein „erstatten“;hosting: „Hosting-Abo“;vor_einrichtung: enthält den Wert vonpreisund „erstatten“, kein Löschdatum-Kasten;datenzeigtloeschdatumund den Satz zu den Gästen); K22/K23 ohneSie/Ihr(Du-Form); K23 nenntprojektund führt zuportal_url;K02/K03:funktionenan ⇒ Text enthält „Terminplaner“, aus ⇒ nicht; K03 mitabo: false, funktionen: true⇒ Text fehlt (Speicher-Kündigung); bestehende K02-Hosting-Prüfung (/Wartungs-Abo/darf nicht vorkommen, Z. 388) bleibt grün. - FAIL → Vorlagen schreiben, Aufrufstellen in
routes/abo.jsergänzen → PASS →npm test→ Commit „Mails K22, K23 und Hinweis auf das Ende der Funktionen in K02/K03“
Task E4: gleicheAboAb Schritt 1 (aktive abschalten) und Schritt 2 (K22 nachholen)
Files: Create server/services/termine/aboAbgleich.js, test/termineAboAbgleich.test.js; Modify server/services/siteFeatures.js (customerOf und projectName aus Z. 420–435 exportieren statt ein drittes Mal zu kopieren; B10b hat sie schon exportiert, dann entfällt der Eintrag).
Consumes: E1 aboMemo, aboLage; A11 disable({ reason }); E2 loeschzeit, loeschTag; E3 K22; listAllDocuments, withLocks, getDocument/updateDocument (appwriteAdmin.js), datumKurz (termine/zeit.js:120), config.features.termine.priceLabel (Block B, „149 €“; Wert für {{preis}} in K22).
Produces: gleicheAboAb(now) ⇒ Promise<{ geprueft, abgeschaltet, wiederEin, mails, ausgenommen, fehler }> (wirft nie; nur Zählungen). geprueft = bewertete Dokumente (aktiv mit unlockedByPurchase === true, ordered/setup, disabled mit Grund abo_ende*); Pilot-/Kulanz-Dokumente zählen nicht dort, sondern in ausgenommen (aktive Dokumente mit unlockedByPurchase !== true: der Abgleich fasst sie nicht an, der Tagesbericht macht sie für den Inhaber sichtbar; Kanzleifrage Q7). Intern/exportiert für E5/E9: meldeAboEnde(doc, { lage, now }) ⇒ Promise<boolean> – der gemeinsame Melder für das Ende (Schritt 2 und der Sofortpfad E9 rufen nur ihn, nie meldeErstattung oder K22 einzeln). E4 baut ihn für den Grund abo_ende (K22 und Marker); E5 erweitert dieselbe Funktion um abo_ende_vor_einrichtung (T19, K22, Marker). Rückgabe true = nichts mehr offen (Marker gesetzt oder nichts zu melden), false = Versand gescheitert (der nächste Tag wiederholt).
Ablauf: eine listAllDocuments(portalFeatures, [Query.equal('featureKey', 'termine'), Query.notEqual('state', 'off')]) (die vielen off-Entwürfe bleiben ungelesen; notEqual mit Einzelwert aus A2, der Rest der Filter im Code), gruppiert je customerId, ein memo = aboMemo(); je Kunde lage = await memo.lage(id) in try/catch. Schritt 1: Dokumente mit state === 'active' && unlockedByPurchase === true bei !lage.laeuft: withLocks(['feature-admin:' + projectId]) ⇒ Dokument frisch lesen, noch active und unlockedByPurchase === true, frische aboLage noch !laeuft ⇒ disable(id, { reason: 'abo_ende', adminNote: 'Abgeschaltet: Wartungs-Abo beendet' }). Schritt 2 (Nachlauf): für jedes disabled-Dokument mit Grund abo_ende ohne aboEndeMailAt (auch die eben abgeschalteten) und weiter !lage.laeuft: meldeAboEnde: K22 an customers.email (Block hosting, wenn lage.art hosting oder wechsel_widerrufen, sonst abo_ende; daten: true), Werte enddatum = Berliner Tag von disabledAt, loeschdatum = datumKurz(loeschzeit(disabledAt)), preis, portal_url = ${publicBaseUrl}/dashboard.html#termine; erst nach Erfolg aboEndeMailAt = now. Kunde fehlt oder ohne E-Mail ⇒ Marker setzen (kein Wiederholen), Log ohne Adresse. Läuft das Abo wieder, überspringt Schritt 2 (E6 übernimmt).
- Tests
termineAboAbgleich.test.js(Aufbau wietermineJobs.test.js: Mock mitportalFeatures/subscriptions/termineConfig/termineBookings,mailSink, festeJETZT, Logs auf Gast-/Kundendaten prüfen, Umgebung peruseTestEnv({ appwriteUrl, stripeUrl: 'http://127.0.0.1:1' }); Abo-Dokumente austest/support/fixtures.jsper dynamischem Import):- Abo beendet ⇒ abgeschaltet:
status: 'canceled'⇒ Dokumentdisabled,enabled false,disabledReason 'abo_ende',disabledAt === JETZT, Notiz „Abgeschaltet: Wartungs-Abo beendet“; Ergebnis{ geprueft: 1, abgeschaltet: 1, wiederEin: 0, mails: 1, ausgenommen: 0, fehler: 0 }; K22 an den Kunden (BetreffDein Terminplaner wurde beendet – <Projekt>, Löschdatum =disabledAt+ 30 Tage),aboEndeMailAt === JETZT. Zweiter Lauf am Folgetag: nichts mehr,disabledAtunverändert, keine zweite Mail (Frist wird nie verschoben). - Kein Abo-Dokument ⇒ abgeschaltet; nur Hosting-Abo aktiv ⇒ abgeschaltet, K22 mit Block
hosting(„Hosting-Abo“). - Läuft weiter (nie abschalten):
past_due,trialing,activemitcancelAtPeriodEnd: trueundcancelAtin 10 Tagen ⇒ Dokument bleibtactive, 0 Mails;unpaid,incomplete_expired,canceled⇒ abgeschaltet (Tabelle). - Lesefehler:
failNext({ collection: 'subscriptions', method: 'GET', status: 500 })⇒ bleibtactive,fehler: 1, keine Mail; derselbe Fehler erst bei der frischen Prüfung unter der Sperre (after: 1) ⇒ bleibtactive; der folgende Lauf ohne Fehler schaltet ab. Fehler beim Lesen der Dokumentliste ⇒gleicheAboAbwirft nicht (Ergebnis mitfehler), nichts geändert. - Pilot/Kulanz:
unlockedByPurchase: falseund fehlend, Kunde ohne Abo ⇒ unverändertactive, nicht ingeprueft,ausgenommenzählt sie (2 bei beiden Fällen), keine Mail;off-Entwürfe werden nicht gelesen (listCallszeigtnotEqual state off). - Rennen: zwischen Liste und Sperre kommt ein aktives Abo (Einwurf wie
mitEinwurf,termineJobs.test.js:234) ⇒ frische Prüfung ⇒ bleibtactive; zwischen Liste und Sperre schaltet das Team ab (reason: 'team') ⇒ Grund bleibtteam,disabledAtunverändert, kein K22; Sperre: hält der Testfeature-admin:<projectId>, bleibt der Abgleich offen, bis sie frei ist. - Memo: ein Kunde mit drei Projekten ⇒ genau eine Abfrage auf
subscriptions, drei abgeschaltete Dokumente, drei K22 (je Projekt eine). - K22-Nachlauf: Transport wirft ⇒
disabled,aboEndeMailAtleer,fehler 1,mails 0; nächster Tag ohne Fehler ⇒ K22 geht raus, Marker gesetzt,disabledAtund Löschdatum im Text unverändert; Kunde gelöscht/ohne E-Mail ⇒ kein Wurf, Marker gesetzt, am Folgetag nichts. - Alt-
disabledohne Grund,team,kunde_geloescht⇒ nie angefasst, nie K22.
- Abo beendet ⇒ abgeschaltet:
- FAIL → implementieren → PASS →
npm test→ Commit „Abo-Abgleich: aktive Terminplaner ohne Wartungs-Abo abschalten, K22 nachholen“
Task E5: Schritt 1b (Abo endet vor der Einrichtung) und T19 über meldeErstattung
Files: Modify server/services/termine/aboAbgleich.js, test/termineAboAbgleich.test.js. (siteFeatures.js bleibt unberührt: es gibt keine eigene erstattungMelden.)
Consumes: E1–E4; B10b meldeErstattung(doc, { customer, sitzung, nachAboEnde }) (die einzige T19-Stelle: Sperre feature-meldung:<docId>, Marker zahlungMeldung === 'gemeldet' erst nach sent === true, wirft nie); Plankopf/B11 activateClosedTickets nur als Prüfpunkt im Test.
Produces: Schritt 1b in gleicheAboAb: Dokumente mit state ∈ {'ordered','setup'} bei !lage.laeuft ⇒ unter feature-admin:<projectId> frisch lesen, noch ordered/setup, frische aboLage noch !laeuft ⇒ disable(id, { reason: 'abo_ende_vor_einrichtung', adminNote: 'Abgeschaltet: Wartungs-Abo endete vor der Einrichtung' }). Schritt 2 erweitert sich auf Grund abo_ende_vor_einrichtung; die Meldelogik liegt in meldeAboEnde (E4), nicht in der Schleife, damit der Sofortpfad E9 dasselbe tut. meldeAboEnde für abo_ende_vor_einrichtung: bei unlockedByPurchase === true erst meldeErstattung(doc, { customer, nachAboEnde: false }) (T19 „[Abo] Erstattung fällig: Terminplaner-Einrichtung“; Aufruf außerhalb jeder feature-admin/feature-paid-Sperre), dann K22 mit Block vor_einrichtung (daten: false), dann Marker aboEndeMailAt; sent === false ohne schonGemeldet ⇒ Rückgabe false, fehler += 1, weder K22 noch Marker (der nächste Tag wiederholt T19 und K22 in dieser Reihenfolge). Ein unbezahltes ordered-Dokument (unlockedByPurchase !== true) wird nur abgeschaltet: kein T19, kein K22, kein Marker, kein Schreibzugriff (es ist nichts zu erstatten; kommt die Zahlung später, löst B10b/E10 T19 aus, und Schritt 2 sendet dann K22). Die offene Stripe-Sitzung wird nicht storniert (Spec 6 Schritt 1b). Das Ticket wird nicht angefasst.
- Tests:
- Bezahlt, im Aufbau (
setup,unlockedByPurchase,stripeSessionId 'cs_test_1', Ticket vorhanden) und kein Abo ⇒disabled, Grundabo_ende_vor_einrichtung; T19 genau einmal anconfig.chatNotify.emails(Betreff[Abo] Erstattung fällig: Terminplaner-Einrichtung, Text nennt Kunde, Projekt,cs_test_1, Rechnungslink, Ticketnummer),zahlungMeldung === 'gemeldet'undpurchaseStatusweiterpaid; K22 mit Blockvor_einrichtung(Preis genannt, kein Löschkasten); das Ticket bleibtOpen(Dokument inworkordersunverändert); zweiter Lauf ⇒ weder T19 noch K22. - T19 schlägt fehl (Transport wirft;
sendAdminNoticefängt ab ⇒sent: false) ⇒zahlungMeldungbleibt leer, Dokument trotzdemdisabled,fehler ≥ 1, weder K22 nochaboEndeMailAtbis dahin; nächster Lauf sendet T19 und danach K22 nach, danach nie wieder. Ein anschließendes Stripe-Ereignis zur selben Sitzung (async_payment_succeeded) ⇒ kein zweites T19. - Unbezahlt
orderedohne Abo ⇒disabled, kein T19, kein K22,aboEndeMailAtleer,stripeSessionIdunverändert. setupohneticketId(„Ticket fehlt“) ⇒ T19 ohne Blockticket_offen.- Läuft das Abo (
past_due) ⇒ordered/setupbleiben unberührt; Lesefehler ⇒ unberührt. - Review Focus #1, „nie ohne Spur“: ein
setup-Dokument, dessen Abo endet, und das Ticket wird danach geschlossen (workorders.status = 'Closed') ⇒activateClosedTicketsaktiviert dasdisabled-Dokument nicht (Zustand bleibtdisabled). - Rennen: Team aktiviert zwischen Liste und Sperre (Zustand
active) ⇒ Schritt 1b greift nicht (frisch gelesen). meldeAboEndedirekt (der Aufruf, den E9 nutzt): bezahltessetup-Dokument mit Grundabo_ende_vor_einrichtung⇒ ein T19, ein K22vor_einrichtung, Marker gesetzt, Rückgabetrue; unbezahltesordered-Dokument ⇒ weder T19 noch K22 noch Marker, Rückgabetrue, Dokument unverändert; zweiter Aufruf ⇒ nichts mehr.
- Bezahlt, im Aufbau (
- FAIL → implementieren → PASS →
npm test→ Commit „Abo-Abgleich: Abo endet vor der Einrichtung ⇒ abgeschaltet, T19 und K22“
Task E6: Schritt 3 Wiederkehr und K23
Files: Modify server/services/termine/aboAbgleich.js, test/termineAboAbgleich.test.js.
Consumes: E1–E5; ladeEinstellung(featureId) (kontext.js:75, prüft den Modus nicht und liefert null, wenn keine lesbare Einstellung da ist), activate (A11).
Produces: Schritt 3 in gleicheAboAb: Dokumente mit state === 'disabled', Grund abo_ende und lage.laeuft === true: unter feature-admin:<projectId> Dokument frisch lesen (noch disabled/abo_ende), frische aboLage noch laeuft, await ladeEinstellung(doc.$id) ≠ null (Daten da), dann activate(id, { adminNote: 'Wieder eingeschaltet: Wartungs-Abo läuft wieder' }) (Daten und Einstellungen bleiben, activate leert Grund/Marker/disabledAt) und K23 nur, wenn K22 ging (aboEndeMailAt vor dem activate gelesen); scheitert K23, bleibt die Reaktivierung bestehen (fehler += 1). Festlegung: Die Rückkehr gilt, solange die Einstellung noch da ist, auch wenn der Lauf erst nach Tag 30 kommt (Abgleich läuft vor der Löschung im selben Tagesjob; sonst blieben Löschung und Rückkehr gegenseitig blockiert). Nach der Löschung ist das Dokument off (E8) und wird nie automatisch aktiv. Nicht automatisch: Grund abo_ende_vor_einrichtung (Erstattung läuft; Wiedereinschalten entscheidet das Team per WOMS), team, kunde_geloescht, Altbestand ohne Grund.
- Tests:
- Rückkehr innerhalb von 30 Tagen:
disabled/abo_ende,disabledAt= JETZT − 10 Tage,aboEndeMailAtgesetzt, Einstellung und eine künftige Buchung vorhanden, neues Wartungs-Aboactive⇒ Dokumentactive,enabled true,activatedAt === JETZT,disabledAt/disabledReason/aboEndeMailAtnull; Einstellung (Version) und Buchung unverändert; K23 genau einmal (BetreffDein Terminplaner läuft wieder – <Projekt>); zweiter Lauf ⇒ nichts; ErgebniswiederEin: 1. - K22 ging nie (Marker leer, Abo kam vorher zurück) ⇒ reaktiviert, kein K22, kein K23.
- Nach der Löschung nie automatisch: Dokument
offmit laufendem Abo ⇒ unberührt;disabled/abo_endeohne Einstellung (termineConfigfehlt) ⇒ bleibtdisabled, kein K23. team,kunde_geloescht, ohne Grund,abo_ende_vor_einrichtung+ laufendes Abo ⇒ unberührt (auch kein Wegschreiben von Feldern).- Rennen: Abo endet wieder zwischen Liste und Sperre ⇒ frische Prüfung ⇒ bleibt
disabled; Lesefehler ⇒ bleibtdisabled,fehler 1. disabledAt= JETZT − 35 Tage, Einstellung noch da, Abo läuft ⇒ reaktiviert; derselbe Tagesjob löscht danach nichts (siehe E8).- Wechsel widerrufen (Spiegel noch Wartung):
wechsel-Ereigniswiderrufen⇒ ein aktives Dokument wird abgeschaltet (Schritt 1, K22 Blockhosting) und eindisabled/abo_ende-Dokument bleibtdisabled(kein Flattern); ein neueres, wirksameswechsel-Ereignis hebt das auf ⇒ Rückkehr. - K23 schlägt fehl ⇒ Dokument bleibt
active,fehler 1.
- Rückkehr innerhalb von 30 Tagen:
- FAIL → implementieren → PASS →
npm test→ Commit „Abo-Abgleich: Rückkehr innerhalb der Frist schaltet wieder ein (K23)“
Task E7: Einbindung in den Tagesjob, geteilter Schalter-Guard, Bericht
Files: Modify server/services/termine/jobs.js (fuehreAus Z. 91, taeglicherJob Z. 420, tagesbericht Z. 437, Kopfkommentar Z. 20–37), test/termineJobs.test.js (Test „Modus off“ Z. 277, Tagesjob Z. 867, Berichtszeile Z. 905).
Consumes: E4–E6 gleicheAboAb; A12 (fuehreAus(job, now, arbeit, { teil }) mit Sperre je job:teil), A3a/A3b/A10a3 (gedeckelt, geloescht, aktiveBetriebe, Berichtssegmente); termineAus (schalter.js). Block A hat Tagesjob und tagesbericht schon erweitert: E7 hängt nur sein Segment an und ordnet den Schritt ein.
Produces: fuehreAus(job, now, arbeit, { teil = null, trotzAus = null } = {}) (erweitert das Optionsobjekt aus A12; teil und die Überlappungssperre je job:teil bleiben unverändert): bei Modus off läuft der Job nur, wenn await trotzAus?.() wahr ist; wirft trotzAus, wird geloggt und { aus: true } geliefert. taeglicherJob übergibt trotzAus = eine Abfrage listDocuments(portalFeatures, [equal('featureKey','termine'), equal('unlockedByPurchase', true), limit(1)]). Modus off mit bezahlten Dokumenten (eingeschränkter Lauf): nur gleicheAboAb und loescheAbgeschaltete, Ergebnis { eingeschraenkt: true, aboAbgleich, abgeschaltet, gedeckelt: { loeschfristen: 0, abgeschaltet: <n>, zellen: 0 }, geloescht: <abgeschaltet.buchungen>, aktiveBetriebe: null, fehler } (dieselben Felder, die meldeTagesjob (A10a3) und letzterTagesjob der Status-Route lesen, damit dort nichts undefined ist); alle übrigen Schritte (Löschfristen der Buchungen, Outbox, Zellen) bleiben aus (Annahme, offene Frage). Sonst (pilot/on) neuer Schritt gleicheAboAb(now) nach loeschfristen und vor loescheAbgeschaltete, in schritt(lauf, 'Abo-Abgleich', …, ersatz) (ein Fehler dort stoppt weder die Löschung noch den Rest; lauf.fehler += aboAbgleich.fehler); Ergebnis erhält aboAbgleich. tagesbericht hängt Abo-Abgleich N geprüft / N abgeschaltet / N wieder ein / N Mails / N ausgenommen vor N Fehler an (nur Zahlen, keine IDs). Reihenfolge der Segmente (verbindlich für A3b, A10a3, C6, E7): …, N Mails nicht zugestellt, [Löschläufe am Deckel: n, Rest folgt an den nächsten Tagen, ][Entwürfe n gelöscht (m offen), ]Abo-Abgleich …, N Fehler, n aktive Betriebe; die vollständige Zeile in E7 (Entwürfe fehlen hier noch, C6 fügt sie zwischen Deckel und Abo-Abgleich ein): [termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe; für den eingeschränkten Lauf eine eigene Zeile [termine] Tagesjob (Modus off, nur Abo-Abgleich und Löschung): ….
- Tests:
- Test „Modus off“ angepasst:
minutenJob/fuenfMinutenJob⇒{ aus: true }und 0 Anfragen;taeglicherJobohne bezahlte Dokumente ⇒{ aus: true }und genau 1 Anfrage (die Guard-Abfrage), sonst nichts; schlägt die Guard-Abfrage fehl ⇒{ aus: true }, Warnung, keine Löschung. - Modus off, bezahltes Dokument: abgeschaltet seit 31 Tagen mit Buchungen ⇒ Buchungen/Zellen/Einstellung gelöscht und Dokument
off(E8); aktives bezahltes Dokument ohne Abo ⇒ abgeschaltet, K22 geht raus; verfallene Buchung eines anderen Betriebs bleibt (keinloeschfristen-Schritt); Ergebnis hateingeschraenkt: true,tagesberichtliefert die eingeschränkte Zeile ohne IDs. - Pilot-Modus:
taeglicherJob⇒erg.aboAbgleichmit den vier Zählern; Reihenfolge (Abgleich vor Löschung) per Test: ein Dokument, das heute zurückkehrt (Abo läuft,disabledAtvor 40 Tagen, Einstellung da), wird nicht gelöscht;erg.fehlerenthält die Fehler des Abgleichs; wirft der Abgleich (Liste nicht lesbar), läuft die Löschung weiter (Fehler gezählt,ersatzmit Nullen). - Berichtszeile (Z. 905) gleicht gegen das Literal oben (zusätzlich eine Variante mit
gedeckelt> 0 und dem Deckel-Segment), weiter ohneb_,f_,@; Z. 867 prüft zusätzlicherg.aboAbgleich; der eingeschränkte Lauf liefertgedeckelt,geloeschtundaktiveBetriebe: null, undmeldeTagesjob/statusDatenlesen daraus ohneundefined. - Überlappung: der eingeschränkte Lauf nutzt dieselbe Sperre wie der volle Tagesjob (zweiter paralleler Aufruf ⇒
{ uebersprungen: true }); die Sperren der beidenminutenJob-Teile (job:teil, A12) bleiben davon unabhängig (hängender Tagesjob ⇒ Verfall und Mailversand laufen weiter, und umgekehrt).
- Test „Modus off“ angepasst:
- FAIL → implementieren → PASS →
npm test→ Commit „Tagesjob: Abo-Abgleich vor der Löschung, auch bei TERMINE_ENABLED=off mit bezahlten Dokumenten“
Task E8: Löschung nach 30 Tagen: frische Abo-Prüfung und Zurücksetzen auf off
Files: Modify server/services/termine/jobs.js (ABGESCHALTET_BEHALTEN_MS Z. 50 aus LOESCHFRIST_TAGE, nochAbgeschaltet Z. 325, loescheAbgeschaltete Z. 347), test/termineJobs.test.js (Z. 753–780, 822, 846).
Consumes: E1 aboLage, E2 LOESCHFRIST_TAGE, A11 disabledReason; Block A (seitenweise Löschung und Deckel: die beiden Änderungen hängen an nochAbgeschaltet und an der Stelle, wo die Einstellung gelöscht wird, nicht an der Seitenlogik).
Produces: nochAbgeschaltet prüft bei Grund abo_ende zusätzlich frisch (await aboLage(frisch.customerId)).laeuft; läuft das Abo wieder ⇒ false mit Log („Abo läuft wieder, Löschung abgebrochen“); ein Lesefehler dabei ⇒ false und melde (im Zweifel wird nichts gelöscht). Die Prüfung gilt vor jedem unumkehrbaren Schritt (Buchungsliste, alle 50 Buchungen, Einstellung); für team, kunde_geloescht, abo_ende_vor_einrichtung, Altbestand gibt es keine Abo-Abfrage. Zurücksetzen: sind alle Buchungen weg (vollstaendig) und die Einstellung gelöscht oder schon nicht mehr da (404), setzt der Job unter withLocks(['feature-admin:<projectId>', 'termine-cfg:<id>']) nach nochmaligem Prüfen das Feature-Dokument zurück: stateFields('off'), unlockedByPurchase false, purchaseStatus 'zurueckgesetzt' (dauerhafte Marke „hier wurde nach dem Abo-Ende gelöscht“: Block C6 löscht ein so markiertes Funktionsdokument nie, nur seine Einstellung, damit der siteKey bestehen bleibt; weder paid noch Meldezustand), zahlungMeldung '', ticketId '', activatedAt null, disabledAt null, disabledReason null, aboEndeMailAt null, adminNote 'Daten gelöscht (Löschfrist abgelaufen)'; siteKey bleibt (das eingebettete Snippet bleibt gültig), Bestell-/Rechnungsfelder bleiben bis zur nächsten Bestellung stehen. Gilt für jeden Grund. Der Zähler hat die Form { funktionen, buchungen, gedeckelt } (A4; ein reines Zurücksetzen ohne Löschung zählt nicht). Ist nur noch das Zurücksetzen offen (Einstellung weg, keine Buchungen), entfällt die Abo-Prüfung (nichts Unumkehrbares mehr).
- Tests (
termineJobs.test.js, Abo-Dokumente übersubscriptionsim Mock-Schema ergänzen):- Bestehender Test Z. 753: die Zeile „das Funktionsdokument selbst bleibt“ (Z. 776) wird zu: Dokument vorhanden mit
state 'off',enabled false,unlockedByPurchase false,purchaseStatus 'zurueckgesetzt',zahlungMeldung '',ticketId '',disabledAt null,siteKey 'abcdefghijklmnop'unverändert; die Dokumente der anderen Funktionen (noch nicht abgelaufen, wieder aktiv, anderefeatureKey, ohnedisabledAt) bleiben unverändert; zweiter Lauf{ funktionen: 0, buchungen: 0, gedeckelt: 0 }(diedeepEqual-Pins der TP1-Tests tragengedeckelt: 0seit A4) und das Dokument wird nicht mehr gelistet (keine tägliche Neuprüfung); Entwurfsjob (C6) nach 91 Tagen ⇒ das zurückgesetzte Dokument behält densiteKey(Test in C6). - Abo-Vorprüfung: Grund
abo_ende, Abo läuft wieder,disabledAtvor 31 Tagen ⇒ nichts gelöscht (Buchungen, Zellen, Einstellung da), Dokument bleibtdisabled, Warnung im Log; dasselbe Dokument mit Grundteam⇒ gelöscht und 0 Abfragen aufsubscriptions; Lesefehler aufsubscriptions⇒ nichts gelöscht,fehlergezählt; das Abo kommt erst nach 60 Buchungen zurück (Einwurf nach der 50. Löschung, Muster Z. 822) ⇒ Löschung endet dort, Einstellung bleibt. - Zurücksetzen: scheitert eine Buchungslöschung (
failNextDELETEtermineBookings) ⇒ Dokument bleibtdisabled, Einstellung bleibt; nächster Lauf löscht und setzt zurück. Scheitert das Zurücksetzen (failNextPATCHportalFeatures) ⇒ Einstellung ist weg, Dokument nochdisabled; nächster Lauf setzt zurück, auch wenn das Abo inzwischen läuft (kein Unumkehrbares mehr offen). Wird das Dokument zwischen Löschen der Einstellung und Zurücksetzen vom Team aktiviert (Einwurf), bleibt esactive(frisch gelesen, nicht überschrieben). - Nach dem Zurücksetzen ist eine Neubestellung möglich:
markOrdered(Block B) auf demoff-Dokument ⇒ordered(Verweis auf B-Test, hier nur der Zustandoff).
- Bestehender Test Z. 753: die Zeile „das Funktionsdokument selbst bleibt“ (Z. 776) wird zu: Dokument vorhanden mit
- FAIL → implementieren → PASS →
npm test→ Commit „Löschung nach 30 Tagen: Abo-Vorprüfung, Funktionsdokument danach auf off“
Task E9: Sofortpfad Widerruf Wechsel und Schutz der Team-Aktivierung
Files: Modify server/services/termine/aboAbgleich.js (+ endeTermineFuerKunde), server/routes/abo.js (Z. 1051–1058, nach dem Analyse-Block), server/services/siteFeatures.js (adminActivate Z. 351), Tests test/publicForms.test.js (neben Z. 422), test/funktionenTermine.test.js, test/termineAboAbgleich.test.js.
Consumes: E4–E6, E1 aboLage.
Produces: endeTermineFuerKunde(customerId, { now = new Date() } = {}) ⇒ Promise<{ beendet: string[] }> (wirft nie, wie endAnalyseForCustomer): alle termine-Dokumente des Kunden (listCustomerFeatureDocs), active mit unlockedByPurchase === true ⇒ disable({ reason: 'abo_ende', adminNote: 'Abgeschaltet: Wechsel widerrufen' }), ordered/setup ⇒ abo_ende_vor_einrichtung; je Dokument unter feature-admin:<projectId> mit frischem Lesen, ohne Abo-Prüfung (der Spiegel ist veraltet); danach sofort meldeAboEnde (E4/E5: abo_ende ⇒ K22; abo_ende_vor_einrichtung mit unlockedByPurchase === true ⇒ erst T19 über meldeErstattung (B10b), dann K22 vor_einrichtung und Marker; ein unbezahltes ordered-Dokument wird nur abgeschaltet und bekommt weder T19 noch K22 noch Marker), ein Fehler bei einem Dokument stoppt die anderen nicht. routes/abo.js ruft sie per dynamischem Import in einem eigenen try/catch nach dem Analyse-Block (inTime && type === 'wechsel'); der Widerruf antwortet auch bei einem Fehler dort 200. adminActivate: für termine und ein Dokument mit unlockedByPurchase === true ist das Aktivieren nur erlaubt, wenn (await aboLage(customerId)).laeuft; sonst 409 „Das Wartungs-Abo des Kunden läuft nicht. Der Terminplaner kann erst wieder freigeschaltet werden, wenn es läuft.“ (verhindert, dass der nächste Abgleichslauf die Kulanz zurücknimmt und K22 erneut sendet); Lesefehler ⇒ Fehler, nicht aktiviert. Nicht betroffen: news, Dokumente mit unlockedByPurchase !== true (Pilot/Kulanz ohne Kauf).
- Tests:
publicForms.test.js(Muster Z. 401–440, Dokumentterminestattanalyse): Widerruf des Wechsels in der Frist ⇒ aktives bezahltes Termine-Dokumentdisabled/abo_ende, K22 in der Senke, Analyse-Verhalten unverändert; wirft die Termine-Abschaltung (Dokument-Update scheitert), bleibt der Widerruf 200; Widerruf nach Fristende oder Zusatzspeicher ⇒ Termine unverändert (wf_feat-Test erweitert).- Bezahlter Aufbau über den Sofortpfad (Review Focus #1): Termine-Dokument
setup(unlockedByPurchase,stripeSessionId 'cs_test_1', Ticket vorhanden), Widerruf des Wechsels in der Frist ⇒disabled/abo_ende_vor_einrichtung, genau ein T19 (Blockticket_offen), K22 mit Blockvor_einrichtung,aboEndeMailAtgesetzt,zahlungMeldung 'gemeldet'; der folgendegleicheAboAb-Lauf sendet nichts mehr (weder T19 noch K22). Unbezahltesordered⇒disabled/abo_ende_vor_einrichtung, kein T19, kein K22, kein Marker; kommt die Zahlung danach, meldet B10b T19 genau einmal und der Abgleich K22. T19-Versand scheitert ⇒ der Widerruf antwortet trotzdem 200, K22 und Marker fehlen, der Abgleich am Folgetag sendet T19 und K22 nach. - Kein Flattern: nach dem Sofortpfad (Spiegel
sub_haufproductKey 'wartung',active) läuftgleicheAboAb⇒ Dokument bleibtdisabled, kein K23, kein zweites K22. funktionenTermine.test.js: Team-„Aktivieren“ bei bezahltemdisabled-Dokument ohne laufendes Abo ⇒ 409 mit dem Text, Zustand unverändert; mit laufendem Abo ⇒ 200,active, Grundnull; Dokument ohne Kauf (unlockedByPurchase: false) ohne Abo ⇒ 200;news⇒ unberührt; Abo-Lesefehler ⇒ 5xx, nicht aktiviert.
- FAIL → implementieren → PASS →
npm test→ Commit „Abo-Ende: Sofortpfad beim Widerruf des Wechsels, Team-Aktivierung nur mit laufendem Abo“
Task E10: Zahlung nach dem Abo-Ende (T19 statt Ticket) – Zusatztests gegen B10
Files: Modify test/termineAboAbgleich.test.js (nur Tests). Kein eigener Code und kein eigener Zweig in onFeaturePaid: das Verhalten (kein Ticket, kein K15/T16, T19 genau einmal, Webhook 500 bei Versandfehler, Wiederholung sendet genau einmal) baut B10a/B10b.
Consumes: B10a (markPaid-Fall abo_ende, Marker zahlungMeldung), B10b (meldeErstattung, onFeaturePaid), E4–E6 (gleicheAboAb).
Produces: Tests für das Zusammenspiel von Zahlungs-Webhook und täglichem Abgleich (Review Focus #1: nie Geld ohne Spur, nie doppelt).
- Tests (
handleFeatureCheckoutEventdirekt, Hilfenevent/paidSessionwie infunktionenRoutes.test.js:51–58, Dokumenttermineim Zustandordered, danachgleicheAboAbohne Abo):- Zahlung nach Abschalten ⇒ genau ein T19 (Block
zahlung_nach_ende, Sitzungs-ID), kein Eintrag inworkorders, kein K15/T16,unlockedByPurchase true,purchaseStatus 'paid',zahlungMeldung 'gemeldet', Zustand bleibtdisabled; dasselbe Event noch einmal und danachasync_payment_succeeded⇒ kein zweites T19 (nie doppelt). - T19-Versand schlägt fehl ⇒
handleFeatureCheckoutEventwirft (Webhook 500),unlockedByPurchaseist trotzdemtrue,zahlungMeldungoffen; zweiter Aufruf mit funktionierendem Versand ⇒ genau ein T19. - Folgetag-Abgleich ⇒ K22
vor_einrichtungan den Kunden,aboEndeMailAtgesetzt, kein zweites T19 (Markergemeldet). - Abgleich vor der verspäteten Zahlung-Wiederholung: Schritt 2 meldet T19 (das Dokument ist durch die erste Verbuchung bezahlt), die Wiederholung des Webhooks danach bleibt stumm; parallel (
Promise.all) ⇒ insgesamt ein T19. - Zahlung für ein Dokument mit Grund
teambleibt beim Verhalten aus B10b (T16 „abgeschaltet“, kein T19); Zahlung beisetup/ordered(Abo läuft) unverändert (Ticket, K15, T16). - Rennen Reihenfolge: Zahlung vor dem Abgleich (Dokument
setup, Ticket da) und Abgleich danach ⇒ T16/Ticket und ein T19 (mindestens eine Spur, T19 höchstens einmal); anschließend Stripe-Retry desselben Ereignisses ⇒ stumm.
- Zahlung nach Abschalten ⇒ genau ein T19 (Block
- FAIL → Tests schreiben (bei Rot: den Fehler in B10 beheben, nicht hier umgehen) → PASS →
npm test→ Commit „Zahlung nach Abo-Ende: Zusatztests Webhook und Abgleich (T19 nie doppelt)“
Task E11: Meldungen und Banner im Portal nach Abschaltgrund
Files: Modify server/routes/termine.js (MELDUNG.abgeschaltet Z. 59 und Z. 152; GET /einstellungen Z. 201), public/termine-view.js (+ abschaltText), public/termine.js (hinweisAbgeschaltet Z. 66, Aufrufe Z. 96–98 und Z. 200); Tests test/termineRoutes.test.js (Z. 994), test/termineView.test.js, test/ui/termine-portal.ui.mjs (Z. 1024, Playwright, nicht in npm test).
Consumes: E2 loeschTag, featureView-Felder. Eigentum: E11 besitzt Banner, Meldungstexte und das Feld abgeschaltet (Reihenfolge A → B → E → C → D): Block C (C4 schreibt MELDUNG, withTermine und GET /einstellungen neu, C10b ändert zeigeProjekt) und Block D (D2 verschiebt den Snippet-Aufruf in GET /einstellungen) setzen auf diesen Stand auf und erhalten MELDUNG.abgeschaltet, das Feld abgeschaltet, abschaltText und hinweisAbgeschaltet; C baut keinen eigenen disabled-Banner und kein eigenes Löschdatum.
Produces: GET /api/termine/einstellungen liefert zusätzlich abgeschaltet: { grund, seit, loeschungAm } | null (nur bei state === 'disabled'; grund = disabledReason oder null, seit = disabledAt, loeschungAm = 'JJJJ-MM-TT'). Die 409-Meldung bei Schreibversuchen (Code bleibt abgeschaltet) nennt je Grund das Löschdatum (dd.mm.yyyy): abo_ende „Der Terminplaner ist abgeschaltet, weil dein Wartungs-Abo beendet ist. Du kannst Termine nur noch ansehen und exportieren. Ab dem 04.11.2026 löschen wir die Daten.“, abo_ende_vor_einrichtung „…Abo endete, bevor der Terminplaner eingerichtet war…“, sonst der bisherige Satz plus (mit Datum) „Ab dem … löschen wir die Daten.“. abschaltText({ grund, loeschungAm }) ⇒ { davor, danach, mitExport } in termine-view.js (rein, Du-Form, datumKurz für das Datum): mitExport falsch nur bei abo_ende_vor_einrichtung; hinweisAbgeschaltet(projectId, abgeschaltet) setzt [davor, exportLink(projectId, 'exportieren'), danach] (Linktext und genau ein Link bleiben, die UI-Prüfung und der Link-Wächter in termineView.test.js ~Z. 290 gelten weiter), Text nur über h(). Wortlaut ist Vorschlag für die Rechtsrunde (Block F).
- Tests:
termineRoutes.test.js(Test Z. 994 erweitert): disabled ohne Grund/disabledAt⇒abgeschaltet: { grund: null, seit: null, loeschungAm: null }und der bisherige Satz unverändert;abo_endemitdisabledAt: '2026-10-04T23:30:00.000Z'⇒loeschungAm '2026-11-04', jede Schreibroute 409 mitcode 'abgeschaltet'und Text mit „Wartungs-Abo“ und „04.11.2026“;abo_ende_vor_einrichtung⇒ Text mit „eingerichtet“;active⇒abgeschaltet: null; Lesen und Export bleiben 200.termineView.test.js:abschaltTextje Grund (abo_ende, abo_ende_vor_einrichtung, team, kunde_geloescht,null) mit und ohne Datum;mitExportnur bei vor_einrichtung falsch; kein „Sie“/„Ihr“ (die Dateischleife Z. 277 bleibt grün, keine neue Datei).- UI (
nice, vorherfree -m): Fixturedisabled+abo_ende⇒ Banner nennt „Wartungs-Abo“ und das Datum, der Export-Link bleibt der einzige Link (.tm-box-warn a, Text „exportieren“); Fixture ohne Grund ⇒ wie bisher (Z. 1039).
- FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner-Banner und Meldung nach Abschaltgrund mit Löschdatum“
Task E12: WOMS „Abgeschaltet (Abo beendet), Löschung am …“
Files: Modify src/lib/funktionenView.js (+ abgeschaltetText), src/components/customer/CustomerFunktionen.jsx (Statuszelle, Z. 96–98), tests/unit/funktionenView.test.mjs.
Consumes: E2 (disabledReason, loeschungAm in features[key] über GET /api/team/funktionen/:customerId; adminView reicht die Felder von listCustomerFeatures durch, kein Portal-Eingriff), fmtYmd (src/lib/format.js:41); Block D (D9/D10) ändert dieselbe Komponente nach E12 (Anleitung, aktionen(state, key)) und erhält die Zeile mit abgeschaltetText in der Statuszelle; E12 ergänzt nur diese Zeile und die reine Funktion.
Produces: abgeschaltetText(feature) ⇒ string (rein): '', wenn state !== 'disabled'; sonst Abgeschaltet + (Abo beendet) bei abo_ende, (Abo endete vor Einrichtung, Erstattung prüfen) bei abo_ende_vor_einrichtung, (Team) bei team, (Kunde gelöscht) bei kunde_geloescht, nichts ohne Grund; mit loeschungAm anschließend , Löschung am ${fmtYmd(loeschungAm)}. Die Komponente zeigt den Text als area-muted-Zeile unter dem Abzeichen (das Abzeichen bleibt ZUSTAND.disabled, der Test dort bleibt unverändert). Das Team-„Aktivieren“ meldet bei fehlendem Abo (E9) den Servertext im bestehenden Fehlerbereich.
- Tests (
funktionenView.test.mjs):abgeschaltetText({ state: 'disabled', disabledReason: 'abo_ende', loeschungAm: '2026-11-04' }) === 'Abgeschaltet (Abo beendet), Löschung am 04.11.2026'; die drei anderen Gründe; ohne Grund und Datum'Abgeschaltet'; ohne Grund mit Datum'Abgeschaltet, Löschung am …';active/setup/off/undefined⇒''; unbekannter Grund wird wie „ohne Grund“ behandelt; bestehende Tests (ZUSTAND,aktionen,mergeFunktion) unverändert grün.npm test(WOMS) und einmalnice npx vite build. - FAIL → implementieren → PASS → WOMS
npm test+nice npx vite build→ Commit „Funktionen-Reiter: Abschaltgrund und Löschdatum des Terminplaners“
Abschlussprüfung Block E (Controller): npm test komplett grün (test/previewDevRouter.test.js bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)' (analyticsContract.test.js importiert ../analytics/src und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob' bleibt leer. WOMS-Anteil (E12): npm test im WOMS-Worktree und einmal nice npx vite build. Check vor dem Livegang: der Abgleich läuft zunächst nur beobachtend (Block G: solange kein Dokument unlockedByPurchase === true hat, schaltet er nichts ab).
Block C: Fragebogen
Block C macht aus dem TP1-Einstellungsdokument den Fragebogen: In off ist termineConfig.einstellung der Entwurf und zugleich die spätere Live-Einstellung, dazu das neue Attribut termineConfig.fragebogen (Schrittbestätigungen, Einbauwünsche), acht Schritte aus den TP1-Editoren und die Bestellseite „Prüfen und bestellen“. Der strikte Gate bestellpruefung ersetzt den Stub aus Block B. Reihenfolge: Server C1–C6 (C4a, C4b), Client C7a–C10b (C7a, C7b, C8, C9a, C9b, C10a, C10b), Playwright C11 (Voraussetzung: A, B, E sind fertig; C vor D und F).
Gelesen und geprüft (Portal-Worktree): server/routes/termine.js (withTermine :137-157, GET :201-216, Vorlage :230-252, PATCH :254-272), server/services/termine/{kontext,einstellungen,vorlagen,schema,jobs}.js, public/termine*.js, public/funktionen.js (Termine-Kachel :139-197), server/routes/funktionen.js:109, server/services/customerAdmin.js:285-292, UI-Testserver test/ui/termineTestServer.mjs. Abweichungen von der Spec: (1) BRANCHEN liegt nach der Schnittstellenliste in fragebogen.js, nicht in vorlagen.js; (2) das Attribut kommt in server/services/termine/schema.js (das Setup-Skript scripts/termine-setup.mjs und test/support/schema.js lesen von dort, beide bleiben unverändert); (3) feiertage(t) aus termine-team-ausnahmen.js wird nicht übernommen, Schritt 4 braucht ein Bundesland ohne Vorauswahl und ein Speichern je „Weiter“.
Annahmen an andere Blöcke (abgestimmt, verbindlich):
- B Register:
featureConfig('termine')hat die Felder aus B1 (consent: { version, knopf, items: [{ id, text, haken }] },leistungsbeschreibung,avv,widerruf,priceLabel,setupEstimate). Die Widerrufsbelehrung liefert der HakenfeatureHaken('termine').belehrung(F7); C erfindet keine Registerfelderpflichtinfooderwiderruf{belehrung,muster}. NurbestellungAnsicht()(C2) bildet das Register für die Bestellseite ab. - B Checkout
POST /api/funktionen/termine/checkout: Body{ projectId, zustimmung: { version, haken: [id, …] } }(haken= angehakteconsent.items[].idmithaken: true); „Zahlung fortsetzen“ inorderedsendet{ projectId, fortsetzen: true }und bekommt die offene Sitzung nur, wenn für sie ein Nachweis vorliegt, sonst 409zustimmung_fehlt(die Seite zeigt wieder das Formular); Antwort{ url }. Bei nicht leererbestellpruefungantwortet B mit 409bestellung_offenundbody.offen(Liste{ schritt, text }), bei veralteter Fassung mit 409fassung_veraltetundbody.consent, bei Abo-Mangel mit 409 undcodekein_abo|gekuendigt|nicht_aktiv(Abo-Stufe (7) in B6, nicht über das Gate). - B (Checkout) und C4a (Vorschau) rufen
bestellpruefungmitladeEinstellung(featureId)(C3 liefert dort jetzt auchfragebogen): B6 im Checkout alsbestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), mitAbo: false })(ohneabo, die Abo-Stufe (7) liegt in B6), C4a im GET alsbestellpruefung({ einstellung, fragebogen, feature: featureConfig('termine'), abo: await bestellbar(customerId) })(bei Lesefehler ohneabo⇒ Punkt (h) „unbekannt“). Block D liest die Einbauwünsche über denselben Weg. Abo-Hinweistexte kommen ausaboMeldung(feature, grund)(featureOrder.js, B6); C definiert keine eigenen Wortlaute. - E liefert
aboStatus.laeuft/bestellbar(E1) und besitzt Abschalt-Banner, Meldungstexte und das GET-Feldabgeschaltet(E11). C4a/C4b, C10b und D2 setzen darauf auf und erhaltenMELDUNG.abgeschaltet, das Feldabgeschaltet,abschaltTextundhinweisAbgeschaltet. - A
kundenansichtGuard:PUT /api/termine/fragebogenbleibt außerhalb der Erlaubnisliste; Einfügepunkt für C6 isttaeglicherJobnachloescheAbgeschaltete(auch A und E ändern diese Funktion undtagesbericht; Segmentreihenfolge siehe E7).
Task C1: Fragebogen-Attribut, Branchen, Schritte, Normalisierung
Files: Create server/services/termine/fragebogen.js (Teil 1; hat Block B die Datei schon mit dem Stub angelegt, wird sie ergänzt), test/termineFragebogen.test.js. Modify server/services/termine/schema.js (termineConfig: str('fragebogen', 20000) hinter updatedBy), test/termineSetup.test.js (Attributliste in „Schema: Attribute je Collection“ um fragebogen am Ende; Größen-Test; neuer --check-Test).
Consumes: text aus einstellungen.js; VORLAGEN aus vorlagen.js.
Produces: SCHRITTE = ['branche','wer','leistungen','zeiten','regeln','gastfelder','benachrichtigung','website']; BRANCHEN = [{ key, name, vorlage: string|null, gesundheit: boolean }] (Reihenfolge von vorlagenListe(), danach der Eintrag { key: 'gesundheit', name: 'Gesundheitsbereich', vorlage: null, gesundheit: true }; Schlüssel gesundheit und Name „Gesundheitsbereich“ gelten wörtlich für C2, C5, C9a und C11); leerFragebogen(); normalisiereFragebogen(eingabe) ⇒ { fragebogen } | { fehler: { [pfad]: text } }; FRAGEBOGEN_MAX = 16000. Form: { schema: 1, branche: <BRANCHEN-key>|null, bestaetigt: [Schrittschlüssel], bundeslandGewaehlt: boolean, preiseGeprueft: boolean, einbau: { modus: 'inline'|'knopf'|null, knopfText ≤ 40, stelle ≤ 300, gestaltung ≤ 1000, bemerkung ≤ 1000 } }. branche ist gegenüber der Spec ergänzt, sonst kennt der Gate (f) die Branche nicht.
- Tests:
SCHRITTEgenau diese acht;BRANCHEN: jedevorlagesteht inVORLAGEN, alle neun Vorlagen kommen genau einmal vor, genau ein Eintraggesundheit: trueohne Vorlage,sonstigesbleibt wählbar (D6).normalisiereFragebogen:{}⇒leerFragebogen(); unbekannte Schlüssel und__proto__fliegen raus;bestaetigt: ['website','branche','branche','quatsch']⇒['branche','website'](Reihenfolge der Schritte, ohne Doppelte und Fremde);knopfTextmit 41 Zeichen wird auf 40 gekürzt; Steuerzeichen (\u0000,) ingestaltungverschwinden, Absätze bleiben;modus: 'popup'⇒fehler['einbau.modus'];bundeslandGewaehlt: 'ja'⇒ Fehler; Nicht-Objekt ⇒fehler._; idempotent (normalisiere(normalisiere(x).fragebogen)gleich); größte gültige Eingabe bleibt unterFRAGEBOGEN_MAX. Setup:keysOf('termineConfig')endet auffragebogen; Größe 20000 (Inhalt ≤ 16 000 erzwingt der Server, die größere Spalte wird in Appwrite eine Textspalte und zählt nicht zur Zeilenbreite; Annahme, vor Produktiontermine-setup.mjs --checkund Anlegen gegen eine Kopie);main({ check: true })meldet in einer vorhandenen Collection ohnefragebogen„termineConfig.fragebogen FEHLT“, der Lauf ohne--checklegt genau dieses Attribut an, der zweite nichts. - FAIL → implementieren (
normalisiereFragebogenbaut ein neues Objekt Feld für Feld, Texte übertext();stelle/gestaltung/bemerkungmehrzeilig) → PASS →npm test→ Commit „Terminplaner TP2: Fragebogen-Attribut, Branchen, Schritte, Normalisierung“
Task C2: Bestell-Gate und Gesundheits-Stichwortprüfung
Files: Modify server/services/termine/fragebogen.js (Teil 2, ersetzt den Stub aus B; die Marke STUB entfällt), server/services/termine/einstellungen.js (sichtbareLeistungen :568 und MELDUNG_PFLICHT :562 exportieren, sonst unverändert), test/termineFragebogen.test.js. Delete test/termineFragebogenStub.test.js (B5; sie prüft den Stub und bliebe rot). Create test/support/fragebogenStand.js. B-Tests, die nur mit dem Stub ([]) bestellbar waren (grep -l checkout test/*.test.js), bekommen bestellbarerStand().
Produces: bestellpruefung({ einstellung, fragebogen, feature, abo, mitAbo = true }) ⇒ Array<{ schritt, text }> (schritt ∈ SCHRITTE ∪ 'bestellen', sortiert nach Schrittfolge; feature = Register-Eintrag, nur für label.name und aboMeldung (aus featureOrder.js, B6, Argumente (feature, grund)); abo = Ergebnis von bestellbar, gelesen nur bei mitAbo !== false); SCHRITT_TITEL; gesundheitsWarnungen(einstellung) ⇒ string[]; bestellungAnsicht(feature) ⇒ Promise<{ preis, preisHinweis: config.ustHinweis, einrichtung, consent: { version, knopf, items: [{ id, text, haken }] }, dokumente: [{ id, titel, version, url }], belehrung } | null> (reine Abbildung von oeffentlicheAnsicht(feature.key) (B1) für die Bestellseite: consent unverändert inklusive knopf und haken-Flag (der Knopf zeigt genau diesen Text, B speichert genau diesen Text), dokumente aus den Registerfeldern leistungsbeschreibung/avv/widerruf mit gesetzter url, belehrung = Ergebnis von featureHaken(feature.key).belehrung() (derselbe Text, den B7 im Nachweis speichert; bis F7 der Platzhalter BELEHRUNG_PLATZHALTER aus featureRegistry.js (B7), „ENTWURF (Block F): Widerrufsbelehrung mit Muster-Widerrufsformular.“); keine Felder pflichtinfo/widerruf). Testhelfer: bestellbarerStand({ now }) ⇒ { einstellung, fragebogen } (Vorlage salon, Telefon, Anschrift, Impressum, Datenschutz, alle Schritte bestätigt, bundeslandGewaehlt, preiseGeprueft, einbau.modus: 'inline', branche: 'salon') und entwurfDoc(featureId, { customerId, projectId, stand }) (Seed für termineConfig).
Gate (a)–(h), jeder Punkt ein eigener Eintrag: (a) fehlendePflichtangaben (Telefon ⇒ Schritt website, Anschrift ⇒ branche, Text aus MELDUNG_PFLICHT); (b) keine sichtbareLeistungen ⇒ leistungen; (c) je unbestätigtem Schritt ein Punkt „Bitte bestätige den Schritt „…“.“, dazu leistungen, wenn eine sichtbare Leistung einen Preis hat und preiseGeprueft fehlt; (d) bundeslandGewaehlt !== true ⇒ zeiten (der Serverstandard RP zählt nicht); (e) einbau.modus fehlt, oder knopf ohne knopfText ⇒ website; (f) branche fehlt oder Gesundheit ⇒ branche; (g) impressumUrl oder datenschutzUrl fehlt ⇒ website; (h) nur bei mitAbo !== false (der Checkout in B6 ruft mit mitAbo: false und ohne abo; dort liefert die Abo-Stufe (7) die Codes): abo.ok !== true ⇒ bestellen mit aboMeldung(feature, abo.grund), abo fehlt (Lesefehler) ⇒ derselbe Schritt mit aboMeldung(feature, 'unbekannt') (im Zweifel nicht bestellbar). einstellung: null ⇒ ein Punkt branche.
Stichwortprüfung (Regel: Treffer nur am Wortanfang, vor dem Treffer steht kein Buchstabe \p{L}; „Gesichtsbehandlung“ ist keine Behandlung). Gruppe 1, Stamm mit beliebiger Fortsetzung bis zum Wortende (Zusammensetzungen zählen): physio, therap (Therapie, Therapeut/in), arzt, ärzt, zahnarzt, zahnärzt, heilpraktik, krankengymnast, osteopath, diagnos. Gruppe 2, Stamm plus höchstens eine Beugungsendung aus e, en, n, s, es und dann Wortende: rezept, beschwerde, behandlung (so trifft „Rezepte“, „Beschwerden“, „Behandlungen“, aber nicht „Rezeption“ oder „Rezeptur“: rezept + ion/ur ist keine erlaubte Endung); geprüft werden Leistungsnamen, Beschreibungen, Zusatzfeld-Beschriftungen und -Auswahlen, ergänzt um den Betriebsnamen; höchstens 10 Meldungen, ohne Doppelte, ohne Gästedaten. Nur Warnblock für das Team (T16-Block gesundheit und Ticket-Kurzfassung, beide füllt Block D4), dem Kunden zeigt Block C nichts.
- Tests:
bestellbarerStand()mitabo: { ok: true }⇒[]; je Kriterium (a)–(h) ein Fall mit genau einem Punkt und dem erwartetenschritt(Tabelle im Test), u. a. Telefon leer ⇒{ schritt: 'website' }, Anschrift leer ⇒'branche',bestaetigt: []⇒ acht Punkte in Schrittfolge,bundeslandGewaehlt: falsebeieinstellung.bundesland === 'RP'⇒ trotzdem Punkt, alle Preisekeiner⇒ kein Preis-Punkt,branche: 'gesundheit'⇒ Punkt inbranche,abo: { ok: false, grund }für die drei Gründe ⇒ Text ==aboMeldung(feature, grund),abo: undefined⇒aboMeldung(feature, 'unbekannt'),mitAbo: false⇒ kein (h)-Punkt, auch beiabo: undefinedund beiabo: { ok: false, grund: 'kein_abo' }; Reihenfolge stabil; alle Texte Du-Form (kein\bSie\b).gesundheitsWarnungen: je Stichwort ein Treffer (Name, Beschreibung, Zusatzfeld), Groß-/Kleinschreibung und Umlaute; Regeltest (Treffer: „Rezept“, „Rezepte“, „Behandlungen“, „Beschwerden“, „Physiotherapie“, „Therapiestunde“, „Zahnärztin“, „Krankengymnastik“, „Osteopathie“; kein Treffer: „Rezeption“, „Rezeptur“, „Gesichtsbehandlung“, „Beratung“, „Besichtigung“); alle neunausVorlage-Vorlagen ergeben[](schützt „Gesichtsbehandlung“, „Beratung“, „Besichtigung“);null⇒[].bestellungAnsicht: gleichtoeffentlicheAnsicht('termine')(Items mithaken-Flag,consent.knopfwörtlich,dokumentenur miturl),belehrung== Ergebnis des Hakens,nullfür unbekannte Funktion, enthält nie interne Felder (ticket,mails). Integration Checkout (B6, mit dem echten Gate): Kunde ohne Abo mitbestellbarerStand()⇒ 409kein_abo(nichtbestellung_offen); Kunde ohne Abo mit offenem Fragebogen ⇒ 409bestellung_offenohne Eintragbestellen. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Bestell-Gate und Gesundheits-Stichwortpruefung“
Task C3: Fragebogen in Kontext und Speicher
Files: Modify server/services/termine/kontext.js (liesDokument :58, ladeEinstellung :75, baueKontext :82, speichereEinstellung :156), test/termineKontext.test.js.
Consumes: C1 (leerFragebogen, normalisiereFragebogen).
Produces: ctx.fragebogen (normalisiert; fehlt oder kaputtes JSON ⇒ leerFragebogen(), kaputtes JSON mit einer Logzeile, ohne Wurf); ladeEinstellung(featureId) ⇒ { einstellung, version, fragebogen } | null (additiv, B und D nutzen es); kontextFuerPortal(feature) ⇒ Promise<ctx> (portalFeatures-Dokument in jedem Zustand, kein Modus-/Zustandscheck, den macht der Aufrufer; kontextPerSiteKey und kontextPerFeature bleiben unverändert); speichereFragebogen(feature, aendere, { by, now }) ⇒ { fragebogen } (unter termine-cfg:<id>; aendere(bisher) ⇒ Eingabe, wird normalisiert, Fehler tragen err.fehler; schreibt nur fragebogen, updatedAt, updatedBy, ohne version; fehlt das termineConfig-Dokument ⇒ 409 code: 'keine_einstellung'); neue Option fragebogen bei speichereEinstellung(feature, …, { version, by, now, fragebogen }) schreibt Einstellung und Fragebogen in derselben Sperre (Neuanlage mit Branche, „Vorlage ersetzen“ mit zurückgesetzten Bestätigungen). Beide leeren den Zwischenspeicher.
- Tests:
speichereFragebogenändertfragebogen/updatedAt/updatedBy,versionundeinstellungbleiben bytegleich,ladeEinstellungliefert den neuen Stand;speichereEinstellungohne Option lässtfragebogenstehen; mit Option auf leerer Collection entsteht ein Dokument mit beidem; gleichzeitigspeichereEinstellung(Version 1) undspeichereFragebogen⇒ beide gelten, Version steigt genau einmal; ohne Dokument ⇒keine_einstellung, nichts angelegt; kaputtes Fragebogen-JSON ⇒ leerer Fragebogen und gültige Einstellung;kontextFuerPortalliefert füroff,ordered,setup,active,disabledeinen Kontext,kontextPerSiteKeyfüroff,ordered,setup,disabledmit vollständiger Einstellungnull(Review-Focus 3: Entwurf nie öffentlich), nachactivateden Kontext. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Fragebogen im Kontext und Speicher“
Task C4a: Entwurfszugriff – lesen in jedem Zustand, GET ohne Dokument, GET-Felder
Files: Modify server/routes/termine.js (MELDUNG, withTermine, GET /einstellungen), test/termineRoutes.test.js (Test „Funktion aus, noch nicht bestellt oder ohne Dokument“ :247-264 wird ersetzt), Create test/termineFragebogenRoutes.test.js (Teil 1).
Consumes: C1–C3, aboMemo (bestellbar, E1), aboMeldung(feature, grund) aus featureOrder.js (B6), featureConfig (B), betriebAusKonto (bestehend), analyseHosts; E11 bleibt erhalten: MELDUNG.abgeschaltet (Funktion nach Grund), das GET-Feld abgeschaltet und hinweisAbgeschaltet werden von C4a nicht neu geschrieben, sondern übernommen (C4a ersetzt nur die Zweige für off/ordered/setup).
Produces: withTermine({ schreiben, entwurf }): ohne entwurf unverändert (active|disabled). Mit entwurf: true (nur GET/PATCH /einstellungen, POST /einstellungen/vorlage, PUT /fragebogen): lesen in jedem Zustand; Dokument fehlt ⇒ virtueller Kontext off, nichts wird angelegt (die Schreibregeln folgen in C4b). Alle anderen Routen behalten die Schranke active|disabled. GET liefert zusätzlich branchen: [{ key, name, gesundheit }], fragebogen, kundenansicht: boolean, in off: abo: { ok, grund? } | null, offen: bestellpruefung({ einstellung, fragebogen, feature, abo }) | null (mit abo, also inklusive Punkt (h); bei Lesefehler ohne abo ⇒ (h) „unbekannt“), bestellung: await bestellungAnsicht(…); ohne Einstellung betriebVorschlag: { name, adresse, email, telefon, websiteUrl } (Konto, Website nur aus liveDomain); einbau.snippet und feeds nur in active/disabled.
- Tests (Zustandsmatrix je Zeile Kunde/Kundenansicht, Cookie via
kv()wie im vorhandenen Kundenansicht-Test): GET ohne Dokument ⇒ 200{ state: 'off', einstellung: null, version: 0 }undaw.docs('portalFeatures').sizeunverändert; GET inordered/setup200, aber/buchungen,/zeiten,/export.csv,POST /ressourcen/:id/feed,POST /buchungen⇒ 409 „Der Terminplaner ist für diese Website nicht aktiv.“; Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf GET; Abo: ohne Abo GET 200 mitabo.ok === false/grund 'kein_abo',cancelAtPeriodEnd⇒gekuendigt;aw.failNextaufsubscriptions⇒ GETabo: nullmit offenem Punkt „nicht geprüft“ (Text ausaboMeldung(…, 'unbekannt'));bestellung.consent.knopf== Registertext; E11 unverändert:disabled⇒abgeschaltet-Feld und Meldung nach Grund wie in den E11-Tests (sie bleiben grün). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Entwurfszugriff lesen, GET ohne Dokument, GET-Felder“
Task C4b: Entwurfszugriff – Schreibregeln, PATCH mit fehlt, nie öffentlich
Files: Modify server/routes/termine.js (withTermine Schreibregeln, PATCH /einstellungen), test/termineRoutes.test.js (PATCH-Antwort mit fehlt in :399, die übrigen fehlt-Prüfungen :282, :316, :357, :478 bleiben), test/termineFragebogenRoutes.test.js (Teil 1b).
Consumes: C4a, laeuft (E1), aboMeldung(feature, grund) aus featureOrder.js (B6).
Produces: Schreibregeln für entwurf: true: off und active ja, ordered/setup nur mit req.session.viewAs (sonst 409 code: 'gesperrt': „Dein Terminplaner ist bestellt. Die Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“), disabled 409 wie bisher (Text von E11); in off zusätzlich laeuft(customerId) – nicht bestellbar: eine Kündigung oder offene Zahlung sperrt nur die Bestellung (Gate (h)), nicht das Speichern des Entwurfs (bewusste Abweichung von Spec 6, im Plan-Kopf vermerkt) –, sonst 409 code: 'kein_abo' mit Text aus aboMeldung, bei Lesefehler 503 abo_pruefung (Entwurf entsteht nur für Kunden mit laufendem Wartungs-Abo). Der erste Schreibzugriff legt das Dokument über ensureFeatureDoc(customerId, projectId, 'termine') erst im Handler an, nach der Eingabeprüfung. PATCH ruft normalisiereEinstellung(…, { entwurf: state !== 'active' }) und antwortet mit fehlt: fehlendePflichtangaben(…).
- Tests (Zustandsmatrix wie C4a): PATCH
off⇒ 200,active⇒ 200,ordered/setupKunde ⇒ 409gesperrt(Dokument unverändert), Kundenansicht ⇒ 200 undtermin_einstellungenprotokolliert,disabled⇒ 409 auch in der Kundenansicht; Mandanten (Review-Focus 3): Kunde c2 mit Projekt von c1 ⇒ 403 auf PATCH,termineConfigdanach unverändert; PATCHoffohne Telefon/Anschrift ⇒ 200 mitfehlt: ['betrieb.telefon','betrieb.adresse'], dieselbe Eingabe inactive⇒ 400, ungültige URL inoff⇒ weiter 400 (der Entwurf überspringt nur die Pflichtangaben); Abo: ohne Abo PATCH ⇒ 409kein_abo, kein Dokument entstanden;cancelAtPeriodEndoderpast_due⇒ PATCH weiter 200 (Entwurf bleibt speicherbar; die Bestellung sperrt Gate (h));aw.failNextaufsubscriptions⇒ PATCH 503; öffentlich: füroff,ordered,setupmit vollständiger Einstellung liefernGET /api/public/termine/v1/:siteKey/configundPOST …/buchungen{ aktiv: false }und der Kalender-Link mit dem richtigen Token 404, nach Aktivierung geht es; Einbauwünsche nie öffentlich:fragebogen.einbau.stelle = 'GEHEIMWUNSCH'taucht inJSON.stringifyder öffentlichen Config nicht auf, ebenso nichtfragebogen,einbau,knopfText. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Entwurf schreiben, PATCH mit fehlt, nie oeffentlich“
Task C5: Branche und Vorlage im Entwurf, PUT /fragebogen
Files: Modify server/routes/termine.js (POST /einstellungen/vorlage :230-252, neue Route PUT /fragebogen), test/termineFragebogenRoutes.test.js (Teil 2); die bestehenden Vorlage-Tests in termineRoutes.test.js müssen unverändert grün bleiben.
Consumes: C1–C4b, ausVorlage mit betrieb-Feldern (bestehend).
Produces: POST /api/termine/einstellungen/vorlage { projectId, branche | vorlage, betrieb?: { name, adresse, telefon, email, websiteUrl }, ersetzen?: true, version?: int }: branche ⇒ Vorlage laut BRANCHEN; unbekannt ⇒ 400; gesundheit ⇒ 400 code: 'gesundheitsbereich', es wird nichts angelegt (D6, harter Ausschluss); vorlage ohne branche wie bisher (TP1). Die Betriebsfelder überschreiben nur nichtleere Kontowerte. Eine Einstellung ist nur mit ersetzen: true, version (muss stimmen, sonst 409 EINSTELLUNG_VERALTET) und Zustand off ersetzbar (neue IDs und Feed-Token, fragebogen neu mit gewählter branche, bestaetigt: [], preiseGeprueft: false, bundeslandGewaehlt: false, einbau bleibt), in einem Schreibvorgang über die Option fragebogen aus C3. Antwort 201 mit einstellung (ohne Feed-Token), version, fehlt, fragebogen. PUT /api/termine/fragebogen { projectId, fragebogen }: erst req.session.viewAs ⇒ 403 „In der Kundenansicht kann nichts bestätigt werden.“, dann withTermine({ entwurf: true, schreiben: true }) (Abo-Gate in off, in ordered/setup/disabled 409), limitWrites, normalisiereFragebogen (400 mit fehler), speichereFragebogen; Antwort { fragebogen, offen }; ohne Einstellung 409 keine_einstellung; kein Eintrag im Kundenansicht-Protokoll.
- Tests: Vorlage mit
branche: 'salon'legt Feature-Dokumentoff(mit dauerhaftem siteKey) undtermineConfigmitfragebogen.branche === 'salon'an;betrieb.nameüberschreibt das Kontowert, leeres Feld nicht;branche: 'gesundheit'⇒ 400,portalFeaturesundtermineConfigunverändert (auch ohne vorher vorhandenes Dokument); Vorlage zweimal ⇒ 409;ersetzenohne/mit falscherversion⇒ 400/409, inactive⇒ 409, inoff⇒ neue Leistungen, neue Feed-Token,bestaetigtleer,einbauerhalten; PUT: speichert,versionder Einstellung bleibt,updatedBy=kunde:<id>; zwei Tabs mit offenem Editor (alteversion) können danach weiter per PATCH speichern (kein 409); ungültiger Modus ⇒ 400 mitfehler['einbau.modus']; PUT inordered,setup,disabled⇒ 409; PUT in der Kundenansicht ⇒ 403view_blockedüber den Wächter und 403 mit Text direkt am Router (zweite App ohne Wächter); Mandant c2 ⇒ 403; Schreibbudget: ein kompletter Durchlauf (1 Vorlage, 8 PATCH, 8 PUT, 5 Editor-Speicherungen) bleibt unter 120 je Stunde, der 121. Aufruf ist 429. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Branche und Vorlage im Entwurf, PUT /fragebogen“
Task C6: Entwürfe aufbewahren und löschen
Files: Create server/services/termine/entwuerfe.js, test/termineEntwuerfe.test.js. Modify server/services/termine/jobs.js (taeglicherJob nach loescheAbgeschaltete; Rückgabe entwuerfe; tagesbericht bekommt das Segment Entwürfe <n> gelöscht (<m> offen) nach dem Deckel-Segment (A3b) und vor Abo-Abgleich (E7); vollständige Zeile: [termine] Tagesjob: 0 Buchungen gelöscht (verfallen 0, abgesagt 0, abgelehnt 0, erledigt 0, nichterschienen 0, abgeschaltete Funktionen 0), 0 Funktionen bereinigt, 0 Zellen, 0 Outbox, 0 Mails nicht zugestellt, Entwürfe 0 gelöscht (0 offen), Abo-Abgleich 0 geprüft / 0 abgeschaltet / 0 wieder ein / 0 Mails / 0 ausgenommen, 0 Fehler, 0 aktive Betriebe), server/services/customerAdmin.js (nach disableCustomerFeatures, Fehler nur loggen), test/termineJobs.test.js (Test „taeglicherJob: Löschfristen, …“ :867-906 und die festgenagelte Berichtszeile), test/funktionenTermine.test.js (Test „Kunde loeschen …“ :173).
Produces: loescheEntwuerfe(now) ⇒ { geprueft, geloescht, uebrig, fehler } und loescheEntwuerfeVonKunde(customerId) ⇒ { geloescht }; Konstanten ENTWURF_TAGE = 90, MAX_GEPRUEFT = 2000, MAX_GELOESCHT = 100. Kandidaten: portalFeatures mit featureKey 'termine' und state 'off', seitenweise (limit 100, orderAsc('$id'), cursorAfter). Frist: der jüngere Zeitpunkt aus $updatedAt des Funktionsdokuments und termineConfig.updatedAt ist älter als 90 Tage. Vor jedem Löschen unter withLocks([feature-order:<kunde>:<projekt>, feature-admin:<projekt>, termine-cfg:<id>]) das Dokument frisch lesen (noch off, Frist noch überschritten, keine Buchung zur Funktion), dann termineConfig löschen (404 ist ok), das Funktionsdokument nur, wenn es nie in Betrieb oder bezahlt war (kein activatedAt, ticketId, disabledAt, kein unlockedByPurchase, purchaseStatus weder paid noch zurueckgesetzt; sonst bleibt es wegen des siteKeys). zurueckgesetzt ist die dauerhafte Marke, die E8 nach der Löschung bei Abo-Ende setzt (E8 leert activatedAt, ticketId, disabledAt und unlockedByPurchase wieder, damit das Dokument neu bestellbar ist, und lässt den siteKey stehen, damit das eingebettete Snippet gültig bleibt): ein so markiertes Dokument verliert höchstens seine Einstellung, nie das Funktionsdokument. Nie ordered/setup. Deckel: nach 100 Löschungen zählt der Rest der Seite als uebrig, nach 2000 geprüften Dokumenten endet der Lauf mit einer Logzeile (ohne IDs); gelöschte Kandidaten fehlen am nächsten Tag in der Liste, der Überlauf läuft also an Folgetagen weiter. Einzelfehler zählen in fehler, brechen den Lauf nicht ab. Kundenlöschung entfernt alle off-Entwürfe des Kunden vollständig (Funktionsdokument und Einstellung).
- Tests: 89 Tage alt bleibt, 91 Tage ⇒
termineConfigund Funktionsdokument weg; frisch bearbeiteter Entwurf (alte$updatedAt, neuetermineConfig.updatedAt, nurPUT /fragebogen) bleibt;ordered,setup,active,disabledbleiben unabhängig vom Alter; Funktionsdokument mitactivatedAtverliert nur die Einstellung; von E8 zurückgesetztes Dokument (off,purchaseStatus 'zurueckgesetzt', sonst alle Spuren leer) behält nach 91 Tagen ohne Änderung sein Dokument und densiteKeyunverändert (eine vorhandene Einstellung wird gelöscht); nach fehlgeschlagener Zahlung (purchaseStatus: 'failed') ist der Entwurf nach 90 Tagen weg; Rennen: der Test hältwithLocks(['feature-order:<kunde>:<projekt>']), startetloescheEntwuerfe, schreibt das Dokument auforderedund gibt die Sperre frei ⇒ der Lauf liest frisch und löscht nichts; Buchungen zur Funktion ⇒ nichts gelöscht; 130 Kandidaten ⇒ erster Laufgeloescht 100, uebrig 30, zweiter30/0; Einzelfehler (aw.failNextDELETE) ⇒fehler 1, die übrigen werden gelöscht; Tagesjob enthältentwuerfe, die Berichtszeile hat keine IDs, kein@, keine Namen; beiTERMINE_ENABLED=offläuft der Schritt nicht (Entwürfe sind nie bezahlt; bei der Aufteilung vonfuehreAusdurch Block E gehört er zum stillstehenden Teil); Kunde löschen entfernt seineoff-Entwürfe samt Einstellung, die eines anderen Kunden nicht, ein Fehler dabei hält das Löschen nicht auf (Logzeile). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Entwuerfe nach 90 Tagen und bei Kundenloeschung entfernen“
Task C7a: Client-Bausteine 1 – reine Helfer in termine-view.js
Files: Modify public/termine-view.js (reine Helfer), test/termineView.test.js (Dateiliste, „Spiegel“-Tests), Create test/termineFragebogenView.test.js.
Produces: in termine-view.js: FRAGEBOGEN_SCHRITTE = [[key, titel]] (Spiegel von SCHRITTE), schrittStatus(fragebogen) ⇒ [{ key, titel, erledigt }], ersterOffenerSchritt(fragebogen) ⇒ key | 'bestellen', ressourcenAusNamen({ bisher, typ, namen, neueId }) ⇒ { ressourcen, entfernt, neu } (trimmt, entfernt Doppelte ohne Rücksicht auf Groß-/Kleinschreibung, höchstens LIMITS.ressourcen; gleiche Namen behalten ID und Plan, übrige alte Einträge werden in Reihenfolge umbenannt, weitere Namen kopieren den Plan des ersten, typ gilt für alle), buchungsartFuerAlle(leistungen, art), buchungsartLage(leistungen) ⇒ 'sofort'|'anfrage'|'gemischt', zusammenfassung(einstellung, fragebogen, branchen) ⇒ [{ schritt, titel, zeilen: [string] }] (acht Gruppen, nur Text). Kein loeschDatum: Löschdatum und Abschalt-Texte gehören E11 (abschaltText).
- Tests:
FRAGEBOGEN_SCHRITTEgleichSCHRITTEdes Servers (Spiegel-Test),schrittStatus/ersterOffenerSchrittfür leer, teilweise, vollständig;ressourcenAusNamen: „Stylist/in“ → „Anna“ behält ID und Plan, zweiter NameBenbekommt neue ID und den Plan von Anna, Entfernen liefert die IDs, Doppelte/Leere fallen weg, über 20 Namen werden gekürzt,typ: 'platz'gilt für alle, Ergebnis bestehtnormalisiereEinstellung(mit Leistungen, die allen zugeordnet sind);buchungsartFuerAlleändert nurbuchungsart;zusammenfassungfür die Vorlagesalonenthält Name, Leistungen mit Preis, Wochenplan, Bundesland nur beibundeslandGewaehlt, niefeedToken. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Fragebogen-Helfer im Client“
Task C7b: Client-Bausteine 2 – Exporte der Reiter, wochenplanFeld, fehlt
Files: Modify public/termine-tab-einstellungen.js (Abschnitte exportieren), public/termine-tab-team.js (Wochenplan-Feld herauslösen), public/termine-speichern.js:116, public/termine.js (nur ctx-Bau herauslösen, Verhalten gleich), test/termineView.test.js, test/termineFragebogenView.test.js (statische Teile und Verhalten); Create test/support/fakeDom.js.
Consumes: C7a.
Produces: Exporte aus termine-tab-einstellungen.js: betrieb(ctx, e, { nur, titel, hinweis }) (nur = Teilmenge von name, email, telefon, adresse, kleinunternehmer, impressumUrl, datenschutzUrl, agbUrl, websiteUrl, lese schreibt nur diese Felder; ohne nur wie bisher), darstellung, regeln, gastFelder, erinnerung, storno, rechtliches, zahlung. Aus termine-tab-team.js: wochenplanFeld(r, pfad) ⇒ { el, lese() ⇒ { plan } | { fehler } } (die Tagesfelder :81-86 und der Wochenplan-Block :108-111 aus formular, das ihn danach selbst nutzt). termine-speichern.js übernimmt fehlt: antwort.fehlt ?? [] statt []. Testhilfe test/support/fakeDom.js: installFakeDom({ antworten = [] } = {}) ⇒ { document, fetchLog, finde(baum, vergleich), alle(baum, vergleich), text(baum), klick(el), setze(el, wert), restore() } – ein Minimal-document auf globalThis (createElement, Elemente mit setAttribute, append, addEventListener, dataset, className, value, checked, children) und ein Fake-fetch, das jeden Aufruf als { method, url, body } in fetchLog schreibt und die nächste Antwort aus antworten ({ status, body }, Standard 200 {}) liefert; h aus termine-dom.js liest document bei jedem Aufruf, deshalb braucht der Client keine Einhängepunkte. C8, C9a, C9b und C10b nutzen dieselbe Datei.
- Tests: statisch:
termine-speichern.jssetztfehltaus der Antwort;termine-tab-einstellungen.jsexportiert die acht Abschnitte,betriebohnenurbaut weiter alle neun Felder,wochenplanFeldwird vontermine-tab-team.jsselbst benutzt (gemeinsame bestehende UI-Skriptetermine-portal.ui.mjslaufen unverändert grün). Verhalten mit Fake-DOM (termineFragebogenView.test.js): (a)betrieb(ctx, e, { nur: ['email'] })baut nur das Feldemail,lese()liefert nur{ email }(Schlüsselmenge geprüft); ohnenurentstehen weiter alle neun Felder; (b)wochenplanFeld(r, pfad): gültige Fenster in zwei Tagesfeldern ⇒lese()liefert{ plan }mit genau diesen Fenstern, ein ungültiges Fenster ⇒{ fehler }mit Text, ein leerer Tag ⇒ geschlossen; (c)termine-speichern.js: antwortet der Fake-fetchauf den PATCH mitfehlt: ['betrieb.telefon'], enthält das Ergebnis des Speichernsfehlt: ['betrieb.telefon'](ohnefehltin der Antwort[]). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Reiter-Exporte und wochenplanFeld fuer den Fragebogen“
Task C8: Bestellseite und Statuskarten (funktion-bestellen.js)
Files: Create public/funktion-bestellen.js (heißt bewusst nicht termine*.js: einzige Datei mit href-Links, wird relativ importiert, Regel R4 bleibt; diese Datei entsteht hier, F8 ändert sie nicht), test/termineFragebogenView.test.js (Teil), Modify public/termine.css (tm-bestellen*, tm-rechtstext, tm-statuskarte).
Consumes: C2 (bestellung aus GET /api/termine/einstellungen), C7a (zusammenfassung), B (POST /api/funktionen/termine/checkout), h, request aus termine-dom.js.
Produces: sicherLink(url) ⇒ string (nur https: oder Pfade unter /rechtliches/, sonst ''); bestellKoerper({ projectId, bestellung, gewaehlt }) ⇒ { projectId, zustimmung: { version, haken } } (version = bestellung.consent.version, haken = die angehakten IDs der Items mit haken: true, nie Texte; das ist der Vertrag aus B6); fortsetzenKoerper(projectId) ⇒ { projectId, fortsetzen: true }; renderBestellen(root, { ctx, daten, zumSchritt }) und statusKarte({ projekt, bestellung, einrichtung, danke, kundenansicht }, { weiter }). Es gibt keine Knopftext-Konstante im Client: der Knopf zeigt bestellung.consent.knopf (derselbe Text, den B7 als Eintrag knopf im Nachweis speichert; Review Focus 5: gezeigter Text = gespeicherter Text). Reihenfolge auf der Seite: Zusammenfassung (je Gruppe Knopf „Ändern“ ⇒ zumSchritt(key)), offene Punkte aus daten.offen (je Punkt „Zum Schritt“), Preis mit bestellung.preisHinweis und Einrichtungsdauer, die vollständige Widerrufsbelehrung samt Muster-Formular aus bestellung.belehrung als Text (scrollbarer Bereich, nie eingeklappt, kein <details>; der Text kommt vom Server: bis F7 der Platzhalter BELEHRUNG_PLATZHALTER (B7), ab F7 die Belehrung; F8 ändert diese Datei nicht), Links aus bestellung.dokumente (versionierte Adressen, nur über sicherLink), die Zustimmungs-Haken in Registerreihenfolge (Wortlaut aus bestellung.consent.items; Items mit haken: true sind Haken, andere reiner Text), zuletzt der Knopf. Knopf gesperrt, solange ein Haken fehlt, offen nicht leer ist oder kundenansicht (dann Hinweis „In der Kundenansicht kann nicht bestellt werden.“); ein Klick sperrt ihn, sendet bestellKoerper und setzt window.location.href nur auf eine https:-Adresse; bei Fehler Meldung am Knopf, bei 409 lädt die Seite daten neu und setzt die Haken zurück: bestellung_offen zeigt die Punkte aus body.offen, fassung_veraltet die neue Fassung aus body.consent, ein Abo-Code den Text aus der Antwort. statusKarte: ordered ⇒ Pille „Zahlung offen“, Bestelldatum, Knopf „Zahlung fortsetzen“ (POST fortsetzenKoerper, Navigation zur offenen Sitzung; antwortet die Route 409 zustimmung_fehlt, lädt die Seite neu und zeigt wieder das Bestellformular; Hinweis, dass SEPA einige Tage dauern kann), Chat-Knopf; setup ⇒ „In Einrichtung“, Ticketnummer (ticketWoid, ohne Ticket „Wir legen dein Ticket gerade an.“), Bestelldatum, Rechnungslink über sicherLink, Zeiteinschätzung, Hinweis „Deine Einstellungen sind jetzt gesperrt, Änderungen laufen über den Chat.“; danke (Rückkehr von Stripe) zeigt „Danke! Sobald die Zahlung da ist, legen wir los.“
- Tests (Node, ohne DOM):
sicherLinklässthttps://x.de/aund/rechtliches/avv-terminplaner-v1.htmldurch, weistjavascript:,data:,http:,//evil.exampleund Leerraum-Tricks ab;bestellKoerperliefert genau{ projectId, zustimmung: { version, haken } }mit nur den angehakten IDs derhaken: true-Items (keinconsent, keine Texte; die gesendeten IDs sind Teilmenge der Register-IDs, Querprüfung gegenfeatureConfig('termine').consent.itemsintermineFragebogenView.test.js);fortsetzenKoerperliefert{ projectId, fortsetzen: true }ohnezustimmung; statisch (wie dietermine*.js-Regeln): keininnerHTML/confirm/alert, kein Literal „Zahlungspflichtig“ im Client (der Knopftext kommt ausbestellung.consent.knopf), Du-Form im Code (die Rechtstexte kommen vom Server), jedeshrefnur übersicherLink(,location.href =nur nach Prüfung aufhttps:, keinlocalStorage, kein<details>für die Belehrung; Datei ist intermine*.jsnicht enthalten und wird von keinem absoluten Import geladen (funktionen.jsbehält genau/analyse-view.jsund/geraete.js); Reihenfolge im erzeugten Baum (Fake-DOM aus C7b): Belehrungstext steht vor dem Knopf; Knopftext ==bestellung.consent.knopf(Integration „Knopftext == Registertext == gespeicherterknopf-Eintrag“ in C11). - FAIL → implementieren → PASS →
npm test→ Commit „Funktionen: Bestellseite und Statuskarten“
Task C9a: Fragebogen-Oberfläche 1 – Gerüst und Schritte 1 bis 3
Files: Create public/termine-fragebogen.js, Modify public/termine.css (tm-schritte, tm-schritt, Handy-Ansicht), test/termineView.test.js (Dateiliste: termine-fragebogen.js zwischen termine-formulare.js und termine-gaestetexte.js; neue Regeln unten), test/termineFragebogenView.test.js (Verhalten mit Fake-DOM aus C7b).
Consumes: C4a/C4b, C5 (Routen), C7a/C7b, C8, neuerSpeicher (einziger PATCH-Weg), render aus termine-tab-leistungen.js.
Produces: renderFragebogen(root, ctx) (ctx wie bei den Reitern, hinweise leer): Fortschrittsleiste <ol> mit aria-current="step" (Handy: „Schritt 3 von 8“ + Liste), Start beim ersten offenen Schritt (alles erledigt ⇒ Bestellseite), freie Navigation, ein <form> je Schritt, „Weiter“ = ein PATCH über den neuerSpeicher des Schritts (nur wenn der Schritt Felder hat) und danach PUT /fragebogen mit dem Schritt in bestaetigt; Fehler am Feld (setzeFehler), Fokus auf die Schrittüberschrift beim Wechsel, Hinweis „Wir speichern deinen Entwurf bis zu 90 Tage nach deiner letzten Änderung.“ Schritte 1 bis 3:
1 Branche und Betrieb: ohne Einstellung Radiogruppe aus daten.branchen plus betrieb(…, { nur: ['name','adresse','kleinunternehmer'] }) mit betriebVorschlag, „Mit dieser Branche starten“ ⇒ POST vorlage { branche, betrieb } ⇒ PUT; „Gesundheitsbereich“ ⇒ Erklärtext statt Weiter, es wird keine Anfrage gesendet; mit Einstellung: „Branche wechseln“ mit Rückfrage in der Seite („ersetzt Leistungen, Zeiten und Regeln, alle Bestätigungen gehen zurück“) ⇒ ersetzen: true + version.
2 Wer wird gebucht: Einzelperson / Team / Plätze, Namen je Zeile (ressourcenAusNamen); neue Einträge werden im selben PATCH allen Leistungen zugeordnet, entfernte aus Leistungen und Abwesenheiten genommen; mindestens ein Name.
3 Leistungen: render aus termine-tab-leistungen.js unverändert (speichert selbst) plus Haken „Ich habe die Preise geprüft (die Preise der Vorlage sind Platzhalter)“ ⇒ preiseGeprueft; Weiter braucht eine sichtbare Leistung und bei Preisen den Haken; nur PUT.
daten.kundenansicht === true: PATCH bleibt möglich, „Weiter“ geht ohne PUT weiter, die Bestellseite ist gesperrt (Review-Focus 3, Kundenansicht bestätigt nichts). Nach Schritt 8 (C9b) folgt renderBestellen.
- Tests: statisch in
termineView.test.js: Datei in der Liste, keininnerHTML, Du-Form, keinhref:(der einzige bleibtexportUrl),'PATCH'weiter nur intermine-speichern.js,'PUT'nur intermine-fragebogen.js, kein Zugriff auflocalStorage; Eintrag per ID statt Stelle (bestehende Regel gilt auch hier);renderFragebogenwird nur übertermine.jsgeladen. Verhalten in Node (test/termineFragebogenView.test.js, Fake-DOM und Fake-fetchaus C7b): (a) Schritt 1, Auswahl „Gesundheitsbereich“: der Erklärtext steht im Baum, es gibt keinen Knopf „Mit dieser Branche starten“ und kein „Weiter“,fetchLogbleibt leer (kein einziger Aufruf); (b) „Weiter“ in einem Schritt mit Feldern (Schritt 7):fetchLogist genau[PATCH /api/termine/einstellungen, PUT /api/termine/fragebogen]in dieser Reihenfolge, der PUT-Body enthältprojectIdundfragebogen.bestaetigtmit dem Schritt; antwortet der PATCH mit 400 undfehler, folgt kein PUT und der Fehler steht am Feld (data-fehler); (c) Schritt 3 mit Preisen ohne den Haken „Preise geprüft“ ⇒ „Weiter“ meldet den Fehler und ruft nichts auf, mit Haken ⇒ nur ein PUT (kein PATCH) mitpreiseGeprueft: true; (d)daten.kundenansicht === true: „Weiter“ sendet den PATCH, aber keinen PUT und wechselt trotzdem zum nächsten Schritt; (e) Start beim ersten offenen Schritt (aria-current="step"am richtigen Listenpunkt); (f) Schritt 2 mit den Namen „Anna“ und „Ben“ ⇒ der PATCH-Body enthält beide Ressourcen und jede Leistung ist beiden zugeordnet. Browser-Layout und echter Server in C11 (Playwright), reine Teile in C7a. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Fragebogen-Geruest und Schritte 1 bis 3“
Task C9b: Fragebogen-Oberfläche 2 – Schritte 4 bis 8 und Bestellseite
Files: Modify public/termine-fragebogen.js, public/termine.css, test/termineView.test.js (zusätzliche Importregeln), test/termineFragebogenView.test.js (Verhalten mit Fake-DOM aus C7b).
Consumes: C9a, C7b (wochenplanFeld, betrieb, darstellung, regeln, gastFelder, erinnerung, storno, rechtliches, zahlung), C8 (renderBestellen), abwesenheiten/sonderoeffnungen aus termine-team-ausnahmen.js.
Produces: Schritte 4 bis 8:
4 Zeiten: je Person/Platz wochenplanFeld, Bundesland ohne Vorauswahl (leere erste Option) und „An Feiertagen geschlossen“, im PATCH; danach PUT mit bundeslandGewaehlt: true; darunter abwesenheiten(t) und sonderoeffnungen(t) (speichern selbst, mit eigenem Container und eigenem zeichne, damit ungespeicherte Zeiten oben beim Neuzeichnen nicht verloren gehen).
5 Regeln: Sammelknopf „Alle sofort buchbar / Alle nur als Anfrage“ (buchungsartFuerAlle, Anzeige buchungsartLage), regeln, storno(ctx, e, regeln.absage) (das Muster folgt der Absagefrist), rechtliches, zahlung.
6 Gastfelder: gastFelder. 7 Benachrichtigung: betrieb(…, { nur: ['email'] }) (einzige Adresse, auch Reply-To), darstellung, erinnerung. 8 Website: betrieb(…, { nur: ['telefon','impressumUrl','datenschutzUrl','agbUrl','websiteUrl'] }) plus Einbauwünsche (Modus „direkt auf der Seite / hinter einem Knopf“, Knopftext bei Knopf, Stelle, Gestaltung, Bemerkung; Hinweis, dass ein zweites Widget, Knöpfe je Leistung und Sonderdesign nach Stunden gehen) im PUT. Danach renderBestellen.
- Tests: statisch in
termineView.test.js:termine-fragebogen.jsimportiertabwesenheiten/sonderoeffnungenaustermine-team-ausnahmen.jsundwochenplanFeldaustermine-tab-team.js, kein zweiter Zugriff auf'PATCH'/'PUT'außerhalb der erlaubten Dateien, Bundesland-Auswahl hat keine Vorauswahl (leere erste Option im Quelltext). Verhalten in Node (test/termineFragebogenView.test.js, Fake-DOM und Fake-fetch): (a) Schritt 4: die erste Option des Bundesland-selecthatvalue ''und keine Option istselected; „Weiter“ ohne Wahl ⇒ Fehler am Feld,fetchLogleer; mit WahlRP⇒ PATCH (bundesland: 'RP', Wochenplan auswochenplanFeld), danach PUT mitbundeslandGewaehlt: trueundbestaetigtmitzeiten; (b) Schritt 7: der PATCH enthält ausbetriebnuremail(Schlüsselmenge geprüft), Schritt 8 nurtelefon,impressumUrl,datenschutzUrl,agbUrl,websiteUrl; der PUT enthälteinbaumitmodus,knopfText,stelle,gestaltung,bemerkung, bei Modus „hinter einem Knopf“ ohne Knopftext ⇒ Fehler am Feld und kein Aufruf; (c) nach Schritt 8 zeigtrenderBestellendie Bestellseite (Knopfbeschriftung ==bestellung.consent.knopf). Browser-Layout und echter Server in C11 (Playwright). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Fragebogen-Schritte 4 bis 8 und Bestellseite“
Task C10a: Kachel mit Bestellweg und Abo-Felder in GET /api/funktionen
Files: Modify public/funktionen.js (termineTile :151-180, pickTermineProject), server/routes/funktionen.js:109, test/funktionenTiles.test.js (Tests ab :82 „Aus ⇒ …“ und „Abgeschaltet“), test/funktionenTermine.test.js (Test „GET /api/funktionen: termine.verfuegbar …“ :144), test/ui/termine-portal.ui.mjs (Prüfungen fx_kachel_aus_aktiv_abgeschaltet :306 und fx_projektwahl :377 pinnen die alten Texte).
Consumes: E1 (bestellbar, aboMeldung über featureOrder.js, B6), E11 (Texte der Abschaltung bleiben unberührt), B (featureConfig('termine')).
Produces: GET /api/funktionen ⇒ termine: { verfuegbar: false } unverändert; bei true zusätzlich bestellbar: boolean|null, grund: 'kein_abo'|'gekuendigt'|'nicht_aktiv'|null, preis (priceLabel), einrichtung (setupEstimate); ein Lesefehler im Abo ergibt bestellbar: null und kostet die Liste nichts. Kachel (Pille / Knopf): off mit Abo ⇒ „Aus“ / „Terminplaner einrichten · 149 €“, mit vorhandenem Entwurf (Funktionsdokument da, siteKey gesetzt) „Fragebogen fortsetzen“; off ohne Abo ⇒ kein Knopf, „Im Wartungs-Abo enthalten. Zu Verträge“ (kein_abo), „Dein Wartungs-Abo ist gekündigt …“, „… gerade nicht aktiv …“, bei bestellbar === null „Das Abo konnte gerade nicht geprüft werden.“; ordered ⇒ „Zahlung offen“ / „Status ansehen“; setup ⇒ „In Einrichtung“ / „Status ansehen“; active ⇒ „Aktiv“ / „Termine öffnen“; disabled ⇒ „Abgeschaltet“ / „Termine ansehen“. Zielprojekt: aktiv, dann ordered/setup, dann abgeschaltet, dann Entwurf, dann erstes.
- Tests:
funktionenTermine:{ verfuegbar: false }exakt wie bisher, beitruedie vier neuen Felder für Kunde mit Wartungs-Abo, ohne Abo (kein_abo), mitcancelAtPeriodEnd(gekuendigt), Abo-Lesefehler (bestellbar: null, Projekte kommen trotzdem);funktionenTiles(Node,fetch-Attrappe wie bisher): jede Zeile der Kacheltabelle mit Pillenklasse und Knopftext,ordered/setupohne Zahl-Abfrage an/api/termine, Mehrprojekt-Auswahl, Entwurf ⇒ „Fragebogen fortsetzen“, keindata-view="termine"ohne Abo, Projektname maskiert; die E11-Tests zudisabledbleiben unverändert grün; die zwei Playwright-Prüfungen oben sind auf die neuen Kachel-Texte angepasst und grün. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Kachel mit Bestellweg, Abo-Felder in der Funktionen-Liste“
Task C10b: Ansicht #termine nach Zustand und Rückkehr von Stripe
Files: Modify public/termine.js (zeigeProjekt :75-101, initTermine, Reiterliste), test/funktionenTiles.test.js (Rückkehr-Tests), test/termineFragebogenView.test.js (Ansicht je Zustand mit Fake-DOM aus C7b), test/ui/termine-portal.ui.mjs (Fixture um abo, branchen erweitern).
Consumes: C4a (GET-Felder), C8, C9a/C9b, C10a (Kachel führt in die Ansicht), E1 (bestellbar), E11 (Banner und Texte der Abschaltung bleiben unberührt), B (featureConfig('termine')).
Produces: Ansicht (zeigeProjekt): off ⇒ ohne abo.ok Hinweis wie in der Kachel (kein Fragebogen), sonst renderFragebogen; active ohne Einstellung weiter die TP1-Vorlagenauswahl; ordered/setup ⇒ statusKarte und darunter die Reiter Leistungen, Team & Plätze, Einstellungen schreibgeschützt (nurLesen, der Reiter „Termine“ entfällt, er bekäme 409); disabled ⇒ unverändert der Banner aus E11 (hinweisAbgeschaltet/abschaltText, Löschdatum aus abgeschaltet.loeschungAm); C10b baut keine eigenen Abschalttexte und kein eigenes Löschdatum. Rückkehr #termine?bestellt=1&projekt=<id> (B2 success_url) und Abbruch #termine?projekt=<id> (B2 cancel_url): der Parameter projekt wird in beiden Fällen ausgewertet (Projekt wählen), danke nur mit bestellt=1; Flag und Projekt einmal lesen, Hash per history.replaceState bereinigen (wie initNews).
- Tests: Ansicht je Zustand (Fake-DOM und Fake-
fetchaus C7b, der Fake liefert die GET-Antwort von/api/termine/einstellungen):offmit Abo ⇒renderFragebogen(Fortschrittsleiste im Baum),offohneabo.ok⇒ Hinweis und kein Fragebogen,activeohne Einstellung ⇒ TP1-Vorlagenauswahl,ordered/setup⇒statusKarteund die schreibgeschützten Reiter ohne Reiter „Termine“,disabled⇒ Banner aus E11 (hinweisAbgeschaltet) mit dem Löschdatum ausabgeschaltet.loeschungAm; Rückkehr:#termine?bestellt=1&projekt=p2wählt p2 und zeigtdanke,#termine?projekt=p2(Abbruch) wählt p2 ohnedanke, in beiden Fällen ist der Hash bereinigt; die E11-Tests zudisabledbleiben unverändert grün; Playwright-Fixture-Skripte angepasst und grün. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner TP2: Ansicht nach Zustand und Rueckkehr von Stripe“
Task C11: UI-Testserver für Entwürfe und Playwright „Fragebogen bis Bestellklick“
Files: Modify test/ui/termineTestServer.mjs (Schema um subscriptions, featureConsents; zuruecksetzen({ entwurf: true }): kein Funktionsdokument, keine Einstellung, Wartungs-Abo wartungDoc für c1; Aufräumliste um beide Collections; srv.abo(zustand) und srv.kundenansichtCookie()), Create test/ui/termine-fragebogen.ui.mjs. Nicht Teil von npm test; vorher free -m, Aufruf nice node test/ui/termine-fragebogen.ui.mjs, Bilder nach /root/reports/terminplaner-ui/fragebogen-*.png.
Consumes: C4a–C10b, B (Collection featureConsents über feature-setup.mjs/schemaKeys, Checkout-Route), E (aboStatus), fake Stripe (checkout.stripe.test, wird per page.route abgefangen, kein Netz).
Prüfungen (je eine, nacheinander, check()-Rahmen wie in termine-portal.ui.mjs: fester Browser-Takt Montag 2026-10-05 10:00, Portal-Cookie srv.portalCookie(), Seitenfehler und Browser-Dialoge sind Fehler, Hosts außerhalb 127.0.0.1/localhost/checkout.stripe.test sind Fehler):
ohne_abo: Kachel „Im Wartungs-Abo enthalten“, Ansicht zeigt den Hinweis, es gibt keinen Fragebogen,portalFeaturesbleibt leer.gesundheit_blockt: Kachel „Terminplaner einrichten · 149 €“ ⇒ Branche „Gesundheitsbereich“ ⇒ Erklärtext, kein „Weiter“, kein einziger Schreibaufruf, kein Dokument in Appwrite.fragebogen_komplett_bis_bestellklick: Branche „Friseur und Salon“ (Name/Anschrift aus dem Konto) ⇒ Start legt Funktionsdokumentoffund Einstellung an, Schritte 2 bis 8 ausfüllen (Team mit zwei Namen, Preise geprüft, Bundesland „Rheinland-Pfalz“ gewählt, „Alle sofort buchbar“, Telefon, Impressum, Datenschutz, Modus „hinter einem Knopf“ mit Text) und nach jedem „Weiter“ steht der Schritt infragebogen.bestaetigt(austermineConfiggelesen),versionder Einstellung steigt nur durch PATCH; Bestellseite: offene Punkte leer, Knopf gesperrt, Widerrufsbelehrung und Muster-Formular stehen im DOM vor dem Knopf (compareDocumentPosition; bis F7 der PlatzhaltertextBELEHRUNG_PLATZHALTER, B7), nach allen Haken aktiv; Klick ⇒ Navigation zuhttps://checkout.stripe.test/c/pay/cs_test_…; danach FunktionsdokumentorderedmitstripeSessionId, genau einPOST /v1/checkout/sessionsmitmetadata.featureKey 'termine'undprojectId, einfeatureConsents-Dokument mit den gezeigten Texten (Haken, Belehrung, Knopf: gespeicherterknopf-Eintrag ==bestellung.consent.knopf== Beschriftung des geklickten Knopfes), Fassungen und Zeit, gespeichert bevor die Navigation lief (Zeitstempel kleiner als der Aufruf der Stripe-Attrappe; die Sitzung selbst entsteht davor, B7).offene_punkte_sperren: Fragebogen mit fehlendem Impressum ⇒ Bestellseite listet den Punkt mit „Zum Schritt“, der Knopf bleibt gesperrt, Klick auf den Punkt öffnet Schritt 8; ohne Bundesland ⇒ Punkt im Schritt „Zeiten“, obwohl der ServerstandardRPgespeichert ist.rueckkehr_und_zahlung_fortsetzen: Seedorderedmit Stripe-Sitzung und demfeatureConsents-Dokument dieser Sitzung; Seite#termine?bestellt=1&projekt=p1⇒ Statuskarte „Zahlung offen“ mit „Danke!“, Hash bereinigt, Einstellungen schreibgeschützt (Felder gesperrt, Reiter ohne „Termine“); „Zahlung fortsetzen“ (Body{ projectId, fortsetzen: true }) ⇒ Navigation zur selben Sitzung, es gibt weiterhin genau eine Checkout-Sitzung und weiterhin genau einfeatureConsents-Dokument; ohne dasfeatureConsents-Dokument (Seed) ⇒ keine Navigation, die Seite zeigt wieder das Bestellformular (409zustimmung_fehlt); Abbruch-Rückkehr#termine?projekt=p1ohne „Danke!“; ein PATCH direkt am Router ⇒ 409gesperrt; Zustandsetupper Seed ⇒ Karte mit Ticketnummer und Rechnungslink.entwurf_bleibt_beim_neuladen: nach Schritt 4 Seite neu laden ⇒ Fragebogen startet bei Schritt 5, Eingaben der Schritte 1 bis 4 stehen in den Feldern; Branche wechseln mit Rückfrage ⇒ Bestätigungen zurückgesetzt,einbaubleibt.kundenansicht: Portal mit Kundenansicht-Cookie (srv.kundenansichtCookie(), neu, baut aufkundenansichtCookieaustest/support/fixtures.js; der Wächter hängt im UI-Testserver nicht, geprüft wird die Absicherung am Router selbst) ⇒ Schritte lassen sich lesen und per „Weiter“ durchklicken,fragebogen.bestaetigtbleibt unverändert, Bestellknopf gesperrt mit Hinweis,PUT /fragebogendirekt ⇒ 403.handy_390: Fortschritt „Schritt n von 8“, keine seitliche Rollleiste (scrollWidth <= innerWidth) auf Schritt 1, 4, 8 und Bestellseite; Bilder.- FAIL → implementieren (nur Testserver und Skript, keine Produktivcode-Änderung) → alle Prüfungen grün → vorhandene UI-Skripte
termine-portal.ui.mjsundfunktionen.ui.mjseinmal nacheinander mit durchlaufen lassen → Commit „Terminplaner TP2: UI-Testserver Entwurf und Playwright Fragebogen bis Bestellklick“
Abschlussprüfung Block C (Controller): npm test komplett grün (test/previewDevRouter.test.js bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)' (analyticsContract.test.js importiert ../analytics/src und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob' bleibt leer. Playwright-Läufe (termine-fragebogen.ui.mjs, termine-portal.ui.mjs, funktionen.ui.mjs) nacheinander, vorher free -m, mit nice, nicht Teil von npm test.
Block D: Ticket, Einbauanleitung, WOMS
Aus Einstellungen und Fragebogen entsteht eine reine, deterministische Einbauanleitung (einbauanleitung.js): vollständig im WOMS-Panel mit Kopierknöpfen, als Kurzfassung (≤ 7000 Zeichen) in ticket.details. Die Freischaltung über das Register verallgemeinert Block B (B11, Termine: Einstellung vorhanden und Abo läuft); D liefert die Hindernis-Codes (termine/freischaltung.js) und die Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“. Das Ticket selbst (createFeatureTicket: Deckel, gezielte Wiederholung, Notfall-Details) gehört Block B (B8); ein eigener D5 entfällt. Nie feedToken in Ausgaben, Kundentexte nur als Text, kein Termine-Modus-Gate und kein Zugriff aus der Kundenseite.
Vorbedingungen aus früheren Blöcken: B (featureConfig, config.features.termine.{ priceLabel, ticket, ticketFreischaltung }, B8 ticketTexte/anleitungsLink/kuerze/TICKET_DETAILS_MAX in featureRegistry.js und das Gerüst termine/ticketDetails.js, B9b T16 mit Block gesundheit, B9a K15/K16, B11 activateClosedTickets samt Standardhaken, markPaid ruft ticket.topic/details, K16/T16 mit {{funktion}}), C (termineConfig.fragebogen, gesundheitsWarnungen), E (aboStatus.laeuft, aboMemo). Reihenfolge D1 → D10 (D1–D8 Portal, D9–D10 WOMS; D3a vor D3b; D5 entfällt).
Task D1: Datenschutz-Absatz als Rechtstext-Baustein
Files: Modify server/services/termine/rechtstexte.js (neu datenschutzAbsatz, RECHTSTEXTE_ENTWURF; Kopfkommentar „ohne config“ ersetzen durch „nur Hilfsfunktionen aus einstellungen.js“), test/termineRechtstexte.test.js. docs/rechtliches/termine-datenschutz-absatz.md (Fassung 2026-10-02) bleibt inhaltlich unverändert; den endgültigen Wortlaut setzt Block F in Markdown und Funktion gemeinsam.
Consumes: zeigtWiderruf(einstellung) aus einstellungen.js (Bedingung „Gäste Verbraucher“; deckt sich mit dem Widerrufsknopf im Widget), RECHTSTEXTE_VERSION. Produces: datenschutzAbsatz(einstellung, anrede) ⇒ string (reiner Text, Absätze durch Leerzeile, erste Zeile „Online-Terminbuchung“, nicht HTML-maskiert wie widerrufsbelehrung; anrede === 'du' ⇒ Du-Fassung, sonst Sie wie formularHinweis), RECHTSTEXTE_ENTWURF = true (Block F setzt false, sobald die ENTWURF-Köpfe fallen). Bedingte Sätze nach der Markdown-Quelle: Telefon-Feld (gastFelder.telefon !== 'aus'), Nachricht/Zusatzfelder (gastFelder.nachricht !== 'aus' || gastFelder.zusatz.length > 0), Erinnerung (erinnerung.anbieten), Widerruf (zeigtWiderruf), Telefon hinterlegt (betrieb.telefon). Platzhalter: betrieb.name/adresse/email/telefon, aufbewahrungTage (ungültig ⇒ 90 wie formularHinweis). Fehlen Name oder E-Mail ⇒ TypeError wie betriebsdaten.
- Tests: (a) Pin gegen die Quelle: aus „Fassung Sie“/„Fassung Du“ den Blockquote lesen (
>,**,###entfernt, Leerraum normalisiert),[[wenn …: X]]bei „alle Bedingungen an“ durch X ersetzen, bei „alle aus“ streichen,{{Firma}}/{{Anschrift}}/{{E-Mail}}/{{Telefon}}/{{Tage}}durch Testwerte ersetzen; das Ergebnis ist gleichdatenschutzAbsatz(…)(ebenfalls normalisiert) für beide Zustände und beide Anreden. (b) Jede Bedingung schaltet genau ihren Satz (z. B.anbieten: false⇒ kein „Erinnerung“,gaesteSind: 'unternehmer'⇒ kein „Vertrag widerrufen“,telefon: 'aus'⇒ kein „Telefonnummer“). (c)aufbewahrungTage: 365⇒ „365 Tage nach dem Termin“,'viel'⇒ 90. (d) Sie-Fassung ohne Du-Wörter, Du-Fassung ohne Sie-Wörter (Regexe aus dem bestehenden Datenschutz-Absatz-Test wiederverwenden). (e) Ohne Name oder E-Mail ⇒TypeError; leere Anschrift ⇒ „Verantwortlich ist X, E-Mail Y.“ ohne leeres Komma. (f)RECHTSTEXTE_ENTWURF === true(Block F10b2 setzt die Konstante auffalseund ändert diese Assertion zusammen mit den KöpfenKOPFder Dokumente). - FAIL → implementieren (Absätze als kleine Funktionen mit Bedingung, je Anrede eine Textliste; kein Lesen der Markdown-Datei zur Laufzeit) → PASS →
npm test→ Commit „Terminplaner: Datenschutz-Absatz als Rechtstext-Baustein“
Task D2: Einbau-Snippet (verschoben, mit Knopf) und Farbvorschlag mit WCAG-Kontrast
Files: Create server/services/termine/einbauanleitung.js (zunächst nur diese Bausteine), test/termineEinbauanleitung.test.js; Modify server/routes/termine.js (:182-194 lokales esc und einbauSnippet(ctx) entfernen, Aufrufstelle :212 ⇒ einbauSnippet({ siteKey: ctx.feature.siteKey, apiBase: config.publicBaseUrl, telefon: ctx.einstellung?.betrieb?.telefon }); Modus bleibt inline, die Kundenansicht ändert sich nicht; Merge-Hinweis: Block C ändert dieselbe Datei an withTermine und GET /einstellungen), server/services/termine/einstellungen.js (VORGABE_FARBE exportieren), test/termineEmbedStatic.test.js (Farbparität).
Produces: esc(text), einbauSnippet({ siteKey, apiBase, telefon, modus = 'inline', knopfText }), luminanz(hex), kontrast(hexA, hexB), textFarbeAuf(hex) ⇒ '#ffffff' | '#111827' | '#000000', cssVorschlag(farbe) ⇒ { css, akzent, akzentText, kontrastText, kontrastSeite, hinweise: string[] }. Snippet: wie bisher (<div data-webklar-termine=… data-api=… data-modus=…>, Ausweichtext mit Telefon, <script src="…/embed/termine.js" defer>); Modus knopf ergänzt data-knopf="…" nur bei gesetztem Text (Widget-Vorgabe „Termin buchen“); Text ≤ 40 Zeichen, Zeilenumbrüche zu Leerzeichen, durch esc maskiert; unbekannter Modus ⇒ inline. cssVorschlag: nur gültige #rrggbb (sonst VORGABE_FARBE, nie Rohtext im CSS), Kommentar + [data-webklar-termine]{ --wkt-akzent; --wkt-akzent-text }; hinweise warnt, wenn der Akzent gegen Weiß unter 3 : 1 liegt (Rahmen freier Tage, termine-einbau.md Abschnitt 3).
- Tests: Snippet inline ist für
{ siteKey: 'abcdefghijklmnop', apiBase: 'https://project.webklar.com', telefon: '06301 1234' }zeichengleich zu Abschnitt 1 vondocs/funktionen/termine-einbau.md;telefon: '06301 <12>'⇒<12>; ohne Telefon „Termine telefonisch vereinbaren.“; ModusknopfmitknopfText: 'Jetzt "buchen" & mehr'⇒data-modus="knopf" data-knopf="Jetzt "buchen" & mehr"; Knopf ohne Text ⇒ keindata-knopf; Text mit 60 Zeichen und\n⇒ 40 Zeichen, keine Umbrüche; Modus'dialog'⇒inline.luminanz('#ffffff') === 1,kontrast('#000000', '#ffffff') === 21,textFarbeAuf('#0f766e') === '#ffffff',textFarbeAuf('#f5c542') === '#111827'; für alle 4096 Farben mit Kanalwerten in 17er-Schritten istkontrast(farbe, textFarbeAuf(farbe)) >= 4.5; Parität: dieselben 4096 Farben liefern inpublic/embed/termine.js(Marken@farben-anfang/@farben-ende, pervmwiefarbFunktionen()im Bestandstest) und hier dasselbetextFarbeAuf.cssVorschlag('#9d4b6f').cssenthält--wkt-akzent:#9d4b6f;und--wkt-akzent-text:#ffffff;;'red','#fff',undefined,'#9d4b6f;}</style>'⇒#7c3aedund der Rohtext steht nirgends in der Ausgabe;cssVorschlag('#f5c542').hinweisenennt den Kontrast gegen Weiß. BestehendetermineRoutes.test.js-Assertions zum Snippet (:283-305) bleiben unverändert grün. - FAIL → implementieren (Farbfunktionen 1:1 aus
public/embed/termine.js:192-216, nicht aus dem Widget importieren) → PASS →npm test→ Commit „Terminplaner: Einbau-Snippet mit Knopf und Farbvorschlag nach WCAG verschoben“
Task D3a: Generator einbauanleitung() 1 – Modell, Leistungen, Ressourcen, Regeln, Gastfelder, Benachrichtigungen
Files: Modify server/services/termine/einbauanleitung.js, test/termineEinbauanleitung.test.js.
Consumes: einstellungen.js (preisText, zahlungText, zeigtWiderruf, hatPreis), zeit.js (heute, datumKurz), B config.features.termine.priceLabel (hier „149 €“), config.publicBaseUrl. Produces: einbauanleitung({ feature, project, einstellung, fragebogen, version, now, updatedAt = null, kunde = null, entwurf = RECHTSTEXTE_ENTWURF }) ⇒ { stand: { version, updatedAt }, warnungen, abschnitte, text, kurz } (feature = portalFeatures-Dokument (der Registereintrag heißt in B feature, der Aufrufer D4 übergibt ctx.doc), project = websiteProjects-Dokument, fragebogen = geparstes Objekt oder null, kunde = { name }, die Anrede kommt aus einstellung.anrede ('sie' | 'du', kein eigener Parameter); rein = kein I/O, config wird beim Aufruf gelesen; kurz kommt in D4), hier die Abschnitte modell, leistungen, ressourcen, regeln, gastfelder, benachrichtigungen (feste Reihenfolge, kopierbar: false) und stand. Der Link ins WOMS ist anleitungsLink(customerId, projectId) aus featureRegistry.js (B8). Feldweißliste: gelesen werden ausdrücklich benannte Felder, nie Objekte kopiert (kein feedToken, keine Betriebs-E-Mail außer im Datenschutz-Absatz). Kundentexte (Leistungsnamen, Ressourcenbezeichnungen) nur als Text. Ablaufende Dinge (Abwesenheiten, Sonderöffnungen) nur ab heute(now).
- Tests (Fixture
ausVorlage('salon', BETRIEB, NOW)mit Farbe#9d4b6f, Telefon, Anschrift, Impressum-/Datenschutz-URL): die sechs Abschnitt-IDs in fester Reihenfolge, allekopierbar: false; Leistungen mit Name, Dauer,preisText, Buchungsart, Personen, inaktive markiert; Ressourcen mit Wochenplan („Mo 09:00–12:30, 14:00–18:00“), nur künftige Abwesenheiten; Regeln mit Raster, Vorlauf, Horizont, Absagefrist,maxOffeneJeEmail, Storno-Text samt Fassung,zahlungText; feedToken-Test: jede Ressource trägt einen echten Token,JSON.stringify(ergebnis)enthält weder einen Token nochfeedToken; Weißlistentest: Zusatzfeldergeheim: 'XYZ-GEHEIM'an Betrieb, Ressource und Leistung (undnotizan der Leistung) tauchen nirgends auf. Determinismus: gleiche Eingabe undnow⇒deepEqual; anderesnowändert nur die „erzeugt am“-Zeile und die Filterung vergangener Abwesenheiten.standgleich{ version, updatedAt }. - FAIL → implementieren (je Abschnitt eine kleine Funktion
(daten) ⇒ { id, titel, text, kopierbar }; Hilfsfunktionen für Wochenplan-Text und Kürzen mit „und N weitere“) → PASS →npm test→ Commit „Terminplaner: Einbauanleitung als reiner Generator (Modell bis Benachrichtigungen)“
Task D3b: Generator einbauanleitung() 2 – Einbau, Snippet, CSS, Datenschutz, Checkliste, Abrechnung, Warnungen, Volltext
Files: Modify server/services/termine/einbauanleitung.js, test/termineEinbauanleitung.test.js.
Consumes: D3a, D1 datenschutzAbsatz, RECHTSTEXTE_ENTWURF; D2 Bausteine; einstellungen.js (fehlendePflichtangaben, zeigtWiderruf, hatPreis), C gesundheitsWarnungen(einstellung) ⇒ string[] aus fragebogen.js, config.womsBaseUrl. Produces: ENTWURF_BANNER = „ENTWURF – nur Pilot, vor Einsatz bei fremden Betrieben prüfen“; die restlichen Abschnitt-IDs in fester Reihenfolge hinter D3a: einbau, snippet, css, datenschutz_sie, datenschutz_du, checkliste, abrechnung (zusammen die 13 IDs modell, leistungen, ressourcen, regeln, gastfelder, benachrichtigungen, einbau, snippet, css, datenschutz_sie, datenschutz_du, checkliste, abrechnung); kopierbar: true nur für snippet, css, datenschutz_sie, datenschutz_du. text = alle Abschnitte mit Überschrift, aber nur die zur Anrede (einstellung.anrede, D3a) passende Datenschutz-Fassung (die Abschnitte datenschutz_sie und datenschutz_du rufen datenschutzAbsatz(einstellung, 'sie') bzw. (einstellung, 'du')). Kundentexte (Wünsche aus fragebogen.einbau: stelle, gestaltung, bemerkung, knopfText) nur als Text, gesammelt in einem Block „Kundenwunsch (Text des Kunden)“ im Abschnitt einbau, nie in snippet/css; Grenzen 40/300/1000/1000 erneut angewandt. Ohne Fragebogen: Modus inline. Abnahme-Checkliste bedingt: zeigtWiderruf ⇒ Widerrufsbelehrung aufklappbar, Haken für vorzeitigen Beginn bei Preis, Knopf „Vertrag widerrufen“; Modus knopf und zeigtWiderruf ⇒ Widerrufsknopf dauerhaft neben dem Buchungsknopf, Modus knopf ⇒ --wkt-akzent immer setzen; immer: Widget unsichtbar bis zur Freischaltung, Ausweichtext, Datenschutz-Absatz auf der Website, URLs von Impressum/Datenschutz, „Freischalten (Abnahme)“, danach Probebuchung bis „Fast geschafft“ und Stornierung (die öffentliche Schnittstelle antwortet erst bei active), Kontrast, 390 px, Tastatur (Punkte aus Abschnitt 7 von termine-einbau.md). Abrechnung: „Einrichtung bezahlt (149 €, Rechnung | Rechnung folgt im Portal), Zeit im Arbeitsblatt, nicht auf Abo-Stunden“; Leistungsumfang „ein Widget an einer Stelle, Gestaltung nach Fragebogen, Einbau; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden, vorher im Chat abstimmen“; bei feature.unlockedByPurchase !== true „ohne Zahlung freigeschaltet (Pilot/Kulanz), keine Abrechnung“. Datenschutz-Absatz in der Checkliste: bis Block F steht der Zusatz „liegt die Datenschutzerklärung nicht in unserem Repo: Absatz per Chat an den Kunden senden“; F10b2 ersetzt ihn durch die Endregel aus F4 (WEBklar fügt den Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet).
Warnungen (Reihenfolge): ENTWURF_BANNER genau dann, wenn entwurf; fehlende Pflichtangaben (Telefon/Anschrift, Gäste können nicht buchen); fehlende Impressum-/Datenschutz-URL; gesundheitsWarnungen; Kontrasthinweise aus cssVorschlag; Datenschutz-Absatz nicht erzeugbar (kein Name/keine E-Mail ⇒ statt Wurf Hinweistext im Abschnitt + Warnung). Ohne Einstellung (einstellung === null): Warnung „Keine Einstellungen gespeichert“ und nur der Abschnitt snippet (mit Modus aus dem Fragebogen, falls vorhanden).
- Tests (Fixture wie D3a): Abschnitt-IDs (alle 13) und
kopierbar-Flags exakt wie oben;textenthält genau eine Datenschutz-Fassung (Sie beieinstellung.anrede === 'sie', Du bei'du'),abschnittebeide; Fragebogen{ einbau: { modus: 'knopf', knopfText: 'Termin sichern', stelle: 'Startseite unter dem Hero', gestaltung: '…', bemerkung: '<script>alert(1)</script>' } }⇒ Snippet mitdata-modus="knopf" data-knopf="Termin sichern", die Wünsche stehen nur im Kundenwunsch-Block und das Skript-Tag erscheint nirgends insnippet/css; Texte über Grenze werden gekürzt. Warnungen: Banner genau beientwurf(Standard undentwurf: falsegetrennt prüfen; der Standard hängt anRECHTSTEXTE_ENTWURF, F10b2 stellt die Standard-Assertion um),betrieb.telefon: ''⇒ Pflichtangaben-Warnung, fehlende URL ⇒ Warnung, Leistung „Physiotherapie“ ⇒ Warnung mit „Physio“ (Liste gehört C). Checkliste:gaesteSind: 'unternehmer'⇒ keine Widerrufspunkte; Verbraucher + Modusknopf⇒ dauerhafter Widerrufsknopf, Verbraucher +inline⇒ kein solcher Punkt; „Freischalten (Abnahme)“ und „stornieren“ immer. Abrechnung mitinvoiceUrl⇒ URL, „149 €“, „nicht auf Abo-Stunden“; ohne ⇒ „Rechnung folgt“;unlockedByPurchase: false⇒ „keine Abrechnung“.einstellung: null⇒abschnitte.map(a => a.id)gleich['snippet']und Warnung „Keine Einstellungen gespeichert“. Fehlende E-Mail im Betrieb ⇒ kein Wurf, Abschnitt mit Hinweis. - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Einbauanleitung als reiner Generator (Einbau bis Abrechnung, Warnungen)“
Task D4: Kurzfassung für ticket.details, T16-Gesundheitsblock und Registry-Anbindung
Files: Modify server/services/termine/einbauanleitung.js (kurz, ticketStand, driftWarnung), server/services/termine/kontext.js (ladeStand), server/services/termine/ticketDetails.js (B8 hat den Stub angelegt: der Inhalt wird ersetzt; server/config.js bleibt unberührt, denn features.termine.ticket.details ruft die Datei schon lazy auf), server/services/siteFeatures.js (teamNotice: den von B9b deklarierten T16-Block gesundheit füllen), Create test/termineTicket.test.js; Tests test/termineEinbauanleitung.test.js, test/termineKontext.test.js, test/featureMails.test.js (T16-Fall).
Consumes: D3a/D3b; B8: featureConfig('termine').ticket.details(ctx) mit ctx = { feature, doc, project, customer } (feature = Register-Eintrag, doc = portalFeatures-Dokument, customer darf null sein; now setzt D4 im Rumpf), TICKET_DETAILS_MAX, anleitungsLink, kuerze aus featureRegistry.js; B9b (T16-Platzhalter gesundheit_text, Block gesundheit); C gesundheitsWarnungen und Attribut termineConfig.fragebogen (JSON-String, in test/support/schema.js über schemaKeys('termineConfig') enthalten). Produces: kurz (Klartext, ≤ TICKET_DETAILS_MAX aus B8; die Konstante wird hier nicht neu definiert), ladeStand(featureId) ⇒ Promise<{ einstellung|null, version, updatedAt, fragebogen|null } | null> (null nur ohne termineConfig-Dokument; unlesbares JSON ⇒ jeweiliges Feld null), ticketStand(details) ⇒ number | null (liest ^Konfigurationsstand: Version (\d{1,9})), driftWarnung(ticketVersion, jetzt) ⇒ string | null („Einstellungen seit der Bestellung geändert (Ticket Version N, jetzt M).“ nur bei jetzt > N), termineTicketDetails(ctx) ⇒ Promise<string> (wirft nie: bei Lesefehlern Kopfzeilen + „Anleitung konnte nicht erzeugt werden, im WOMS öffnen“ + Link; setzt now = new Date() im Rumpf und ruft einbauanleitung({ feature: ctx.doc, project: ctx.project, … }) auf – in D3 ist feature das portalFeatures-Dokument, den Registereintrag ctx.feature braucht der Generator nicht; siteKey und $id kommen aus ctx.doc). T16-Block gesundheit: teamNotice füllt gesundheit_text aus gesundheitsWarnungen((await ladeEinstellung(doc.$id))?.einstellung) (zusammengefügt, höchstens 500 Zeichen) und schaltet den Block ein, wenn der Text nicht leer ist; ein Lesefehler schaltet den Block aus, T16 geht trotzdem raus (nie Geld ohne Spur). Aufbau von kurz (Absätze durch Leerzeile, erster Absatz = Kopf): Kopf (Funktion: Terminplaner, Projekt, Domain = liveDomain || previewUrl || subdomain wie createFeatureTicket, Kunde, siteKey, Konfigurationsstand: Version N, TT.MM.JJJJ bzw. Version 0 (noch keine Einstellungen gespeichert)); Kundenwunsch-Block (Modus/Stelle/Gestaltung/Bemerkung); Zählungen mit ersten 8 Namen („12 Leistungen (11 aktiv): A, B, … und 4 weitere“, Ressourcen, Zusatzfelder); Warnungen (höchstens 5, ohne Banner-Text-Doppel); die Gesundheitswarnungen stehen in einem eigenen Absatz „Gesundheitsbereich prüfen“ und zählen nicht zu den 5; Snippet; 6 Schritte (WOMS-Panel öffnen, Code einfügen, Gestaltung/Kontrast, Datenschutz-Absatz, „Freischalten (Abnahme)“ + Probebuchung + Stornierung, Ticket erledigen und Zeit buchen, „Nicht auf das Stundenkontingent anrechnen“); Verweis anleitungsLink. Ohne Datenschutz-Absatz, ohne Tokens. Passt die Summe nicht, fallen erst Zählungen, dann die übrigen Warnungen, dann der Kundenwunsch-Block weg (Kopf, Snippet, Schritte, Verweis und der Gesundheitsabsatz bleiben immer; eine Gesundheitswarnung verschwindet nie unter Platzdruck).
- Tests:
kurz.length <= 7000bei Maximalbefüllung (50 Leistungen mit 100 Zeichen, 20 Ressourcen, 5 Zusatzfelder, Wünsche 300/1000/1000, 5 Warnungen und 10 Gesundheitswarnungen); enthält Kopf, Snippet mitsiteKey, Verweis, „Nicht auf das Stundenkontingent anrechnen“ und nicht „Online-Terminbuchung“ (Datenschutz-Absatz) oder einenfeedToken; erster Absatz endet vor der ersten Leerzeile mit derKonfigurationsstand-Zeile;ticketStand('…Konfigurationsstand: Version 7, 03.10.2026…') === 7, ohne die Zeile oder mitVersion 99999999999⇒null;driftWarnung(7, 9)Text wie oben,driftWarnung(9, 9),(9, 7),(null, 9)⇒null.ladeStand: ohne Dokumentnull, mit Dokument alle Felder, kaputtesfragebogen-JSON ⇒fragebogen: nullbei gültiger Einstellung.termineTicketDetails: mit Dokument ⇒ enthältKonfigurationsstand: Version 1; ohnetermineConfig⇒ String mit „noch keine Einstellungen“ und Snippet;aw.failNext({ collection: 'termineConfig', method: 'GET', status: 500 })⇒ löst auf (kein Wurf) mit Kopf und Link. Registry:featureConfig('termine').ticket.details(ctx)liefert denselben Text wietermineTicketDetails, mitctxgenau in der Form, diemarkPaidübergibt ({ feature, doc, project, customer };siteKeyausctx.doc, nicht ausctx.feature). Gesundheit: Leistung „Physiotherapie“ + Maximalbefüllung ⇒kurzenthält „Physio“ und bleibt ≤ 7000; T16 (teamNotice) für ein Termine-Dokument mit dieser Leistung enthält den Warnblock, ohne Treffer kein Block,ladeEinstellungscheitert (failNext) ⇒ T16 geht ohne Block raus. Integration (Review Focus 1):markOrdered+markPaidfürtermine⇒workorders-Dokument mitdetails≤ 7000, enthältdata-webklar-termine="<siteKey>"; derselbe Ablauf mit kaputtemtermineConfig-Lesen (failNext) legt das Ticket trotzdem an und liefertcreated: true(Zahlung nie ohne Ticket oder T16). - FAIL → implementieren (Budgetliste mit Prioritäten statt Abschneiden mitten im Satz) → PASS →
npm test→ Commit „Terminplaner: Kurzfassung der Einbauanleitung fuer das Einrichtungs-Ticket, T16-Gesundheitsblock“
Task D5: entfällt (vollständig in B8 aufgegangen)
Die Härtung von createFeatureTicket (Titel auf 128 Zeichen, Details auf 7000 mit einheitlicher Schlusszeile, surrogatsicheres Kürzen, gezielte Wiederholung nur bei status 400 und details.length > 1000 mit den Minimal-Details, kein Doppel-Ticket bei 5xx) und ihre Tests liegen in Task B8; webhook_retry_setzt_ticketlose_einrichtung_fort (funktionenRoutes.test.js:321-345) bleibt dort grün. Die Konstanten TICKET_TOPIC_MAX, TICKET_DETAILS_MAX, LANGE_DETAILS und kuerze stehen in featureRegistry.js (B8); D4 und D3 nutzen sie. Block D ändert ticketAdmin.js nicht.
Task D6: Freischalt-Hindernisse und Freigabe-Mail (aus B11 herausgelöst)
Files: Create server/services/termine/freischaltung.js; Modify server/services/siteFeatures.js (runActivation Z. 578–593: den K16-Versand in die neue Funktion meldeFreigeschaltet herauslösen), server/services/featureRegistry.js (der Standardhaken freischaltbar für termine delegiert an freischaltHindernis); Test neu test/termineFreischaltung.test.js. activateClosedTickets, der Hook freischaltbar, die Behandlung einer blockierten Freischaltung (ein adminNote-Eintrag, kein Mailversand) und die Anpassung des Tests siteFeatures.test.js:235 gehören Block B (B11); D6 ändert weder config.js noch activateClosedTickets.
Consumes: B11 (activateClosedTickets, featureHaken, Standardhaken mit der Prüfung „Einstellung vorhanden und laeuft“), B9a (K16 mit {{funktion}}/{{portal_url}}/Knopftext aus dem Register), E1 aboMemo()/laeuft, ladeEinstellung(featureId) (C3). Produces: freischaltHindernis(doc, { abo = aboMemo() } = {}) ⇒ Promise<null | 'kein_abo' | 'keine_einstellung' | 'abo_unbekannt'> (Reihenfolge: Abo zuerst, dann Einstellung vorhanden; laeuft wirft ⇒ 'abo_unbekannt', also nie freischalten bei Lesefehler); der B11-Standardhaken wird freischaltbar(doc) = (await freischaltHindernis(doc)) === null (Verhalten und Tests von B11 bleiben unverändert); meldeFreigeschaltet(doc, { attachments = [] } = {}) (K16 an den Kunden, Fehler geloggt und nie geworfen; attachments nimmt die PDFs der Staff-Aktivierung aus F13a auf); runActivation ruft sie für jedes aktivierte Dokument.
- Tests (Review Focus 2):
freischaltHindernis-Tabelle: Wartungs-Abo da + Einstellung da ⇒null; kein Abo ⇒'kein_abo'(auch ohne Einstellung: Abo zuerst); Abo da, keine Einstellung ⇒'keine_einstellung'; Abo-Lesefehler (failNextaufsubscriptions) ⇒'abo_unbekannt'; Abopast_duezählt als laufend (null); Wechsel-Widerruf (E1) ⇒'kein_abo'.meldeFreigeschaltet: K16 mit „Terminplaner“ im Betreff und Link#termine; mitattachmentssteht das PDF in der gesendeten Mail (mailSink); Versandfehler ⇒ kein Wurf,console.error. Delegation: die Tests von B11 laufen unverändert grün;runActivationsendet für ein aktiviertes Termine-Dokument genau ein K16. - FAIL → implementieren → PASS →
npm test→ Commit „Funktionen: Freischalt-Hindernisse und Freigabe-Mail ausgelagert“
Task D7: Team-Aktionen „Freischalten (Abnahme)“ und „Ticket nachlegen“
Files: Modify server/services/siteFeatures.js (neu freischaltenNachAbnahme, ticketNachlegen), server/routes/admin/funktionenAdmin.js (POST /:customerId/:key, :73-103: erlaubte action ⇒ activate | disable | abnahme | ticket; bei abnahme/ticket zusätzlich featureConfig(key)?.ticketFreischaltung sonst 409; adminNote wie bisher über composeAdminNote), Test test/funktionenTermine.test.js.
Consumes: D6 (freischaltHindernis, meldeFreigeschaltet), B11 (activateClosedTickets bleibt unberührt), B (Ticketanlage mit Register-Text in markPaid: dort createFeatureTicket({ …, topic, details }) in eine interne Funktion legeTicketAn(doc, project, customer) herauslösen, falls B das nicht schon getan hat), handleFeatureCheckoutEvent(event) und retrieveCheckoutSession(id). Produces: freischaltenNachAbnahme({ customerId, projectId, key, by, adminNote, now }) ⇒ { doc, activated: boolean }: nur aus setup (bereits active ⇒ no-op mit activated: false, Antwort 200; off/ordered/disabled ⇒ 409 „Freischalten (Abnahme) gibt es nur für Funktionen in Einrichtung.“; ohne Dokument 409 „Für dieses Projekt gibt es noch {keine}“), Hindernis ⇒ 409 mit Klartext (kein_abo: „Der Kunde hat kein laufendes Wartungs-Abo. Freischalten ist erst mit laufendem Abo möglich.“, keine_einstellung: „Es sind noch keine Einstellungen gespeichert.“, abo_unbekannt ⇒ 503 „Das Abo konnte gerade nicht geprüft werden.“); Schreiben unter feature-activate:<id> mit erneutem Lesen und Hook; setzt nur active, activatedAt, adminNote (Ticket und unlockedByPurchase bleiben, das Ticket wird nicht geschlossen), danach meldeFreigeschaltet (K16). ticketNachlegen({ customerId, projectId, key, by, adminNote }) ⇒ { doc, created: boolean }: nur in setup; (a) ticketId leer ⇒ Zahlung bei Stripe prüfen (retrieveCheckoutSession(doc.stripeSessionId), payment_status === 'paid' sonst 409 „Die Zahlung ist noch nicht eingegangen.“, Stripe nicht erreichbar ⇒ 503) und denselben Weg wie der Webhook laufen lassen (handleFeatureCheckoutEvent({ type: 'checkout.session.completed', data: { object: session } })), damit Ticket, K15 und T16 genau einmal entstehen und ein späterer Stripe-Retry nichts doppelt sendet; (b) ticketId gesetzt, Ticket aber gelöscht ⇒ neues Ticket über legeTicketAn, ticketId ersetzen, keine Mails (K15/T16 gingen beim ersten Mal raus); (c) Ticket vorhanden ⇒ no-op created: false. Keine Sperre feature-paid:<session> um (a) halten (withLocks ist nicht wiederbetretbar, markPaid nimmt sie selbst); (b) läuft unter dieser Sperre.
- Tests:
abnahmeaufsetupmit Einstellung und Abo ⇒ 200,termine.state === 'active',activatedAtgesetzt,ticketIdund Ticket-StatusOpenunverändert,unlockedByPurchase === true,adminNotepasst auf/Andrej: abnahme/, K16 genau einmal; zweiter Klick ⇒ 200 ohne zweite Mail; zwei parallele Klicks ⇒ eine Mail; gleichzeitigrunActivationmit geschlossenem Ticket ⇒ eine Mail insgesamt;off/ordered/disabled⇒ 409; kein Abo ⇒ 409 undsetupbleibt; keine Einstellung ⇒ 409; Abo-Lesefehler ⇒ 503; Projekt eines anderen Kunden ⇒ 403; Kundensitzung statt JWT ⇒ 401/403;news+abnahmefunktioniert ohne Abo-Prüfung (Hook fehlt),analyse⇒ 404 wie bisher.ticket: (a)setupohneticketId, Sitzung inst.checkoutSessionsbezahlt ⇒ Ticket mit Kurzfassung (D4),ticketIdgesetzt, K15 und T16 je einmal, zweiter Klick ⇒created: false, keine weiteren Mails, ein folgender Webhook mit derselben Sitzung ⇒ ebenfalls nichts Neues (Review Focus 1: nie doppelt); unbezahlte Sitzung ⇒ 409; Stripe-Ausfall ⇒ 503; (b) gelöschtes Ticket ⇒ neues Ticket, neueticketId, keine Mail; (c) vorhandenes ⇒ no-op;ordered/active⇒ 409; zwei parallele Klicks ⇒ ein Ticket. Bisherige Tests dieser Datei (Pruefungen wie bei News:action: 'loeschen'⇒ 400) bleiben grün. - FAIL → implementieren → PASS →
npm test→ Commit „Funktionen: Freischalten nach Abnahme und Ticket nachlegen (Team)“
Task D8: Team-Route Anleitung
Files: Modify server/routes/admin/funktionenAdmin.js (neu GET /:customerId/termine/anleitung; adminView :50-62 ergänzt hindernis über freischaltHindernis für termine-Dokumente in setup, ohne portalFeatures erneut zu lesen), docs/funktionen/termine-einbau.md (Einleitung um drei Sätze: Anleitung je Kunde im WOMS-Panel, Kurzfassung im Ticket; alle von termineEmbedStatic.test.js:212-228 gepinnten Strings bleiben), Test test/funktionenTermine.test.js.
Consumes: D3a/D3b, D4 (ladeStand, ticketStand, driftWarnung), D6 (freischaltHindernis, aboMemo je Anfrage), assertOwnProject, getFeatureDoc, B-Meldungstext „Für dieses Projekt gibt es noch keinen Terminplaner“ (FEATURE_LABELS.termine.keine). Produces: GET /api/team/funktionen/:customerId/termine/anleitung?projectId= ⇒ { stand: { version, updatedAt }, warnungen, abschnitte, text, state, ticketWoid, ticketVersion, hindernis } (ohne kurz; hindernis nur in setup, sonst null). Hinter requireStaffJwt (bestehender Mount), kein TERMINE_ENABLED-Gate, kein customerViewLog, kein Pfad unter /api/funktionen (Kundenrouter). 400 ohne projectId, 403 fremdes Projekt (assertOwnProject), 409 ohne Funktionsdokument (Text wie die bestehende Meldung), sonst 200 auch in off/ordered/setup/active/disabled; fehlt die Einstellung ⇒ 200 mit Warnung und nur Snippet. Drift: Ticket über feature.ticketId lesen (404 ⇒ keine Warnung, kein Fehler), ticketStand(details) gegen stand.version, Warnung am Ende von warnungen. Rechnung und Preis stammen live aus dem Dokument (backfillInvoiceLinks ergänzt den Link später, die Anleitung zeigt ihn dann auch bei altem Ticket).
- Tests: ohne JWT 401, mit Kundencookie 401/403,
GET /api/funktionen/termine/anleitung(Kundenrouter) 404; fehlendesprojectId400; Projekt von c2 für c1 ⇒ 403; Projekt ohne Dokument ⇒ 409;config.termine.mode = 'off'⇒ trotzdem 200;customerViewLogunverändert; Antwort-JSON enthält keinen der seededfeedTokenund keinkurz; mit Einstellung ⇒abschnitte.map(a => a.id)wie D3b,stand.version === 1; ohne Einstellung ⇒ nursnippetund die Warnung; Ticket-Details „Konfigurationsstand: Version 1“, Einstellung inzwischen Version 3 ⇒ Warnung „Einstellungen seit der Bestellung geändert (Ticket Version 1, jetzt 3).“; Ticket gelöscht oder ohneticketId⇒ keine Warnung;setupohne Abo ⇒hindernis: 'kein_abo', mit Abonull,active⇒null;GET /api/team/funktionen/:customerIdlieferthindernisjesetup-Dokument und liestportalFeaturesweiterhin einmal (Bestandstest „liest die Funktionsdokumente des Kunden einmal“ grün); Rechnungs-Link erscheint im Abschnittabrechnung, sobaldinvoiceUrlim Dokument steht. - FAIL → implementieren → PASS →
npm test→ Commit „Funktionen: Team-Route fuer die Terminplaner-Einbauanleitung“
Task D9: WOMS – Ansicht-Logik und API-Client
Files: Modify src/lib/funktionenView.js, src/lib/teamApi.js, tests/unit/funktionenView.test.mjs (Worktree /home/webklar/apps/ticket.webklar.com/dev-termine, Branch feature/terminplaner-tp2).
Consumes: D7/D8 (Routen und action-Werte abnahme, ticket; Feld hindernis in der Funktionsliste). Produces: aktionen(state, key, { ticketMissing = false } = {}): News unverändert (off ['activate'], ordered/setup ['activate','disable'], active ['disable'], disabled ['activate']; ohne key wie News), Termine off ['activate'], ordered ['disable'] (kein „Aktivieren (ohne Zahlung)“, sonst verschluckt das Portal eine spätere Zahlung), setup ['abnahme','disable'] bzw. mit ticketMissing ['ticket','abnahme','disable'], active ['disable'], disabled ['activate']; FUNKTIONEN[termine].bestaetigen um abnahme („Terminplaner freischalten? Der Kunde bekommt die Mail „bereit“ und Gäste können buchen.“) und ticket („Fehlendes Einrichtungs-Ticket neu anlegen?“) erweitert; AKTION_LABEL = { activate: 'Aktivieren (ohne Zahlung)', disable: 'Abschalten', abnahme: 'Freischalten (Abnahme)', ticket: 'Ticket nachlegen' }; hindernisText(code) (kein_abo „Kein laufendes Wartungs-Abo: Freischalten ist gesperrt.“, keine_einstellung „Noch keine Einstellungen gespeichert.“, abo_unbekannt „Abo gerade nicht prüfbar.“, sonst ''); anleitungKopien(antwort) ⇒ [{ id, label, text }] (zuerst alles mit antwort.text, dann alle kopierbar-Abschnitte; feste Beschriftungen für snippet „Einbau-Code kopieren“, css „CSS-Variablen kopieren“, datenschutz_sie „Datenschutz-Absatz (Sie) kopieren“, datenschutz_du „… (Du) kopieren“, unbekannte kopierbare ⇒ „ kopieren“; null oder ohne Text ⇒ []); anleitungPfad(customerId, projectId) ⇒ /funktionen/<id>/termine/anleitung?projectId=<id> (kodiert); teamApi.getTermineAnleitung(customerId, projectId) = request(anleitungPfad(…)); panelModell(antwort, feature) ⇒ { kopf, ticketPfad, warnungen, hinweis, kopien, abschnitte } (rein, die gesamte Entscheidungslogik des Panels aus D10): kopf = Stand Version N, TT.MM.JJJJ (aus antwort.stand, ohne Antwort ''), ticketPfad = /tickets?woid=<ticketWoid> (ohne antwort.ticketWoid null), warnungen = antwort.warnungen (sonst []), hinweis = hindernisText(feature.hindernis), kopien = anleitungKopien(antwort), abschnitte = [{ id, titel, text }] in Antwortreihenfolge; null-Antwort ⇒ alles leer; anleitungOffen(search, projectId) ⇒ boolean (true, wenn die Adresse ?anleitung=<projectId> trägt, kodiert oder nicht, sonst false).
- Tests (WOMS
npm test):FUNKTIONEN-Test an die neuen Texte angepasst;aktionenfür alle Zustände × {news,termine} inkl.ticketMissing, ohnekeyund mitundefined-Zustand wie bisher (funktionenView.test.mjs:48-55anpassen, News-Zeilen unverändert);AKTION_LABELexakt;hindernisTextfür alle Codes und'x';anleitungKopienmit einer Beispielantwort (Reihenfolge, Beschriftungen, nicht kopierbare Abschnitte fehlen,null⇒[]);anleitungPfadkodiertprojectIdmit Sonderzeichen;panelModell: Beispielantwort ⇒ alle sechs Felder (KopfStand Version 3, 03.10.2026, Ticketpfad, Warnliste,hinweisbeihindernis: 'kein_abo', Abschnitte in Reihenfolge, Kopien),null⇒ leeres Modell, Antwort ohneticketWoid⇒ticketPfad: null;anleitungOffen('?tab=funktionen&anleitung=p%2F1', 'p/1') === true, anderes Projekt, leere und fehlende Suche ⇒false;mergeFunktion/siteKeyOf-Tests unverändert grün. - FAIL → implementieren (
teamApi.jsbleibt dünn, weil esappwritelädt und nicht unter Node testbar ist) → PASS →npm test→ Commit „WOMS: Aktionen und Anleitungs-Logik fuer den Terminplaner“
Task D10: WOMS – Anleitungs-Panel und Aktionsknöpfe
Files: Modify src/components/customer/CustomerFunktionen.jsx (Zeilen :80-124), src/styles/admin-areas.css (.funktionen-panel*).
Consumes: D9 (aktionen, AKTION_LABEL, hindernisText, anleitungKopien, panelModell, anleitungOffen, getTermineAnleitung), bestehend copyText (CopyableCredential), Card/Badge/Button. Produces: je Termine-Zeile mit Funktionsdokument (Zustand ≠ off) ein Knopf „Anleitung“, der ein Panel unter der Zeile aufklappt (Zeilen werden Fragment mit key, das Panel ist eine eigene Zeile außerhalb der Gitterspalten); Panel (alle Inhalte und Entscheidungen kommen aus panelModell und anleitungOffen in D9, die Komponente verdrahtet nur): Kopfzeile modell.kopf + Ticketlink modell.ticketPfad, modell.warnungen als Liste (area-error), modell.hinweis als Hinweis, Knopfreihe aus modell.kopien (Kopieren mit „Kopiert“-Rückmeldung wie SiteKey), darunter die Abschnitte als aufklappbare Blöcke (<details>, Text in <pre> mit pre-wrap, ausschließlich React-Textknoten, kein dangerouslySetInnerHTML, nie als Link oder HTML); Lade-/Fehlerzustand mit „Erneut laden“; nach jeder Aktion (save) für dieselbe Zeile neu laden, weil sich Version und Hindernis ändern; Panel öffnet automatisch, wenn anleitungOffen(window.location.search, projectId) wahr ist (T16 und Ticket verlinken so); aktionen(feature.state, f.key, { ticketMissing: feature.ticketMissing }) und Beschriftung aus AKTION_LABEL; in setup neben dem Status hindernisText(feature.hindernis).
- Prüfung:
npm testgrün (die Panel-Logik ist in D9 getestet:panelModell,anleitungOffen,anleitungKopien,aktionen,hindernisText;CustomerFunktionen.jsxenthält keine eigene Entscheidungslogik); einmalnice npx vite build(vorherfree -m) ohne Fehler; keine neuen Abhängigkeiten. Sichtprüfung des Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) gehört zum Livegang-Test (Plan-Kopf Schritt 8), nicht innpm test. - Implementieren → Build → Commit „WOMS: Einbauanleitung als Panel mit Kopierknoepfen, Abnahme-Knopf und Ticket nachlegen“
Bewusst nicht in Block D (Spec 7.3, letzter Punkt): Die Textform-Pflicht für adminActivate bei Termine ab Modus on (außer Pilot-Adressen, wie activateAnalyse mit validateTextform und Zielgruppen-Haken) ist keine kleine Ergänzung: sie braucht Eingabefelder (Datum, Kanal, Haken) in der Bestätigungszeile des WOMS, Modusweichen und eigene Tests. Der Einhängepunkt ist vorbereitet: Zweig action === 'activate' in funktionenAdmin.js (POST /:customerId/:key) und die Bestätigungszeile in CustomerFunktionen.jsx (isConfirming). Block F (oder ein eigener Task nach D10) setzt dort an; „Freischalten (Abnahme)“ ist davon nicht betroffen, weil die Zahlung samt Zustimmungsnachweis (featureConsents) vorliegt.
Abschlussprüfung Block D (Controller): npm test komplett grün (test/previewDevRouter.test.js bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)' (analyticsContract.test.js importiert ../analytics/src und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob' bleibt leer. WOMS-Anteil (D9, D10): npm test im WOMS-Worktree und einmal nice npx vite build (vorher free -m); Sichtprüfung des Panels gehört zum Livegang-Test (Block G).
Block F: Rechtstexte, Kanzlei-Paket, News-Korrektur
Block F liefert den Textsatz, ohne den TERMINE_ENABLED=on nicht zu verantworten ist: Leistungsbeschreibung (LB), AVV und Widerrufsbelehrung des Terminplaners (Markdown → HTML → PDF), Datenschutz 6c samt §6/§7, endgültige Gäste- und Bestelltexte, die Korrektur der News-Texte, den Widerruf je Funktion, die Textform-Sperre für die Staff-Aktivierung, die WOMS-Karten und das Kanzlei-Paket. Alle Texte sind „Endfassung zur Kanzlei-/Hausprüfung“, keine Rechtsberatung; jede Aussage, die die Kanzlei bestätigen muss, steht im Kanzlei-Paket (F15a, F15b) als ⚖ Q<n> (in den veröffentlichten Dateien stehen keine Marker).
Fassungsdatum <F>: siehe Kopf, Abschnitt „Grundlagen der Blöcke“ (Berliner Kalendertag des F1-Commits, die drei Stände, was beim Anheben neu entsteht). Dateien einer ausgelieferten Fassung werden nie überschrieben: Änderung nach der Kanzlei = neues Datum + neue Dateien. Review-Fokus 5 (Zustimmungsnachweis: genau die gezeigten Texte + Fassungen + Zeitpunkt, gespeichert vor der Umleitung auf Stripe; Widerrufs-Anker = Klick) gilt für F7–F9; „nie Geld ohne Spur“ (Fokus 1) für den Ausfall der K15-Anhänge in F8. Reihenfolge: F1, F2 · F3–F6 (Dokumente) · F7–F9 (Verdrahtung, nach B) · F10a, F10b1, F10b2, F11, F12a, F12b · F13a (nach B/D), F13b · F14 · F15a, F15b zuletzt · Block G (Livegang-Checkliste, Controller) am Ende dieser Datei.
Befunde beim Lesen des Codes (ergänzen oder korrigieren die Spec):
- „inklusive Mehrwertsteuer“ steht nur in
public/funktionen.js:272; die News-LB sagt „inklusive Umsatzsteuer“ (funktionen-leistungsbeschreibung.html:49,60), K15 „(brutto, einmalig; … kein Abo)“,stripeBilling.js:642undfunktionen-news-design.md:78„brutto“. Alle fallen unter die Korrektur (F2). „§ 356 Abs. 4“ steht inconfig.js:192,196, LB:51,73,funktionen-news-design.md:73, TestlegalTexts.test.js:485. - Die Analyse-Rechtstexte liegen in
public/rechtliches/(nicht untervertragsunterlagen/); ihr HTML ist von Hand gebaut, eine Markdown-Bibliothek gibt es nicht (F6 baut einen kleinen Generator).analyseLegal.jskennt nur die Analyse;build-legal-pdfs.shnurall|analyse. ablauf.js:231(stornoNoetig) hängt nur am Stornotext, nicht am Preis der Leistung; im Widget zeigenembed/termine.js:1215-1219,1259,1279Stornobox, Haken und Prüfung ebenso preisunabhängig.paidFeatureOrdersliefert seit B4featureKeyundvertragAt(Zeit der Zustimmung zur bezahlten Sitzung);withdrawFeature(abo.js:1088) nimmt aber noch das Maximum über alle Funktionen (F9 filtert je Funktion);ORDERS/orderLabel(abo.js:707,782) kennen nurfunktion.- Das Wort ENTWURF ist gepinnt in
termineRechtstexte.test.js:226(Kopf der vier Dokumente),termineEmbedStatic.test.js:219(+docs/funktionen/termine-einbau.md:127) und im Datenschutz-WächtertermineRechtstexte.test.js:267. - WOMS
scripts/setup-legal.mjsDOCUMENTS(Z. 89-96) hat sechs Karten; die Analyse-Karten (README nennt „datenschutz-website-analyse“) stehen nicht im Skript. Nicht Teil dieses Auftrags, dem Inhaber melden. - Der gesetzliche Wertersatz (§ 357a Abs. 2 BGB, Anlage 1: Anteil am Gesamtpreis) ist nicht D3 „nach Stundenaufwand“; die Belehrung trägt den Gesetzeswortlaut, der Aufwand ist nur das Maß für den Anteil (⚖ Q5).
adminActivate(siteFeatures.js:350) prüft weder Einwilligung noch AVV;validateTextform(analyse.js:117) ist exportiert und nimmtminDate.
Task F1: Fassungskonstante und Dokument-Registry
Files: Create server/services/legalDocs.js, test/legalDocs.test.js; Modify server/config.js (termine.avvVersion; features.termine.leistungsbeschreibung und .avv als Getter { version: config.termine.avvVersion, url: '/rechtliches/leistungsbeschreibung-terminplaner-<version>.html' | '/rechtliches/avv-terminplaner-<version>.html' }, sie ersetzen die ENTWURF-Platzhalter aus B1; config.js darf legalDocs.js nicht importieren, die Adresse wird dort aus Slug und Version gebaut, ein Test gleicht sie mit legalPaths ab; ustHinweis gibt es seit B1), server/services/analyseLegal.js (dünner Wrapper), server/index.js (:41,114: app.use('/rechtliches', legalRouter())).
Consumes: B1 (features.termine, config.ustHinweis). Der letzte Test nutzt B featureConfig aus server/services/featureRegistry.js.
Produces: config.termine.avvVersion (= <F>, der echte Berliner Kalendertag des F1-Commits laut Kopf, kein Platzhalter und nicht vor 2026-10-03) und die Getter features.termine.leistungsbeschreibung/.avv (Version und Adresse folgen der Konstante auch zur Laufzeit). In legalDocs.js: Sätze analyse (avv, lb) und termine (lb, avv, widerruf; Slugs leistungsbeschreibung-terminplaner, avv-terminplaner, widerrufsbelehrung-funktionen; PDF-Namen Leistungsbeschreibung-Terminplaner-<v>.pdf, AVV-Terminplaner-<v>.pdf, Widerrufsbelehrung-Muster-Widerrufsformular-<v>.pdf); legalPaths(satz, version) ⇒ { [doc]: '/rechtliches/<slug>-<v>.html' }; legalPdfFiles(satz, version) ⇒ { [doc]: { filename, path } }; legalPdfAttachments(satz, version, { ohne = [] } = {}) ⇒ Promise<Array<{ filename, content, contentType }>> (Reihenfolge termine: lb, avv, widerruf; analyse: avv, lb; wirft bei fehlender Datei); featureLegalAttachments(featureKey, opts) ⇒ Promise<Array> (termine ⇒ Satz termine in aktueller Fassung, news ⇒ []); featureLegalReady(featureKey) ⇒ Promise<boolean>; legalRouter() (Adressen ohne Fassung für beide Sätze, liefert die Fassung aus config; nur für Menschen und WOMS-Karten, keine Mail und kein Zustimmungstext darf sie verlinken).
analyseLegal.js behält legalPaths(version) ⇒ { avv, lb }, legalPdfFiles, legalPdfAttachments, analyseLegalRouter (rufen die Registry mit 'analyse'), damit analyseLegal.test.js und analyseRoutes.test.js:115 ohne Änderung grün bleiben. Versionsprüfung bleibt ^\d{4}-\d{2}-\d{2}$.
- Tests (
legalDocs.test.js):legalPaths('termine', V)nennt drei versionierte Adressen;'../x'und unbekannter Satz werfen;legalPdfFilesDateinamen;legalPdfAttachments('termine','1999-01-01')rejected;featureLegalAttachments('news')=[],featureLegalReady('news')= true;config.termine.avvVersionpasst auf das Datumsmuster und liegt nach'2026-10-02'(der Altfassung der Gästetexte),config.ustHinweiswörtlich (aus B1, nur gelesen); Analyse-Tests unverändert grün; (nach B)featureConfig('termine').leistungsbeschreibungund.avv={ version: config.termine.avvVersion, url: legalPaths('termine', version).lb | .avv }(Drift-Wächter gegen die inconfig.jsgebaute Adresse; ändert ein Testconfig.termine.avvVersion, folgt der Getter);featureLegalAttachments('news')ist[](die News-Rechtsdokumenteleistungsbeschreibung/Fassung 2026-09-30 aus B1 bleiben unberührt und gehören F2). - FAIL → implementieren → PASS →
npm test→ Commit „Rechtstexte: Dokument-Registry und Fassungskonstante des Terminplaners“
Task F2: News-Korrektur (Zitat, Wertersatz, Umsatzsteuer-Wortlaut)
Files: Modify server/config.js (features.news.consent, Alias funktionen.consentText, Kommentar :192), public/vertragsunterlagen/funktionen-leistungsbeschreibung.html (:49,51,60,73, „Stand“), public/funktionen.js:272 (bzw. die Stelle, an die B den Preis verschoben hat), server/services/stripeBilling.js:642 (Kommentar), docs/superpowers/specs/2026-09-30-funktionen-news-design.md (:73,:78, Korrekturvermerk); features.news.mails.preisHinweis steht in config.js (die K15-Vorlage ändert F2 nicht, B9a hat sie schon auf {{preis_hinweis}} umgestellt; ustHinweis im GET liefert B12). Tests test/legalTexts.test.js:485, test/funktionenRoutes.test.js (~153), neu test/funktionenRecht.test.js; angepasste B-Pins: test/featureMails.test.js (B9a: der eingefrorene News-Wortlaut „(brutto, einmalig; …)“ und die K15-Preiszeile bekommen den neuen Satz ohne „brutto“), test/featureRegistry.test.js (B1: der Wortlaut-Pin des News-Satzes und der Fassung; die übrigen Pins lesen config.features.news), test/funktionenCheckout.test.js/test/funktionenRoutes.test.js (B7: News-Nachweis liest Text und Fassung aus config).
Consumes: B-Register (featureConfig('news').consent), F1 config.ustHinweis.
Produces: News-Zustimmung (ein Haken, id beginn, consent.version = <F>): „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit dem Einbau beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung des Einbaus 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).“ (⚖ Q3; Aufbau wie der geprüfte Gästetext ZUSTIMMUNG_VORZEITIG). LB: Kopf-Tabelle und Abschnitt 2 „30,00 € einmalig (ohne Umsatzsteuer gemäß § 19 UStG)“, Abschnitt 6 „… erlischt nach § 356 Abs. 5 Nr. 2 BGB …“ plus Satz zum Wertersatz mit „(§ 357a Abs. 2 BGB)“; die LB bleibt „ENTWURF, Freigabe Nutzer ausstehend“ (Freigabe ist Sache des Inhabers). Bestellseite: „30 € · ohne Umsatzsteuer gemäß § 19 UStG“ aus ustHinweis (B12); K15: nur der Registerwert features.news.mails.preisHinweis verliert „brutto“ („einmalig, ohne Umsatzsteuer gemäß § 19 UStG; die Nutzung ist kostenlos, es läuft kein Abo“). Bestehende News-Bestellungen behalten Text und Fassung (keine Migration).
- Tests (
funktionenRecht.test.js):news_zustimmung_wortlaut(Register = Alias = exakter Text, enthältAbs. 5 Nr. 2und§ 357a Abs. 2, nichtAbs. 4);waechter_zitat_und_preistext(über config.js, News-LB, funktionen.js bzw. Bestellseite, K15, stripeBilling.js kein Treffer auf/§ 356 Abs\. 4/,/inklusive (Mehr|Umsatz)steuer/i,/\bbrutto\b/i; LB nenntohne Umsatzsteuer gemäß § 19 UStGzweimal);bestellseite_hinweis(GET /api/funktionen⇒ustHinweis === config.ustHinweis(B12), Client setzt ihn pertextContent);k15_news(gerenderter Text:30,00 €, Hinweis, kein „brutto“);alte_bestellung_unveraendert(Dokument mit altemconsentTextbleibt beim Lesen unverändert, neue Bestellung speichert Register-Text und Fassung).legalTexts.test.js:485prüft['30,00 €','§ 356 Abs. 5 Nr. 2 BGB','§ 357a Abs. 2 BGB','1–2 Wochen','keine laufenden Kosten']; der ENTWURF-Pin der News-LB (:481) bleibt. - FAIL → implementieren → PASS →
npm test→ Commit „News: Zitat § 356 Abs. 5 Nr. 2, Wertersatz-Satz und § 19 UStG statt brutto“
Task F3: Widerrufsbelehrung der Funktionen (Text aus Code, Dokument gepinnt)
Files: Modify server/services/termine/rechtstexte.js (die Absatz-Bausteine nehmen die „Widerrufsfunktion“ als Parameter), Create docs/rechtliches/widerrufsbelehrung-funktionen.md; Test test/termineRechtstexte.test.js (erweitern, Hilfsfunktionen musterZeilen/fristBaustein unverändert nutzen).
Produces: widerrufsbelehrungFunktion() ⇒ string (Sie-Form, Text mit Zeilenumbrüchen wie widerrufsbelehrung): WEBklar als Unternehmer (WEBKLAR_KONTAKT = { name: 'WEBklar GbR', adresse: 'Am Schwimmbad 10, 67722 Winnweiler', telefon: '0176 23726355', email: 'support@webklar.com' }), Fristbeginn „des Vertragsabschlusses.“, Widerrufsfunktion im Satzrahmen der Anlage 1 mit der Adresse https://project.webklar.com/widerrufen.html (Wortlaut wie im Abo, abo-bedingungen.html Abschnitt „Widerrufsbelehrung“), Wertersatz für Dienstleistungen, Muster-Widerrufsformular mit „An WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, E-Mail: support@webklar.com:“. Die Ausgabe von widerrufsbelehrung(betrieb) (Gäste) bleibt byte-gleich.
Dokument widerrufsbelehrung-funktionen.md: # Widerrufsbelehrung und Muster-Widerrufsformular (Einrichtung von Funktionen), **Fassung <F>**, Hinweis „Das Widerrufsrecht gilt nur für Verbraucher“ (D1: Belehrung und Haken erhalten trotzdem alle Besteller, ⚖ Q1), Belehrung + Formular wörtlich aus widerrufsbelehrungFunktion(), dann „Hinweise zum Widerruf“ (nicht Teil der gesetzlichen Belehrung): 1. gilt je Bestellung einer Funktion und je Website-Projekt; 2. der Widerruf der Einrichtung beendet die Funktion, das Wartungs-Abo bleibt unberührt (⚖ Q7); 3. hat WEBklar auf ausdrücklichen Wunsch vor Fristende mit der Einrichtung begonnen, schuldet der Kunde einen angemessenen Betrag für die bis dahin erbrachte Leistung, bemessen als Anteil am Gesamtpreis; Maß ist der im Arbeitsblatt erfasste Aufwand (⚖ Q5); 4. Rückzahlung über das ursprüngliche Zahlungsmittel (Stripe).
- Tests:
belehrung_funktion_muster_woertlich(jeder Absatz der Anlage 1 für Dienstleistungen steht wörtlich darin, nur die Lücken sind gefüllt);belehrung_funktion_widerrufsfunktion(Satzrahmen aus Anlage 1 wörtlich, Lücke = die Adresse);belehrung_funktion_formular(Anlage 2 wörtlich, „An“-Zeile mit WEBklar);belehrung_gleich_abo(die Absätze zwischen „Widerrufsrecht“ und „Ende der Widerrufsbelehrung“ inpublic/abo-bedingungen.htmlsind nach Normalisierung der Absatzgrenzen wortgleich zur Funktionsausgabe, wobei Kontaktklammer und „An“-Zeile vorher in beiden durch einen Platzhalter ersetzt werden, weil die Gäste-Bausteine „Telefon: …, E-Mail: …“ mit Doppelpunkt setzen; Drift-Wächter zwischen den beiden WEBklar-Belehrungen);belehrung_dokument_gepinnt(Textblock in der Datei = Funktionsausgabe, Kopf trägtFassung ${config.termine.avvVersion}, kein[,…,{{); Gäste-Belehrung unverändert (bestehende Tests grün). - FAIL → implementieren → PASS →
npm test→ Commit „Rechtstexte: Widerrufsbelehrung der Funktionen aus dem Gesetzesmuster“
Task F4: Leistungsbeschreibung Terminplaner (Markdown)
Files: Create docs/rechtliches/leistungsbeschreibung-terminplaner.md, test/termineVertragsunterlagen.test.js.
Aufbau (Nummern verbindlich: AVV, Paket und Mails verweisen darauf): # Leistungsbeschreibung Terminplaner, **Fassung <F>** · Zusatzbedingungen zum Wartungs-Abo und Abschnitte 1 Geltung · 2 Leistung · 3 Nicht enthalten · 4 Voraussetzungen und Pflichten des Kunden · 5 Einrichtung und Preis · 6 Verfügbarkeit und Störungen · 7 Haftung für verpasste Termine · 8 Ende der Leistung · 9 Änderungen und Einstellung · 10 Widerruf · 11 Hinweise. Dritte Person („der Kunde“) wie die Analyse-LB; Gerüst und Tonfall aus leistungsbeschreibung-website-analyse.md.
Inhalt (Festlegungen):
- 1: Zusatz zum Wartungs-Abo, Hosting-Abo genügt nicht; vereinbart mit der Bestellung und der Zustimmung zu LB, AVV Terminplaner und Belehrung (AVV Vorbemerkung regelt den Ablauf); Rangfolge: LB vor Abo-Bedingungen, in Datenschutzfragen der AVV; LB B Nr. 3 der Abo-Bedingungen („Rechtstexte nicht enthalten“) gilt nicht für Datenschutz-Absatz und Gästetexte.
- 2: je Website-Projekt ein Widget an einer Stelle (inline oder Knopf), Gestaltung nach Fragebogen; Leistungen, Personen/Plätze, Zeiten, Regeln; Portal-Ansicht, CSV-Export; E-Mails (Bestätigungslink, Bestätigung, Änderung, Absage, Erinnerung nur mit Haken, Eingang eines Widerrufs); geheimer Kalender-Link (ICS); Missbrauchsschutz; Löschfristen nach AVV Nr. 9; nur Zahlung vor Ort; Gästetexte, Datenschutz-Absatz und Belehrung für Gäste sind Mustertexte der Standardkonfiguration.
- 3: nicht enthalten: SMS, Online-Zahlung, Kalender-Sync (nur ICS-Abo), Konten der Gäste, Marketing-Mails, Gruppen, Mehrsprachigkeit, Gesundheitsbereich; Mehrwünsche (zweites Widget, Knöpfe je Leistung, Sonderdesign) nach Stunden (§ 4 Abo-Bedingungen).
- 4: laufendes Wartungs-Abo; Betriebsangaben samt Impressum- und Datenschutz-Adresse vor der Bestellung; kein Angebot im Gesundheitsbereich (WEBklar darf Einrichtung ablehnen oder abschalten, ⚖ Q11); kein Angebot gezielt an Kinder unter 16; Telefon-Ausweichlösung; Haken- und Hinweistexte unverändert lassen; Beschäftigte über Namen/Zeiten und Kalender-Link informieren; Pflichten gegenüber Gästen (Preisangaben, AGB/Stornobedingungen, Einstufung Freizeit/Verbraucher) trägt der Kunde. Datenschutz-Absatz: liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar ihn beim Einbau ein, Teil der Einrichtung und nicht auf das Stundenkontingent angerechnet; sonst Textbaustein, den der Kunde selbst einfügt (gleiche Regel wie Analyse-LB Nr. 4 Abs. 4, ⚖ Q18).
- 5: Einrichtung nach Fragebogen, Einbau, Datenschutz-Absatz, Abnahme, Freischaltung; 149 € einmalig je Website-Projekt, ohne Umsatzsteuer gemäß § 19 UStG; die voraussichtliche Dauer nennt die Bestellseite (Einschätzung, keine Zusage); Zahlung über Stripe, Rechnung; Preis nicht auf Stunden anrechenbar.
- 6: Verfügbarkeitsziel (§ 5 Abo-Bedingungen) gilt für die Website, nicht für die Funktion; bei Ausfall zeigt das Widget den Ausweichtext mit Telefonnummer; Zeiten in Europe/Berlin; keine Gewähr für Zustellung und Lesen von E-Mails (Postfach des Empfängers, Mailgrenzen des Anbieters); gesetzliche Rechte, §§ 12, 13 Abo-Bedingungen bleiben.
- 7: (1) Hilfsmittel; Einhaltung der Termine bleibt Sache des Kunden. (2) keine Gewähr für Zustellung der E-Mails. (3) Entgangener Gewinn und Folgeschäden aus verpassten Terminen nur im Rahmen von § 13 Abs. 1 und 2 der Abo-Bedingungen. (4) Der Haftungsrahmen des Kunden gegenüber seinen Gästen bleibt unberührt (⚖ Q8).
- 8: (1) „Mit dem Ende des Wartungs-Abos endet der Terminplaner. WEBklar schaltet ihn spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf des Abschlusses oder eines Wechsels ins Wartungs-Abo.“ (⚖ Q7). (2) Wirkung: keine neuen Buchungen, Ausweichtext; Termine bleiben im Portal sichtbar, CSV-Export; Gäste können über ihren Link absagen; bereits angeforderte Erinnerungen laufen weiter; der Kunde informiert seine Gäste. (3) „WEBklar löscht alle Termindaten und die Einstellungen 30 Tage nach dem Abschalten.“ (⚖ Q9). (4) Rückkehr (D2): „Beginnt innerhalb dieser 30 Tage wieder ein Wartungs-Abo, schaltet WEBklar den Terminplaner mit den vorhandenen Daten und Einstellungen ohne weitere Kosten wieder ein. Nach der Löschung ist eine neue Einrichtung nötig (neuer Fragebogen, neuer Preis nach Nr. 5).“ (5) Erstattung (D3): „Endet das Wartungs-Abo, bevor WEBklar den Terminplaner freigeschaltet hat, erstattet WEBklar den Einrichtungspreis vollständig.“ (⚖ Q6). (6) Auf Wunsch des Kunden in Textform schaltet WEBklar den Terminplaner jederzeit ab; eine Erstattung des Einrichtungspreises folgt daraus nicht (außer Widerruf, ⚖ Q6). Hinweis zur Kanzleifrage Q7: Dokumente, die WEBklar ohne Bestellung freigeschaltet hat (Pilot/Kulanz, auch F13a), nimmt der tägliche Abgleich aus und zählt sie im Tagesbericht als
ausgenommen; sie bleiben bis zur Abschaltung durch WEBklar aktiv. - 9: Technik verbessern, solange der Leistungsumfang nicht schlechter wird; Einstellung nur bei Rechtslage oder nicht behebbarer Sicherheitslücke mit Frist (Muster Analyse-LB Nr. 8 Abs. 2 und 3); keine Einstellung ohne Grund; Rechte nach § 327r BGB bleiben (Erstattung bei Einstellung durch WEBklar offen, ⚖ Q8).
- 10: Verbraucher widerrufen die Bestellung der Einrichtung nach der Belehrung (Anlage); Erlöschen und Wertersatz nach der Zustimmung bei der Bestellung (⚖ Q3, Q5); der Widerruf beendet die Funktion.
- 11: Keine Rechtsberatung: Mustertexte für die Standardkonfiguration, Einstufung (Verbraucher/Unternehmer, Freizeit) trägt der Kunde; Barrierefreiheit: „Das Widget ist für die Bedienung mit Tastatur und Hilfstechniken gestaltet (Beschriftungen, Fokusführung, Fehlermeldungen als Live-Region). Eine Konformität nach WCAG 2.1 AA oder EN 301 549 sagt WEBklar nicht zu. Ob der Kunde als Dienstleistungserbringer unter das BFSG fällt (Ausnahme für Kleinstunternehmen, § 3 Abs. 3 BFSG), entscheidet und prüft der Kunde.“ (⚖ Q22; kein axe-/Screenreader-Lauf in TP1, daher keine Konformitätszusage).
- Tests (
termineVertragsunterlagen.test.js, LB-Teil):lb_aufbau(h2-Liste exakt, KopfFassung ${config.termine.avvVersion});lb_sauber(keinENTWURF,Prüfpunkt,TODO,{{,<!--);lb_festwerte(149 €,§ 19 UStG, „Hosting-Abo genügt nicht“-Satz, die Sätze von Nr. 8 Abs. 1, 3, 4, 5 wörtlich);lb_preis_gleich_register(149 €=featureConfig('termine').priceLabel);lb_fristen_gleich_code(30 Tage Abschalten =LOESCHFRIST_TAGEaustermine/frist.js(E2), kein Regex-Lesen vonjobs.js);lb_verweise(jedes „Nr. N“ auf LB/AVV und „§ N Abo-Bedingungen“ existiert);lb_ohne_du(keine Du-Form);lb_bfsg_ohne_konformitaetszusage(kein „WCAG … entspricht“). - FAIL → Text schreiben → PASS →
npm test→ Commit „Rechtstexte: Leistungsbeschreibung Terminplaner“
Task F5: AVV Terminplaner (Markdown)
Files: Create docs/rechtliches/avv-terminplaner.md; Test test/termineVertragsunterlagen.test.js (AVV-Teil).
Aufbau wie avv-website-analyse.md (eigenes Geschwister, kein Zusatz zum AVV der Analyse, damit Analyse-Kunden nicht neu zustimmen): # Auftragsverarbeitungsvertrag Terminplaner (Art. 28 DSGVO), **Fassung <F>**, Parteien, Vorbemerkung, Nr. 1–12, Anlage TOM.
Inhalt (Festlegungen):
- Vorbemerkung: Abschluss per Haken bei der Bestellung zusammen mit der LB; das Portal speichert Fassung, Zeitpunkt, Konto und Person (
featureConsents); danach schickt WEBklar K15 mit den PDFs. Mitarbeiter dürfen die Funktion nur einschalten, wenn der Kunde vorher in Textform zugestimmt hat (F13a); der Kunde erhält die Unterlagen anschließend per E-Mail; Verhältnis zu Anlage 1 der Abo-Bedingungen: für den Terminplaner geht dieser Vertrag vor. - Nr. 1 Dauer: ab Einschalten bis Abschalten, spätestens Ende des Wartungs-Abos (Abschalten spätestens am Folgetag); Pflichten bis zur Löschung. Nr. 2: Termine verwalten, E-Mails im Auftrag, Missbrauchsschutz; keine eigenen Zwecke, keine Profile.
- Nr. 3 Daten: Gäste (Name, E-Mail; optional Telefon, Nachricht, Zusatzfelder des Betriebs; Leistung, Person/Platz, Termin; Status mit Zeitpunkten; Nachweis der Zustimmungen als Zeitpunkt und Hashes der gezeigten Texte; Erinnerungswunsch; Zustellstatus der E-Mails) und Beschäftigte des Kunden (Namen/Bezeichnungen, Arbeitszeiten, Abwesenheiten, geheimer Kalender-Link). Art. 9 DSGVO ausgeschlossen (Gesundheitsbereich; Pflicht Nr. 11). IP-Adresse und Browser sieht der Server technisch, Mengenbegrenzung nur im Arbeitsspeicher, mit der Buchung wird keine IP gespeichert; Server-Protokolle 14 Tage (Anlage 2 der Abo-Bedingungen).
- Nr. 4 Weisungen (Einstellungen im Portal, sonst Textform), Nr. 5 Vertraulichkeit, Nr. 6 TOM (Anlage), Nr. 7 Unterauftragsverarbeiter: Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen: Falkenstein (Server, Sicherung) und Nürnberg (Webhosting-SMTP für die Buchungsmails), keine Drittländer, Änderung mit vier Wochen Vorlauf und zwei Wochen Widerspruch (Muster Analyse-AVV Nr. 7), Stripe betrifft nur den Vertrag Kunde–WEBklar (⚖ Q10).
- Nr. 8 Betroffenenrechte: Anfragen von Gästen leitet WEBklar an den Kunden weiter; Unterstützung durch die Aktion „Gastdaten löschen“ (
anonymisieren) und den CSV-Export; Datenpannen unverzüglich, möglichst binnen 24 Stunden; Archiv der Fassungen der Gästetexte (docs/rechtliches/archiv/termine-gaestetexte-<V>.md) bis drei Jahre nach Ende (Nachweis speichert nur Hashes, ⚖ Q26). - Nr. 9 Löschung genau wie gebaut: nicht bestätigte Buchungen 48 Stunden nach Ablauf der Bestätigungsfrist von 30 Minuten; abgesagte/abgelehnte Termine 30 Tage; bestätigte und erledigte Termine
aufbewahrungTage(Vorgabe 90, einstellbar 14–365) nach Terminende; Versandwarteschlange: Empfängeradresse und Inhalt nach dem Versand geleert, Eintrag nach 7 Tagen gelöscht, nicht zustellbare nach 48 Stunden; Abgeschaltet oder Ende des Abos: alle Termindaten und Einstellungen 30 Tage danach (CSV-Export bis dahin, Rückkehr nach LB Nr. 8 Abs. 4); Entwürfe aus dem Fragebogen ohne Bestellung 90 Tage nach der letzten Änderung; Sicherungen: gelöschte Daten bleiben bis zu 7 Tage (§ 3 Abs. 5 Abo-Bedingungen, ⚖ Q9). - Nr. 10 Nachweise/Kontrollen (wie Analyse), Nr. 11 Pflichten des Kunden (Verantwortlicher; Information der Gäste nach Art. 13 mit Datenschutz-Absatz und Hinweis am Formular; Beschäftigte informieren; kein Gesundheitsbereich; Erinnerungs-Haken nicht verändern), Nr. 12 Haftung (§ 13 Abo-Bedingungen, Art. 82), Änderungen mit sechs Wochen Vorlauf, im Übrigen § 16.
- Anlage TOM (Art. 32): TLS; keine Cookies, nichts im Browser des Gastes (gesichert durch
termineEmbedStatic.test.js:24); Zugriff: Portal-Sitzung, Kunde sieht nur eigene Daten, öffentliche Schnittstelle nur über Site-Key, Kalender-Link mit geheimem Token (konstantzeitiger Vergleich); Missbrauchsschutz (Honeypot, Mindestzeit, Grenzen und Mailbudget nur im Arbeitsspeicher,limits.js); Datenminimierung; Trennung je Funktions-Dokument; Löschlauf täglich; Protokolle ohne Gast-Daten; Sicherung Hetzner (7 Tagessicherungen); Rechenzentrum ISO/IEC 27001; Organisation (Verpflichtung, Überprüfung jährlich). Jede Zeile der Anlage nennt in der Test-Tabelle ihre Fundstelle. - Tests (AVV-Teil):
avv_aufbau(Überschriften Vorbemerkung, 1–12, Anlage; Kopf mit Fassung);avv_sauber(wie LB);avv_unterauftragsverarbeiter(Nr. 7 nennt Hetzner mit Anschrift,FalkensteinundNürnberg, kein Drittland);avv_loeschfristen_gleich_code(48 h, 30 Tage, Vorgabe 90 / 14 / 365, Outbox 7 Tage / 48 h; die 30 Tage Abschalten ausLOESCHFRIST_TAGE(termine/frist.js, E2), die übrigen Werte aus exportierten Konstanten bzw. per Regex ausjobs.js/outbox.js/AUFBEWAHRUNG_*, soweit nicht exportiert);avv_keine_ip_in_buchung(TERMINE_COLLECTIONS.termineBookings.attributesinschema.jsenthält kein Attribut, dessen Name auf/^(ip|ipAdresse|clientIp|userAgent)$/ipasst – ein loses/ip/träfestripeSessionIdundstripePaymentIntent; dernachweiseiner überhaltegehaltenen Buchung enthält keinen Schlüsselip/userAgent;baueNachweisselbst ist nicht exportiert);avv_verweise_auf_lb(jedes „Nummer N der Leistungsbeschreibung“ existiert);avv_ohne_analyse_bezug(Analyse-Kunden brauchen keine neue Zustimmung: kein Verweis auf eine Änderung des Analyse-AVV). - FAIL → Text schreiben → PASS →
npm test→ Commit „Rechtstexte: AVV Terminplaner“
Task F6: HTML-Generator, PDF-Modus und ausgelieferte Dateien
Files: Create scripts/build-legal-html.mjs, test/buildLegalHtml.test.js, public/rechtliches/{leistungsbeschreibung-terminplaner,avv-terminplaner,widerrufsbelehrung-funktionen}-<F>.{html,pdf}; Modify scripts/build-legal-pdfs.sh (Modi termine und datenschutz, Variable TERMINE_VERSION mit Vorgabe <F>; die Zeile pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf bleibt in all), docs/rechtliches/archiv/README.md (Tabelle „Terminplaner“: Fassung, Dateien, „gültig ab“ offen bis Livegang).
Consumes: F1 legalPaths, F3–F5 Markdown.
Produces: node scripts/build-legal-html.mjs termine [--check] rendert die drei Markdown-Quellen deterministisch (keine Datumsangabe) in dieselbe Seitenhülle wie die Analyse-Seiten (Navigation zu den anderen Dokumenten, @page-Kopf „WEBklar · “, Seite x von y, meta mit Fassung); Teilmenge von Markdown (h1–h3, Absätze, - -Listen, **fett**, `code`, Tabellen); jede andere Syntax (``` , >, Bilder, rohes HTML) bricht laut ab. --check vergleicht mit den eingecheckten Dateien (Exit 1). render(md, meta) ⇒ html wird exportiert. build-legal-pdfs.sh termine erzeugt die drei PDFs auf dem Host (vorher free -m, mit nice, Chrome aus ~/.cache/ms-playwright); PDFs und HTML werden explizit gestaged.
- Tests (
buildLegalHtml.test.js):html_gleich_generator(eingecheckte HTML =render(md)für alle drei);html_text_gleich_markdown(jeder Absatz der Quelle steht nach Tag-Entfernung und Normalisierung im HTML);html_sauber(kein<!--,Entwurf,Prüfpunkt,<script,on…=;Fassung <F>im Kopf; Links nur relativ oderhttps:);generator_lehnt_unbekannte_syntax_ab;generator_escaped(<,&,"in Text und Attributen);pdf_dateien(%PDF, > 20 KB je Datei, Dateinamen auslegalPdfFiles);anhaenge_drei(legalPdfAttachments('termine', F)liefert lb, avv, widerruf mitapplication/pdf);router_alias(/rechtliches/avv-terminplaner.html= Bytes der Fassungsdatei, unbekannte Fassung 404,-1999-01-01.html404);script_modus(build-legal-pdfs.shenthält die Moditermine,datenschutzund die unveränderteall-Zeile). - FAIL → implementieren, Generator laufen lassen, PDFs bauen → PASS →
npm test→ Commit „Rechtstexte: HTML-Generator und PDFs des Terminplaners“
Task F7: Bestelltexte (Zustimmungen), Dokumente und Belehrung im Register
Files: Modify server/config.js (features.termine.consent; features.termine.widerruf als Getter { version: config.termine.avvVersion, url: '/rechtliches/widerrufsbelehrung-funktionen-<version>.html' } wie in F1), server/services/featureRegistry.js (Standardhaken featureHaken('termine').belehrung = widerrufsbelehrungFunktion(), F3); Test neu test/featureConsentTexte.test.js; angepasste B-Tests (gezielt, ihre Pins hängen an Texten und Fassung): test/featureRegistry.test.js (B1: Item-Texte und Fassung; der todo-Test „Bestelltexte ohne ENTWURF“ wird grün; die Item-IDs unterlagen/gesundheit/beginn/information stehen seit B1), test/featureConsents.test.js (B4: nachweisTexte mit belehrung, zustimmungsfassungen mit widerruf), test/funktionenCheckout.test.js (B6/B7: vier Haken, Nachweis mit belehrung und knopf).
Consumes: B-Register (featureConfig, server/services/featureRegistry.js: consent: { version, knopf, items }, priceLabel, setupEstimate, leistungsbeschreibung), B featureConsents.js und Checkout (Reihenfolge laut B7: Stripe-Sitzung erzeugen, Nachweis speichern, dann die URL zurückgeben), C2 (bestellungAnsicht liest belehrung aus demselben Haken), F1, F3.
Produces: features.termine.consent = { version: config.termine.avvVersion, knopf: 'Zahlungspflichtig bestellen · 149 €', items } mit vier Haken (alle Besteller, D1; alle haken: true, ohne url; die B-/C-Oberfläche rendert jedes Item mit haken: true als Haken, die ids sind für Tests und Nachweis verbindlich):
unterlagen: „Ich stimme der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version} zu.“ (die versionierten Links stehen daneben: C8 rendert sie ausbestellung.dokumente, F1/F7 liefern die Adressen)gesundheit: „Mein Angebot liegt nicht im Gesundheitsbereich (zum Beispiel keine Arzt-, Zahnarzt-, Psychotherapie-, Heilpraktiker- oder Physiotherapiepraxis).“beginn: „Ich verlange ausdrücklich, dass WEBklar vor Ablauf der Widerrufsfrist mit der Einrichtung des Terminplaners beginnt. Mir ist bekannt, dass mein Widerrufsrecht mit der vollständigen Erbringung der Einrichtung 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).“ (⚖ Q3, Q5)information: „Ich habe die Pflichtinformationen zu dieser Bestellung gelesen: Leistung (Einrichtung des Terminplaners für diese Website: ein Widget an einer Stelle, gestaltet nach dem Fragebogen, mit Einbau und Datenschutz-Absatz), Gesamtpreis ${priceLabel} einmalig (${ustHinweis}), voraussichtliche Dauer ${setupEstimate}, der Terminplaner endet mit dem Wartungs-Abo. Die Widerrufsbelehrung mit Muster-Widerrufsformular habe ich gelesen.“ (⚖ Q20) Die dynamischen Teile setztconfig.jseinmal beim Laden ein, gespeichert wird immer der fertige Text. Knopf: „Zahlungspflichtig bestellen · 149 €“ (⚖ Q19). Nachweis (Review Focus 5): B7 speichert zusätzlich zu den Haken den Knopftext als Eintrag{ id: 'knopf', text }und die gezeigte Widerrufsbelehrung samt Muster-Formular (Hookbelehrung) als Eintrag{ id: 'belehrung', text };zustimmungsfassungenträgtwiderruf: version. Die Bestellseite (C8) zeigtbestellung.belehrungaus demselben Hook, gezeigter Text und gespeicherter Text sind also dieselbe Zeichenkette.
- Tests (
featureConsentTexte.test.js):termine_haken_woertlich(ids genau['unterlagen','gesundheit','beginn','information'], allehaken true, Texte exakt);termine_version_gleich_dokumente(=config.termine.avvVersion,Fassungin Haken 1);information_nennt_register_werte(149 €,ohne Umsatzsteuer gemäß § 19 UStG,setupEstimate);keine_urls_im_text;textstand_pin(SHA-256 über die verbundenen Texte beider Funktionen steht im Test, Kommentar „bei Textänderung neues Fassungsdatum und neuen Hash“: Textänderung ohne Fassungswechsel fällt auf);nachweis_vor_umleitung(Integration über B7:POST /api/funktionen/termine/checkoutlegtfeatureConsentsmittexte= Register-Haken +belehrung+knopf,fassungen(mitwiderruf) und Zeitpunkt an,acceptedAt== Klickzeit; das Dokument existiert, bevor die Antwort miturlzurückkommt, die Stripe-Sitzung entsteht davor (B7); schlägt das Speichern fehl ⇒ 500 ohneurlund ohneordered, die verwaiste Sitzung läuft nach 24 h ab);belehrung_gezeigt_gleich_gespeichert(gespeicherterbelehrung-Text ==bestellung.belehrungausGET /api/termine/einstellungen==widerrufsbelehrungFunktion());knopf_gleich_register_und_nachweis(bestellung.consent.knopf== Registertext == gespeicherterknopf-Eintrag);alte_fassung_409(Body mit alter Fassung ⇒ 409 mit neuer Fassung). - FAIL → implementieren → PASS →
npm test→ Commit „Bestelltexte des Terminplaners (vier Zustimmungen, Belehrung im Nachweis, Fassung)“
Task F8: K15-Anhänge, Preisangabe und Unterlagen in der Bestellseite
Files: Modify server/services/siteFeatures.js (K15-Versand in onFeaturePaid: lazy import('./legalDocs.js') und featureLegalAttachments(doc.featureKey); kein Registerhaken mails.anhaenge, B9a legt keinen an), server/services/featureRegistry.js (der Haken unterlagenBereit für termine = featureLegalReady('termine')), server/routes/funktionen.js (GET-Feld legal), server/mail-templates/K15 Bestellung Funktion.html, server/mail-templates/T16 Funktion bestellt.html (Block anhang_fehlt), test/mailTemplates.test.js (Platzhalter/Blöcke), Tests test/funktionenRecht.test.js (erweitern), test/funktionenRoutes.test.js, test/featureMails.test.js (B9a/B9b-Pin „Platzhalter-/Blocklisten exakt“: K15 um fassung, lb_url, avv_url, widerruf_url, zustimmungen_text und die Blöcke anlagen, zustimmungen, T16 um den Block anhang_fehlt ergänzen). Die Bestellseite public/funktion-bestellen.js (Darstellung der Belehrung und der Links) gehört C8; F8 ändert sie nicht.
Consumes: F1 featureLegalAttachments, featureLegalReady, legalPaths; F3 widerrufsbelehrungFunktion; B onFeaturePaid, zustimmungFuerSitzung (B4).
Produces: K15 trägt für termine drei PDF-Anhänge (LB, AVV, Belehrung mit Muster-Formular; dauerhafter Datenträger, § 312f BGB; Muster legalPdfAttachments/K18). Mechanismus (einziger Weg): onFeaturePaid ruft vor dem K15-Versand await featureLegalAttachments(doc.featureKey) (lazy import() aus legalDocs.js; news ⇒ []) und übergibt das Ergebnis als attachments; ein Registerhaken mails.anhaenge besteht nicht. Neue K15-Werte (Texte in Du-Form wie die übrigen K-Vorlagen; preis_hinweis hat B9a schon deklariert und mit mails.preisHinweis gefüllt): fassung, lb_url, avv_url, widerruf_url (absolute, versionierte Adressen), zustimmungen_text (Wortlaut und Zeitpunkt der erteilten Zustimmungen aus zustimmungFuerSitzung(session.id), B4: die Zustimmung zur bezahlten Sitzung); Blöcke anlagen und zustimmungen nur für Funktionen mit Rechtsdokumenten (⚖ Q20). Sind die PDFs beim Versand nicht lesbar, geht K15 ohne Anhänge raus (Bestätigung vor Anhang; die Zahlung hat Spur) und T16 meldet im Block anhang_fehlt „Unterlagen von Hand nachsenden“; Fehler loggen. Checkout: der B6-Haken unterlagenBereit ist für termine an featureLegalReady('termine') gehängt (Stufe 5b in B6: false ⇒ 503 unterlagen_fehlen „Die Vertragsunterlagen sind gerade nicht abrufbar. Bitte versuch es später noch einmal oder schreib uns im Chat.“, vor jeder Stripe-Sitzung; die Stufe steht im Reihenfolge-Test von B6). GET /api/funktionen ergänzt legal: { termine: { version, links: { lb, avv, widerruf } (absolut, https), belehrung: widerrufsbelehrungFunktion() }, news: null } (ustHinweis liefert schon B12). Dieselben Werte (die versionierten Adressen über dokumente, die Belehrung über den Haken aus F7) fließen in bestellungAnsicht (C2), aus der die Bestellseite (C8) rendert: Links mit rel="noopener noreferrer" und target="_blank", die Belehrung samt Formular als Text im nicht einklappbaren Bereich über dem Knopf (Absätze per textContent, kein <details>).
- Tests:
k15_termine_anhaenge(drei Anhänge in der Reihenfolge lb, avv, widerruf, Namen auslegalPdfFiles, alle%PDF;Object.hasOwn(featureConfig('termine').mails, 'anhaenge') === false);k15_news_ohne_anhaenge(News unverändert, kein Blockanlagen);k15_links_versioniert(Text enthält-${F}.html, keine Adresse ohne Fassung);k15_anhang_nicht_lesbar(legalPdfAttachmentswirft ⇒ K15 ohne Anhänge, T16 mit Blockanhang_fehlt, Dokument trotzdemsetup, Ticket angelegt, Webhook 200);k15_zustimmungen_text(Text der vier Haken und Zeit im Mail-Text);checkout_503_ohne_unterlagen(keine Stripe-Sitzung, keinordered-Dokument);liste_legal(legal.termine.linksallehttps://und versioniert,belehrung= Funktionsausgabe);bestellseite_statisch(keininnerHTMLfür die Belehrung, kein<details>,rel="noopener noreferrer");mailTemplates.test.js: deklarierte = verwendete Platzhalter/Blöcke für K15 und T16;featureMails.test.js: die exakten Listen aus B9a/B9b sind um die oben genannten Werte ergänzt (kein zweiter Test dafür). - FAIL → implementieren → PASS →
npm test→ Commit „Funktionen: Vertragsunterlagen als K15-Anhang, Belehrung vor dem Bestellknopf“
Task F9: Widerruf je Funktion (Formular, Frist, K04/T09)
Files: Modify public/widerrufen.html (:23), server/routes/abo.js (ORDERS :707, orderLabel :782, withdrawFeature :1088, :1118, :1143, :1166), server/mail-templates/K04 Eingang Widerruf.html, server/mail-templates/T09 Widerruf eingegangen.html, test/mailTemplates.test.js (:254-258, Kopfkommentar), Tests test/publicForms.test.js (neben :518).
Consumes: B4 paidFeatureOrders(customerId) mit featureKey und vertragAt (Zeit der Zustimmung zur bezahlten Sitzung = Klick, Review-Fokus 5; B4 stellt withdrawFeature bereits auf vertragAt || orderedAt um), featureConfig(key) (label.name, priceLabel). F9 baut keinen zweiten Anker (kein contractAt, kein letzteZustimmung: eine spätere, unbezahlte Zustimmung zu einer neuen Sitzung würde den Anker verfälschen).
Produces: widerrufen.html: neuer Radioknopf value="termine" mit data-text="Terminplaner (Einrichtung)", der bestehende funktion („Funktion (z. B. News-Pinnwand)“) bleibt unverändert. ORDERS + termine; FEATURE_OF_ORDER = { funktion: 'news', termine: 'termine' }; orderLabel('termine') = 'Terminplaner (Einrichtung)'. withdrawFeature(d, receivedAt, featureKey) filtert die Bestellungen aus paidFeatureOrders auf die gewählte Funktion (featureKey) und rechnet die Frist aus deren vertragAt (spätestes Fristende je Funktion, Zeilen mit eigenem Fristende je Projekt). produkt = featureConfig(key).label.name (statt fest „News-Pinnwand“), neu preis = featureConfig(key).priceLabel in T09. K04 Block funktion (Sie-Form bleibt): Wertersatz-Satz für „begonnene“ statt „abgeschlossene“ Einrichtung, Verweis § 357a Abs. 2 BGB. T09 Block funktion: „Widerruf einer Funktion ({{produkt}}, {{preis}} einmalig) …“; Handgriffe: Erstattung in Stripe binnen 14 Tagen, ggf. abzüglich Wertersatz für den begonnenen Einbau (Aufwand im Arbeitsblatt, ⚖ Q5), Funktion im Reiter „Funktionen“ abschalten; bei Terminplaner: Wartungs-Abo bleibt unberührt.
- Tests:
widerruf_termine_frist_je_funktion(News vor 20 Tagen, Terminplaner vor 3 Tagen bestellt, Widerruftermine⇒ T09RECHTZEITIG, Fristende des Terminplaners; umgekehrt Widerruffunktion⇒NACH Fristende; Regression des bisherigen Maximums);widerruf_termine_mails(K04 nennt „Terminplaner (Einrichtung)“, T09 „149 €“ und das Projekt;Funktion (z. B. News-Pinnwand)-Test beifunktionunverändert grün);widerruf_anker_vertragAt(orderedAtspäter überschrieben, Zustimmung zur bezahlten Sitzung früher ⇒ Frist ausvertragAt; ohnefeatureConsents-Eintrag Rückfall auforderedAt; die Grundlage prüft der B4-Test, hier nur die Funktionsfilterung darauf);widerruf_unbekannte_funktion(kein bezahltes Dokument ⇒ „ungeprüft (keine bezahlte Funktion gefunden)“);mailTemplates.test.js: T09 deklariertpreis, K04/T09 Blöcke unverändert;widerrufen_html_radio(Werttermine, Beschriftung wörtlich). - FAIL → implementieren → PASS →
npm test→ Commit „Widerruf: Terminplaner als eigene Auswahl, Frist und Mails je Funktion“
Task F10a: Gästetexte – Archiv-Skript und eingefrorene Altfassung 2026-10-02
Files: Create scripts/termine-texte-archiv.mjs, docs/rechtliches/archiv/termine-gaestetexte-2026-10-02.md (Altfassung, aus den unveränderten Texten erzeugt, eigener Commit vor jeder Textänderung), test/termineRechtstexteArchiv.test.js; Modify docs/rechtliches/archiv/README.md (Tabelle „Terminplaner-Gästetexte“).
Consumes: server/services/termine/rechtstexte.js im Stand 2026-10-02 (noch ohne Änderung durch F10b1).
Produces: termine-texte-archiv.mjs [--check] schreibt docs/rechtliches/archiv/termine-gaestetexte-<V>.md aus rechtstexte.js mit Platzhalter-Betrieb: Fassung, Formular-Hinweis (Sie/Du), alle Haken, Gesundheitshinweis, Knopftexte (Tabelle), Belehrung (Platzhalter-Fassung), Hinweis „Der Nachweis speichert Hashes der Texte mit den Betriebsangaben zum Buchungszeitpunkt“ (⚖ Q26); --check vergleicht mit der eingecheckten Datei (Exit 1). Die README-Tabelle „Terminplaner-Gästetexte“ nennt die Fassung 2026-10-02.
- Tests:
archiv_aktuelle_fassung(Skriptausgabe = eingecheckte Datei für die aktuelleRECHTSTEXTE_VERSION; Datei und README nennen die Fassung);archiv_altfassung_eingefroren(die Datei 2026-10-02 enthält den alten Erinnerungstext ohne den Widerrufssatz, ihr SHA-256 ist im Test festgenagelt). - FAIL → implementieren, Altfassung erzeugen → PASS →
npm test→ Commit „Gaestetexte: Archiv-Skript und eingefrorene Fassung 2026-10-02“ (vor F10b1)
Task F10b1: Gästetexte final 1 – Fassung , Erinnerungs- und Abo-Ende-Satz, Archiv
Files: Modify server/services/termine/rechtstexte.js (RECHTSTEXTE_VERSION, ZUSTIMMUNG_ERINNERUNG, datenschutzAbsatz aus D1), docs/rechtliches/termine-datenschutz-absatz.md (Satz (c); Quelle der D1-Pins, Funktion und Markdown ändern gemeinsam), docs/rechtliches/archiv/README.md; Create docs/rechtliches/archiv/termine-gaestetexte-<V>.md (mit dem Skript aus F10a); Tests test/termineRechtstexte.test.js (Version, Sätze (b) und (c), D1-Pins), test/publicTermine.test.js (Nachweis-fassung), test/termineRechtstexteArchiv.test.js.
Consumes: F10a, D1 datenschutzAbsatz(einstellung, anrede), F1 (config.termine.avvVersion = <F>).
Produces: RECHTSTEXTE_VERSION = '<V>' (= <F>, Literal in rechtstexte.js, nicht 2026-10-02). Änderungen gegenüber 2026-10-02: (b) ZUSTIMMUNG_ERINNERUNG(n) = „Ich möchte ${n} Stunden vor dem Termin eine Erinnerung per E-Mail erhalten. Diese Einwilligung kann ich jederzeit widerrufen.“ (Art. 7 Abs. 3 DSGVO, ⚖ Q17; der Hash im Nachweis deckt den Satz); (c) Datenschutz-Absatz Sie/Du: „Wird die Online-Terminbuchung abgeschaltet, zum Beispiel weil unser Vertrag mit WEBklar endet, werden alle Termindaten 30 Tage nach dem Abschalten gelöscht.“ (Quelle datenschutzAbsatz() und Markdown gemeinsam); termine-texte-archiv.mjs (F10a) erzeugt docs/rechtliches/archiv/termine-gaestetexte-<V>.md. Die README-Tabelle „Terminplaner-Gästetexte“ nennt beide Fassungen. Die ENTWURF-Köpfe und RECHTSTEXTE_ENTWURF bleiben bis F10b2 unverändert.
- Tests:
archiv_aktuelle_fassung(aus F10a, jetzt für<V>: Skriptausgabe = eingecheckte Datei,RECHTSTEXTE_VERSIONin Datei und README);archiv_altfassung_eingefroren(aus F10a, unverändert grün);textstand_pin(SHA-256 der aktuellen Archivdatei im Test: Textänderung ohne neues Datum fällt auf);erinnerung_widerrufssatz(Haken-Text, Dokument undtexte.erinnerungder öffentlichen Konfiguration wörtlich gleich);absatz_abo_ende(Funktion und Markdown, Sie und Du, nennen „30 Tage nach dem Abschalten“; bestehende Regex-Pins bleiben grün);version_gleich_register(RECHTSTEXTE_VERSION === config.termine.avvVersionund ungleich'2026-10-02'); Nachweis-fassungeiner neuen Buchung =RECHTSTEXTE_VERSION, Altbuchungen behalten2026-10-02. - FAIL → implementieren → PASS →
npm test→ Commit „Gästetexte des Terminplaners: Fassung , Erinnerungs- und Abo-Ende-Satz, Archiv je Fassung“
Task F10b2: Gästetexte final 2 – Entwurf-Kennzeichnung entfernt
Files: Modify server/services/termine/rechtstexte.js (Kopf, RECHTSTEXTE_ENTWURF = false (D1)), server/services/termine/einbauanleitung.js (Checklisten-Text zum Datenschutz-Absatz, D3b), docs/rechtliches/termine-hinweise.md, -datenschutz-absatz.md, -widerrufsbelehrung-muster.md, Rename termine-datenschutz-6c-entwurf.md → termine-datenschutz-6c.md (hier nur Kopf entfernt, Inhalt in F12a), docs/funktionen/termine-einbau.md (:127); Tests test/termineRechtstexte.test.js (:49, Liste DOKUMENTE :219 mit neuem 6c-Namen, :226, Pfad im Wächter :267, D1-Test (f): RECHTSTEXTE_ENTWURF === false), test/termineEinbauanleitung.test.js (D3b: die Banner-Tests mit Standardwert drehen um, Checklisten-Text), test/termineEmbedStatic.test.js:219.
Consumes: F10a, F10b1 (Version, Sätze und Archiv stehen), D RECHTSTEXTE_ENTWURF und einbauanleitung (der Banner hängt nach D1/D3b an RECHTSTEXTE_ENTWURF; er fällt hier weg).
Produces: RECHTSTEXTE_ENTWURF = false. Änderungen: (a) ENTWURF-Köpfe aus allen vier Dokumenten und die „Für die Rechtsprüfung offen“-Listen aus den drei Gäste-Dokumenten entfernt (die offenen Punkte stehen als Fragen im Kanzlei-Paket); (d) termine-einbau.md Abschnitt 6 ohne die Pilot-Einschränkung; (e) der Checklisten-Text der Einbauanleitung nennt die Endregel aus F4 Nr. 4 (WEBklar fügt den Datenschutz-Absatz ein, wenn die Website von WEBklar betreut wird, sonst Textbaustein per Chat; Teil der Einrichtung, nicht auf das Stundenkontingent angerechnet) statt der Zwischenregel aus D3b.
- Tests:
kein_entwurf_mehr(keinENTWURFim Sinne von/ENTWURF(?![_A-Z])/in den vier Dokumenten (Köpfe), in den Texten vonrechtstexte.jsund indocs/funktionen/termine-einbau.md; der BezeichnerRECHTSTEXTE_ENTWURFist erlaubt und steht auffalse; das alte Kopf-GebottermineRechtstexte.test.js:226wird zum Verbot);einbau_ohne_banner(nach D: die Anleitung im Standardaufruf enthält kein ENTWURF-Banner; D3b-Banner-Tests mit explizitementwurf: truebleiben);checkliste_datenschutz_endregel(Text der Checkliste = Endregel, die Zwischenregel „liegt die Datenschutzerklärung nicht in unserem Repo“ ist weg);termineEmbedStatic.test.js:219prüft statt'ENTWURF'das Wort'Fassung'. - FAIL → implementieren → PASS →
npm test→ Commit „Gästetexte des Terminplaners: Entwurf-Kennzeichnung entfernt“
Task F11: Portal-Hinweistexte und Storno nur bei Preis
Files: Modify public/termine-tab-einstellungen.js (WIDERRUF_HINWEIS, FREIZEIT_HINWEIS :16-23 exportieren; Storno-Abschnitt ~:222), server/services/termine/ablauf.js (:231,:282), server/services/termine/vorlagen.js (ausVorlage ~:209), public/embed/termine.js (:1215-1219,:1259,:1279); Tests test/termineAblaufGast.test.js, test/termineEinstellungen.test.js, test/termineView.test.js, test/termineEmbedStatic.test.js, (test/ui/termine-widget.ui.mjs von Hand).
Produces: FREIZEIT_HINWEIS (Du-Form): „Nur ankreuzen, wenn deine Termine Freizeitveranstaltungen zu einem festen Termin sind (§ 312g Abs. 2 Nr. 9 BGB), zum Beispiel ein einzelner Tanzabend, ein Kurs oder ein Escape Room. Nach unserer Einschätzung gilt bei Friseur, Kosmetik, Nagel- und Fußpflege, Tattoo, Fotoshootings, Coaching und Beratung in der Regel ein Widerrufsrecht; bei Massage und Wellness ist die Einordnung unklar, im Zweifel gilt ein Widerrufsrecht. Kreuzt du es an, zeigt der Terminplaner keine Widerrufsbelehrung. Kreuzt du es zu Unrecht an, können deine Gäste noch bis zu 12 Monate und 14 Tage widerrufen. Das ist keine Rechtsberatung: Im Zweifel nicht ankreuzen oder dich rechtlich beraten lassen.“ (nimmt Massage und Weiterbildung aus der Liste „keine Freizeitleistung“, die die Recherche nicht trägt, ⚖ Q15). WIDERRUF_HINWEIS bleibt, erhält nach dem dritten Satz: „Hat ein Gast ausdrücklich verlangt, dass du vor Ablauf der Frist beginnst, und widerruft er danach, kann er für die bis dahin erbrachte Leistung zu einem angemessenen Betrag verpflichtet sein (Wertersatz, § 357a Abs. 2 BGB).“ Storno: stornoNoetig nur bei hatPreis(leistung); bei kostenlosen Leistungen kein Pflichthaken, kein nachweis.storno, im Widget weder Stornobox noch Haken noch Prüfung (nach l.preis der gewählten Leistung); ausVorlage setzt storno.text = '', wenn keine Leistung der Vorlage einen Preis hat; Abschnitt „Stornobedingungen“ ergänzt: „Die Stornobedingungen gelten nur für Leistungen mit Preis; bei kostenlosen Leistungen fragt der Terminplaner Gäste nicht danach.“ (⚖ Q16). Muster-Kennzeichnung (Spec 7.3): der vorgeschlagene Stornotext (Vorgabe 50 % / 24 h) wird im Editor als Muster gekennzeichnet, mit einem Hinweis neben dem Feld: „Das ist ein Muster, kein Rechtstext. Bitte prüfe es und passe es an deine Bedingungen an.“ (Du-Form, nur im Portal, nicht im Gästetext).
- Tests:
storno_haken_nur_bei_preis(termineAblaufGast: Leistung mit Preis ohne Haken ⇒ 400 wie bisher;S_GRATISohne Haken ⇒ Buchung gehalten,nachweis.stornofehlt; mitstorno: truebei Gratis ⇒ ebenfalls ohnenachweis.storno; Handeintrag unverändert);vorlage_ohne_preis_ohne_storno(Vorlage mit nur kostenlosen Leistungen ⇒storno.text === '', Vorlagen mit Preisen unverändert);widget_storno_je_leistung(termineEmbedStatic: Box, Haken und Prüfung hängen anl.preis);freizeit_hinweis_text(nennt weder „Massage“ noch „Weiterbildung“ als Beispiel für „keine Freizeitleistung“, enthält „keine Rechtsberatung“, Du-Form);widerruf_hinweis_wertersatz;storno_abschnitt_text;storno_muster_gekennzeichnet(Hinweis steht neben dem Stornofeld, wörtlich, Du-Form; er erscheint nicht in den Gästetexten und nicht im Hash des Nachweises); Playwright-Lauf des Widgets bleibt außerhalb vonnpm test(einmal von Hand,nice, nachfree -m). - FAIL → implementieren → PASS →
npm test→ Commit „Terminplaner: Hinweistexte nach Recherche, Storno nur bei Preis“
Task F12a: Datenschutzerklärung (6c, §6, §7) und PDF
Files: Modify public/datenschutz.html (Stand; §6 :82; nach 6b :93-95 neu <h2 id="termine">; §7 :99), docs/WEBklar-Datenschutzerklaerung.pdf (neu per build-legal-pdfs.sh datenschutz); docs/rechtliches/termine-datenschutz-6c.md (aus F10b2 umbenannt; Markdown-Quelle des Abschnitts, ohne „Einbau in TP2“, ohne „nicht eingebaut“, ohne „Für die Rechtsprüfung offen“); Tests test/legalTexts.test.js (:494- Kundenansicht-Pin, Stand-Pin), test/termineRechtstexte.test.js (:267 Wächter entfernen).
Consumes: B (config.collections.featureConsents), A/C (Entwurfs-Löschung nach 90 Tagen), E (Abo-Ende, Rückkehr, K22), F10b2 (Umbenennung).
Produces: 6c final (<h2 id="termine">6c. Online-Terminbuchung auf Websites unserer Kunden</h2> zwischen 6b und §7, kein Kommentar im HTML): Rolle (Auftragsverarbeiter für Gäste; Verantwortliche für Betriebsdaten, Fragebogen-Entwurf und Bestellung); Daten wie Entwurf; Einstellungen und Entwurf: nicht bestellte Entwürfe werden 90 Tage nach der letzten Änderung gelöscht (auch bei Kundenlöschung); E-Mail-Versand über Hetzner (Rechenzentrum Nürnberg, Abschnitt 8), Server in Falkenstein; „mit der Buchung speichern wir die IP-Adresse nicht“ (belegt durch avv_keine_ip_in_buchung); Aufruf: Skript und Texte beim Laden der Seite, Termine im Modus inline beim Durchklicken, im Modus knopf erst nach Klick (termine-einbau.md §6); Fristen wie AVV Nr. 9; Ende des Abos: „Endet das Wartungs-Abo oder schalten wir die Funktion ab, schalten wir den Terminplaner spätestens am folgenden Tag ab und löschen alle Termindaten und Einstellungen 30 Tage danach; beginnt innerhalb dieser Frist wieder ein Wartungs-Abo, schalten wir ihn mit den vorhandenen Daten wieder ein“; Sicherungen halten gelöschte Daten bis zu 7 Tage. §6: „… und dabei für Sie News-Beiträge anlegen, ändern oder löschen und, wenn Sie den Terminplaner nutzen, dessen Einstellungen und Termine ansehen und ändern.“ (Sperre für Bestellungen, Zahlungen, Fragebogen-Bestätigung bleibt). §7: neuer Absatz: „Bestellen Sie eine Funktion (News-Pinnwand, Terminplaner), speichern wir als Nachweis die Texte und Fassungen, denen Sie zugestimmt haben, den Zeitpunkt Ihres Klicks auf „Zahlungspflichtig bestellen“, die Stripe-Sitzung, IP-Adresse und Browserkennung sowie Ihr Konto und die angemeldete Person. Rechtsgrundlagen sind Art. 6 Abs. 1 lit. b, c und f DSGVO; unser berechtigtes Interesse ist der Nachweis des Vertragsschlusses und der Zustimmungen, insbesondere für den Widerruf. Diese Angaben löschen wir drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet. Den Entwurf Ihres Fragebogens speichern wir bis zu 90 Tage nach Ihrer letzten Änderung und löschen ihn danach, wenn Sie nichts bestellt haben.“ (⚖ Q23; Spec 3.7: 6c und §7 nennen die Entwurfsdaten und die Frist). Stand = Änderungstag.
- Tests:
legal_datenschutz_termine(Reihenfolgeid="analyse"<id="termine"<<h2>7.; Pflichtsätze: Auftragsverarbeiter,Falkenstein,Nürnberg,48 Stunden,30 Tage,90 Tage,sieben Tage, „nicht dauerhaft“; kein<!--im Block; Nummerierung 7–15 unverändert); die bestehenden Wächter bleiben grün, weil 6c ihre Verbote meidet:legal_datenschutz_newsschneidet von 6a bis §7 (also auch 6c; verboten sind dort „Abschnitt 6 und 7“, „spätestens nach 24 Stunden“, „Endet die Zusammenarbeit, schalten wir“),Datenschutz: Abos, produktneutralverbietet im ganzen Dokument u. a. „Unterauftragsverarbeiter“ und „im Rahmen des Wartungs-Abos“;datenschutz_6c_gleich_markdown(HTML-Block = Markdown-Quelle);legal_datenschutz_kundenansichtangepasst (neuer Satz wörtlich,Standper Regex/Stand \d{2}\.\d{2}\.2026/und ≥ 01.10.2026 und ≥ Datum austermsVersion);legal_datenschutz_funktionsbestellung(§7-Absatz wörtlich, einschließlich des Satzes zu den Entwurfsdaten und den 90 Tagen);wiederkehr_satz_gleich_lb(Satz zur Rückkehr in 6c/LB/AVV/K22 enthält jeweils „30 Tage“ und „wieder ein“, K22 per Globserver/mail-templates/K22 *.html); der 6c-WächtertermineRechtstexte.test.js:267entfällt;build-legal-pdfs.sh datenschutzerzeugt das PDF (> 20 KB,%PDF). - FAIL → implementieren, PDF bauen → PASS →
npm test→ Commit „Datenschutz: Abschnitt 6c Terminplaner, Kundenansicht und Nachweise bei Funktionsbestellung“
Task F12b: Aufbewahrung der Zustimmungsnachweise (Retention)
Files: Modify server/services/retention.js (findDueDeletions :74 samt Bedingung if (!posts.length && !features.length) continue, applyDeletions :172, formatReport :244); Test test/retention.test.js.
Consumes: B3 (config.collections.featureConsents), F12a (der §7-Satz „drei Jahre nach Ablauf des Jahres, in dem Ihr letztes Abo endet“).
Produces: findDueDeletions plant die featureConsents fälliger Kunden mit (plan.news[].consentIds), applyDeletions löscht sie (deleted.featureConsents), formatReport nennt sie (damit der §7-Satz stimmt; Kunden ohne Abo, z. B. nur News, bleiben wie bisher außerhalb der Retention, siehe offene Fragen).
- Tests:
retention_loescht_featureConsents(fälliger Kunde ⇒ Einträge geplant und gelöscht, nicht fälliger Kunde unberührt, Bericht nennt Anzahl; ein Kunde ohne Abo, z. B. nur News, bleibt außerhalb der Retention wie bisher). - FAIL → implementieren → PASS →
npm test→ Commit „Retention: Zustimmungsnachweise der Funktionsbestellung drei Jahre nach Abo-Ende loeschen“
Task F13a: Staff-Aktivierung nur mit Textform-Nachweis (Modus on) – Portal
Files: Modify server/services/termine/schalter.js, server/routes/admin/funktionenAdmin.js (POST + GET), server/services/siteFeatures.js (adminActivate), Tests test/termineSchalter.test.js, test/funktionenTermine.test.js.
Consumes: B4 speichereZustimmung (erlaubt leeres stripeSessionId/ip/userAgent und Texte ohne haken), D6 meldeFreigeschaltet(doc, { attachments }); validateTextform(textform, { minDate }) aus analyse.js; F1 legalPaths, featureLegalAttachments.
Produces: termineTextformNoetig(email) ⇒ boolean (termineModus() === 'on' und die Adresse steht nicht in config.termine.pilotEmails; '*' zählt nicht als Pilotadresse). POST /api/team/funktionen/:customerId/termine mit action: 'activate' verlangt dann textformDate (JJJJ-MM-TT, nicht vor config.termine.avvVersion, nicht in der Zukunft) und textformChannel (chat | email), sonst 400. Bei Erfolg: speichereZustimmung({ customerId, projectId, featureKey: 'termine', stripeSessionId: '', fassungen: { leistungsbeschreibung: v, avv: v }, texte: [{ id: 'textform', text: 'Der Kunde hat der Leistungsbeschreibung Terminplaner und dem Auftragsverarbeitungsvertrag Terminplaner (Fassung <v>) am <TT.MM.JJJJ> per <Chat im Kundenportal | E-Mail> in Textform zugestimmt und bestätigt, dass sein Angebot nicht im Gesundheitsbereich liegt.' }], ip: '', userAgent: '', konto: 'Mitarbeiter: <Name>', at }), danach K16 über meldeFreigeschaltet(doc, { attachments }) mit den beiden PDFs (LB, AVV; featureLegalAttachments('termine', { ohne: ['widerruf'] })), damit der Kunde die Unterlagen auf einem dauerhaften Datenträger erhält (⚖ Q21). GET /api/team/funktionen/:customerId ⇒ zusätzlich termine: { textformNoetig, avvVersion, links }. Hinweis: Dokumente, die so ohne Bestellung freigeschaltet werden (unlockedByPurchase bleibt falsch), nimmt der Abo-Abgleich aus und zählt sie im Tagesbericht als ausgenommen (Kanzleifrage Q7).
- Tests (Portal):
textform_pflicht_modus_on(Kunde fremd ⇒ ohne Angaben 400, mit gültigen 200,active,featureConsentsmit Datum, Weg, Fassung, Mitarbeitername, leeremstripeSessionId, K16 mit zwei%PDF-Anhängen inmailSink);textform_entfaellt(Modusoff/pilot, Modusonmit Pilotadresse, News, Abschalten);textform_datum(vor Fassung, Zukunft, ungültiger Weg ⇒ 400, nichts angelegt);get_admin_termine_feld;termineTextformNoetigje Modus;chat_vorlage_versioniert(nur Adressen mit-${v}.html);zustimmungFuerSitzung('')findet den Staff-Nachweis nicht (B4). - FAIL → implementieren → PASS →
npm test(Portal) → Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), Portal“
Task F13b: Staff-Aktivierung nur mit Textform-Nachweis (Modus on) – WOMS
Files (WOMS, Worktree dev-termine): Modify src/lib/funktionenView.js, src/components/customer/CustomerFunktionen.jsx, src/lib/teamApi.js; Tests tests/unit/funktionenView.test.mjs.
Consumes: F13a (Feld termine.textformNoetig, Body textformDate/textformChannel), D9/D10 (Bedienung „Aktivieren (ohne Zahlung)“ nur in off/disabled, Panel in CustomerFunktionen.jsx).
Produces: im Bestätigungsdialog „Terminplaner ohne Zahlung aktivieren?“ erscheinen bei textformNoetig Haken (Text wie in F13a), Datum und Weg (Wiederverwendung von CHANNELS/canSubmitTextform aus analyseView.js), termineChatTemplate(version, links) (Kopierknopf): „Damit wir den Terminplaner für Dich einschalten können, brauchen wir Deine Zustimmung in Textform. Es gelten die Leistungsbeschreibung Terminplaner und der Auftragsverarbeitungsvertrag Terminplaner in der Fassung ${version}: ${lb} · ${avv}. Bitte antworte hier mit „Ich stimme zu“ und bestätige, dass Dein Angebot nicht im Gesundheitsbereich liegt.“ (versionierte Adressen); News und „Abschalten“ unverändert.
- Tests (WOMS):
termineChatTemplatewörtlich,canSubmitTextformmitminDate= Fassung, Dialogtext, Body{ …, textformDate, textformChannel }; zusätzlich einmalnice npx vite build. - FAIL → implementieren → PASS →
npm test(WOMS) undnice npx vite build→ Commit „Terminplaner: Staff-Aktivierung nur mit Zustimmung in Textform (Modus on), WOMS“
Task F14: WOMS „Rechtliches“: Karten der Terminplaner-Unterlagen
Files: Modify scripts/setup-legal.mjs (DOCUMENTS :89-96) im WOMS-Worktree; Test tests/setup-legal.test.mjs.
Produces: drei neue Karten (sort 7–9): leistungsbeschreibung-terminplaner („Leistungsbeschreibung Terminplaner“, liveUrl https://project.webklar.com/rechtliches/leistungsbeschreibung-terminplaner.html), avv-terminplaner („AVV Terminplaner (Auftragsverarbeitung)“, …/avv-terminplaner.html), widerrufsbelehrung-funktionen („Widerrufsbelehrung und Muster-Widerrufsformular (Funktionen)“, …/widerrufsbelehrung-funktionen.html); die Adressen ohne Fassung sind die Alias-Seiten aus F1. Das Skript legt nur die Karten an (idempotent); Ausführung mit API-Key und Upload der Versionen (PDF/HTML der Fassung) macht der Inhaber (Livegang-Schritt 7; md ist im Bucket nicht erlaubt, das Datenschutz-PDF geht als neue Version der vorhandenen Karte datenschutz). --dry-run listet sie. Die Analyse-Karten werden hier nicht ergänzt.
- Tests:
documents_keys_eindeutig_und_sort_lueckenlos(Schlüssel^[a-z0-9-]{1,64}$,sort1..n),terminplaner_karten_vorhanden(drei Schlüssel, Titel,liveUrlbeginnt mithttps://project.webklar.com/rechtliches/und endet ohne Fassungsdatum),bestehende_karten_unveraendert(die sechs alten Einträge deepEqual),dry_run_listet_karten(Skript mit--dry-run, keine Netzaufrufe). - FAIL → implementieren → PASS →
npm test(WOMS) → Commit „Rechtliches: Karten für Leistungsbeschreibung, AVV und Widerrufsbelehrung des Terminplaners“
Task F15a: Kanzlei-Paket 1 – Aufbau, Texte, Ablauf, Fundstellen (Abschnitte 1–3 und 5–7)
Files: Create docs/rechtliches/termine-kanzlei-paket.md, test/termineKanzleiPaket.test.js.
Consumes: alle Texte aus F2–F14 (Zitate), Register, Code-Fundstellen.
Aufbau (Überschriften verbindlich): ## 1. Zweck und Umfang (Kernpaket nach D5: Widerrufsbelehrung + Muster-Formular der 149-€-Bestellung, Zustimmungssatz, AVV, Haftungs- und Ende-mit-Abo-Klauseln der LB, Gästetexte mit Gesetzlichkeitsfiktion; Hausrunde: 6c, Portal-Hinweise, Mails; keine Rechtsberatung durch WEBklar; Fassungen <F>/<V>) · ## 2. Produktablauf in Kurzform (Abo-Prüfung → Fragebogen-Entwurf → Bestellung mit vier Haken → Nachweis → Stripe → Ticket/Einbau → Freischaltung → Betrieb; Gast: Haltezeit 30 Min., Bestätigungslink, Widerrufsfunktion; Ende des Abos: Abschalten spätestens am Folgetag, 30 Tage, Rückkehr, Erstattung vor Freischaltung; Zahlen 149 €, 14 Tage, 48 Stunden, 90 Tage, 7 Tage) · ## 3. Texte in Endfassung (wörtlich als Zitatblöcke > : vier Bestell-Haken + Knopf, Widerrufsbelehrung + Formular der Funktionen, LB Nr. 5, 7, 8, 11, AVV Nr. 7 und 9, Gäste-Belehrung, Gäste-Haken, Datenschutz-Hinweis am Formular, Knopftexte; AVV und LB in voller Länge als PDF-Anlagen A/B; hinter jedem Zitat ⚖ Q<n>) · ## 4. Fragen an die Kanzlei (### Q1 … ### Q26, je: Frage, Fundstelle im Text, unsere Annahme, Alternative) · ## 5. Fundstellen im Code (Tabelle Thema | Datei | Funktion/Konstante, ohne Zeilennummern) · ## 6. Nicht Teil der Kanzleiprüfung (Hausrunde; Liste 7.4 „Später“) · ## 7. Checkliste für den Inhaber (Hetzner-AV für Cloud und Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an; Uploads in WOMS; Beauftragung der Kanzlei; Freigabe der Fassung vor dem ersten fremden Betrieb).
Umfang dieses Tasks: alle sieben Überschriften und die Abschnitte 1, 2, 3, 5, 6, 7 mit Inhalt; die Zitate in Abschnitt 3 tragen schon ihre ⚖ Q<n>-Marker (die Nummern sind in F15b festgelegt). Abschnitt 4 besteht hier nur aus der Überschrift und der Zeile „Fragen Q1 bis Q26: Task F15b.“; F15b ersetzt sie.
- Tests (
termineKanzleiPaket.test.js):paket_aufbau(sieben Überschriften exakt, Fassungenconfig.termine.avvVersionundRECHTSTEXTE_VERSIONgenannt);paket_zitate_woertlich(jeder>-Absatz steht nach Whitespace-Normalisierung in einer Quelle: Register-Haken,rechtstexte.js-Ausgaben, die drei Markdown-Dokumente,config.ustHinweis);paket_fundstellen(jede Datei der Tabelle existiert, der genannte Bezeichner steht darin);paket_zahlen_gleich_code(149 €, 14 Tage, 30 Minuten, 48 Stunden, 90 Tage, 7 Tage, 30 Tage aus Register bzw. den Konstanten);paket_konsistenz_abo_ende(LB Nr. 8, AVV Nr. 9, 6c, K22 nennen alle „30 Tage“ und die Rückkehr). - FAIL → Paket schreiben → PASS →
npm test→ Commit „Kanzlei-Paket Terminplaner: Aufbau, Texte, Ablauf, Fundstellen“
Task F15b: Kanzlei-Paket 2 – Fragen Q1 bis Q26 (Abschnitt 4)
Files: Modify docs/rechtliches/termine-kanzlei-paket.md (Abschnitt ## 4. Fragen an die Kanzlei: ### Q1 … ### Q26, je Frage, Fundstelle im Text, unsere Annahme, Alternative), test/termineKanzleiPaket.test.js.
Consumes: F15a (Aufbau und ⚖ Q<n>-Marker in den Zitaten), alle Texte aus F2–F14.
Fragen (feste Nummern): Q1 Alle Besteller wie Verbraucher (D1): Haken und Belehrung für alle, Hinweis „gilt nur für Verbraucher“ ausreichend? · Q2 Fristbeginn: Klick auf „Zahlungspflichtig bestellen“ (unser Anker), Zahlungseingang oder K15? · Q3 Zustimmungssatz (Zählung § 356 Abs. 5 Nr. 2, Kenntnis-Bestätigung, Dauerhafter Datenträger durch K15) · Q4 D7 News-Altkunden: Bestellungen seit 2026-09-30 mit altem Text (Abs. 4, ohne Wertersatz-Hälfte, ohne Musterbelehrung): lief die Frist nach § 356 Abs. 3 BGB nicht an; Belehrung nachholen per Mail oder nur künftig; Anzahl der Fälle liefert der Inhaber · Q5 Wertersatz: gesetzlicher Anteil am Gesamtpreis (Anlage 1) vs. D3 „nach Stundenaufwand“ · Q6 volle Erstattung bei Ende des Abos vor Freischaltung (Klausel LB Nr. 8 Abs. 5, § 307 BGB) · Q7 Ende mit dem Abo, auch Wechsel und Widerruf; Verhältnis Widerruf des Abos zum Einrichtungsvertrag; Ausnahme: ohne Bestellung freigeschaltete Dokumente (Pilot/Kulanz, F13a) nimmt der Abgleich aus (ausgenommen im Tagesbericht): genügt „schaltet spätestens am folgenden Tag ab“ trotzdem, oder braucht es eine Kulanz-Klausel? · Q8 Haftung für verpasste Termine (LB Nr. 7, § 309 Nr. 7, Höchstbetrag gegenüber Unternehmern?), Erstattung bei Einstellung durch WEBklar · Q9 30 Tage: Anker disabledAt, Rückkehr gratis, Backup-Überhang 7 Tage; Alternative Modell der Analyse (Tag 61, Export bis Tag 46) · Q10 AVV: eigenes Geschwister statt Rahmen-AVV, Hetzner Falkenstein und Nürnberg, „keine Drittlandübermittlung“ trotz Kalender-Abo bei Google/Apple (Abonnement durch den Kunden) · Q11 Gesundheits-Ausschluss: Branchenfrage + Haken + Stichwort-Warnung + Ablehnungsrecht ausreichend (Art. 9)? · Q12 Gästebelehrung: Abweichungen vom Muster (Lieferkosten-Klammer, Widerrufsfunktion als „anderer geeigneter Hinweis“ ohne Adresse, „beginnen soll“ im Singular) und Gesetzlichkeitsfiktion · Q13 Gästetext vorzeitiger Beginn und Wertersatz-Satz · Q14 Anfrage mit Festpreis, Bestätigungslink und Seitenknopf „Zahlungspflichtig bestätigen“ als Bestellung (§ 312j Abs. 3) · Q15 Einstufung Freizeit/Massage/Fotograf/Coaching und der Portal-Hinweis · Q16 Storno-Muster 50 %/24 h (§ 307) und Wegfall bei kostenlosen Leistungen · Q17 Pflicht von Datenschutz-/Impressum-Link und „jederzeit widerrufen“-Satz am Erinnerungshaken · Q18 Datenschutz-Absatz auf der Kundenseite: Einfügen durch WEBklar im Preis, eine Regel für beide LB · Q19 Preisangabe „ohne Umsatzsteuer gemäß § 19 UStG“ (bei Fortbestehen des Kleinunternehmerstatus), „· 149 €“ am Knopf · Q20 Pflichtinformationen (Art. 246a § 1 EGBGB) als Haken und Wortlaut in K15 · Q21 Staff-Aktivierung mit Zustimmung in Textform und K16 mit PDFs · Q22 BFSG-Satz (keine Konformitätszusage) · Q23 Nachweis der Bestellung mit IP/Browser, Frist drei Jahre (Art. 6 Abs. 1 lit. f) · Q24 Rollenverteilung in 6c (Auftragsverarbeiter/Verantwortliche), Entwurfsaufbewahrung 90 Tage · Q25 Mindestlaufzeit des Wartungs-Abos und Terminplaner (kein Sonderkündigungsrecht) · Q26 Nachweis der Gäste: Hash + Archiv der Textfassung genügt (Betriebsdaten ändern sich später)?
- Tests (
termineKanzleiPaket.test.js):paket_marker_und_fragen(jedes⚖ Q<n>hat### Q<n>, jede Frage ist mindestens einmal verwiesen, Nummern lückenlos 1–26, Q4 nennt „News“, „§ 356 Abs. 3“ und „Altkunden“). - FAIL → Fragen schreiben → PASS →
npm test→ Commit „Kanzlei-Paket Terminplaner: Fragen Q1 bis Q26“
Abschlussprüfung Block F (Controller): npm test komplett grün (test/previewDevRouter.test.js bei Ausreißer einzeln wiederholen); Node-20-Lauf wie in Produktion (der Host läuft auf Node 24 und meldet Node-21+-APIs nicht): docker run --rm --cpus 3 -v /home/webklar/apps/kundenbereich.webklar.com/dev-termine:/app:ro -w /app node:20 sh -c 'node --test --test-concurrency=3 $(ls test/*.test.js | grep -v analyticsContract)' (analyticsContract.test.js importiert ../analytics/src und scheitert unter Node 20 schon in der Basis; Basis V2: Portal 2235 Tests, WOMS 294); Grep-Wächter: git diff 3a0b008 -- server public scripts | grep -nE 'Object\.groupBy|Promise\.withResolvers|Array\.fromAsync|\.union\(|fs\.glob' bleibt leer. WOMS-Anteil (F13b, F14): npm test im WOMS-Worktree und einmal nice npx vite build. PDFs: build-legal-pdfs.sh termine und datenschutz laufen auf dem Host (vorher free -m, mit nice).
Block G: Livegang-Checkliste (Controller, je Schritt Freigabe des Inhabers)
Kein Subagent-Block. Der Controller arbeitet die Schritte in dieser Reihenfolge ab (Spec Abschnitt 9) und holt vor jedem Schritt mit Schreibzugriff auf Produktion oder Stripe die Freigabe des Inhabers ein. Claude pusht nicht auf main; das macht der Inhaber. Betriebsannahme: genau ein Portal-Prozess (Sperren, Grenzen und das Mailbudget liegen im Speicher; jeder zweite Prozess bricht das; festhalten, nicht ändern). Die Schritte G1–G10 entsprechen 9.1–9.10 der Spec.
- G1 Rechtsrunde (9.1): Texte aus F2–F12 und das Kanzlei-Paket (F15a, F15b) fertigstellen; der Inhaber beauftragt die Kanzlei; Hausrunde für den Rest (Portal-Hinweise, Mails, 6c).
- G2 Code in den Worktrees (9.2): Blöcke A–F fertig, jede Abschlussprüfung grün (einschließlich Node-20-Lauf und Grep-Wächter), WOMS-Build ohne Fehler; die Test-Pins aus den Berichten sind mitgeändert (
funktionenRoutes,termineRoutes,termineSetup,termineView,kundenansichtGuard,legalTexts,mailTemplates,termineJobs, dazufeatureRegistry,featureConsents,featureMails,funktionenCheckout). - G3 Schema in der Produktions-Appwrite vor dem Code-Deploy (9.3, Freigabe):
node scripts/termine-setup.mjs --check, dann anwenden (termineConfig.fragebogen, IndizestermineBookings(status,endsAt)und(status,updatedAt),termineOutbox(bookingId));node scripts/feature-setup.mjs --check, dann anwenden (portalFeatures.disabledReason,aboEndeMailAt,zahlungMeldung, neue CollectionfeatureConsentssamt Indizes).scripts/setup-abo-collections.mjswird nicht ausgeführt. Der Schritt liegt vor dem Deploy, weilactivate()die neuen Felder schreibt (ohne SchemaUnknown attribute); der Indexaufbau ist asynchron, die Abfragen funktionieren auch ohne. - G4 Stripe (9.4, Freigabe):
node scripts/stripe-products.mjs --pruefen, dann--anlegenfürtermineSetup(Lookup-Keywebklar_termine_einrichtung_14900); der Inhaber prüft im Dashboard, dass der Live-Webhookasync_payment_succeeded,async_payment_failedundexpiredabonniert hat (Annahme: ja, News läuft seit 2026-09-30). - G5 Messprüfungen (9.5, Spec 8 #7, Freigabe): (1) tatsächliche Größe von
workorders.details(trägtTICKET_DETAILS_MAX = 7000?); (2) Seiten-Listing über 500 Dokumente auf der Live-Appwrite 1.8.1 (die Seitenläufe aus A3a–A6 lesen mitlimit ≤ 100ohne Cursor; im Nachbau bisher nur Spike); (3)Query.notEqualmit Liste undtotal/countDocuments(A2, A9a, A10b, E4) auf 1.8.1 mit derselben Bedeutung wie im Nachbau,limit(1)-Antwort mittotalbis 5000; (4) tatsächliche Hetzner-Antwort bei mehr als 500 Mails je Stunde; (5) Hetzner-AV-Prüfung durch den Inhaber (Cloud und Webhosting, Anlage 1 mit „Terminbucher/Beschäftigte/Buchungsdaten“, Kontoadresse = Impressum, Backups an). Ergebnisse ins Ledger; weicht ein Wert ab, geht der betroffene Task an seinen Block zurück. - G6 Deploy Portal + WOMS (9.6, der Inhaber pusht): Modus bleibt
pilotmit leeremTERMINE_PILOT_EMAILSplus Pilotkunde „test“. Der Abo-Abgleich läuft zunächst nur beobachtend: das ist durch den Datenstand gegeben, denn er schaltet nur Dokumente mitunlockedByPurchase === trueab, und davon gibt es vor der ersten bezahlten Bestellung keines (Pilotkunde und Kulanzfreigaben stehen im Tagesbericht alsausgenommen); ein eigener Beobachtungsschalter ist nicht gebaut. Tagesbericht undGET /api/team/termine/statusbeobachten. - G7 Rechtstexte live (9.7): PDFs bauen (
build-legal-pdfs.sh termineunddatenschutz), HTML/PDF unter/rechtliches, 6c + PDF der Datenschutzerklärung, WOMS-Karten (scripts/setup-legal.mjsmit API-Key, Freigabe) und Uploads der Fassung durch den Inhaber, Gästetexte final (RECHTSTEXTE_VERSION). - G8 Ende-zu-Ende-Test (9.8) mit einem Testkunden und echtem Wartungs-Abo: Fragebogen → Bestellung (echte 149 €, Rückerstattung durch den Inhaber danach; Annahme: es gibt kein Test-Konto für diesen Preis) → Ticket + Anleitung → „Freischalten (Abnahme)“ → Probebuchung → Abo-Ende-Simulation (Testkunde) → K22 → Rückkehr K23 → Löschlauf auf Wegwerfdaten. Dazu die Sichtprüfung des WOMS-Panels (390 px und Desktop, Kopieren, Warnungen, Aktionsknöpfe je Zustand) und die Playwright-Läufe (
termine-fragebogen.ui.mjs,termine-portal.ui.mjs,funktionen.ui.mjs,termine-widget.ui.mjs), nacheinander, vorherfree -m, mitnice. - G9 Erster fremder Kunde im Pilot (9.9), erst nach dem Kanzleibefund (Entscheidung D5 der Spec): nur seine Adresse in
TERMINE_PILOT_EMAILS; Beobachtung von Tagesbericht, Mailbudget und Status-Route (Freigabe der Adresse). - G10
TERMINE_ENABLED=on(9.10) als letzter Schritt (Compose-Änderung, nurkundenbereich-serverneu erstellen); danach eine Woche Beobachtung, News-Altfälle (D7) und der Entwurfs-Löschjob werden geprüft.