Files
Webklar-Kundenbereich/docs/superpowers/plans/2026-10-01-website-analyse-rechtsrunde.md
T

56 KiB
Raw Blame History

Website-Analyse – Pflicht-Änderungen aus der Rechtsprüfung: 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: Die technischen Voraussetzungen der geprüften Rechtstexte (Fassung 2026-10-02) herstellen, damit ANALYSE_ENABLED=on ohne falsche Angaben möglich ist: Einwilligung und Widerspruch wirken im Browser, der Messdienst speichert weniger und löscht verlässlich, jede Beendigung wird durchgesetzt, Einschalten ist nachweisbar (Historie, Textform für Mitarbeiter), die geprüften Texte sind veröffentlicht und gehen als PDF an den Kunden.

Architecture: Änderungen in vier Bereichen auf dem bestehenden Stand (feature/website-analyse = live main 30412cb): (1) Client analytics/client/ (w.js, core.js, committetes dist/), (2) Messdienst analytics/src/ (Annahme, Nachtjob, Skript-Auslieferung, Admin-Route), (3) Portal server/ + public/ (Aktivierung, Historie, Texte, Mails, Tickets, Beendigungswege), Runner runner/ (Skript-URL, Zusatzseiten-Vorlage), (4) WOMS (Textform-Formular). Ein Datenmodell-Schritt (scripts/analyse-setup.mjs) kommt vor dem Portal-Code live.

Tech Stack: Node 24 (Messdienst, node:sqlite, node --test), Node 20 (Portal/Runner), vanilla JS (Portal-UI, Client), React + node --test (WOMS; package.json nutzt node --test tests/*.test.mjs tests/unit/*.test.mjs, nicht vitest), Chrome headless (PDF).

Spec: /root/reports/website-analyse-2026-10-01/rechtstexte-geprueft/technik-aenderungen.md (T1–T22), /root/reports/website-analyse-2026-10-01/rechtstexte-geprueft/00-gutachten.md (Abschnitt „Entscheidungen für dich“, E1–E6, und P-Tabelle). Texte: /root/reports/website-analyse-2026-10-01/rechtstexte-geprueft/banner-portal-mailtexte.md (im Plan nur als „§ n“ zitiert, nicht neu getippt), avv-website-analyse.{md,html}, leistungsbeschreibung-website-analyse.{md,html}, datenschutz-analyse.html. Bisheriger Plan/Design: docs/superpowers/plans/2026-10-01-website-analyse.md, docs/superpowers/specs/2026-10-01-website-analyse-design.md.

Worktrees: Portal/Runner/Analytics /home/webklar/apps/kundenbereich.webklar.com/dev-analyse (Branch feature/website-analyse). WOMS /home/webklar/apps/ticket.webklar.com/dev-analyse. Quelle der Textdateien: RT = /root/reports/website-analyse-2026-10-01/rechtstexte-geprueft.

Global Constraints

  • Umfang: T1–T11, T13–T17, T19 (T5 und T18 sind Compose-Änderungen und gehören in den Livegang L1). Nicht im Plan: T12, T20, T21, T22 (siehe „Außerhalb des Plans“).
  • Fassung überall '2026-10-02': config.analyse.avvVersion, BANNER_VERSION im Client, Dateinamen der veröffentlichten Texte, Textform-Mindestdatum.
  • Entscheidungen: E1=B (Ping nur mit Seitenadresse ohne Einwilligung), E2 (WEBklar fügt den Hinweis in die Datenschutzerklärung ein, keine Anrechnung; externer Generator ⇒ Baustein per Chat), E3 (Mitarbeiter schaltet nur mit Textform ein: Pflichtfelder Datum und Weg), E4 (Du-Anrede auf Kunden-Websites), E5 (Tageswerte nach 25 Monaten löschen), E6 (nur Text, keine Technik).
  • Subagents pushen nie (kein git push, auch keine Tags pushen). Lokale Commits und lokale Tags sind erlaubt. Ich (Controller) pushe auch nicht auf main; das macht der Nutzer (L2, L4).
  • ANALYSE_ENABLED bleibt off bis L5. Nichts live schalten, keine Compose-Datei ändern (liegt außerhalb der Worktrees).
  • Texte deutsch, Du-Anrede im Portal und auf den Kunden-Websites; kein confirm(). Texte aus banner-portal-mailtexte.md werden 1:1 aus dem jeweiligen Abschnitt kopiert, nicht umformuliert. Neue Texte, die dort fehlen (Fehlermeldungen, Ticket-Details für „veröffentlichen“), sind im Plan mit „NEU“ markiert und kurz zu halten; der Nutzer sieht sie im Review.
  • Keine neuen Abhängigkeiten im Messdienst und im Client-Skript. Keine IP, kein User-Agent gespeichert oder geloggt.
  • Datenbank-Schreibzugriffe im Portal nur auf Attribute, die scripts/analyse-setup.mjs anlegt (Test-Mock erzwingt das).
  • Commit-Messages deutsch, Präfix „Analyse: …“, enden mit der Attribution-Zeile der Sitzung (Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>).
  • Testkommandos: Messdienst cd analytics && npm test; Runner cd runner && npm test; Portal im Worktree-Wurzelordner npm test; WOMS npm test. Nach jeder Task müssen alle vier Suiten grün sein, in denen die Task Dateien berührt.

Review Focus

  1. Nach „Ablehnen“, bei GPC/DNT und bei gespeichertem „Ablehnen“ verlässt kein pv/bn/en/ck/ct mehr den Browser, auch nicht über pushState/popstate/pageshow oder andere Tabs; vor „Annehmen“ weder ref/vp noch ein Lesezugriff auf document.referrer/innerWidth. → Task 1
  2. Keine millisekundengenauen Zeitstempel (events_raw, clicks) und keine kennungsartigen Pfadsegmente in der Datenbank; nichts davon ist umgehbar über den neuen Typ cx. → Task 2
  3. Jede Beendigung (Ausschalten, Abo-Ende, Kunde/Projekt weg, Domain/Öffentlichkeit weg, Löschfrist, Widerruf Wechsel) setzt active=false + offSince im Messdienst; danach liefert w.js?s= ein leeres Skript; Tageswerte > 25 Monate und gelöschte Zeilen sind aus Datei und WAL verschwunden. → Task 3, 4, 9
  4. Einschalten ohne Firma/Name oder gültige E-Mail ist unmöglich (statt stillem „aktiv“); Mitarbeiter-Weg ohne Datum und Weg unmöglich; Einwilligungs-Historie ist nur anfügbar. → Task 5, 7, 10
  5. Veröffentlichte Texte, PDFs, K18-Anhänge, Banner-Text und Zusatzseiten-Vorlage sind byte-/textgleich zur geprüften Fassung 2026-10-02; keine Entwurfsseite bleibt öffentlich. → Task 6, 8

Verifizierter Stand (Abweichungen zu technik-aenderungen.md)

Die Zeilenangaben stimmen mit dem Live-Code überein (w.js:44,55,65,155,162-168,177-231,240-245; ingest.js:129,142,151,160,167; jobs.js:70-84; db.js:57; analyse.js:94-102,104-121,123-140,200-218,221-242; funktionen.js:9,595,608,619,763). Befunde, die der Plan zusätzlich berücksichtigt:

  • activateAnalyse setzt bei erneutem Einschalten activatedMailAt zurück, nicht privacyTicketId ⇒ nach Aus/An gäbe es kein neues Ticket. Task 5/8 setzt es zurück.
  • Der tägliche runAboCheck läuft schon heute unabhängig vom Schalter (server/index.js:175). Der neue Abgleich (Task 9) hängt sich dort ein.
  • retention.js ruft siteFeatures.disable(), das über stopAnalyseCollecting nur putSite(..., active:false) mit Domains aus dem Projekt versucht; fehlt das Projekt, scheitert es still (nur console.warn). Task 9 ersetzt das durch einen domainlosen Aufruf.
  • Im Portal-Code gibt es keinen Pfad, der websiteProjects löscht oder liveDomain schreibt (nur WOMS/Appwrite-Konsole). Gelöschte Projekte und geänderte Live-Domains werden deshalb über den täglichen Abgleich erfasst; Subdomain-/Öffentlichkeitsänderungen laufen über giteaAdmin.updateWebsiteProject (dort Hook).
  • Es gibt keine Historien-Sammlung; portalFeatures hat nur das aktuelle Dokument. Neue Collection analyseConsents (Task 5).
  • Der Client sendet heute über navigator.sendBeacon (w.js:39); die Test-Harness (analytics/test/client.test.js, harness()) fängt nur sendBeacon ab und muss auf fetch umgestellt werden.
  • Die veröffentlichte AVV-Seite hat den Titel „Entwurf – vor Verwendung anwaltlich prüfen lassen“, test/avvPage.test.js prüft genau das und kippt bewusst (Task 6).
  • runner/test/analytics-inject.test.js erwartet im Zusatzseiten-Text „Cookie-Einstellungen“; die geprüfte Vorlage sagt „Datenschutz-Einstellungen“.
  • PDFs: scripts/build-legal-pdfs.sh (Chrome headless, Chromium unter ~/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome vorhanden, PDFs werden eingecheckt, Commit d4241d2 war so entstanden). Das Skript erzeugt alle Vertrags-PDFs auf einmal; Task 6 ergänzt einen Modus analyse, damit andere PDFs unverändert bleiben. K01 hängt PDFs bereits über sendCustomerMail(..., { attachments:[{filename, content, contentType}] }) an; K18 nutzt denselben Weg.

Teil A – Messdienst und Client

Task 1: Client – Einwilligung vor Senden (T1, T2-Client, T4-Client, T15, T19, Banner-Texte aus T10)

Files: analytics/client/core.js, analytics/client/w.js, analytics/client/dist/w.js + dist/h.js (neu bauen: node client/build.js), analytics/test/client.test.js.

Produces (core.js): export const BANNER_VERSION = '2026-10-02'; Textkonstanten BANNER_TEXT (Fließtext bis einschließlich „Details: “, 1:1 aus banner-portal-mailtexte.md § 1), BANNER_LINK_LABEL = 'Datenschutz', BANNER_ARIA = 'Einwilligung zur Reichweitenmessung', SETTINGS_LABEL = 'Datenschutz-Einstellungen'. readConsent(storage, now) liefert nur noch Einträge mit c.bv === BANNER_VERSION und gibt { v:1, a, t, bv } zurück; ältere Einträge ohne/mit anderer bv zählen als nicht vorhanden (Besucher wird neu gefragt). Schlüssel wk-consent bleibt.

Ablauf in w.js (neu geordnet):

  1. Skript-/siteKey-Prüfung und Heatmap-Modus bleiben vorn, unverändert.
  2. optOut (GPC/doNotTrack/msDoNotTrack) und stored = readConsent(...) werden vor jedem Senden und vor jedem Hook berechnet. optOut ⇒ return: kein pv, kein Banner, kein Einstellungsknopf, kein pageshow/popstate/pushState-Hook.
  3. choiceNow() liest die Wahl bei jedem Aufruf frisch (Speicher, sonst memChoice); counting() = choiceNow() !== 'reject'.
  4. sendPv(first) bricht ohne counting() ab. Nutzlast ohne Wahl exakt { t:'pv', site, path }; erst bei choiceNow()==='accept' kommen ref (nur first) und vp hinzu. document.referrer und innerWidth werden ausschließlich in diesem Zweig gelesen.
  5. Hooks (pageshow, pushState-Wrapper, popstate) nur installieren, wenn beim Start choiceNow() !== 'reject'; scheduleNav() prüft counting() im Timer-Callback erneut. ensureHooks() ist idempotent (spätes „Annehmen“ nach gespeichertem „Ablehnen“ installiert sie nachträglich).
  6. choose('reject'): Wahl speichern, genau ein bn mit a:'reject', stopTracking(), danach sendet nichts mehr (auch nicht pv per Seitenwechsel). choose('accept'): bn accept, startTracking(), dann onAccept(): wurde auf dieser Seite schon ein pv gesendet, genau einmal cx { t:'cx', site, path:lastPath, ref:refHost(DOC.referrer, ownHost()), vp:vp() } (Flag cxSent), sonst sendPv(true) mit ref/vp.
  7. storage-Handler: reject ⇒ stopTracking() (Zählen endet über counting()), accept ⇒ onAccept(), geleerter Eintrag ⇒ unverändert „keine Wahl“.
  8. Gespeichertes „Ablehnen“: nichts senden, nur addSettingsLink() und [data-webklar-consent]-Delegation; gespeichertes „Annehmen“: pv mit ref+vp wie bisher, kein Banner, kein cx.
  9. send(obj): ausschließlich fetch(endpoint, { method:'POST', body, keepalive:true, credentials:'omit', headers:{ 'Content-Type':'text/plain' } }).catch(()=>{}); kein navigator.sendBeacon mehr.
  10. Banner: aria-label = BANNER_ARIA, Fließtext = BANNER_TEXT + Link (BANNER_LINK_LABEL → /datenschutz-analyse.html), Knopf zum Wiederöffnen = SETTINGS_LABEL. choose() speichert { v:1, a, t, bv: BANNER_VERSION }.
  11. T19: Solange das Banner offen ist, setzt openBanner() DOC.body.style.paddingBottom auf die Banner-Höhe (bannerHost.getBoundingClientRect().height || offsetHeight, aufgerundet, in px; bei resize nachziehen) und stellt beim Schließen den vorherigen Inline-Wert wieder her. Einstellungsknopf: min-width:24px;min-height:32px;padding:8px 12px;font-size:13px.

Tests (zuerst, müssen scheitern; harness() auf fetch-Capture umbauen, document.referrer und win.innerWidth als zählende Getter):

  • core: readConsent verwirft {v:1,a:'accept',t} ohne bv, verwirft bv:'2026-01-01', akzeptiert bv:'2026-10-02'; abgelaufen (≥ 6 Monate) weiter null.
  • GPC (navigator.globalPrivacyControl=true) und DNT '1': h.sent.length === 0; h.winL.pageshow/h.winL.popstate undefiniert; history.pushState ist die Original-Funktion; h.body.children.length === 0 (kein Banner, kein Einstellungsknopf).
  • Gespeichertes {a:'reject',bv}: sent leer, nur Einstellungsknopf in body, keine pageshow/popstate-Listener; Klick auf den Knopf öffnet das Banner ohne Senden; dort „Annehmen“ ⇒ Typen ['bn:accept','pv'] und das pv trägt ref und vp.
  • Keine Wahl: erstes Ereignis ist deepEqual({t:'pv', site, path:'/start'}) (genau diese drei Schlüssel), danach bn:shown; Zähler für document.referrer und innerWidth = 0.
  • „Ablehnen“: Typen enden auf genau ein bn:reject; danach pushState + Timer feuern, pageshow{persisted:true}, Scroll, Klick, pagehide ⇒ nichts Neues. Zweiter Tab: Speicher auf reject setzen + storage-Ereignis, danach Seitenwechsel ⇒ nichts Neues; Seitenwechsel-Timer, der vor dem Ablehnen geplant war, sendet nichts.
  • „Annehmen“ auf laufender Seite (zuvor pv ohne Daten): Reihenfolge bn:accept, cx mit ref === refHost('https://www.google.com/','mein.de') und vp:'d'; zweites „Annehmen“ nach Widerruf sendet kein zweites cx für dieselbe Seite.
  • Gespeichertes „Annehmen“: erstes pv hat ref und vp, kein cx.
  • T15: navigator.sendBeacon ist ein Spy und wird nie aufgerufen; fetch bekommt keepalive:true, credentials:'omit', method:'POST', Header Content-Type: text/plain.
  • T19: Fake-offsetHeight=90 ⇒ body.style.paddingBottom === '90px' bei offenem Banner, nach Schließen wieder der Ausgangswert; Shadow-CSS des Einstellungsknopfs enthält min-width:24px und min-height:32px.
  • Banner-Aufbau: aria-label === BANNER_ARIA, Text beginnt mit BANNER_TEXT, Knopfname SETTINGS_LABEL; Konstanten stimmen mit § 1 überein (Test liest RT/banner-portal-mailtexte.md nicht, sondern vergleicht in Task 6 gegen die Archivdatei).
  • Bestehende Tests anpassen, die bewusst kippen: „GPC und DNT ⇒ nur pv“ (jetzt: nichts), „ohne Einwilligung nur pv + bn shown“ (jetzt pv ohne ref/vp), sendBeacon-Erwartungen; dist ist aktuell nach Neubau grün.
  • FAIL → implementieren → cd analytics && node client/build.js && npm test PASS → Commit „Analyse: Client prüft Einwilligung vor dem Senden, Fassung der Banner-Wahl, fetch ohne Cookies“

Task 2: Messdienst – Annahme (T2-Server, T3, T14)

Files: analytics/src/ingest.js, analytics/src/jobs.js (nur aggregateRows), analytics/src/heat.js (Sortierung), analytics/test/ingest.test.js, analytics/test/stats.test.js.

Produces:

  • FIELDS.pv = ['t','site','path','ref','vp'] bleibt, vp ist optional: fehlt vp (nicht vorhanden/undefined), speichert store() vp = NULL; ein vorhandener, ungültiger vp verwirft das Ereignis wie bisher.
  • FIELDS.cx = ['t','site','path','ref','vp']; store(): vp gültig Pflicht, ref über cleanRef, Zeile type='cx', ref, vp, country/device leer.
  • T3: alle gespeicherten Zeitstempel Math.floor(t / HOUR) * HOUR (pv, cx, en, ct in events_raw; ck in clicks). Berliner Offsets sind ganze Stunden, daher bleibt jeder Zeitstempel im selben Berliner Tag; heat.js sortiert ORDER BY ts DESC, id DESC.
  • T14: cleanPath() ersetzt Segmente, die kennungsartig aussehen, durch :id: Segment (auch nach decodeURIComponent, Fehler ⇒ Rohwert) mit Ziffernfolge /\d{6,}/, UUID /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i oder enthält @ (E-Mail, auch %40). Danach weiter Länge ≤ 300 und Steuerzeichen-Prüfung. Gilt für alle Typen (pv, cx, en, ck, ct).
  • jobs.js aggregateRows: pv zählt pv_total und pv_path/pv_country/pv_device wie bisher, pv_ref aber nur wenn r.vp != null (nur Besucher mit Einwilligung); cx zählt ausschließlich pv_ref (r.ref || ''), nie pv_total.

Tests:

  • pv ohne ref/vp ({t:'pv',site,path:'/'}) ⇒ 1 Zeile, vp IS NULL, ref IS NULL, device aus User-Agent, country aus Geo.
  • pv mit vp:'x' ⇒ verworfen; cx ohne gültiges vp ⇒ verworfen; cx mit ref:'https://www.google.com/x' ⇒ Zeile type='cx', ref='www.google.com'; cx mit eigener Domain als ref ⇒ ref IS NULL.
  • Zeitstempel: now = Date.UTC(2026,9,1,10,30,45) ⇒ für pv, cx, en, ct und clicks.ts jeweils === Date.UTC(2026,9,1,10,0,0); kein Wert mit ts % 3600000 !== 0.
  • Pfade: /kunde/123456789/rechnung ⇒ /kunde/:id/rechnung; /u/3f2b8c1e-5d4a-4e0b-9a7c-1b2c3d4e5f60 ⇒ /u/:id; /mail/max%40example.com und /mail/max@example.com ⇒ /mail/:id; unverändert bleiben /preise/2026, /blog/12345, /; Query/Hash weiter abgeschnitten; gilt auch für en, ck, ct.
  • Verdichtung: Zeilen pv(vp NULL) + cx(ref 'google.com') ⇒ pv_total 1, pv_ref genau {google.com:1} (kein ''-Schlüssel); pv(vp 'd', ref NULL) ⇒ pv_ref {'':1}; cx erhöht pv_total nie; Stats-Endpunkt liefert refs entsprechend (live und aus daily, nie doppelt).
  • Heatmap: zwei Klicks gleicher Stunde, LIMIT 1 liefert den mit höherer id.
  • FAIL → implementieren → cd analytics && npm test PASS → Commit „Analyse: Messdienst: Ping ohne Einwilligung, Zeitstempel auf Stunde, Pfade ohne Kennungen“

Task 3: Messdienst – Aufbewahrung, sicheres Löschen, Export (T6-Messdienst, T7, E5, T16)

Files: analytics/src/db.js, analytics/src/jobs.js, analytics/scripts/export-site.mjs (neu), analytics/test/stats.test.js (oder neu analytics/test/retention.test.js), analytics/test/export.test.js (neu).

Produces:

  • db.js openDb: nach journal_mode = WAL zusätzlich db.exec('PRAGMA secure_delete = ON').
  • jobs.js: export const DAILY_MONTHS = 25; dailyCutoffDay(now) ⇒ 'YYYY-MM-DD' = Berliner Tag von now minus 25 Kalendermonate (Tag am Monatsende auf den letzten Tag des Zielmonats begrenzt). runNightly: nach der Verdichtung DELETE FROM daily WHERE day < ? und DELETE FROM agg_state WHERE day < ? mit diesem Tag (alle Websites). Verwaiste Einträge: (a) UPDATE sites SET offSince = ? WHERE active = 0 AND offSince IS NULL (Frist beginnt dann bei Entdeckung), (b) Zeilen in events_raw/clicks/daily/agg_state, deren site in sites fehlt, werden gelöscht. Am Ende (nach allen Transaktionen) try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)') } catch (e) { log }. Rückgabe erweitert: { aggregated, sitesDeleted, dailyDeleted, orphansMarked, orphanRowsDeleted }; scheduleNightly loggt die neuen Zähler.
  • analytics/scripts/export-site.mjs: exportSite({ dbPath, siteKey, outDir }) öffnet die Datenbank nur lesend (new DatabaseSync(dbPath, { readOnly: true })) und schreibt drei CSV-Dateien (RFC 4180, UTF-8, \r\n): <siteKey>-tageswerte.csv (Kopf day,dim,key,count), <siteKey>-ereignisse.csv (Kopf time,type,path,vp,ref,country,device,visitor,sec,scroll,kind,host, time als ISO-UTC), <siteKey>-klicks.csv (Kopf time,path,vp,x,y,sel). Zellen, die mit =, +, - oder @ beginnen, bekommen ein vorangestelltes '. CLI: node scripts/export-site.mjs <siteKey> [--db <Pfad>] [--out <Ordner>]; Standard --db /srv/webklar-analytics/analytics.db, Exit-Code 1 bei ungültigem Schlüssel oder unbekannter Website.

Tests:

  • dailyCutoffDay(Date.UTC(2026,9,1,10)) = '2024-09-01'; dailyCutoffDay für 2026-03-31 = '2024-02-29'.
  • daily-Zeilen mit day '2024-08-31' werden gelöscht, '2024-09-01' bleibt; zugehörige agg_state-Zeilen ebenso; dailyDeleted zählt.
  • Website active=0, offSince NULL ⇒ nach Lauf offSince gesetzt (= new Date(now).toISOString()), zweiter Lauf ändert den Wert nicht; Website mit gesetztem offSince > 60 Tage wird weiter komplett gelöscht.
  • Zeilen ohne sites-Eintrag werden gelöscht, Zeilen von Websites mit Eintrag bleiben.
  • secure_delete: Datei-DB im Temp-Ordner, Marker-String 'GEHEIMER-PFAD-4711' als events_raw.path einfügen, löschen, wal_checkpoint(TRUNCATE); Rohbytes der .db-Datei enthalten den Marker nicht; PRAGMA secure_delete liefert 1.
  • runNightly auf Datei-DB: danach statSync(path + '-wal').size === 0.
  • Export: 3 Dateien mit den exakten Kopfzeilen; Zeilenzahlen = Zeilen der Website (andere Website erscheint nicht); Zelle a,"b"\nc korrekt gequotet; sel '=1+1' ⇒ '=1+1; Aufruf mit unbekanntem Schlüssel ⇒ Exit 1 ohne Dateien; Datei-DB bleibt byte-gleich (Hash vor/nach); Schreibversuch über die geöffnete Verbindung wirft.
  • FAIL → implementieren → cd analytics && npm test PASS → Commit „Analyse: Tageswerte nach 25 Monaten löschen, secure_delete, CSV-Export“

Task 4: Leeres Skript für ausgeschaltete Websites, siteKey in der Skript-URL (T13a)

Files: analytics/src/assets.js, analytics/src/server.js, runner/src/analytics-inject.js (scriptTag), analytics/test/assets.test.js, analytics/test/sites.test.js, runner/test/analytics-inject.test.js, runner/test/jobs.test.js (falls Tag erwartet).

Produces:

  • createAssets({ distDir, log, isActive }): /a/w.js liefert nur dann das echte Skript, wenn ?s= dem Muster ^[a-z0-9]{16,40}$ entspricht und isActive(s) wahr ist; sonst (ohne s, ungültig, unbekannt, ausgeschaltet) HTTP 200 mit Körper /* WEBklar Analyse: aus */\n, Header Content-Type: text/javascript; charset=utf-8, Cache-Control: public, max-age=60, X-Content-Type-Options: nosniff, Cross-Origin-Resource-Policy: cross-origin, Access-Control-Allow-Origin: *, Vary: Accept-Encoding. Echtes Skript und /a/h.js behalten ihre bisherigen Header (max-age=3600, ETag, gzip). 503 bei fehlender Datei bleibt.
  • server.js: createAssets({ distDir, isActive: (k) => !!db.prepare('SELECT 1 FROM sites WHERE siteKey = ? AND active = 1').get(k) }).
  • Runner: scriptTag() ergibt <script data-wk-analytics src="${baseUrl}w.js?s=${siteKey}" data-site="${siteKey}" data-color="${color}" defer></script>. w.js entfernt die Query bereits (src.replace(/[?#].*$/,'')), data-site bleibt für Alt-Fassungen. Alte Tags ohne ?s= werden beim nächsten Neubau ersetzt (bestehende Ersetzungslogik).

Tests:

  • Aktive Website, GET /a/w.js?s=<key> ⇒ echtes Skript, max-age=3600, ETag, 304 bei If-None-Match.
  • Unbekannter Schlüssel, ungültiger Schlüssel (?s=ABC), kein s, ausgeschaltete Website ⇒ Körper /* WEBklar Analyse: aus */\n, Status 200, Cache-Control: public, max-age=60, kein Content-Encoding, kein ETag.
  • Ende-zu-Ende mit Admin- und Public-Server auf einer DB: PUT active:true ⇒ echtes Skript; PUT active:false ⇒ sofort leer; erneut true ⇒ echt.
  • Runner: Tag enthält w.js?s=abcdef0123456789abcd; zweimal ausführen ⇒ ein Tag; alter Tag ohne ?s= wird ersetzt; ungültige Eingaben werfen weiter.
  • FAIL → implementieren → cd analytics && npm test und cd runner && npm test PASS → Commit „Analyse: Skript nur für aktive Websites, leeres Skript sonst, siteKey in der Skript-URL“

Teil B – Portal und Runner

Task 5: Datenmodell, Einwilligungs-Historie, Aktivierung mit Nachweis und Sperre (T8 Server, T9 Server)

Files: scripts/analyse-setup.mjs, server/config.js, server/services/analysePrivacy.js (neu, rein, ohne Imports), server/services/analyseConsent.js (neu), server/services/analyse.js, server/services/previewDeploy.js (buildAnalytics), server/routes/funktionen.js, server/routes/admin/analyse.js, test/analyseSetup.test.js, test/analyse.test.js, test/analyseRoutes.test.js, test/previewDeploy.test.js, ggf. test/support/appwriteMock.js (neue Collection im Mock-Schema).

Produces:

  • config.analyse.avvVersion = '2026-10-02'; config.collections.analyseConsents = process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents'.
  • analyse-setup.mjs: portalFeatures-Attribute zusätzlich avvConsentDate (10), avvConsentChannel (10), removalTicketId (40), publishTicketId (40) (alle string, optional; gesamt 12). Neue Collection analyseConsents (Datenbank wie portalFeatures, nur Server-Key, keine Client-Rechte) mit Attributen customerId 36, projectId 36, siteKey 40, event 8 (on/off), avvVersion 20, via 8 (portal/woms), acceptedAt 40, acceptedBy 100, textformDate 10, textformChannel 10 und Index auf projectId. Das Skript bleibt idempotent; der Client erhält ensureCollection(id, name) und nimmt die Collection-ID als Parameter.
  • analysePrivacy.js: analysePrivacyData(customer) ⇒ { ok, company, address, email, missing } mit derselben Bereinigung wie heute in buildAnalytics (Steuerzeichen raus, Firma = companyName || name ≤ 200, Adresse street, PLZ Ort ≤ 300, E-Mail ≤ 200 und ^[^\s@]+@[^\s@]+\.[^\s@]+$); missing ⊆ ['firma','email']; ok ⇔ missing leer. buildAnalytics ruft sie auf (kein zweiter Regelsatz).
  • analyseConsent.js: recordConsent(entry) ⇒ Dokument (Appwrite createDocument mit ID.unique()), listConsents(projectId). Es gibt bewusst keine Änderungs- und Löschfunktion.
  • analyse.js:
    • validateTextform({ date, channel }, { today, minDate }) mit date YYYY-MM-DD (echtes Datum, nicht nach today in Berlin, nicht vor minDate = config.analyse.avvVersion), channel ∈ {'chat','email'}; Fehler 400.
    • activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform }): Kunde (by==='customer') braucht consent.accepted === true, consent.avvVersion === config.analyse.avvVersion und actor mit name || email; Mitarbeiter braucht gültiges textform und trägt seinen Namen in by. Vor putSite: analysePrivacyData(await customerOf(customerId)) muss ok sein, sonst 409 mit NEU-Text „Für die Zusatzseite fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse. Schreib uns im Chat, wir ergänzen sie.“ (WOMS-Variante: Fehlt-Hinweis mit Feldnamen). Beim Wechsel off→active: putSite → recordConsent({ event:'on', via, avvVersion, acceptedAt:now, acceptedBy, textformDate, textformChannel, customerId, projectId, siteKey }) (schlägt es fehl: putSite(..., active:false) zurücknehmen und Fehler werfen) → updateDocument mit avvVersion, avvAcceptedAt, avvAcceptedBy (= Name/E-Mail der Person bzw. Mitarbeitername), avvConsentDate/avvConsentChannel (leer beim Kunden-Weg), activatedAt, offSince:'', activatedMailAt:'', privacyTicketId:'', removalTicketId:'', publishTicketId:''.
    • deactivateAnalyse schreibt zusätzlich recordConsent({ event:'off', via, acceptedBy }) (Fehler dabei nur loggen, Ausschalten darf nicht scheitern).
  • Routen: POST /api/funktionen/analyse/activate übergibt actor = { name: req.session.name, email: req.session.email }. POST /api/admin/analyse/:projectId Body { on, textformDate, textformChannel, audienceConfirmed }: on:true verlangt audienceConfirmed === true, gültiges Datum und Weg (sonst 400, NEU-Texte kurz); avvConfirmedVerbally wird nicht mehr akzeptiert (400 „Zustimmung in Textform nötig“). GET /api/admin/analyse/:projectId liefert zusätzlich avvConsentDate, avvConsentChannel, privacy: { ok, missing }. GET /api/funktionen (analyseView) liefert zusätzlich controller: { company, address, email, ok, missing }.

Tests:

  • Setup: ANALYSE_ATTRIBUTES hat 12 Einträge mit den Größen aus der Liste; main mit Mock legt nur fehlende Attribute an; Collection analyseConsents wird angelegt, wenn sie fehlt, und übersprungen, wenn sie existiert; --check legt nichts an und meldet missing.
  • analysePrivacyData: Firma leer + Name leer ⇒ missing ['firma']; nur name ⇒ company === name; E-Mail 'abc' ⇒ missing ['email']; Steuerzeichen/Zeilenumbruch in der Firma werden zu Leerzeichen; Adresse nur mit street ohne Ort ⇒ nur Straße.
  • buildAnalytics: Kunde mit nur name ⇒ privacy.company === name; ungültige E-Mail ⇒ null.
  • Aktivieren ohne Firma/Name oder mit email:'x' ⇒ 409, kein putSite, kein Ticket, keine K18, Feld state bleibt off.
  • Kunde aktiviert: ein Historien-Dokument event:'on', via:'portal', avvVersion:'2026-10-02', acceptedBy:'<Name>'; acceptedAt ISO; Portal-Dokument trägt dieselben Werte; zweites Aktivieren (schon aktiv) ⇒ kein weiteres Historien-Dokument; Farbwechsel allein schreibt keines.
  • Aus → An: neues Historien-Dokument, privacyTicketId/removalTicketId/publishTicketId zurückgesetzt; listConsents zeigt on,off,on in zeitlicher Folge; das Modul exportiert nur recordConsent, listConsents.
  • recordConsent wirft ⇒ putSite wird mit active:false zurückgenommen, Dokument bleibt off.
  • validateTextform: '2026-10-01' (vor Fassung) ⇒ 400; Datum in der Zukunft ⇒ 400; '2026-02-30' ⇒ 400; Weg 'telefon' ⇒ 400; {date:'2026-10-02', channel:'email'} ok.
  • Admin-Route: on:true ohne textformDate ⇒ 400; ohne textformChannel ⇒ 400; ohne audienceConfirmed ⇒ 400; mit altem avvConfirmedVerbally:true ⇒ 400; gültig ⇒ 200 und Historie via:'woms', acceptedBy = Mitarbeitername, textformDate/textformChannel gesetzt, Portal-Dokument avvConsentDate/avvConsentChannel.
  • GET /api/funktionen: controller.ok === false und missing gefüllt, wenn Angaben fehlen; GET /api/admin/analyse/:id enthält privacy.
  • FAIL → implementieren → npm test PASS → Commit „Analyse: Einwilligungs-Historie, Nachweis beim Einschalten, Sperre ohne Verantwortlichen-Angaben, Textform für Mitarbeiter“

Task 6: Rechtstexte veröffentlichen, Zusatzseiten-Vorlage, Archiv (T10 Dateien, T11)

Files: public/rechtliches/avv-website-analyse-2026-10-02.html, public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html, public/rechtliches/avv-website-analyse-2026-10-02.pdf, public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.pdf, public/rechtliches/avv-website-analyse.html (entfernen), server/services/analyseLegal.js (neu), server/index.js (Route), scripts/build-legal-pdfs.sh, runner/src/templates/datenschutz-analyse.html, docs/rechtliches/avv-website-analyse.md, docs/rechtliches/leistungsbeschreibung-website-analyse.md, docs/rechtliches/archiv/ (neu), test/avvPage.test.js, test/legalTexts.test.js (neu test/analyseLegal.test.js), runner/test/analytics-inject.test.js.

Schritte und Produces:

  • Kopieren (Bytes unverändert) aus RT: avv-website-analyse.html → public/rechtliches/avv-website-analyse-2026-10-02.html, leistungsbeschreibung-website-analyse.html → public/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html, avv-website-analyse.md/leistungsbeschreibung-website-analyse.md → docs/rechtliches/ (ersetzen die Entwürfe), datenschutz-analyse.html → runner/src/templates/datenschutz-analyse.html. Die bisherige Entwurfsseite public/rechtliches/avv-website-analyse.html wird gelöscht (T11).
  • analyseLegal.js: legalPaths(version) ⇒ { avv:'/rechtliches/avv-website-analyse-<version>.html', lb:'/rechtliches/leistungsbeschreibung-website-analyse-<version>.html' }, legalPdfFiles(version) ⇒ { avv:{filename:'AVV-Website-Analyse-<version>.pdf', path}, lb:{filename:'Leistungsbeschreibung-Website-Analyse-<version>.pdf', path} }, legalPdfAttachments(version) ⇒ Promise<[{filename,content,contentType:'application/pdf'},…]> (wirft bei unlesbarer Datei).
  • Route in server/index.js vor express.static: GET /rechtliches/avv-website-analyse.html und GET /rechtliches/leistungsbeschreibung-website-analyse.html liefern res.sendFile der Datei zur aktuellen config.analyse.avvVersion (Adressen ohne Fassung zeigen immer die aktuelle geprüfte Fassung; eine neue Fassung = neue Dateien + Konfiguration).
  • build-legal-pdfs.sh: Aufruf scripts/build-legal-pdfs.sh analyse erzeugt nur die beiden PDFs aus den versionierten HTML-Dateien (Standard all wie bisher); PDFs werden eingecheckt. Ausführen: CHROME=$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome | tail -1) scripts/build-legal-pdfs.sh analyse.
  • Archiv (T4): docs/rechtliches/archiv/README.md mit Tabelle „Fassung | gültig ab | Banner-Text | Zusatzseite“; docs/rechtliches/archiv/banner-2026-10-02.md enthält Fließtext (§ 1), aria-label, Schaltflächennamen, BANNER_VERSION; docs/rechtliches/archiv/datenschutz-analyse-2026-10-02.html ist eine Kopie der Vorlage. „Gültig ab“ bleibt offen bis Livegang und wird in L2/L5 eingetragen. Lokales Git-Tag analyse-texte-2026-10-02 auf den Commit dieser Task (nicht pushen; L2 pusht der Nutzer).

Tests:

  • test/avvPage.test.js neu: beide versionierten HTML-Dateien existieren, enthalten Fassung 2026-10-02, enthalten weder <!-- noch Entwurf noch Prüfpunkt; public/rechtliches/avv-website-analyse.html existiert nicht mehr als Datei; AVV-Seite enthält die Überschrift 12. Haftung (wie bisher).
  • GET /rechtliches/avv-website-analyse.html und …/leistungsbeschreibung-website-analyse.html über die Express-App liefern exakt die Bytes der Dateien für config.analyse.avvVersion; …-2026-10-02.html ebenfalls 200; …-2026-01-01.html 404.
  • PDFs: beginnen mit %PDF, Größe > 20 KB, legalPdfAttachments('2026-10-02') liefert genau 2 Einträge mit den Dateinamen oben.
  • Banner-Archiv: archiv/banner-2026-10-02.md enthält BANNER_TEXT und BANNER_ARIA aus analytics/client/core.js (Import der Konstanten) wortgleich; README.md nennt BANNER_VERSION.
  • Runner-Test Datenschutzseite…: Erwartete Abschnittswörter auf „Datenschutz-Einstellungen“ statt „Cookie-Einstellungen“ ändern; zusätzlich: Seite enthält Stand: 02.10.2026, keine {{-Reste, {{#address}} ohne Adresse entfernt, <!-- nirgends.
  • public/funktionen.js AVV_URL-Test aus avvPage.test.js wird in Task 7 angepasst; hier nur entfernen/ersetzen durch Existenzprüfung der versionierten Dateien.
  • npm test und cd runner && npm test PASS → Commit „Analyse: geprüfte Rechtstexte Fassung 2026-10-02 veröffentlichen (HTML, PDF, Zusatzseiten-Vorlage, Archiv)“

Task 7: Portal-Oberfläche (T8 Frontend, T10 Portal-Vorschau)

Files: public/funktionen.js, public/analyse-view.js, server/routes/funktionen.js (analyseView: legal), test/avvPage.test.js, test/analyseView.test.js, test/funktionenTiles.test.js, optional test/ui/funktionen.ui.mjs (Playwright, manuell: node test/ui/funktionen.ui.mjs).

Produces:

  • analyseView liefert zusätzlich legal: { avv, lb } (Pfade aus legalPaths(config.analyse.avvVersion)), controller kommt aus Task 5.
  • public/analyse-view.js: BANNER_PREVIEW_TEXT (= BANNER_TEXT aus core.js), controllerLines(controller) ⇒ string[] (Firma, Anschrift falls vorhanden, E-Mail) für die Vorschau, AVV_CHECK_*-Konstanten nicht nötig.
  • public/funktionen.js: AVV_URL und Leistungsbeschreibungs-Link kommen aus a.legal (Konstante AVV_URL entfällt). Texte, jeweils aus banner-portal-mailtexte.md: Panel-Aufzählung (§ 2, ersetzt die drei bisherigen Listenpunkte, Zeile ~608), Vorschau der Verantwortlichen-Angaben vor dem Haken (§ 2; Schreib-uns-Hinweis nur wenn controller.ok), Haken mit zwei Links und Kinder-Satz (§ 2, ersetzt Zeile ~619), bannerPreview zeigt BANNER_PREVIEW_TEXT plus Link und Knopf-Beschriftungen („Ablehnen“, „Annehmen“) (§ 1), Hinweis vor dem Ausschalten (§ 2, ersetzt Zeile ~763), Bestätigung nach „Ja, ausschalten“ (§ 2; Bestätigungstext statt sofortigem reload(), mit Knopf „OK“, der danach reload() ruft). Ist controller.ok === false, bleibt „Analyse einschalten“ gesperrt und ein Hinweis (NEU-Text wie in Task 5) erklärt, was fehlt. consent: { accepted:true, avvVersion } bleibt.

Tests:

  • Parität: BANNER_PREVIEW_TEXT === BANNER_TEXT (beide aus den Modulen importiert); Preview-HTML enthält Ablehnen und Annehmen.
  • Quelltext-Prüfung wie in avvPage.test.js: funktionen.js enthält keine der alten Formulierungen („anonym“, „Cookie-Einstellungen“, „Die bisherigen Daten bleiben noch 60 Tage erhalten“, „Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag“) und verweist nicht mehr auf AVV_URL = '/rechtliches/avv-website-analyse.html'.
  • Render-Test (funktionenTiles-Muster, render() mit Fixture analyse:{state:'off', eligible:true, avvVersion:'2026-10-02', legal:{…}, controller:{ok:true, company:'Muster GmbH', address:'Weg 1, 12345 Ort', email:'info@muster.de'}}): HTML enthält die zwei Links href aus legal, den Text „Fassung 2026-10-02“, „Muster GmbH“ und „info@muster.de“; mit controller.ok:false ist der Knopf disabled und der Fehlt-Hinweis sichtbar.
  • Ausschalt-Ablauf (Fake-fetch): nach „Ja, ausschalten“ erscheint der Bestätigungstext aus § 2, erst „OK“ lädt neu.
  • controllerLines: ohne Adresse nur 2 Zeilen; HTML-Maskierung im Vorschau-Text (esc) für </& in der Firma.
  • npm test PASS; Playwright-Skript einmal laufen lassen (Screenshots 390 px und Desktop nach /root/reports/website-analyse-2026-10-01/) → Commit „Analyse: Portal-Texte, Verantwortlichen-Vorschau und Bestätigungen nach geprüftem Wortlaut“

Task 8: Mails K18/K19, Ticket-Texte, Neubau-Ergebnis (T10 Mails, T13b/c, T17)

Files: server/services/analyse.js, server/services/analyseTexts.js (neu), server/mail-templates/K18 Analyse aktiv.html, server/mail-templates/K19 Analyse beendet.html, test/analyse.test.js, test/mailTemplates.test.js.

Produces:

  • analyseTexts.js: Konstanten PRIVACY_SNIPPET_DU und PRIVACY_SNIPPET_SIE (aus banner-portal-mailtexte.md § 7, Fließtext ohne Markdown-Sternchen, Überschrift „Reichweitenmessung“ eigene Zeile), Funktionen privacyTicketDetails({ name, domains, rebuildNote }) (Kopfzeilen Funktion, Projekt, Domain(s), Zusatzseite, danach die vier Punkte aus § 6 „Beim Einschalten“, darunter beide Bausteine) und removalTicketDetails({ name, domains }) (Kopfzeilen + die drei Punkte aus § 6 „Beim Ausschalten“); publishTicketDetails({ name, domains, reason }) (NEU, kurz: Website neu veröffentlichen, damit Skript und Zusatzseite erscheinen; reason ∈ kein_repo|preview_aus|fehler).
  • rebuildSafe(project) gibt { ok, reason } zurück: kein_repo (kein repoFullName), preview_aus (previewMode === 'off'), fehler (Ausnahme oder result.error), sonst ok:true.
  • privacyTicket (T17) nutzt privacyTicketDetails, Thema bleibt „Datenschutzerklärung von um Hinweis auf Analyse ergänzen“. Beim Einschalten: erst rebuildSafe, dann Ticket; ist der Neubau nicht möglich oder gescheitert, zusätzlich Ticket Website-Analyse von <Projekt> veröffentlichen (Marker publishTicketId, genau eines je Aktivierung).
  • deactivateAnalyse: nach rebuildSafe Ticket „Website-Analyse von entfernen“ (Details aus § 6 „Beim Ausschalten“; Marker removalTicketId, genau eines je Beendigung; Ticket-Fehler loggen, nie das Ausschalten verhindern). Gilt für Kunde, Mitarbeiter und runAboCheck (geht über deactivateAnalyse).
  • mailSafe(customerId, template, project, { values, blocks, attachments }): K18 bekommt legalPdfAttachments(config.analyse.avvVersion); sind sie unlesbar, wird nicht gesendet (false, Marker activatedMailAt bleibt leer ⇒ Wiederholung beim nächsten Abgleich, Task 9). Zusätzliche Werte K18: fassung, zeitpunkt (Berlin, TT.MM.JJJJ, HH:MM Uhr über berlinDateTime), person, datum (TT.MM.JJJJ), weg (Chat im Kundenportal bzw. E-Mail), avv_url, lb_url (absolut mit config.publicBaseUrl); Blöcke portal_weg (Kunde) und textform_weg (Mitarbeiter).
  • K18: Absatz nach „Hallo {{kunde_name}},“ durch § 4 ersetzen, Tabelle um die Zeilen „Vereinbart“ und „Zugestimmt“ (§ 4) erweitern, Kopfkommentar Platzhalter und Optionale Blöcke nachziehen (mailTemplates.test.js prüft Kopfkommentar gegen Vorlage). K19: Absatz durch § 5 ersetzen.

Tests:

  • Einschalten (Kunde): K18 verschickt, attachments.length === 2, Dateinamen AVV-Website-Analyse-2026-10-02.pdf und Leistungsbeschreibung-Website-Analyse-2026-10-02.pdf, Inhalte beginnen mit %PDF; HTML enthält 2026-10-02, die beiden absoluten Links und die Zeile mit durch <Name>; Text enthält den Einleitungssatz aus § 4 („Das Einwilligungsbanner erscheint mit der nächsten Veröffentlichung“).
  • Einschalten (Mitarbeiter, textform {date:'2026-10-03', channel:'email'}): Mail enthält in Textform am 03.10.2026 per E-Mail.
  • PDFs unlesbar (Pfad per Testschalter ungültig) ⇒ keine Mail, activatedMailAt leer, Aktivierung selbst bleibt gültig.
  • Ticket beim Einschalten: Details enthalten „Textbaustein“, „offensichtlicher Widerspruch“, „externer Generator“, „Stundenkontingent“ und den Anfang beider Bausteine („Wir messen mit einer eigenen Reichweitenmessung ohne Cookies“, „Solange Sie im Banner nichts ausgewählt haben“); enthält nicht mehr „Satz + Link in die bestehende Datenschutzerklärung einfügen“.
  • Ticket beim Ausschalten (Kunde, Mitarbeiter, Abo-Ende über runAboCheck): genau ein Ticket mit Thema Website-Analyse von Bäckerei entfernen; zweimal ausschalten ⇒ ein Ticket; Aus → An → Aus ⇒ zwei Tickets; Details enthalten „fünf Werktagen“ und „Stundenkontingent“.
  • rebuildSafe: Projekt ohne repoFullName ⇒ reason 'kein_repo', Rebuild-Stub nicht aufgerufen, beim Einschalten zusätzlich Ticket … veröffentlichen; previewMode:'off' ⇒ preview_aus; Rebuild wirft ⇒ fehler; erfolgreicher Rebuild ⇒ kein Veröffentlichen-Ticket.
  • K19-Text enthält „keine Daten mehr gespeichert“ und „46. Tag“ (§ 5), nicht mehr „Der Banner und die Messung sind von Deiner Website entfernt“.
  • mailTemplates.test.js grün (Kopfkommentar, Platzhalter, Blöcke, Textfassung ohne {{).
  • npm test PASS → Commit „Analyse: K18 mit PDFs und Nachweis, K19 neu, Tickets für Einschalten, Ausschalten und Veröffentlichen“

Task 9: Alle Beendigungswege setzen active=false + offSince (T6-Portal)

Files: analytics/src/server.js, analytics/test/sites.test.js, server/services/analyticsClient.js, server/services/analyse.js, server/services/siteFeatures.js, server/services/customerAdmin.js, server/services/giteaAdmin.js (updateWebsiteProject), server/routes/abo.js (withdrawOrder), server/index.js, test/analyticsClient.test.js, test/analyse.test.js, test/retention.test.js, test/aboRoutes.test.js, test/giteaAdmin.test.js, test/analyticsContract.test.js.

Produces:

  • Messdienst: Admin-Route POST /a/admin/sites/:siteKey/off (Bearer wie alle Admin-Routen, 401 sonst; setzt active=0 und offSince einmalig auf jetzt, ändert einen vorhandenen offSince nicht; unbekannter Schlüssel 404; ruft onSitesChanged). Portal-Client analyticsClient.stopSite(siteKey) (404 zählt als erledigt) und im Vertragstest test/analyticsContract.test.js ergänzt.
  • analyse.js: endAnalyse({ doc, project, reason, now, by, tickets = true, rebuild = true }) als gemeinsamer Kern (Messdienst stopSite, Dokument state:'off' + offSince, optional Neubau und Entfernen-Ticket, wenn Projekt und Kunde noch existieren); deactivateAnalyse ruft ihn. endAnalyseForCustomer(customerId, { reason, mail }) beendet alle aktiven Analysen des Kunden (bei mail:true K19). analyseOnProjectChanged(projectId): aktives Dokument; fällt die Berechtigung weg (nicht_oeffentlich, keine_subdomain) ⇒ endAnalyse(reason:'projekt_nicht_mehr_oeffentlich'), sonst putSite mit neuen analyseDomains(project). runAnalyseReconcile({ now }) (täglich nach runAboCheck, server/index.js): für Analyse-Dokumente active mit fehlendem Projekt oder fehlendem Kunden ⇒ endAnalyse(reason:'projekt_geloescht'|'kunde_geloescht', tickets:false, rebuild:false); für active mit vorhandenem Projekt putSite mit aktuellen Domains (fängt geänderte liveDomain); für off und disabled stopSite (idempotent); für active ohne activatedMailAt K18 erneut versuchen.
  • Hooks: customerAdmin.deleteCustomerWithPortalAccess ruft vor dem Löschen endAnalyseForCustomer(customerId, { reason:'kunde_geloescht', mail:false }) (Fehler loggen); giteaAdmin.updateWebsiteProject ruft nach dem Speichern analyseOnProjectChanged (dynamischer Import wegen Zyklus, Fehler loggen); siteFeatures.stopAnalyseCollecting nutzt stopSite statt putSite mit Projekt-Domains (damit Löschfrist auch ohne Projekt wirkt); withdrawOrder ruft bei type === 'wechsel' und inTime endAnalyseForCustomer(sub.customerId, { reason:'widerruf_wechsel', mail:true }) in try/catch (der Widerruf darf nie daran scheitern).

Tests:

  • Messdienst: POST …/off ohne Token 401; unbekannt 404; bekannt ⇒ active=0, offSince gesetzt, zweiter Aufruf lässt den ersten Wert; direkt danach verwirft /a/e Ereignisse (Cache invalidiert) und /a/w.js?s= liefert das leere Skript.
  • Kunde gelöscht (deleteCustomerWithPortalAccess): stopSite für jeden aktiven Analyse-Schlüssel des Kunden, Dokument state:'off', kein Ticket, keine Mail, Löschen des Kunden gelingt auch wenn stopSite wirft.
  • Projekt gelöscht (Dokument aus websiteProjects entfernt): runAnalyseReconcile ⇒ stopSite, state:'off', offSince gesetzt, kein Ticket; Kunde fehlt ⇒ dasselbe; vorhandenes Projekt mit geänderter liveDomain ⇒ putSite mit neuer Domainliste; off-Dokument ⇒ stopSite aufgerufen.
  • Domain/Öffentlichkeit: updateWebsiteProject mit isPublic:false ⇒ Analyse beendet (stopSite, off, Entfernen-Ticket); mit neuer subdomain ⇒ putSite mit <neu>.project.webklar.com; Fehler im Hook ändert das Ergebnis des Projekt-Updates nicht.
  • Löschfrist: applyDeletions mit Analyse-Dokument, dessen Projekt nicht mehr existiert ⇒ Dokument disabled, Messdienst-Stub bekam stopSite(siteKey) (nicht putSite).
  • Widerruf Wechsel (POST /public/withdraw, order:'wechsel', in der Frist, Kunde hat aktive Analyse): Analyse off, stopSite aufgerufen, K19 gesendet; Widerruf außerhalb der Frist oder order:'zusatzspeicher' ändert nichts; wirft endAnalyseForCustomer, bleibt der Widerruf 200.
  • K18-Wiederholung: aktives Dokument ohne activatedMailAt und lesbaren PDFs ⇒ Abgleich sendet K18 genau einmal und setzt den Marker.
  • npm test und cd analytics && npm test PASS → Commit „Analyse: alle Beendigungswege schalten den Messdienst ab (Kunde/Projekt weg, Domain, Löschfrist, Widerruf Wechsel)“

Teil C – WOMS

Task 10: WOMS – Zustimmung in Textform (T9)

Files (WOMS-Worktree): src/lib/analyseView.js, src/components/AnalyseRow.jsx, src/lib/analyseApi.js, tests/unit/analyseView.test.mjs.

Produces:

  • analyseApi.setAnalyse(projectId, { on, textformDate, textformChannel, audienceConfirmed }) sendet genau diese Felder (kein avvConfirmedVerbally mehr).
  • analyseView.js: avvLabel entfällt; neu textformLabel(version) (Kästchentext aus banner-portal-mailtexte.md § 3, Links werden in der Komponente gesetzt), canSubmitTextform({ confirmed, date, channel, today, minDate }) ⇒ boolean (alle drei Pflichtangaben, date ≥ minDate = Fassungsdatum, date ≤ today, channel ∈ {'chat','email'}), channelLabel(channel) („Chat im Kundenportal“/„E-Mail“), analyseStatus zeigt bei active zusätzlich „Zustimmung in Textform am TT.MM.JJJJ per “, wenn avvConsentDate vorhanden ist.
  • AnalyseRow.jsx: Einschalte-Zeile zeigt Kästchen (§ 3) mit Links auf AVV und Leistungsbeschreibung (aus data.legal, absolut https://project.webklar.com…), Pflichtfeld „Datum der Zustimmung“ (<input type="date" required max=heute min=Fassungsdatum>), Pflichtauswahl „Weg“ (Radio „Chat im Kundenportal“ / „E-Mail“); Knopf „Einschalten“ bleibt gesperrt, bis canSubmitTextform. Aufklappbar „Textvorlage für den Chat“ (§ 3, {Link AVV}/{Link Leistungsbeschreibung} mit den echten Links gefüllt) mit Kopieren-Knopf. Ist data.privacy.ok === false, Einschalten gesperrt und Hinweis, welches Feld fehlt (firma/email).

Tests:

  • canSubmitTextform: ohne Häkchen, ohne Datum, ohne Weg, Datum vor 2026-10-02, Datum morgen ⇒ je false; alle gültig ⇒ true.
  • channelLabel('chat') === 'Chat im Kundenportal', channelLabel('email') === 'E-Mail'.
  • analyseStatus({enabled:true,state:'active',eligible:true,activatedAt:'2026-10-03T08:00:00Z',avvConsentDate:'2026-10-02',avvConsentChannel:'email'}).text enthält An seit 03.10.2026 und Zustimmung in Textform am 02.10.2026 per E-Mail.
  • setAnalyse (Fetch-Stub): Body-JSON hat on:true,textformDate,textformChannel,audienceConfirmed:true und kein avvConfirmedVerbally.
  • npm test und npm run build PASS → Commit „Analyse: Einschalten im WOMS nur mit Datum und Weg der Zustimmung in Textform“

Livegang (jeder Schritt braucht das OK des Nutzers; Reihenfolge einhalten)

  • L1 Vorbereitung auf dem Server (ohne Portal-Code):
    1. Schema: im Portal-Umfeld (Appwrite-Key) node scripts/analyse-setup.mjs --check, dann node scripts/analyse-setup.mjs ausführen (Attribute an portalFeatures + Collection analyseConsents). Muss vor L2 passieren, weil der neue Portal-Code diese Felder schreibt.
    2. /home/webklar/apps/docker-compose.yml, Dienst analytics (Zeilen ~1786-1814): unter mem_limit: 384m die Zeile memswap_limit: 384m ergänzen (T5). Kommentar zum Traefik-Zugriffsprotokoll ergänzen (T18): am Traefik-Block (Zeilen ~15-56) „Zugriffsprotokoll bleibt aus“ und an den Labels von webklar-analytics „falls je eingeschaltet: traefik.http.routers.webklar-analytics.observability.accessLogs=false setzen“. docker compose config -q prüfen, docker compose up -d analytics, danach docker inspect webklar-analytics --format '{{.HostConfig.MemorySwap}}' = 402653184 (384 MiB).
    3. Rollback: Zeile memswap_limit entfernen, up -d analytics; Schema-Skript ist additiv.
  • L2 Portal-Push durch den Nutzer (meine Pushes auf main sind blockiert): Branch feature/website-analyse in main übernehmen und pushen, zusätzlich Tag analyse-texte-2026-10-02 (lokal angelegt in Task 6) pushen; deploy.sh startet bei analytics/-Änderung den Messdienst neu. Prüfen: curl -s https://project.webklar.com/a/health ⇒ {"ok":true}; curl -s 'https://project.webklar.com/a/w.js?s=unbekannt00000000000' ⇒ /* WEBklar Analyse: aus */; https://project.webklar.com/rechtliches/avv-website-analyse.html und …/leistungsbeschreibung-website-analyse.html zeigen die geprüfte Fassung 2026-10-02 ohne „Entwurf“/„Prüfpunkt“; …-2026-10-02.pdf laden; ANALYSE_ENABLED weiter off (Analyse-Routen antworten 409). docs/rechtliches/archiv/README.md: „gültig ab“ mit dem Tag des Einschaltens (L5) nachtragen.
  • L3 Browser-Probe auf einer öffentlichen Wegwerf-Website (statische Testseite unter *.project.webklar.com, Skript-Tag von Hand mit w.js?s=<Probe-Schlüssel>; Website beim Messdienst per Admin-Token (PUT /a/admin/sites/<key>, interner Port) angelegt, Portal-Aktivierung nicht nötig). Frisches Chrome-Profil, Netzwerk-Tab offen; vorher im selben Profil im Kundenportal anmelden, damit Portal-Cookies existieren. Belege als Screenshots/Netzwerk-Export nach /root/reports/website-analyse-2026-10-01/livegang/:
    1. Ohne Wahl: genau ein POST /a/e mit Körper {"t":"pv","site":"…","path":"/…"} (keine Felder ref, vp) und ein bn shown; kein Cookie-Header an project.webklar.com/a/e; Banner-Text, aria-label, Platz unten (Footer-Links erreichbar) und Knopf „Datenschutz-Einstellungen“ ≥ 24 × 24 px prüfen.
    2. „Ablehnen“: genau ein bn reject; danach Seitenwechsel (Link, pushState-Seite falls vorhanden, Zurück-Taste, Scrollen, Klicken) und Neuladen ⇒ keine weiteren /a/e-Anfragen, nur Knopf „Datenschutz-Einstellungen“.
    3. GPC: Browser mit Sec-GPC/navigator.globalPrivacyControl === true (Firefox privacy.globalprivacycontrol.enabled) ⇒ null /a/e-Anfragen, kein Banner, kein Knopf.
    4. „Annehmen“ auf laufender Seite: bn accept, cx mit ref und vp, später ck/ct/en; neue Banner-Fassung testen, indem im Speicher wk-consent ohne bv gesetzt wird ⇒ Banner erscheint erneut.
    5. Datenbank: docker exec webklar-analytics node -e "…SELECT COUNT(*) FROM events_raw WHERE ts % 3600000 != 0…" ⇒ 0 (alle Zeitstempel voll Stunde, auch clicks); unbefragtes pv hat vp IS NULL und ref IS NULL; Pfad mit 7-stelliger Zahl erscheint als :id.
    6. Export: docker exec webklar-analytics node scripts/export-site.mjs <key> --out /srv/webklar-analytics/exports ⇒ drei CSVs mit Daten; danach den Ordner löschen.
    7. Ausschalten: PUT /a/admin/sites/<key> mit active:false ⇒ curl -sI 'https://project.webklar.com/a/w.js?s=<key>' zeigt den leeren Körper und Cache-Control: public, max-age=60; nach > 60 Sekunden Seite neu laden (Cache leeren) ⇒ kein Banner, keine /a/e-Anfragen; offSince gesetzt.
    8. Aufräumen: DELETE /a/admin/sites/<key>, Wegwerf-Website entfernen, Protokolle der Probe löschen.
  • L4 WOMS-Push durch den Nutzer: WOMS-Branch nach dem üblichen Weg (Test, dann main) pushen und ausrollen. WOMS muss zeitnah zu L2 live gehen, weil die neue Portal-Route das alte avvConfirmedVerbally ablehnt (Schalter ist bis L5 aus, es gibt keinen Schaden).
  • L5 Einschalten (Nutzer):
    1. Nutzer lädt AVV und Leistungsbeschreibung Fassung 2026-10-02 in WOMS „Rechtliches“ hoch (anwaltliche Freigabe vorausgesetzt).
    2. ANALYSE_ENABLED=on im Portal-Umfeld setzen, Portal neu starten.
    3. Echter Kundentest mit einer Kunden-Website mit Wartungs-Abo: Einschalten im Portal ⇒ K18 mit beiden PDFs, Historie-Eintrag, Ticket mit Textbaustein, Neubau, w.js?s= und datenschutz-analyse.html auf der Seite; Mitarbeiter-Weg an einem zweiten Testprojekt (Datum + Weg Pflicht, K18 mit „in Textform am …“); Ausschalten ⇒ Bestätigungstext, Ticket „Website-Analyse von … entfernen“, nach Neubau kein Banner.
    4. Rückfall: ANALYSE_ENABLED=off (Routen 409, bestehende Analysen laufen weiter und werden vom täglichen Abgleich weiter beendet, wenn das Abo endet).

Außerhalb des Plans

  • T12: Hetzner-Konto prüfen (Backups für ubuntu-16gb-webklar-v1, AV-Vertrag) – vom Server aus nicht prüfbar, Aufgabe des Nutzers.
  • T20: Portal-Zustimmung zu einer neuen Fassung für laufende Analysen (Hinweis plus Haken); bis dahin Zustimmung in Textform nach AVV Nr. 12 Abs. 2. Technisch erst vor der ersten Fassungsänderung nötig.
  • T21: journald begrenzen (MaxRetentionSec=14day, MaxFileSec=1day, journalctl --vacuum-time=14d); betrifft Abo-Bedingungen v5, nicht die Analyse.
  • T22: Datenbank-Export /opt/webklar-backup/db-dump.sh für SQLite-Anwendungen prüfen; betrifft Abo-Bedingungen v5.
  • Weiter nicht Teil des Plans: abo-bedingungen-v6-vorschlag.md (reiner Text), E6 (nur Text in der Leistungsbeschreibung: drei Monate Frist, Sonderkündigungsrecht, keine Technik), anwaltliche Abnahme der Texte, „gültig ab“-Eintrag im Archiv (L2/L5). T5 und T18 stehen im Livegang L1.

Offene Annahmen (kurz prüfen, bevor die Task startet)

  • WOMS-Tests laufen über node --test (nicht vitest) – Befehle im Plan entsprechend.
  • Das Ticket-Feld details der Collection workorders muss die beiden Textbausteine (ca. 2 KB) aufnehmen; sonst in Task 8 nur den Du-Baustein ins Ticket und den Sie-Baustein in einen zweiten Absatz-Hinweis „liegt in server/services/analyseTexts.js“ legen (vor Task 8 mit grep details APPWRITE_SCHEMA.md bzw. Appwrite prüfen).
  • runDeploy meldet nur synchron erkennbare Fehler (result.error); spätere Runner-Fehler bleiben über previewState: 'failed' sichtbar und lösen kein zusätzliches „veröffentlichen“-Ticket aus.
  • Heatmap-Links für Seiten, deren Pfad durch T14 zu :id wurde, führen auf eine nicht existierende Seite; das ist Absicht (Pfade mit Kennungen sind nicht auswertbar) und braucht keine Sonderbehandlung.
  • Der echte w.js bleibt 3600 s im Browser-Cache; wer vor dem Ausschalten geladen hat, sieht das Banner bis zu eine Stunde lang weiter, der Messdienst speichert dann aber nichts mehr (active=0). Das erfüllt LB Nr. 7 Abs. 2 („spätestens innerhalb von fünf Werktagen“). Soll es kürzer sein, max-age für das echte Skript in Task 4 auf 300 s setzen.

Critical Files for Implementation

  • /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/analytics/client/w.js
  • /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/analytics/src/ingest.js
  • /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/server/services/analyse.js
  • /home/webklar/apps/kundenbereich.webklar.com/dev-analyse/scripts/analyse-setup.mjs
  • /home/webklar/apps/ticket.webklar.com/dev-analyse/src/components/AnalyseRow.jsx