diff --git a/docs/superpowers/plans/2026-10-01-website-analyse-rechtsrunde.md b/docs/superpowers/plans/2026-10-01-website-analyse-rechtsrunde.md new file mode 100644 index 0000000..0054c75 --- /dev/null +++ b/docs/superpowers/plans/2026-10-01-website-analyse-rechtsrunde.md @@ -0,0 +1,323 @@ +# 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 `). +- 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`): `-tageswerte.csv` (Kopf `day,dim,key,count`), `-ereignisse.csv` (Kopf `time,type,path,vp,ref,country,device,visitor,sec,scroll,kind,host`, `time` als ISO-UTC), `-klicks.csv` (Kopf `time,path,vp,x,y,sel`). Zellen, die mit `=`, `+`, `-` oder `@` beginnen, bekommen ein vorangestelltes `'`. CLI: `node scripts/export-site.mjs [--db ] [--out ]`; 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 ``. `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=` ⇒ 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:''`; `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-.html', lb:'/rechtliches/leistungsbeschreibung-website-analyse-.html' }`, `legalPdfFiles(version) ⇒ { avv:{filename:'AVV-Website-Analyse-.pdf', path}, lb:{filename:'Leistungsbeschreibung-Website-Analyse-.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 `