56 KiB
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_VERSIONim 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 aufmain; das macht der Nutzer (L2, L4). ANALYSE_ENABLEDbleibtoffbis 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 ausbanner-portal-mailtexte.mdwerden 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.mjsanlegt (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; Runnercd runner && npm test; Portal im Worktree-Wurzelordnernpm test; WOMSnpm test. Nach jeder Task müssen alle vier Suiten grün sein, in denen die Task Dateien berührt.
Review Focus
- Nach „Ablehnen“, bei GPC/DNT und bei gespeichertem „Ablehnen“ verlässt kein
pv/bn/en/ck/ctmehr den Browser, auch nicht überpushState/popstate/pageshowoder andere Tabs; vor „Annehmen“ wederref/vpnoch ein Lesezugriff aufdocument.referrer/innerWidth. → Task 1 - Keine millisekundengenauen Zeitstempel (
events_raw,clicks) und keine kennungsartigen Pfadsegmente in der Datenbank; nichts davon ist umgehbar über den neuen Typcx. → Task 2 - Jede Beendigung (Ausschalten, Abo-Ende, Kunde/Projekt weg, Domain/Öffentlichkeit weg, Löschfrist, Widerruf Wechsel) setzt
active=false+offSinceim Messdienst; danach liefertw.js?s=ein leeres Skript; Tageswerte > 25 Monate und gelöschte Zeilen sind aus Datei und WAL verschwunden. → Task 3, 4, 9 - 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
- 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:
activateAnalysesetzt bei erneutem EinschaltenactivatedMailAtzurück, nichtprivacyTicketId⇒ nach Aus/An gäbe es kein neues Ticket. Task 5/8 setzt es zurück.- Der tägliche
runAboCheckläuft schon heute unabhängig vom Schalter (server/index.js:175). Der neue Abgleich (Task 9) hängt sich dort ein. retention.jsruftsiteFeatures.disable(), das überstopAnalyseCollectingnurputSite(..., active:false)mit Domains aus dem Projekt versucht; fehlt das Projekt, scheitert es still (nurconsole.warn). Task 9 ersetzt das durch einen domainlosen Aufruf.- Im Portal-Code gibt es keinen Pfad, der
websiteProjectslöscht oderliveDomainschreibt (nur WOMS/Appwrite-Konsole). Gelöschte Projekte und geänderte Live-Domains werden deshalb über den täglichen Abgleich erfasst; Subdomain-/Öffentlichkeitsänderungen laufen übergiteaAdmin.updateWebsiteProject(dort Hook). - Es gibt keine Historien-Sammlung;
portalFeatureshat nur das aktuelle Dokument. Neue CollectionanalyseConsents(Task 5). - Der Client sendet heute über
navigator.sendBeacon(w.js:39); die Test-Harness (analytics/test/client.test.js,harness()) fängt nursendBeaconab und muss auffetchumgestellt werden. - Die veröffentlichte AVV-Seite hat den Titel „Entwurf – vor Verwendung anwaltlich prüfen lassen“,
test/avvPage.test.jsprüft genau das und kippt bewusst (Task 6). runner/test/analytics-inject.test.jserwartet 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/chromevorhanden, PDFs werden eingecheckt, Commitd4241d2war so entstanden). Das Skript erzeugt alle Vertrags-PDFs auf einmal; Task 6 ergänzt einen Modusanalyse, damit andere PDFs unverändert bleiben. K01 hängt PDFs bereits übersendCustomerMail(..., { 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):
- Skript-/siteKey-Prüfung und Heatmap-Modus bleiben vorn, unverändert.
optOut(GPC/doNotTrack/msDoNotTrack) undstored = readConsent(...)werden vor jedem Senden und vor jedem Hook berechnet.optOut⇒return: keinpv, kein Banner, kein Einstellungsknopf, keinpageshow/popstate/pushState-Hook.choiceNow()liest die Wahl bei jedem Aufruf frisch (Speicher, sonstmemChoice);counting() = choiceNow() !== 'reject'.sendPv(first)bricht ohnecounting()ab. Nutzlast ohne Wahl exakt{ t:'pv', site, path }; erst beichoiceNow()==='accept'kommenref(nurfirst) undvphinzu.document.referrerundinnerWidthwerden ausschließlich in diesem Zweig gelesen.- Hooks (
pageshow,pushState-Wrapper,popstate) nur installieren, wenn beim StartchoiceNow() !== 'reject';scheduleNav()prüftcounting()im Timer-Callback erneut.ensureHooks()ist idempotent (spätes „Annehmen“ nach gespeichertem „Ablehnen“ installiert sie nachträglich). choose('reject'): Wahl speichern, genau einbnmita:'reject',stopTracking(), danach sendet nichts mehr (auch nichtpvper Seitenwechsel).choose('accept'):bn accept,startTracking(), dannonAccept(): wurde auf dieser Seite schon einpvgesendet, genau einmalcx{ t:'cx', site, path:lastPath, ref:refHost(DOC.referrer, ownHost()), vp:vp() }(FlagcxSent), sonstsendPv(true)mitref/vp.storage-Handler:reject⇒stopTracking()(Zählen endet übercounting()),accept⇒onAccept(), geleerter Eintrag ⇒ unverändert „keine Wahl“.- Gespeichertes „Ablehnen“: nichts senden, nur
addSettingsLink()und[data-webklar-consent]-Delegation; gespeichertes „Annehmen“:pvmitref+vpwie bisher, kein Banner, keincx. send(obj): ausschließlichfetch(endpoint, { method:'POST', body, keepalive:true, credentials:'omit', headers:{ 'Content-Type':'text/plain' } }).catch(()=>{}); keinnavigator.sendBeaconmehr.- 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 }. - T19: Solange das Banner offen ist, setzt
openBanner()DOC.body.style.paddingBottomauf die Banner-Höhe (bannerHost.getBoundingClientRect().height || offsetHeight, aufgerundet, in px; beiresizenachziehen) 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:readConsentverwirft{v:1,a:'accept',t}ohnebv, verwirftbv:'2026-01-01', akzeptiertbv:'2026-10-02'; abgelaufen (≥ 6 Monate) weiternull.- GPC (
navigator.globalPrivacyControl=true) und DNT'1':h.sent.length === 0;h.winL.pageshow/h.winL.popstateundefiniert;history.pushStateist die Original-Funktion;h.body.children.length === 0(kein Banner, kein Einstellungsknopf). - Gespeichertes
{a:'reject',bv}:sentleer, nur Einstellungsknopf inbody, keinepageshow/popstate-Listener; Klick auf den Knopf öffnet das Banner ohne Senden; dort „Annehmen“ ⇒ Typen['bn:accept','pv']und daspvträgtrefundvp. - Keine Wahl: erstes Ereignis ist
deepEqual({t:'pv', site, path:'/start'})(genau diese drei Schlüssel), danachbn:shown; Zähler fürdocument.referrerundinnerWidth= 0. - „Ablehnen“: Typen enden auf genau ein
bn:reject; danachpushState+ 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
pvohne Daten): Reihenfolgebn:accept,cxmitref === refHost('https://www.google.com/','mein.de')undvp:'d'; zweites „Annehmen“ nach Widerruf sendet kein zweitescxfür dieselbe Seite. - Gespeichertes „Annehmen“: erstes
pvhatrefundvp, keincx. - T15:
navigator.sendBeaconist ein Spy und wird nie aufgerufen;fetchbekommtkeepalive:true,credentials:'omit',method:'POST', HeaderContent-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ältmin-width:24pxundmin-height:32px. - Banner-Aufbau:
aria-label === BANNER_ARIA, Text beginnt mitBANNER_TEXT, KnopfnameSETTINGS_LABEL; Konstanten stimmen mit § 1 überein (Test liestRT/banner-portal-mailtexte.mdnicht, 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
pvohneref/vp), sendBeacon-Erwartungen;dist ist aktuellnach Neubau grün. - FAIL → implementieren →
cd analytics && node client/build.js && npm testPASS → 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,vpist optional: fehltvp(nicht vorhanden/undefined), speichertstore()vp = NULL; ein vorhandener, ungültigervpverwirft das Ereignis wie bisher.FIELDS.cx = ['t','site','path','ref','vp'];store():vpgültig Pflicht,refübercleanRef, Zeiletype='cx',ref,vp,country/deviceleer.- T3: alle gespeicherten Zeitstempel
Math.floor(t / HOUR) * HOUR(pv,cx,en,ctinevents_raw;ckinclicks). Berliner Offsets sind ganze Stunden, daher bleibt jeder Zeitstempel im selben Berliner Tag;heat.jssortiertORDER BY ts DESC, id DESC. - T14:
cleanPath()ersetzt Segmente, die kennungsartig aussehen, durch:id: Segment (auch nachdecodeURIComponent, 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}$/ioder 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:pvzähltpv_totalundpv_path/pv_country/pv_devicewie bisher,pv_refaber nur wennr.vp != null(nur Besucher mit Einwilligung);cxzählt ausschließlichpv_ref(r.ref || ''), niepv_total.
Tests:
pvohneref/vp({t:'pv',site,path:'/'}) ⇒ 1 Zeile,vp IS NULL,ref IS NULL,deviceaus User-Agent,countryaus Geo.pvmitvp:'x'⇒ verworfen;cxohne gültigesvp⇒ verworfen;cxmitref:'https://www.google.com/x'⇒ Zeiletype='cx',ref='www.google.com';cxmit eigener Domain alsref⇒ref IS NULL.- Zeitstempel:
now = Date.UTC(2026,9,1,10,30,45)⇒ fürpv,cx,en,ctundclicks.tsjeweils=== Date.UTC(2026,9,1,10,0,0); kein Wert mitts % 3600000 !== 0. - Pfade:
/kunde/123456789/rechnung⇒/kunde/:id/rechnung;/u/3f2b8c1e-5d4a-4e0b-9a7c-1b2c3d4e5f60⇒/u/:id;/mail/max%40example.comund/mail/max@example.com⇒/mail/:id; unverändert bleiben/preise/2026,/blog/12345,/; Query/Hash weiter abgeschnitten; gilt auch füren,ck,ct. - Verdichtung: Zeilen
pv(vp NULL)+cx(ref 'google.com')⇒pv_total1,pv_refgenau{google.com:1}(kein''-Schlüssel);pv(vp 'd', ref NULL)⇒pv_ref {'':1};cxerhöhtpv_totalnie; Stats-Endpunkt liefertrefsentsprechend (live und ausdaily, nie doppelt). - Heatmap: zwei Klicks gleicher Stunde,
LIMIT 1liefert den mit höhererid. - FAIL → implementieren →
cd analytics && npm testPASS → 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: nachjournal_mode = WALzusätzlichdb.exec('PRAGMA secure_delete = ON').jobs.js:export const DAILY_MONTHS = 25;dailyCutoffDay(now) ⇒ 'YYYY-MM-DD'= Berliner Tag vonnowminus 25 Kalendermonate (Tag am Monatsende auf den letzten Tag des Zielmonats begrenzt).runNightly: nach der VerdichtungDELETE FROM daily WHERE day < ?undDELETE 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 inevents_raw/clicks/daily/agg_state, derensiteinsitesfehlt, 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 };scheduleNightlyloggt 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(Kopfday,dim,key,count),<siteKey>-ereignisse.csv(Kopftime,type,path,vp,ref,country,device,visitor,sec,scroll,kind,host,timeals ISO-UTC),<siteKey>-klicks.csv(Kopftime,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';dailyCutoffDayfür 2026-03-31 ='2024-02-29'.daily-Zeilen mitday'2024-08-31'werden gelöscht,'2024-09-01'bleibt; zugehörigeagg_state-Zeilen ebenso;dailyDeletedzählt.- Website
active=0, offSince NULL⇒ nach LaufoffSincegesetzt (=new Date(now).toISOString()), zweiter Lauf ändert den Wert nicht; Website mit gesetztemoffSince> 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'alsevents_raw.patheinfügen, löschen,wal_checkpoint(TRUNCATE); Rohbytes der.db-Datei enthalten den Marker nicht;PRAGMA secure_deleteliefert 1.runNightlyauf Datei-DB: danachstatSync(path + '-wal').size === 0.- Export: 3 Dateien mit den exakten Kopfzeilen; Zeilenzahlen = Zeilen der Website (andere Website erscheint nicht); Zelle
a,"b"\nckorrekt 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 testPASS → 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.jsliefert nur dann das echte Skript, wenn?s=dem Muster^[a-z0-9]{16,40}$entspricht undisActive(s)wahr ist; sonst (ohnes, ungültig, unbekannt, ausgeschaltet) HTTP 200 mit Körper/* WEBklar Analyse: aus */\n, HeaderContent-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.jsbehalten 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.jsentfernt die Query bereits (src.replace(/[?#].*$/,'')),data-sitebleibt 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,304beiIf-None-Match. - Unbekannter Schlüssel, ungültiger Schlüssel (
?s=ABC), keins, ausgeschaltete Website ⇒ Körper/* WEBklar Analyse: aus */\n, Status 200,Cache-Control: public, max-age=60, keinContent-Encoding, kein ETag. - Ende-zu-Ende mit Admin- und Public-Server auf einer DB:
PUT active:true⇒ echtes Skript;PUT active:false⇒ sofort leer; erneuttrue⇒ 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 testundcd runner && npm testPASS → 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ätzlichavvConsentDate(10),avvConsentChannel(10),removalTicketId(40),publishTicketId(40) (allestring, optional; gesamt 12). Neue CollectionanalyseConsents(Datenbank wie portalFeatures, nur Server-Key, keine Client-Rechte) mit AttributencustomerId36,projectId36,siteKey40,event8 (on/off),avvVersion20,via8 (portal/woms),acceptedAt40,acceptedBy100,textformDate10,textformChannel10 und Index aufprojectId. Das Skript bleibt idempotent; der Client erhältensureCollection(id, name)und nimmt die Collection-ID als Parameter.analysePrivacy.js:analysePrivacyData(customer) ⇒ { ok, company, address, email, missing }mit derselben Bereinigung wie heute inbuildAnalytics(Steuerzeichen raus, Firma =companyName || name≤ 200, Adressestreet, PLZ Ort≤ 300, E-Mail ≤ 200 und^[^\s@]+@[^\s@]+\.[^\s@]+$);missing ⊆ ['firma','email'];ok⇔missingleer.buildAnalyticsruft sie auf (kein zweiter Regelsatz).analyseConsent.js:recordConsent(entry) ⇒ Dokument(AppwritecreateDocumentmitID.unique()),listConsents(projectId). Es gibt bewusst keine Änderungs- und Löschfunktion.analyse.js:validateTextform({ date, channel }, { today, minDate })mitdateYYYY-MM-DD(echtes Datum, nicht nachtodayin Berlin, nicht vorminDate = config.analyse.avvVersion),channel ∈ {'chat','email'}; Fehler 400.activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform }): Kunde (by==='customer') brauchtconsent.accepted === true,consent.avvVersion === config.analyse.avvVersionundactormitname || email; Mitarbeiter braucht gültigestextformund trägt seinen Namen inby. VorputSite:analysePrivacyData(await customerOf(customerId))mussoksein, 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 Wechseloff→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) →updateDocumentmitavvVersion,avvAcceptedAt,avvAcceptedBy(= Name/E-Mail der Person bzw. Mitarbeitername),avvConsentDate/avvConsentChannel(leer beim Kunden-Weg),activatedAt,offSince:'',activatedMailAt:'',privacyTicketId:'',removalTicketId:'',publishTicketId:''.deactivateAnalyseschreibt zusätzlichrecordConsent({ event:'off', via, acceptedBy })(Fehler dabei nur loggen, Ausschalten darf nicht scheitern).
- Routen:
POST /api/funktionen/analyse/activateübergibtactor = { name: req.session.name, email: req.session.email }.POST /api/admin/analyse/:projectIdBody{ on, textformDate, textformChannel, audienceConfirmed }:on:trueverlangtaudienceConfirmed === true, gültiges Datum und Weg (sonst 400, NEU-Texte kurz);avvConfirmedVerballywird nicht mehr akzeptiert (400 „Zustimmung in Textform nötig“).GET /api/admin/analyse/:projectIdliefert zusätzlichavvConsentDate,avvConsentChannel,privacy: { ok, missing }.GET /api/funktionen(analyseView) liefert zusätzlichcontroller: { company, address, email, ok, missing }.
Tests:
- Setup:
ANALYSE_ATTRIBUTEShat 12 Einträge mit den Größen aus der Liste;mainmit Mock legt nur fehlende Attribute an; CollectionanalyseConsentswird angelegt, wenn sie fehlt, und übersprungen, wenn sie existiert;--checklegt nichts an und meldetmissing. analysePrivacyData: Firma leer + Name leer ⇒missing ['firma']; nurname⇒company === name; E-Mail'abc'⇒missing ['email']; Steuerzeichen/Zeilenumbruch in der Firma werden zu Leerzeichen; Adresse nur mitstreetohne Ort ⇒ nur Straße.buildAnalytics: Kunde mit nurname⇒privacy.company === name; ungültige E-Mail ⇒null.- Aktivieren ohne Firma/Name oder mit
email:'x'⇒ 409, keinputSite, kein Ticket, keine K18, Feldstatebleibtoff. - Kunde aktiviert: ein Historien-Dokument
event:'on', via:'portal', avvVersion:'2026-10-02', acceptedBy:'<Name>';acceptedAtISO; 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/publishTicketIdzurückgesetzt;listConsentszeigton,off,onin zeitlicher Folge; das Modul exportiert nurrecordConsent,listConsents. recordConsentwirft ⇒putSitewird mitactive:falsezurückgenommen, Dokument bleibtoff.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:trueohnetextformDate⇒ 400; ohnetextformChannel⇒ 400; ohneaudienceConfirmed⇒ 400; mit altemavvConfirmedVerbally:true⇒ 400; gültig ⇒ 200 und Historievia:'woms',acceptedBy= Mitarbeitername,textformDate/textformChannelgesetzt, Portal-DokumentavvConsentDate/avvConsentChannel. GET /api/funktionen:controller.ok === falseundmissinggefüllt, wenn Angaben fehlen;GET /api/admin/analyse/:identhältprivacy.- FAIL → implementieren →
npm testPASS → 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 Entwurfsseitepublic/rechtliches/avv-website-analyse.htmlwird 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.jsvorexpress.static:GET /rechtliches/avv-website-analyse.htmlundGET /rechtliches/leistungsbeschreibung-website-analyse.htmlliefernres.sendFileder Datei zur aktuellenconfig.analyse.avvVersion(Adressen ohne Fassung zeigen immer die aktuelle geprüfte Fassung; eine neue Fassung = neue Dateien + Konfiguration). build-legal-pdfs.sh: Aufrufscripts/build-legal-pdfs.sh analyseerzeugt nur die beiden PDFs aus den versionierten HTML-Dateien (Standardallwie 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.mdmit Tabelle „Fassung | gültig ab | Banner-Text | Zusatzseite“;docs/rechtliches/archiv/banner-2026-10-02.mdenthält Fließtext (§ 1),aria-label, Schaltflächennamen,BANNER_VERSION;docs/rechtliches/archiv/datenschutz-analyse-2026-10-02.htmlist eine Kopie der Vorlage. „Gültig ab“ bleibtoffen bis Livegangund wird in L2/L5 eingetragen. Lokales Git-Taganalyse-texte-2026-10-02auf den Commit dieser Task (nicht pushen; L2 pusht der Nutzer).
Tests:
test/avvPage.test.jsneu: beide versionierten HTML-Dateien existieren, enthaltenFassung 2026-10-02, enthalten weder<!--nochEntwurfnochPrüfpunkt;public/rechtliches/avv-website-analyse.htmlexistiert nicht mehr als Datei; AVV-Seite enthält die Überschrift12. Haftung(wie bisher).GET /rechtliches/avv-website-analyse.htmlund…/leistungsbeschreibung-website-analyse.htmlüber die Express-App liefern exakt die Bytes der Dateien fürconfig.analyse.avvVersion;…-2026-10-02.htmlebenfalls 200;…-2026-01-01.html404.- 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.mdenthältBANNER_TEXTundBANNER_ARIAausanalytics/client/core.js(Import der Konstanten) wortgleich;README.mdnenntBANNER_VERSION. - Runner-Test
Datenschutzseite…: Erwartete Abschnittswörter auf „Datenschutz-Einstellungen“ statt „Cookie-Einstellungen“ ändern; zusätzlich: Seite enthältStand: 02.10.2026, keine{{-Reste,{{#address}}ohne Adresse entfernt,<!--nirgends. public/funktionen.jsAVV_URL-Test ausavvPage.test.jswird in Task 7 angepasst; hier nur entfernen/ersetzen durch Existenzprüfung der versionierten Dateien.npm testundcd runner && npm testPASS → 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:
analyseViewliefert zusätzlichlegal: { avv, lb }(Pfade auslegalPaths(config.analyse.avvVersion)),controllerkommt aus Task 5.public/analyse-view.js:BANNER_PREVIEW_TEXT(=BANNER_TEXTauscore.js),controllerLines(controller) ⇒ string[](Firma, Anschrift falls vorhanden, E-Mail) für die Vorschau,AVV_CHECK_*-Konstanten nicht nötig.public/funktionen.js:AVV_URLund Leistungsbeschreibungs-Link kommen ausa.legal(KonstanteAVV_URLentfällt). Texte, jeweils ausbanner-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 wenncontroller.ok), Haken mit zwei Links und Kinder-Satz (§ 2, ersetzt Zeile ~619),bannerPreviewzeigtBANNER_PREVIEW_TEXTplus Link und Knopf-Beschriftungen („Ablehnen“, „Annehmen“) (§ 1), Hinweis vor dem Ausschalten (§ 2, ersetzt Zeile ~763), Bestätigung nach „Ja, ausschalten“ (§ 2; Bestätigungstext statt sofortigemreload(), mit Knopf „OK“, der danachreload()ruft). Istcontroller.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ältAblehnenundAnnehmen. - Quelltext-Prüfung wie in
avvPage.test.js:funktionen.jsenthä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 aufAVV_URL = '/rechtliches/avv-website-analyse.html'. - Render-Test (
funktionenTiles-Muster,render()mit Fixtureanalyse:{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 Linkshrefauslegal, den Text „Fassung 2026-10-02“, „Muster GmbH“ und „info@muster.de“; mitcontroller.ok:falseist der Knopfdisabledund 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 testPASS; 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: KonstantenPRIVACY_SNIPPET_DUundPRIVACY_SNIPPET_SIE(ausbanner-portal-mailtexte.md§ 7, Fließtext ohne Markdown-Sternchen, Überschrift „Reichweitenmessung“ eigene Zeile), FunktionenprivacyTicketDetails({ name, domains, rebuildNote })(KopfzeilenFunktion,Projekt,Domain(s),Zusatzseite, danach die vier Punkte aus § 6 „Beim Einschalten“, darunter beide Bausteine) undremovalTicketDetails({ 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(keinrepoFullName),preview_aus(previewMode === 'off'),fehler(Ausnahme oderresult.error), sonstok:true.privacyTicket(T17) nutztprivacyTicketDetails, Thema bleibt „Datenschutzerklärung von um Hinweis auf Analyse ergänzen“. Beim Einschalten: erstrebuildSafe, dann Ticket; ist der Neubau nicht möglich oder gescheitert, zusätzlich TicketWebsite-Analyse von <Projekt> veröffentlichen(MarkerpublishTicketId, genau eines je Aktivierung).deactivateAnalyse: nachrebuildSafeTicket „Website-Analyse von entfernen“ (Details aus § 6 „Beim Ausschalten“; MarkerremovalTicketId, genau eines je Beendigung; Ticket-Fehler loggen, nie das Ausschalten verhindern). Gilt für Kunde, Mitarbeiter undrunAboCheck(geht überdeactivateAnalyse).mailSafe(customerId, template, project, { values, blocks, attachments }): K18 bekommtlegalPdfAttachments(config.analyse.avvVersion); sind sie unlesbar, wird nicht gesendet (false, MarkeractivatedMailAtbleibt leer ⇒ Wiederholung beim nächsten Abgleich, Task 9). Zusätzliche Werte K18:fassung,zeitpunkt(Berlin,TT.MM.JJJJ, HH:MM UhrüberberlinDateTime),person,datum(TT.MM.JJJJ),weg(Chat im Kundenportalbzw.E-Mail),avv_url,lb_url(absolut mitconfig.publicBaseUrl); Blöckeportal_weg(Kunde) undtextform_weg(Mitarbeiter).- K18: Absatz nach „Hallo {{kunde_name}},“ durch § 4 ersetzen, Tabelle um die Zeilen „Vereinbart“ und „Zugestimmt“ (§ 4) erweitern, Kopfkommentar
PlatzhalterundOptionale Blöckenachziehen (mailTemplates.test.jsprüft Kopfkommentar gegen Vorlage). K19: Absatz durch § 5 ersetzen.
Tests:
- Einschalten (Kunde): K18 verschickt,
attachments.length === 2, DateinamenAVV-Website-Analyse-2026-10-02.pdfundLeistungsbeschreibung-Website-Analyse-2026-10-02.pdf, Inhalte beginnen mit%PDF; HTML enthält2026-10-02, die beiden absoluten Links und die Zeile mitdurch <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ältin Textform am 03.10.2026 per E-Mail. - PDFs unlesbar (Pfad per Testschalter ungültig) ⇒ keine Mail,
activatedMailAtleer, 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 ThemaWebsite-Analyse von Bäckerei entfernen; zweimal ausschalten ⇒ ein Ticket; Aus → An → Aus ⇒ zwei Tickets; Details enthalten „fünf Werktagen“ und „Stundenkontingent“. rebuildSafe: Projekt ohnerepoFullName⇒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.jsgrün (Kopfkommentar, Platzhalter, Blöcke, Textfassung ohne{{).npm testPASS → 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; setztactive=0undoffSinceeinmalig auf jetzt, ändert einen vorhandenenoffSincenicht; unbekannter Schlüssel 404; ruftonSitesChanged). Portal-ClientanalyticsClient.stopSite(siteKey)(404 zählt als erledigt) und im Vertragstesttest/analyticsContract.test.jsergänzt. analyse.js:endAnalyse({ doc, project, reason, now, by, tickets = true, rebuild = true })als gemeinsamer Kern (MessdienststopSite, Dokumentstate:'off'+offSince, optional Neubau und Entfernen-Ticket, wenn Projekt und Kunde noch existieren);deactivateAnalyseruft ihn.endAnalyseForCustomer(customerId, { reason, mail })beendet alle aktiven Analysen des Kunden (beimail:trueK19).analyseOnProjectChanged(projectId): aktives Dokument; fällt die Berechtigung weg (nicht_oeffentlich,keine_subdomain) ⇒endAnalyse(reason:'projekt_nicht_mehr_oeffentlich'), sonstputSitemit neuenanalyseDomains(project).runAnalyseReconcile({ now })(täglich nachrunAboCheck,server/index.js): für Analyse-Dokumenteactivemit fehlendem Projekt oder fehlendem Kunden ⇒endAnalyse(reason:'projekt_geloescht'|'kunde_geloescht', tickets:false, rebuild:false); füractivemit vorhandenem ProjektputSitemit aktuellen Domains (fängt geänderteliveDomain); füroffunddisabledstopSite(idempotent); füractiveohneactivatedMailAtK18 erneut versuchen.- Hooks:
customerAdmin.deleteCustomerWithPortalAccessruft vor dem LöschenendAnalyseForCustomer(customerId, { reason:'kunde_geloescht', mail:false })(Fehler loggen);giteaAdmin.updateWebsiteProjectruft nach dem SpeichernanalyseOnProjectChanged(dynamischer Import wegen Zyklus, Fehler loggen);siteFeatures.stopAnalyseCollectingnutztstopSitestattputSitemit Projekt-Domains (damit Löschfrist auch ohne Projekt wirkt);withdrawOrderruft beitype === 'wechsel'undinTimeendAnalyseForCustomer(sub.customerId, { reason:'widerruf_wechsel', mail:true })intry/catch(der Widerruf darf nie daran scheitern).
Tests:
- Messdienst:
POST …/offohne Token 401; unbekannt 404; bekannt ⇒active=0,offSincegesetzt, zweiter Aufruf lässt den ersten Wert; direkt danach verwirft/a/eEreignisse (Cache invalidiert) und/a/w.js?s=liefert das leere Skript. - Kunde gelöscht (
deleteCustomerWithPortalAccess):stopSitefür jeden aktiven Analyse-Schlüssel des Kunden, Dokumentstate:'off', kein Ticket, keine Mail, Löschen des Kunden gelingt auch wennstopSitewirft. - Projekt gelöscht (Dokument aus
websiteProjectsentfernt):runAnalyseReconcile⇒stopSite,state:'off',offSincegesetzt, kein Ticket; Kunde fehlt ⇒ dasselbe; vorhandenes Projekt mit geänderterliveDomain⇒putSitemit neuer Domainliste;off-Dokument ⇒stopSiteaufgerufen. - Domain/Öffentlichkeit:
updateWebsiteProjectmitisPublic:false⇒ Analyse beendet (stopSite,off, Entfernen-Ticket); mit neuersubdomain⇒putSitemit<neu>.project.webklar.com; Fehler im Hook ändert das Ergebnis des Projekt-Updates nicht. - Löschfrist:
applyDeletionsmit Analyse-Dokument, dessen Projekt nicht mehr existiert ⇒ Dokumentdisabled, Messdienst-Stub bekamstopSite(siteKey)(nichtputSite). - Widerruf Wechsel (
POST /public/withdraw,order:'wechsel', in der Frist, Kunde hat aktive Analyse): Analyseoff,stopSiteaufgerufen, K19 gesendet; Widerruf außerhalb der Frist oderorder:'zusatzspeicher'ändert nichts; wirftendAnalyseForCustomer, bleibt der Widerruf200. - K18-Wiederholung: aktives Dokument ohne
activatedMailAtund lesbaren PDFs ⇒ Abgleich sendet K18 genau einmal und setzt den Marker. npm testundcd analytics && npm testPASS → 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 (keinavvConfirmedVerballymehr).analyseView.js:avvLabelentfällt; neutextformLabel(version)(Kästchentext ausbanner-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“),analyseStatuszeigt beiactivezusätzlich „Zustimmung in Textform am TT.MM.JJJJ per “, wennavvConsentDatevorhanden ist.AnalyseRow.jsx: Einschalte-Zeile zeigt Kästchen (§ 3) mit Links auf AVV und Leistungsbeschreibung (ausdata.legal, absoluthttps://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, biscanSubmitTextform. Aufklappbar „Textvorlage für den Chat“ (§ 3,{Link AVV}/{Link Leistungsbeschreibung}mit den echten Links gefüllt) mit Kopieren-Knopf. Istdata.privacy.ok === false, Einschalten gesperrt und Hinweis, welches Feld fehlt (firma/email).
Tests:
canSubmitTextform: ohne Häkchen, ohne Datum, ohne Weg, Datum vor2026-10-02, Datum morgen ⇒ jefalse; 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'}).textenthältAn seit 03.10.2026undZustimmung in Textform am 02.10.2026 per E-Mail.setAnalyse(Fetch-Stub): Body-JSON haton:true,textformDate,textformChannel,audienceConfirmed:trueund keinavvConfirmedVerbally.npm testundnpm run buildPASS → 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):
- Schema: im Portal-Umfeld (Appwrite-Key)
node scripts/analyse-setup.mjs --check, dannnode scripts/analyse-setup.mjsausführen (Attribute anportalFeatures+ CollectionanalyseConsents). Muss vor L2 passieren, weil der neue Portal-Code diese Felder schreibt. /home/webklar/apps/docker-compose.yml, Dienstanalytics(Zeilen ~1786-1814): untermem_limit: 384mdie Zeilememswap_limit: 384mergänzen (T5). Kommentar zum Traefik-Zugriffsprotokoll ergänzen (T18): am Traefik-Block (Zeilen ~15-56) „Zugriffsprotokoll bleibt aus“ und an den Labels vonwebklar-analytics„falls je eingeschaltet:traefik.http.routers.webklar-analytics.observability.accessLogs=falsesetzen“.docker compose config -qprüfen,docker compose up -d analytics, danachdocker inspect webklar-analytics --format '{{.HostConfig.MemorySwap}}'=402653184(384 MiB).- Rollback: Zeile
memswap_limitentfernen,up -d analytics; Schema-Skript ist additiv.
- Schema: im Portal-Umfeld (Appwrite-Key)
- L2 Portal-Push durch den Nutzer (meine Pushes auf
mainsind blockiert): Branchfeature/website-analyseinmainübernehmen und pushen, zusätzlich Taganalyse-texte-2026-10-02(lokal angelegt in Task 6) pushen;deploy.shstartet beianalytics/-Ä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.htmlund…/leistungsbeschreibung-website-analyse.htmlzeigen die geprüfte Fassung 2026-10-02 ohne „Entwurf“/„Prüfpunkt“;…-2026-10-02.pdfladen;ANALYSE_ENABLEDweiteroff(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 mitw.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/:- Ohne Wahl: genau ein
POST /a/emit Körper{"t":"pv","site":"…","path":"/…"}(keine Felderref,vp) und einbn shown; keinCookie-Header anproject.webklar.com/a/e; Banner-Text,aria-label, Platz unten (Footer-Links erreichbar) und Knopf „Datenschutz-Einstellungen“ ≥ 24 × 24 px prüfen. - „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“. - GPC: Browser mit
Sec-GPC/navigator.globalPrivacyControl === true(Firefoxprivacy.globalprivacycontrol.enabled) ⇒ null/a/e-Anfragen, kein Banner, kein Knopf. - „Annehmen“ auf laufender Seite:
bn accept,cxmitrefundvp, späterck/ct/en; neue Banner-Fassung testen, indem im Speicherwk-consentohnebvgesetzt wird ⇒ Banner erscheint erneut. - Datenbank:
docker exec webklar-analytics node -e "…SELECT COUNT(*) FROM events_raw WHERE ts % 3600000 != 0…"⇒ 0 (alle Zeitstempel voll Stunde, auchclicks); unbefragtespvhatvp IS NULLundref IS NULL; Pfad mit 7-stelliger Zahl erscheint als:id. - 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. - Ausschalten:
PUT /a/admin/sites/<key>mitactive:false⇒curl -sI 'https://project.webklar.com/a/w.js?s=<key>'zeigt den leeren Körper undCache-Control: public, max-age=60; nach > 60 Sekunden Seite neu laden (Cache leeren) ⇒ kein Banner, keine/a/e-Anfragen;offSincegesetzt. - Aufräumen:
DELETE /a/admin/sites/<key>, Wegwerf-Website entfernen, Protokolle der Probe löschen.
- Ohne Wahl: genau ein
- 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
avvConfirmedVerballyablehnt (Schalter ist bis L5 aus, es gibt keinen Schaden). - L5 Einschalten (Nutzer):
- Nutzer lädt AVV und Leistungsbeschreibung Fassung 2026-10-02 in WOMS „Rechtliches“ hoch (anwaltliche Freigabe vorausgesetzt).
ANALYSE_ENABLED=onim Portal-Umfeld setzen, Portal neu starten.- 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=unddatenschutz-analyse.htmlauf 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. - 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:
journaldbegrenzen (MaxRetentionSec=14day,MaxFileSec=1day,journalctl --vacuum-time=14d); betrifft Abo-Bedingungen v5, nicht die Analyse. - T22: Datenbank-Export
/opt/webklar-backup/db-dump.shfü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
detailsder Collectionworkordersmuss 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 inserver/services/analyseTexts.js“ legen (vor Task 8 mitgrep details APPWRITE_SCHEMA.mdbzw. Appwrite prüfen). runDeploymeldet nur synchron erkennbare Fehler (result.error); spätere Runner-Fehler bleiben überpreviewState: 'failed'sichtbar und lösen kein zusätzliches „veröffentlichen“-Ticket aus.- Heatmap-Links für Seiten, deren Pfad durch T14 zu
:idwurde, führen auf eine nicht existierende Seite; das ist Absicht (Pfade mit Kennungen sind nicht auswertbar) und braucht keine Sonderbehandlung. - Der echte
w.jsbleibt 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-agefü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