Compare commits
14
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
546d3c84d0 | ||
|
|
495c3a2c40 | ||
|
|
78170567b4 | ||
|
|
fbc40252a3 | ||
|
|
3bac4093af | ||
|
|
ddb91a9950 | ||
|
|
f401dc14d7 | ||
|
|
3d05472b25 | ||
|
|
c6824f83a6 | ||
|
|
97890a8eee | ||
|
|
a73e0be952 | ||
|
|
96409c6306 | ||
|
|
8d64a18d35 | ||
|
|
c2cbc4156f |
No files matched your search
@@ -3,6 +3,11 @@
|
||||
|
||||
export const CONSENT_KEY = 'wk-consent';
|
||||
export const CONSENT_MAX_AGE = 183 * 86400000;
|
||||
export const BANNER_VERSION = '2026-10-02';
|
||||
export const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
|
||||
export const BANNER_LINK_LABEL = 'Datenschutz';
|
||||
export const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
||||
export const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
||||
|
||||
export function vpClass(width) {
|
||||
const w = Number(width);
|
||||
@@ -89,9 +94,10 @@ export function readConsent(storage, now) {
|
||||
if (!raw) return null;
|
||||
const c = JSON.parse(raw);
|
||||
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
||||
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
|
||||
const age = now - c.t;
|
||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||
return { v: 1, a: c.a, t: c.t };
|
||||
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
|
||||
Vendored
+7
-1
@@ -6,6 +6,11 @@
|
||||
|
||||
const CONSENT_KEY = 'wk-consent';
|
||||
const CONSENT_MAX_AGE = 183 * 86400000;
|
||||
const BANNER_VERSION = '2026-10-02';
|
||||
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
|
||||
const BANNER_LINK_LABEL = 'Datenschutz';
|
||||
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
||||
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
||||
|
||||
function vpClass(width) {
|
||||
const w = Number(width);
|
||||
@@ -92,9 +97,10 @@ function readConsent(storage, now) {
|
||||
if (!raw) return null;
|
||||
const c = JSON.parse(raw);
|
||||
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
||||
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
|
||||
const age = now - c.t;
|
||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||
return { v: 1, a: c.a, t: c.t };
|
||||
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
|
||||
Vendored
+80
-33
@@ -6,6 +6,11 @@
|
||||
|
||||
const CONSENT_KEY = 'wk-consent';
|
||||
const CONSENT_MAX_AGE = 183 * 86400000;
|
||||
const BANNER_VERSION = '2026-10-02';
|
||||
const BANNER_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: ';
|
||||
const BANNER_LINK_LABEL = 'Datenschutz';
|
||||
const BANNER_ARIA = 'Einwilligung zur Reichweitenmessung';
|
||||
const SETTINGS_LABEL = 'Datenschutz-Einstellungen';
|
||||
|
||||
function vpClass(width) {
|
||||
const w = Number(width);
|
||||
@@ -92,9 +97,10 @@ function readConsent(storage, now) {
|
||||
if (!raw) return null;
|
||||
const c = JSON.parse(raw);
|
||||
if (!c || c.v !== 1 || (c.a !== 'accept' && c.a !== 'reject') || typeof c.t !== 'number' || !isFinite(c.t)) return null;
|
||||
if (c.bv !== BANNER_VERSION) return null; // andere/fehlende Fassung des Banner-Textes: neu fragen
|
||||
const age = now - c.t;
|
||||
if (age < 0 || age >= CONSENT_MAX_AGE) return null;
|
||||
return { v: 1, a: c.a, t: c.t };
|
||||
return { v: 1, a: c.a, t: c.t, bv: c.bv };
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
@@ -113,7 +119,8 @@ function escapeHtml(s) {
|
||||
}
|
||||
|
||||
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
|
||||
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
|
||||
// Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
|
||||
// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
|
||||
|
||||
function main() {
|
||||
const DOC = document;
|
||||
@@ -149,7 +156,6 @@ function main() {
|
||||
function send(obj) {
|
||||
try {
|
||||
const body = JSON.stringify(obj);
|
||||
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
|
||||
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
|
||||
} catch (e) { /* egal */ }
|
||||
}
|
||||
@@ -157,37 +163,71 @@ function main() {
|
||||
const vp = () => vpClass(WIN.innerWidth);
|
||||
const ownHost = () => WIN.location.hostname;
|
||||
|
||||
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
|
||||
// ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ----
|
||||
let storage = null;
|
||||
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
||||
const dnt = (v) => v === '1' || v === 'yes';
|
||||
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
||||
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
|
||||
const stored = readConsent(storage, Date.now());
|
||||
let memChoice = null;
|
||||
let tracking = false;
|
||||
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
|
||||
function choiceNow() {
|
||||
const c = readConsent(storage, Date.now());
|
||||
return c ? c.a : (memChoice ? memChoice.a : null);
|
||||
}
|
||||
const counting = () => choiceNow() !== 'reject';
|
||||
|
||||
// ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ----
|
||||
let lastPath = null;
|
||||
let pvStart = Date.now();
|
||||
let maxScroll = 0;
|
||||
let enSent = false;
|
||||
let pvSent = false;
|
||||
let cxSent = false;
|
||||
function sendPv(first) {
|
||||
if (!counting()) return;
|
||||
lastPath = cleanPath(WIN.location.pathname);
|
||||
pvStart = Date.now(); maxScroll = 0; enSent = false;
|
||||
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
|
||||
const ev = { t: 'pv', site, path: lastPath };
|
||||
if (choiceNow() === 'accept') {
|
||||
if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; }
|
||||
ev.vp = vp();
|
||||
}
|
||||
pvSent = true;
|
||||
send(ev);
|
||||
}
|
||||
let navTimer = null;
|
||||
function scheduleNav() {
|
||||
if (navTimer) return;
|
||||
if (navTimer || !counting()) return;
|
||||
navTimer = setTimeout(safe(function () {
|
||||
navTimer = null;
|
||||
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
||||
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
||||
}), 300);
|
||||
}
|
||||
sendPv(true);
|
||||
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
|
||||
try {
|
||||
const orig = WIN.history.pushState;
|
||||
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
|
||||
WIN.addEventListener('popstate', safe(scheduleNav));
|
||||
} catch (e) { /* egal */ }
|
||||
let hooksOn = false;
|
||||
function ensureHooks() {
|
||||
if (hooksOn) return;
|
||||
hooksOn = true;
|
||||
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
|
||||
try {
|
||||
const orig = WIN.history.pushState;
|
||||
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
|
||||
WIN.addEventListener('popstate', safe(scheduleNav));
|
||||
} catch (e) { /* egal */ }
|
||||
}
|
||||
// Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden
|
||||
function onAccept() {
|
||||
ensureHooks();
|
||||
if (!pvSent) { sendPv(true); return; }
|
||||
if (cxSent) return;
|
||||
cxSent = true;
|
||||
send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() });
|
||||
}
|
||||
if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); }
|
||||
|
||||
// ---- Tracking (nur nach Annehmen erreichbar) ----
|
||||
let storage = null;
|
||||
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
||||
let memChoice = null;
|
||||
let tracking = false;
|
||||
const hosts = [];
|
||||
const scrollPct = () => {
|
||||
const h = DOC.documentElement.scrollHeight || 1;
|
||||
@@ -239,8 +279,7 @@ function main() {
|
||||
|
||||
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
|
||||
function allowed() {
|
||||
const c = readConsent(storage, Date.now());
|
||||
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
|
||||
const ok = choiceNow() === 'accept';
|
||||
if (!ok && tracking) stopTracking();
|
||||
return ok;
|
||||
}
|
||||
@@ -264,19 +303,14 @@ function main() {
|
||||
}
|
||||
|
||||
// ---- Einwilligung ----
|
||||
const dnt = (v) => v === '1' || v === 'yes';
|
||||
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
||||
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
|
||||
|
||||
const stored = readConsent(storage, Date.now());
|
||||
let bannerHost = null;
|
||||
let linkHostEl = null;
|
||||
|
||||
function choose(a) {
|
||||
memChoice = null;
|
||||
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
||||
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
||||
send({ t: 'bn', site, a });
|
||||
if (a === 'accept') startTracking(); else stopTracking();
|
||||
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||
closeBanner();
|
||||
}
|
||||
|
||||
@@ -297,6 +331,13 @@ function main() {
|
||||
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
|
||||
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
|
||||
|
||||
let prevPad = '';
|
||||
function applyPad() {
|
||||
if (!bannerHost) return;
|
||||
const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0);
|
||||
DOC.body.style.paddingBottom = h + 'px';
|
||||
}
|
||||
const onResize = safe(applyPad);
|
||||
function openBanner(focus) {
|
||||
if (bannerHost) return;
|
||||
const prev = DOC.activeElement;
|
||||
@@ -306,9 +347,9 @@ function main() {
|
||||
hosts.push(bannerHost);
|
||||
const root = bannerHost.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, CSS));
|
||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
|
||||
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: ');
|
||||
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz'));
|
||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
|
||||
const p = el('p', { class: 't' }, BANNER_TEXT);
|
||||
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
|
||||
const row = el('div', { class: 'r' });
|
||||
const no = el('button', { type: 'button' }, 'Ablehnen');
|
||||
const yes = el('button', { type: 'button' }, 'Annehmen');
|
||||
@@ -318,6 +359,10 @@ function main() {
|
||||
bar.appendChild(p); bar.appendChild(row);
|
||||
root.appendChild(bar);
|
||||
DOC.body.appendChild(bannerHost);
|
||||
// Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben
|
||||
prevPad = DOC.body.style.paddingBottom;
|
||||
applyPad();
|
||||
WIN.addEventListener('resize', onResize);
|
||||
if (linkHostEl) linkHostEl.style.display = 'none';
|
||||
if (focus === true) no.focus();
|
||||
}
|
||||
@@ -327,6 +372,8 @@ function main() {
|
||||
if (i !== -1) hosts.splice(i, 1);
|
||||
bannerHost.remove();
|
||||
bannerHost = null;
|
||||
WIN.removeEventListener('resize', onResize);
|
||||
DOC.body.style.paddingBottom = prevPad;
|
||||
if (linkHostEl) linkHostEl.style.display = '';
|
||||
}
|
||||
|
||||
@@ -336,8 +383,8 @@ function main() {
|
||||
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
|
||||
hosts.push(linkHostEl);
|
||||
const root = linkHostEl.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
|
||||
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
|
||||
root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
|
||||
const b = el('button', { type: 'button' }, SETTINGS_LABEL);
|
||||
b.addEventListener('click', safe(function () { openBanner(true); }));
|
||||
root.appendChild(b);
|
||||
DOC.body.appendChild(linkHostEl);
|
||||
@@ -353,7 +400,7 @@ function main() {
|
||||
WIN.addEventListener('storage', safe(function (e) {
|
||||
if (e.key !== null && e.key !== CONSENT_KEY) return;
|
||||
const c = readConsent(storage, Date.now());
|
||||
if (c && c.a === 'accept') startTracking(); else stopTracking();
|
||||
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||
if (c) closeBanner();
|
||||
}));
|
||||
|
||||
|
||||
+74
-33
@@ -1,6 +1,7 @@
|
||||
// WEBklar Analyse: Einwilligungs-Banner + Ereignisse. Laeuft auf oeffentlichen Kunden-Websites.
|
||||
// Keine Cookies, keine Kennung im Browser. Nur pv ohne Einwilligung; ck/ct/en nur nach Annehmen.
|
||||
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY } from './core.js';
|
||||
// Keine Cookies, keine Kennung im Browser. Ohne Wahl nur pv {t,site,path}; ref/vp/cx/ck/ct/en nur nach Annehmen;
|
||||
// nach Ablehnen oder bei GPC/DNT geht nichts mehr raus.
|
||||
import { cleanPath, vpClass, refHost, cssPath, contactKind, linkHost, readConsent, contrastOk, CONSENT_KEY, BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from './core.js';
|
||||
|
||||
function main() {
|
||||
const DOC = document;
|
||||
@@ -36,7 +37,6 @@ function main() {
|
||||
function send(obj) {
|
||||
try {
|
||||
const body = JSON.stringify(obj);
|
||||
if (navigator.sendBeacon && navigator.sendBeacon(endpoint, body)) return;
|
||||
fetch(endpoint, { method: 'POST', body, keepalive: true, credentials: 'omit', headers: { 'Content-Type': 'text/plain' } }).catch(function () {});
|
||||
} catch (e) { /* egal */ }
|
||||
}
|
||||
@@ -44,37 +44,71 @@ function main() {
|
||||
const vp = () => vpClass(WIN.innerWidth);
|
||||
const ownHost = () => WIN.location.hostname;
|
||||
|
||||
// ---- Seitenaufruf (pv): immer, auch ohne Einwilligung ----
|
||||
// ---- Widerspruch (GPC/DNT) und Wahl: vor jedem Senden und vor jedem Hook ----
|
||||
let storage = null;
|
||||
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
||||
const dnt = (v) => v === '1' || v === 'yes';
|
||||
const optOut = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
||||
if (optOut) return; // wie Ablehnen: nichts senden, kein Banner, keine Hooks
|
||||
const stored = readConsent(storage, Date.now());
|
||||
let memChoice = null;
|
||||
let tracking = false;
|
||||
// Wahl bei jedem Aufruf frisch lesen (Speicher, sonst nur fuer diese Seite gemerkt)
|
||||
function choiceNow() {
|
||||
const c = readConsent(storage, Date.now());
|
||||
return c ? c.a : (memChoice ? memChoice.a : null);
|
||||
}
|
||||
const counting = () => choiceNow() !== 'reject';
|
||||
|
||||
// ---- Seitenaufruf (pv): ohne Wahl nur Seitenadresse, ref/vp nur nach Annehmen ----
|
||||
let lastPath = null;
|
||||
let pvStart = Date.now();
|
||||
let maxScroll = 0;
|
||||
let enSent = false;
|
||||
let pvSent = false;
|
||||
let cxSent = false;
|
||||
function sendPv(first) {
|
||||
if (!counting()) return;
|
||||
lastPath = cleanPath(WIN.location.pathname);
|
||||
pvStart = Date.now(); maxScroll = 0; enSent = false;
|
||||
send({ t: 'pv', site, path: lastPath, ref: first ? refHost(DOC.referrer, ownHost()) : '', vp: vp() });
|
||||
const ev = { t: 'pv', site, path: lastPath };
|
||||
if (choiceNow() === 'accept') {
|
||||
if (first) { ev.ref = refHost(DOC.referrer, ownHost()); cxSent = true; }
|
||||
ev.vp = vp();
|
||||
}
|
||||
pvSent = true;
|
||||
send(ev);
|
||||
}
|
||||
let navTimer = null;
|
||||
function scheduleNav() {
|
||||
if (navTimer) return;
|
||||
if (navTimer || !counting()) return;
|
||||
navTimer = setTimeout(safe(function () {
|
||||
navTimer = null;
|
||||
if (cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
||||
if (counting() && cleanPath(WIN.location.pathname) !== lastPath) sendPv(false);
|
||||
}), 300);
|
||||
}
|
||||
sendPv(true);
|
||||
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
|
||||
try {
|
||||
const orig = WIN.history.pushState;
|
||||
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
|
||||
WIN.addEventListener('popstate', safe(scheduleNav));
|
||||
} catch (e) { /* egal */ }
|
||||
let hooksOn = false;
|
||||
function ensureHooks() {
|
||||
if (hooksOn) return;
|
||||
hooksOn = true;
|
||||
WIN.addEventListener('pageshow', safe(function (e) { if (e && e.persisted) sendPv(false); }));
|
||||
try {
|
||||
const orig = WIN.history.pushState;
|
||||
WIN.history.pushState = function () { const r = orig.apply(this, arguments); try { scheduleNav(); } catch (e) { /* egal */ } return r; };
|
||||
WIN.addEventListener('popstate', safe(scheduleNav));
|
||||
} catch (e) { /* egal */ }
|
||||
}
|
||||
// Nach "Annehmen": Herkunft und Fenstergroesse einmal nachreichen, oder (noch kein pv) pv mit Daten senden
|
||||
function onAccept() {
|
||||
ensureHooks();
|
||||
if (!pvSent) { sendPv(true); return; }
|
||||
if (cxSent) return;
|
||||
cxSent = true;
|
||||
send({ t: 'cx', site, path: lastPath, ref: refHost(DOC.referrer, ownHost()), vp: vp() });
|
||||
}
|
||||
if (choiceNow() !== 'reject') { sendPv(true); ensureHooks(); }
|
||||
|
||||
// ---- Tracking (nur nach Annehmen erreichbar) ----
|
||||
let storage = null;
|
||||
try { storage = WIN.localStorage; } catch (e) { /* gesperrt */ }
|
||||
let memChoice = null;
|
||||
let tracking = false;
|
||||
const hosts = [];
|
||||
const scrollPct = () => {
|
||||
const h = DOC.documentElement.scrollHeight || 1;
|
||||
@@ -126,8 +160,7 @@ function main() {
|
||||
|
||||
// Einwilligung unmittelbar vor jedem Senden pruefen (z.B. in einem anderen Tab widerrufen)
|
||||
function allowed() {
|
||||
const c = readConsent(storage, Date.now());
|
||||
const ok = c ? c.a === 'accept' : !!(memChoice && memChoice.a === 'accept');
|
||||
const ok = choiceNow() === 'accept';
|
||||
if (!ok && tracking) stopTracking();
|
||||
return ok;
|
||||
}
|
||||
@@ -151,19 +184,14 @@ function main() {
|
||||
}
|
||||
|
||||
// ---- Einwilligung ----
|
||||
const dnt = (v) => v === '1' || v === 'yes';
|
||||
const gpc = navigator.globalPrivacyControl === true || dnt(navigator.doNotTrack) || dnt(WIN.doNotTrack) || dnt(navigator.msDoNotTrack);
|
||||
if (gpc) return; // wie Ablehnen: kein Banner, kein bn, nie Tracking
|
||||
|
||||
const stored = readConsent(storage, Date.now());
|
||||
let bannerHost = null;
|
||||
let linkHostEl = null;
|
||||
|
||||
function choose(a) {
|
||||
memChoice = null;
|
||||
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now() })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
||||
try { storage.setItem(CONSENT_KEY, JSON.stringify({ v: 1, a, t: Date.now(), bv: BANNER_VERSION })); } catch (e) { memChoice = { a }; /* ohne Speicher: gilt nur fuer diese Seite */ }
|
||||
send({ t: 'bn', site, a });
|
||||
if (a === 'accept') startTracking(); else stopTracking();
|
||||
if (a === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||
closeBanner();
|
||||
}
|
||||
|
||||
@@ -184,6 +212,13 @@ function main() {
|
||||
'button:focus-visible{outline:3px solid #1a73e8;outline-offset:2px}' +
|
||||
'@media (max-width:767px){.bar{display:block;margin:8px;padding:16px;border:1px solid #d0d0d0;border-radius:12px}.r{flex-direction:column;margin-top:12px}button{width:100%}}';
|
||||
|
||||
let prevPad = '';
|
||||
function applyPad() {
|
||||
if (!bannerHost) return;
|
||||
const h = Math.ceil(bannerHost.getBoundingClientRect().height || bannerHost.offsetHeight || 0);
|
||||
DOC.body.style.paddingBottom = h + 'px';
|
||||
}
|
||||
const onResize = safe(applyPad);
|
||||
function openBanner(focus) {
|
||||
if (bannerHost) return;
|
||||
const prev = DOC.activeElement;
|
||||
@@ -193,9 +228,9 @@ function main() {
|
||||
hosts.push(bannerHost);
|
||||
const root = bannerHost.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, CSS));
|
||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': 'Cookie-Einstellungen' });
|
||||
const p = el('p', { class: 't' }, 'Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: ');
|
||||
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, 'Datenschutz'));
|
||||
const bar = el('div', { class: 'bar', role: 'dialog', 'aria-label': BANNER_ARIA });
|
||||
const p = el('p', { class: 't' }, BANNER_TEXT);
|
||||
p.appendChild(el('a', { href: '/datenschutz-analyse.html' }, BANNER_LINK_LABEL));
|
||||
const row = el('div', { class: 'r' });
|
||||
const no = el('button', { type: 'button' }, 'Ablehnen');
|
||||
const yes = el('button', { type: 'button' }, 'Annehmen');
|
||||
@@ -205,6 +240,10 @@ function main() {
|
||||
bar.appendChild(p); bar.appendChild(row);
|
||||
root.appendChild(bar);
|
||||
DOC.body.appendChild(bannerHost);
|
||||
// Platz unten schaffen, damit Footer-Links (Impressum, Datenschutz) erreichbar bleiben
|
||||
prevPad = DOC.body.style.paddingBottom;
|
||||
applyPad();
|
||||
WIN.addEventListener('resize', onResize);
|
||||
if (linkHostEl) linkHostEl.style.display = 'none';
|
||||
if (focus === true) no.focus();
|
||||
}
|
||||
@@ -214,6 +253,8 @@ function main() {
|
||||
if (i !== -1) hosts.splice(i, 1);
|
||||
bannerHost.remove();
|
||||
bannerHost = null;
|
||||
WIN.removeEventListener('resize', onResize);
|
||||
DOC.body.style.paddingBottom = prevPad;
|
||||
if (linkHostEl) linkHostEl.style.display = '';
|
||||
}
|
||||
|
||||
@@ -223,8 +264,8 @@ function main() {
|
||||
st.position = 'fixed'; st.right = '12px'; st.bottom = '12px'; st.zIndex = '2147483646';
|
||||
hosts.push(linkHostEl);
|
||||
const root = linkHostEl.attachShadow({ mode: 'open' });
|
||||
root.appendChild(el('style', {}, ':host{all:initial}button{font:12px/1 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;padding:6px 10px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
|
||||
const b = el('button', { type: 'button' }, 'Cookie-Einstellungen');
|
||||
root.appendChild(el('style', {}, ':host{all:initial}button{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;font-size:13px;line-height:1;min-width:24px;min-height:32px;padding:8px 12px;border:1px solid #bbb;border-radius:6px;background:#fff;color:#444;cursor:pointer}button:focus-visible{outline:3px solid #1a73e8}'));
|
||||
const b = el('button', { type: 'button' }, SETTINGS_LABEL);
|
||||
b.addEventListener('click', safe(function () { openBanner(true); }));
|
||||
root.appendChild(b);
|
||||
DOC.body.appendChild(linkHostEl);
|
||||
@@ -240,7 +281,7 @@ function main() {
|
||||
WIN.addEventListener('storage', safe(function (e) {
|
||||
if (e.key !== null && e.key !== CONSENT_KEY) return;
|
||||
const c = readConsent(storage, Date.now());
|
||||
if (c && c.a === 'accept') startTracking(); else stopTracking();
|
||||
if (choiceNow() === 'accept') { startTracking(); onAccept(); } else stopTracking();
|
||||
if (c) closeBanner();
|
||||
}));
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env node
|
||||
// CSV-Export einer Website (Tageswerte, Einzelereignisse, Klicks). Öffnet die Datenbank nur lesend.
|
||||
//
|
||||
// Ablauf (Runbook): Im Container webklar-analytics ausführen (Code liegt dort unter /analytics, schreibbar ist nur /srv/webklar-analytics):
|
||||
// docker exec webklar-analytics node /analytics/scripts/export-site.mjs <siteKey> --out /srv/webklar-analytics/exports/<siteKey>
|
||||
// Die CSV-Dateien enthalten Besucher-Hashes (Rechte 0600) und müssen dem Kunden als Kopie innerhalb von zwei Wochen
|
||||
// übergeben werden (AVV Nr. 9); danach den Export-Ordner wieder löschen.
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const RE_KEY = /^[a-z0-9]{16,40}$/;
|
||||
const iso = (ts) => new Date(ts).toISOString();
|
||||
|
||||
function cell(v) {
|
||||
if (v === null || v === undefined) return '';
|
||||
let s = String(v);
|
||||
if (typeof v === 'string' && /^[=+\-@\t\r]/.test(s)) s = `'${s}`;
|
||||
return /[",\r\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
||||
}
|
||||
const csv = (head, rows) => [head, ...rows].map((r) => r.map(cell).join(',')).join('\r\n') + '\r\n';
|
||||
|
||||
export function exportSite({ dbPath, siteKey, outDir }) {
|
||||
if (!RE_KEY.test(siteKey || '')) throw new Error('Ungültiger Website-Schlüssel');
|
||||
const db = new DatabaseSync(dbPath, { readOnly: true });
|
||||
try {
|
||||
if (!db.prepare('SELECT 1 FROM sites WHERE siteKey = ?').get(siteKey)) throw new Error('Unbekannte Website');
|
||||
const daily = db.prepare('SELECT day, dim, key, count FROM daily WHERE site = ? ORDER BY day, dim, key').all(siteKey);
|
||||
const ev = db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind, host FROM events_raw WHERE site = ? ORDER BY ts, id').all(siteKey);
|
||||
const cl = db.prepare('SELECT ts, path, vp, x, y, sel FROM clicks WHERE site = ? ORDER BY ts, id').all(siteKey);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const files = {
|
||||
[`${siteKey}-tageswerte.csv`]: csv(['day', 'dim', 'key', 'count'], daily.map((r) => [r.day, r.dim, r.key, r.count])),
|
||||
[`${siteKey}-ereignisse.csv`]: csv(['time', 'type', 'path', 'vp', 'ref', 'country', 'device', 'visitor', 'sec', 'scroll', 'kind', 'host'],
|
||||
ev.map((r) => [iso(r.ts), r.type, r.path, r.vp, r.ref, r.country, r.device, r.visitor, r.sec, r.scroll, r.kind, r.host])),
|
||||
[`${siteKey}-klicks.csv`]: csv(['time', 'path', 'vp', 'x', 'y', 'sel'], cl.map((r) => [iso(r.ts), r.path, r.vp, r.x, r.y, r.sel])),
|
||||
};
|
||||
for (const [n, c] of Object.entries(files)) writeFileSync(join(outDir, n), c, { encoding: 'utf8', mode: 0o600 });
|
||||
return Object.keys(files).map((n) => join(outDir, n));
|
||||
} finally { db.close(); }
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
const args = process.argv.slice(2);
|
||||
const opt = (n, d) => { const i = args.indexOf(n); return i >= 0 ? args[i + 1] : d; };
|
||||
const siteKey = args.find((a, i) => !a.startsWith('--') && !(i > 0 && args[i - 1].startsWith('--')));
|
||||
try {
|
||||
const files = exportSite({ dbPath: opt('--db', '/srv/webklar-analytics/analytics.db'), siteKey, outDir: opt('--out', '.') });
|
||||
console.log(files.join('\n'));
|
||||
} catch (e) {
|
||||
console.error(`Export fehlgeschlagen: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
+23
-1
@@ -9,7 +9,10 @@ import crypto from 'node:crypto';
|
||||
const DEFAULT_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
|
||||
const FILES = { '/a/w.js': 'w.js', '/a/h.js': 'h.js' };
|
||||
|
||||
export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) {
|
||||
const RE_KEY = /^[a-z0-9]{16,40}$/;
|
||||
const EMPTY = Buffer.from('/* WEBklar Analyse: aus */\n');
|
||||
|
||||
export function createAssets({ distDir = DEFAULT_DIR, log = console, isActive = () => true } = {}) {
|
||||
const assets = new Map();
|
||||
for (const [path, file] of Object.entries(FILES)) {
|
||||
try {
|
||||
@@ -28,6 +31,25 @@ export function createAssets({ distDir = DEFAULT_DIR, log = console } = {}) {
|
||||
res.end();
|
||||
return true;
|
||||
}
|
||||
if (pathname === '/a/w.js') {
|
||||
let key = '';
|
||||
try { key = new URL(req.url, 'http://x').searchParams.get('s') || ''; } catch { /* leer */ }
|
||||
let on = false;
|
||||
try { on = RE_KEY.test(key) && !!isActive(key); } catch (e) { log.error('Analytics: isActive:', e.message); }
|
||||
if (!on) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/javascript; charset=utf-8',
|
||||
'Cache-Control': 'public, max-age=60',
|
||||
Vary: 'Accept-Encoding',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Length': EMPTY.length,
|
||||
});
|
||||
res.end(req.method === 'HEAD' ? undefined : EMPTY);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const a = assets.get(pathname);
|
||||
if (!a) {
|
||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
||||
|
||||
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS agg_state (
|
||||
export function openDb(path) {
|
||||
const db = new DatabaseSync(path);
|
||||
if (path !== ':memory:') db.exec('PRAGMA journal_mode = WAL');
|
||||
db.exec('PRAGMA secure_delete = ON');
|
||||
db.exec(SCHEMA);
|
||||
return db;
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
// GET /a/heat?token= (öffentlicher Listener): Klickpunkte für das Heatmap-Overlay.
|
||||
// Token (R3): <b64url(JSON {s,p,v,e})>.<hex HMAC-SHA256(heatSecret, b64part)>, e = Ablauf in Unix-Sekunden.
|
||||
import crypto from 'node:crypto';
|
||||
import { hostOf } from './ingest.js';
|
||||
import { hostOf, cleanPath } from './ingest.js';
|
||||
|
||||
const SITE_KEY = /^[a-z0-9]{16,40}$/;
|
||||
const MAX_AHEAD = 30 * 60; // Sekunden
|
||||
@@ -35,7 +35,7 @@ export function verifyHeatToken(token, secret, nowMs, { ignoreExpiry = false } =
|
||||
export function createHeat({ db, config = {}, now }) {
|
||||
const clock = now || Date.now;
|
||||
const getSite = db.prepare('SELECT domains, active FROM sites WHERE siteKey = ?');
|
||||
const getClicks = db.prepare('SELECT x, y FROM clicks WHERE site = ? AND path = ? AND vp = ? AND ts >= ? ORDER BY ts DESC LIMIT ?');
|
||||
const getClicks = db.prepare('SELECT x, y FROM clicks WHERE site = ? AND path = ? AND vp = ? AND ts >= ? ORDER BY ts DESC, id DESC LIMIT ?');
|
||||
|
||||
// CORS: nur die eigenen https-Domains der Website (das Overlay läuft auf der Kundenseite).
|
||||
function cors(res, req, domains) {
|
||||
@@ -79,7 +79,7 @@ export function createHeat({ db, config = {}, now }) {
|
||||
}
|
||||
cors(res, req, domainsOf(site));
|
||||
const since = clock() - CLICK_DAYS * 86400 * 1000;
|
||||
const points = getClicks.all(t.siteKey, t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y }));
|
||||
const points = getClicks.all(t.siteKey, cleanPath(t.path) || t.path, t.vp, since, POINTS).map((r) => ({ x: r.x, y: r.y }));
|
||||
return end(200, { points });
|
||||
};
|
||||
}
|
||||
+23
-7
@@ -10,6 +10,7 @@ const HOUR = 3600 * 1000;
|
||||
const MINUTE = 60 * 1000;
|
||||
const FIELDS = Object.assign(Object.create(null), {
|
||||
pv: ['t', 'site', 'path', 'ref', 'vp'],
|
||||
cx: ['t', 'site', 'path', 'ref', 'vp'],
|
||||
bn: ['t', 'site', 'a'],
|
||||
en: ['t', 'site', 'path', 'vp', 'sec', 'scroll'],
|
||||
ck: ['t', 'site', 'path', 'vp', 'x', 'y', 'sel'],
|
||||
@@ -55,11 +56,19 @@ function anyHostOf(value) {
|
||||
try { return new URL(value).hostname.toLowerCase(); } catch { return ''; }
|
||||
}
|
||||
|
||||
function cleanPath(p) {
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
// Kennungsartige Segmente (lange Ziffernfolge, UUID, E-Mail) werden nie gespeichert.
|
||||
function looksLikeId(seg) {
|
||||
let d = seg;
|
||||
try { d = decodeURIComponent(seg); } catch { /* Rohwert */ }
|
||||
return /\d{6,}/.test(d) || UUID.test(d) || d.includes('@');
|
||||
}
|
||||
|
||||
export function cleanPath(p) {
|
||||
if (typeof p !== 'string') return null;
|
||||
const s = p.split(/[?#]/)[0];
|
||||
if (!s.startsWith('/') || s.length > 300 || /[\u0000-\u001f\u007f]/.test(s)) return null;
|
||||
return s;
|
||||
return s.split('/').map((seg) => (looksLikeId(seg) ? ':id' : seg)).join('/');
|
||||
}
|
||||
|
||||
function cleanHost(h) {
|
||||
@@ -134,12 +143,19 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
|
||||
}
|
||||
const path = cleanPath(e.path);
|
||||
if (!path) return;
|
||||
if (e.t === 'pv') {
|
||||
const hour = Math.floor(t / HOUR) * HOUR;
|
||||
if (e.t === 'cx') {
|
||||
const vp = cleanVp(e.vp);
|
||||
if (!vp) return;
|
||||
insEvent.run(site, hour, 'cx', path, vp, cleanRef(e.ref, domains), null, null, null, null, null, null, null);
|
||||
return;
|
||||
}
|
||||
if (e.t === 'pv') {
|
||||
const vp = e.vp === undefined ? null : cleanVp(e.vp);
|
||||
if (e.vp !== undefined && !vp) return;
|
||||
const c = String(geo.lookup(ip) || '');
|
||||
const country = /^[A-Za-z]{2}$/.test(c) ? c.toUpperCase() : '';
|
||||
insEvent.run(site, t, 'pv', path, vp, cleanRef(e.ref, domains), country, classifyUa(ua).device, null, null, null, null, null);
|
||||
insEvent.run(site, hour, 'pv', path, vp, vp ? cleanRef(e.ref, domains) : null, country, classifyUa(ua).device, null, null, null, null, null);
|
||||
return;
|
||||
}
|
||||
if (e.t === 'ck') {
|
||||
@@ -148,7 +164,7 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
|
||||
const y = num(e.y);
|
||||
if (!vp || x === null || y === null) return;
|
||||
const sel = typeof e.sel === 'string' ? e.sel.slice(0, 200) : null;
|
||||
insClick.run(site, t, path, vp, Math.round(clamp(x, 0, 1) * 1000) / 1000, Math.round(clamp(y, 0, 1) * 1000) / 1000, sel);
|
||||
insClick.run(site, hour, path, vp, Math.round(clamp(x, 0, 1) * 1000) / 1000, Math.round(clamp(y, 0, 1) * 1000) / 1000, sel);
|
||||
return;
|
||||
}
|
||||
const visitor = salt.visitorId(site, ip, ua);
|
||||
@@ -157,14 +173,14 @@ export function createIngest({ db, geo = { lookup: () => '' }, salt, now = Date.
|
||||
const sec = num(e.sec);
|
||||
const scroll = num(e.scroll);
|
||||
if (!vp || sec === null || scroll === null) return;
|
||||
insEvent.run(site, t, 'en', path, vp, null, null, null, visitor, Math.floor(clamp(sec, 0, 3600)), Math.floor(clamp(scroll, 0, 100)), null, null);
|
||||
insEvent.run(site, hour, 'en', path, vp, null, null, null, visitor, Math.floor(clamp(sec, 0, 3600)), Math.floor(clamp(scroll, 0, 100)), null, null);
|
||||
return;
|
||||
}
|
||||
if (e.t === 'ct') {
|
||||
if (!['tel', 'mail', 'wa', 'out'].includes(e.kind)) return;
|
||||
let host = null;
|
||||
if (e.kind === 'out') { host = cleanHost(e.host); if (!host) return; }
|
||||
insEvent.run(site, t, 'ct', path, null, null, null, null, visitor, null, null, e.kind, host);
|
||||
insEvent.run(site, hour, 'ct', path, null, null, null, null, visitor, null, null, e.kind, host);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+37
-5
@@ -8,6 +8,17 @@ const WALL_FMT = new Intl.DateTimeFormat('en-GB', {
|
||||
const DAY_MS = 86400 * 1000;
|
||||
export const RAW_DAYS = 30;
|
||||
export const OFF_DAYS = 60;
|
||||
export const DAILY_MONTHS = 25;
|
||||
|
||||
// Berliner Tag von `now` minus DAILY_MONTHS Kalendermonate (Monatsende auf den letzten Tag des Zielmonats begrenzt).
|
||||
export function dailyCutoffDay(now) {
|
||||
const [y, m, d] = DAY_FMT.format(now).split('-').map(Number);
|
||||
const idx = y * 12 + (m - 1) - DAILY_MONTHS;
|
||||
const ty = Math.floor(idx / 12);
|
||||
const tm = idx % 12;
|
||||
const last = new Date(Date.UTC(ty, tm + 1, 0)).getUTCDate();
|
||||
return `${String(ty).padStart(4, '0')}-${String(tm + 1).padStart(2, '0')}-${String(Math.min(d, last)).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Kalendertag (YYYY-MM-DD) in Europe/Berlin.
|
||||
export const dayOf = (ts) => DAY_FMT.format(ts);
|
||||
@@ -19,8 +30,11 @@ export function aggregateRows(rows) {
|
||||
const visitors = new Set();
|
||||
for (const r of rows) {
|
||||
if (r.type === 'pv') {
|
||||
add('pv_total', ''); add('pv_path', r.path || ''); add('pv_ref', r.ref || '');
|
||||
add('pv_total', ''); add('pv_path', r.path || '');
|
||||
if (r.vp != null) add('pv_ref', r.ref || ''); // nur Besucher mit Einwilligung; sonst kommt pv_ref aus cx
|
||||
add('pv_country', r.country || ''); add('pv_device', r.device || '');
|
||||
} else if (r.type === 'cx') {
|
||||
add('pv_ref', r.ref || '');
|
||||
} else if (r.type === 'en') {
|
||||
add('en_count', ''); add('en_sec_sum', '', r.sec || 0); add('scroll_sum', '', r.scroll || 0);
|
||||
if (r.visitor) visitors.add(r.visitor);
|
||||
@@ -35,17 +49,17 @@ export function aggregateRows(rows) {
|
||||
return [...m].filter(([, n]) => n > 0).map(([k, n]) => { const [dim, key] = k.split('\u0000'); return [dim, key, n]; });
|
||||
}
|
||||
|
||||
export function runNightly(db, { now = Date.now() } = {}) {
|
||||
export function runNightly(db, { now = Date.now(), log = console } = {}) {
|
||||
const today = dayOf(now);
|
||||
const rawCut = now - RAW_DAYS * DAY_MS;
|
||||
const offCut = new Date(now - OFF_DAYS * DAY_MS).toISOString();
|
||||
const siteKeys = db.prepare(`SELECT DISTINCT site FROM events_raw UNION SELECT siteKey FROM sites`).all().map((r) => r.site);
|
||||
const selRaw = db.prepare('SELECT ts, type, path, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts < ? ORDER BY ts');
|
||||
const selRaw = db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts < ? ORDER BY ts');
|
||||
const selDone = db.prepare('SELECT day FROM agg_state WHERE site = ?');
|
||||
const insDaily = db.prepare(`INSERT INTO daily (site, day, dim, key, count) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(site, day, dim, key) DO UPDATE SET count = count + excluded.count`);
|
||||
const insDone = db.prepare('INSERT OR IGNORE INTO agg_state (site, day) VALUES (?, ?)');
|
||||
const stats = { aggregated: 0, sitesDeleted: 0 };
|
||||
const stats = { aggregated: 0, sitesDeleted: 0, dailyDeleted: 0, orphansMarked: 0, orphanRowsDeleted: 0 };
|
||||
|
||||
const tx = (fn) => {
|
||||
db.exec('BEGIN');
|
||||
@@ -82,6 +96,24 @@ export function runNightly(db, { now = Date.now() } = {}) {
|
||||
stats.sitesDeleted++;
|
||||
});
|
||||
}
|
||||
|
||||
// Tageswerte älter als 25 Monate (alle Websites)
|
||||
tx(() => {
|
||||
const cut = dailyCutoffDay(now);
|
||||
stats.dailyDeleted = Number(db.prepare('DELETE FROM daily WHERE day < ?').run(cut).changes);
|
||||
db.prepare('DELETE FROM agg_state WHERE day < ?').run(cut);
|
||||
});
|
||||
|
||||
// Ausgeschaltete Websites ohne offSince: Frist beginnt bei Entdeckung.
|
||||
stats.orphansMarked = Number(db.prepare('UPDATE sites SET offSince = ? WHERE active = 0 AND offSince IS NULL').run(new Date(now).toISOString()).changes);
|
||||
// Zeilen ohne Website-Eintrag
|
||||
tx(() => {
|
||||
for (const t of ['events_raw', 'clicks', 'daily', 'agg_state']) {
|
||||
stats.orphanRowsDeleted += Number(db.prepare(`DELETE FROM ${t} WHERE site NOT IN (SELECT siteKey FROM sites)`).run().changes);
|
||||
}
|
||||
});
|
||||
|
||||
try { db.exec('PRAGMA wal_checkpoint(TRUNCATE)'); } catch (e) { log.error('Analytics-Nachtjob: wal_checkpoint:', e.message); }
|
||||
return stats;
|
||||
}
|
||||
|
||||
@@ -110,7 +142,7 @@ export function scheduleNightly(db, { startDelayMs = 60000, log = console, now =
|
||||
const run = () => {
|
||||
try {
|
||||
const r = job(db, { now: now() });
|
||||
log.log(`Analytics-Nachtjob: ${r.aggregated} Tage verdichtet, ${r.sitesDeleted} Websites gelöscht`);
|
||||
log.log(`Analytics-Nachtjob: ${r.aggregated} Tage verdichtet, ${r.sitesDeleted} Websites gelöscht, ${r.dailyDeleted} Tageswerte > ${DAILY_MONTHS} Monate gelöscht, ${r.orphansMarked} Websites markiert, ${r.orphanRowsDeleted} verwaiste Zeilen gelöscht`);
|
||||
} catch (e) { log.error('Analytics-Nachtjob:', e.message); }
|
||||
};
|
||||
const arm = (delay, fn) => { if (!stopped) timer = timers.setTimeout(fn, delay); };
|
||||
|
||||
+13
-1
@@ -61,7 +61,7 @@ function validateSite(b) {
|
||||
function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged }) {
|
||||
const ingest = role === 'public' ? createIngest({ db, geo, salt: salt || createSalt({ now }), now }) : null;
|
||||
const heat = role === 'public' ? createHeat({ db, config, now }) : null;
|
||||
const assets = role === 'public' ? createAssets({ distDir: config.distDir }) : null;
|
||||
const assets = role === 'public' ? createAssets({ distDir: config.distDir, isActive: (k) => !!db.prepare('SELECT 1 FROM sites WHERE siteKey = ? AND active = 1').get(k) }) : null;
|
||||
const clock = now || Date.now;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
try {
|
||||
@@ -110,6 +110,18 @@ function createHandlerServer({ config, db, role, geo, salt, now, onSitesChanged
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
// Beenden ohne Domainliste: active=0 und offSince einmalig (Portal: stopSite)
|
||||
if (role === 'admin' && method === 'POST' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'sites' && parts.length === 5 && parts[4] === 'off') {
|
||||
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
||||
const key = parts[3];
|
||||
if (!SITE_KEY.test(key)) return send(res, 400, { error: 'Ungültiger Website-Schlüssel' });
|
||||
const old = db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(key);
|
||||
if (!old) return send(res, 404, { error: 'Website nicht gefunden' });
|
||||
db.prepare('UPDATE sites SET active = 0, offSince = ? WHERE siteKey = ?').run(old.offSince || new Date().toISOString(), key);
|
||||
onSitesChanged?.();
|
||||
return send(res, 200, { ok: true });
|
||||
}
|
||||
|
||||
if (role === 'admin' && method === 'GET' && parts[0] === 'a' && parts[1] === 'admin' && parts[2] === 'stats' && parts.length === 4) {
|
||||
if (!tokenOk(req.headers.authorization, config.adminToken)) return send(res, 401, { error: 'Nicht autorisiert' });
|
||||
const key = parts[3];
|
||||
|
||||
@@ -37,7 +37,7 @@ export function computeStats(db, site, range, now = Date.now()) {
|
||||
// Tage ohne agg_state (heute, ggf. gestern vor 02:30) live aus den Rohdaten
|
||||
const live = new Map();
|
||||
const from = now - (range + 1) * DAY_MS;
|
||||
for (const r of db.prepare('SELECT ts, type, path, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts')
|
||||
for (const r of db.prepare('SELECT ts, type, path, vp, ref, country, device, visitor, sec, scroll, kind FROM events_raw WHERE site = ? AND ts >= ? AND ts <= ? ORDER BY ts')
|
||||
.all(site, from, now)) {
|
||||
const d = dayOf(r.ts);
|
||||
if (!inRange.has(d) || done.has(d)) continue;
|
||||
|
||||
@@ -11,8 +11,13 @@ import { createPublicServer, createAdminServer } from '../src/server.js';
|
||||
|
||||
const DIST = new URL('../client/dist/', import.meta.url);
|
||||
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const OFFKEY = 'offoffoffoffoffoffoff';
|
||||
async function start(config = {}) {
|
||||
const server = createPublicServer({ config, db: openDb(':memory:') });
|
||||
const db = openDb(':memory:');
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '[]');
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,0)').run(OFFKEY, 'p', '[]');
|
||||
const server = createPublicServer({ config, db });
|
||||
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
||||
return { base: `http://127.0.0.1:${server.address().port}`, close: () => server.close() };
|
||||
}
|
||||
@@ -35,7 +40,7 @@ for (const file of ['w.js', 'h.js']) {
|
||||
const s = await start();
|
||||
const want = readFileSync(new URL(file, DIST));
|
||||
const etag = `"${crypto.createHash('sha256').update(want).digest('hex')}"`;
|
||||
const id = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'identity' });
|
||||
const id = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'identity' });
|
||||
assert.equal(id.status, 200);
|
||||
assert.deepEqual(id.body, want);
|
||||
assert.equal(id.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
@@ -46,7 +51,7 @@ for (const file of ['w.js', 'h.js']) {
|
||||
assert.equal(id.headers['cross-origin-resource-policy'], 'cross-origin');
|
||||
assert.equal(id.headers['access-control-allow-origin'], '*');
|
||||
assert.equal(id.headers['content-encoding'], undefined);
|
||||
const gz = await raw(s.base, `/a/${file}`, { 'Accept-Encoding': 'gzip, deflate' });
|
||||
const gz = await raw(s.base, `/a/${file}${file === 'w.js' ? `?s=${KEY}` : ''}`, { 'Accept-Encoding': 'gzip, deflate' });
|
||||
assert.equal(gz.status, 200);
|
||||
assert.equal(gz.headers['content-encoding'], 'gzip');
|
||||
assert.equal(gz.headers.etag, etag);
|
||||
@@ -58,13 +63,13 @@ for (const file of ['w.js', 'h.js']) {
|
||||
|
||||
test('ETag: 304 bei If-None-Match, 200 bei anderem Wert', async () => {
|
||||
const s = await start();
|
||||
const first = await raw(s.base, '/a/w.js');
|
||||
const hit = await raw(s.base, '/a/w.js', { 'If-None-Match': first.headers.etag });
|
||||
const first = await raw(s.base, `/a/w.js?s=${KEY}`);
|
||||
const hit = await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': first.headers.etag });
|
||||
assert.equal(hit.status, 304);
|
||||
assert.equal(hit.body.length, 0);
|
||||
assert.equal(hit.headers.etag, first.headers.etag);
|
||||
assert.equal(hit.headers['cache-control'], 'public, max-age=3600');
|
||||
assert.equal((await raw(s.base, '/a/w.js', { 'If-None-Match': '"anders"' })).status, 200);
|
||||
assert.equal((await raw(s.base, `/a/w.js?s=${KEY}`, { 'If-None-Match': '"anders"' })).status, 200);
|
||||
s.close();
|
||||
});
|
||||
|
||||
@@ -102,3 +107,42 @@ test('fehlende dist-Datei: 503 nur für diesen Pfad, deutscher Fehler im Log', a
|
||||
assert.equal((await fetch(`${s.base}/a/h.js`)).status, 503);
|
||||
s.close();
|
||||
});
|
||||
|
||||
const EMPTY = '/* WEBklar Analyse: aus */\n';
|
||||
for (const [name, q] of [['unbekannter Schlüssel', '?s=unbekannt0123456789ab'], ['ungültiger Schlüssel', '?s=ABC'], ['ohne s', ''], ['ausgeschaltete Website', `?s=${OFFKEY}`]]) {
|
||||
test(`w.js ${name}: leeres Skript`, async () => {
|
||||
const s = await start();
|
||||
const r = await raw(s.base, `/a/w.js${q}`, { 'Accept-Encoding': 'gzip' });
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.body.toString(), EMPTY);
|
||||
assert.equal(r.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
assert.equal(r.headers['cache-control'], 'public, max-age=60');
|
||||
assert.equal(r.headers['x-content-type-options'], 'nosniff');
|
||||
assert.equal(r.headers['cross-origin-resource-policy'], 'cross-origin');
|
||||
assert.equal(r.headers['access-control-allow-origin'], '*');
|
||||
assert.equal(r.headers.vary, 'Accept-Encoding');
|
||||
assert.equal(r.headers['content-encoding'], undefined);
|
||||
assert.equal(r.headers.etag, undefined);
|
||||
s.close();
|
||||
});
|
||||
}
|
||||
|
||||
test('Ende-zu-Ende: Ausschalten wirkt sofort, Einschalten ebenfalls', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const pub = createPublicServer({ config: {}, db });
|
||||
const adm = createAdminServer({ config: { adminToken: 't' }, db });
|
||||
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
|
||||
const pb = `http://127.0.0.1:${pub.address().port}`;
|
||||
const ab = `http://127.0.0.1:${adm.address().port}`;
|
||||
const put = (active) => fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active }) });
|
||||
const get = async () => (await raw(pb, `/a/w.js?s=${KEY}`)).body.toString();
|
||||
assert.equal(await get(), EMPTY);
|
||||
assert.equal((await put(true)).status, 200);
|
||||
assert.notEqual(await get(), EMPTY);
|
||||
assert.equal((await put(false)).status, 200);
|
||||
assert.equal(await get(), EMPTY);
|
||||
assert.equal((await put(true)).status, 200);
|
||||
assert.notEqual(await get(), EMPTY);
|
||||
pub.close(); adm.close();
|
||||
});
|
||||
+186
-63
@@ -7,8 +7,10 @@ import { dirname, join } from 'node:path';
|
||||
import {
|
||||
vpClass, cleanPath, refHost, cssPath, contactKind, readConsent, contrastOk, escapeHtml,
|
||||
} from '../client/core.js';
|
||||
import { BANNER_VERSION, BANNER_TEXT, BANNER_LINK_LABEL, BANNER_ARIA, SETTINGS_LABEL } from '../client/core.js';
|
||||
import { buildAll } from '../client/build.js';
|
||||
|
||||
const BV = '2026-10-02';
|
||||
const DIST = join(dirname(fileURLToPath(import.meta.url)), '..', 'client', 'dist');
|
||||
|
||||
// ---------- core ----------
|
||||
@@ -96,13 +98,17 @@ test('readConsent', () => {
|
||||
const now = 1_700_000_000_000;
|
||||
const day = 86400000;
|
||||
const st = (v) => ({ getItem: () => v });
|
||||
assert.deepEqual(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 10 * day })), now), { v: 1, a: 'accept', t: now - 10 * day });
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'reject', t: now - 182 * day })), now).a, 'reject');
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now - 183 * day })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 2, a: 'accept', t: now })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'maybe', t: now })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: 'x' })), now), null);
|
||||
assert.equal(readConsent(st(JSON.stringify({ v: 1, a: 'accept', t: now + day })), now), null);
|
||||
const J = (o) => st(JSON.stringify(o));
|
||||
assert.equal(BANNER_VERSION, '2026-10-02');
|
||||
assert.deepEqual(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 10 * day }), now), { v: 1, a: 'accept', t: now - 10 * day, bv: BV });
|
||||
assert.equal(readConsent(J({ v: 1, a: 'reject', bv: BV, t: now - 182 * day }), now).a, 'reject');
|
||||
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now - 183 * day }), now), null);
|
||||
assert.equal(readConsent(J({ v: 1, a: 'accept', t: now }), now), null, 'ohne bv');
|
||||
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: '2026-01-01', t: now }), now), null, 'alte Fassung');
|
||||
assert.equal(readConsent(J({ v: 2, a: 'accept', bv: BV, t: now }), now), null);
|
||||
assert.equal(readConsent(J({ v: 1, a: 'maybe', bv: BV, t: now }), now), null);
|
||||
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: 'x' }), now), null);
|
||||
assert.equal(readConsent(J({ v: 1, a: 'accept', bv: BV, t: now + day }), now), null);
|
||||
assert.equal(readConsent(st('kaputt{'), now), null);
|
||||
assert.equal(readConsent(st(null), now), null);
|
||||
assert.equal(readConsent({ getItem() { throw new Error('blocked'); } }, now), null);
|
||||
@@ -144,6 +150,7 @@ function makeEl(tag) {
|
||||
addEventListener(t, f) { (this.listeners[t] ||= []).push(f); },
|
||||
removeEventListener(t, f) { this.listeners[t] = (this.listeners[t] || []).filter((x) => x !== f); },
|
||||
focus() { this.focused = true; },
|
||||
getBoundingClientRect() { return { height: this.fakeHeight || 0 }; },
|
||||
remove() { this.removed = true; if (this.parentNode) this.parentNode.children = this.parentNode.children.filter((c) => c !== this); },
|
||||
closest(sel) {
|
||||
const attr = /^\[([\w-]+)\]$/.exec(sel);
|
||||
@@ -162,10 +169,10 @@ function walk(n, fn) { fn(n); for (const c of n.children || []) walk(c, fn); if
|
||||
function findAll(root, pred) { const out = []; walk(root, (n) => { if (pred(n)) out.push(n); }); return out; }
|
||||
const btn = (root, label) => findAll(root, (n) => n.tagName === 'BUTTON' && n.textContent === label)[0];
|
||||
|
||||
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false } = {}) {
|
||||
const sent = []; const store = {}; let now = 1_700_000_000_000;
|
||||
function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar.com/a/w.js', withConsentLink = false, bannerHeight = 0, bodyPad } = {}) {
|
||||
const sent = []; const reads = { referrer: 0, innerWidth: 0 }; const beacon = []; const fetches = []; const store = {}; let now = 1_700_000_000_000;
|
||||
if (stored) store['wk-consent'] = JSON.stringify(stored);
|
||||
const body = makeEl('body'); const html = makeEl('html');
|
||||
const body = makeEl('body'); if (bodyPad !== undefined) body.style.paddingBottom = bodyPad; const html = makeEl('html');
|
||||
const timers = [];
|
||||
const script = makeEl('script'); script.setAttribute('src', src); script.setAttribute('data-site', 'abcdef0123456789abcd'); script.setAttribute('data-color', '#0a5a8a'); script.src = src;
|
||||
const docL = {}; const winL = {};
|
||||
@@ -173,28 +180,29 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
|
||||
const injected = [];
|
||||
if (link) link.click = () => { for (const f of [...(docL.click || [])]) f({ type: 'click', target: link, preventDefault() {} }); };
|
||||
const document = {
|
||||
currentScript: script, referrer: 'https://www.google.com/', visibilityState: 'visible',
|
||||
currentScript: script, get referrer() { reads.referrer++; return 'https://www.google.com/'; }, visibilityState: 'visible',
|
||||
documentElement: Object.assign(html, { scrollWidth: 1000, scrollHeight: 2000 }), body, head: makeEl('head'),
|
||||
createElement: makeEl, activeElement: null,
|
||||
createElement(t) { const e = makeEl(t); if (bannerHeight) e.offsetHeight = bannerHeight; return e; }, activeElement: null,
|
||||
querySelector(s) { return s === '[data-webklar-consent]' ? link : null; },
|
||||
addEventListener(t, f) { (docL[t] ||= []).push(f); },
|
||||
removeEventListener(t, f) { docL[t] = (docL[t] || []).filter((x) => x !== f); },
|
||||
};
|
||||
body.appendChild = function (c) { c.parentNode = this; this.children.push(c); if (c.attrs.src) injected.push(c); return c; };
|
||||
document.head.appendChild = body.appendChild;
|
||||
const history = { pushState() {}, replaceState(s, t, u) { this.replaced = u; } };
|
||||
const origPush = function () {};
|
||||
const history = { pushState: origPush, replaceState(s, t, u) { this.replaced = u; } };
|
||||
const win = {
|
||||
innerWidth: 1200, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
|
||||
get innerWidth() { reads.innerWidth++; return 1200; }, innerHeight: 800, scrollX: 0, scrollY: 300, document, history,
|
||||
location: { hostname: 'mein.de', origin: 'https://mein.de', pathname: '/start', search, href: 'https://mein.de/start' + search, hash: '' },
|
||||
addEventListener(t, f) { (winL[t] ||= []).push(f); },
|
||||
removeEventListener(t, f) { winL[t] = (winL[t] || []).filter((x) => x !== f); },
|
||||
};
|
||||
const sandbox = {
|
||||
window: win, document, history, location: win.location, URL, URLSearchParams, JSON, Math, Date: { now: () => now }, String, Number, Array, Object, RegExp, Error, parseInt,
|
||||
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { sent.push({ u, d: JSON.parse(d) }); return true; } },
|
||||
navigator: { globalPrivacyControl: gpc, doNotTrack: dnt, sendBeacon(u, d) { beacon.push({ u, d }); return true; } },
|
||||
localStorage: { getItem: (k) => (k in store ? store[k] : null), setItem: (k, v) => { store[k] = String(v); } },
|
||||
setTimeout: (f, ms) => { timers.push(f); return timers.length; }, clearTimeout() {},
|
||||
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: () => Promise.resolve(),
|
||||
setInterval: () => 0, clearInterval() {}, Blob: class {}, fetch: (u, o) => { fetches.push({ u, o }); sent.push({ u, d: JSON.parse(o.body) }); return Promise.resolve(); },
|
||||
};
|
||||
win.localStorage = sandbox.localStorage;
|
||||
sandbox.globalThis = sandbox;
|
||||
@@ -205,31 +213,46 @@ function harness({ gpc, dnt, stored, search = '', src = 'https://project.webklar
|
||||
const leave = () => { for (const f of [...(winL.pagehide || [])]) f({}); document.visibilityState = 'hidden'; for (const f of [...(docL.visibilitychange || [])]) f({}); };
|
||||
const rerun = () => vm.runInContext(readFileSync(join(DIST, 'w.js'), 'utf8'), sandbox);
|
||||
const storage = () => (winL.storage || []).forEach((f) => f({ key: 'wk-consent' }));
|
||||
return { rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
|
||||
const nav = (p) => { win.location.pathname = p; history.pushState(null, '', p); timers.splice(0).forEach((f) => f()); };
|
||||
return { nav, reads, beacon, fetches, origPush, rerun, storage, sent, store, types, ck, leave, body, docL, winL, win, injected, history, timers, setNow: (n) => { now = n; }, link, document };
|
||||
}
|
||||
const target = () => { const b = makeEl('button'); b.parentNode = makeEl('body'); b.attrs.class = 'cta'; return b; };
|
||||
const hostEl = (h) => h.body.children.find((c) => c.shadow && findAll(c.shadow, (n) => n.attrs && n.attrs.role === 'dialog').length);
|
||||
|
||||
test('w.js: ohne Einwilligung nur pv + bn shown, keine ck/ct/en', () => {
|
||||
const SITE = 'abcdef0123456789abcd';
|
||||
const ACC0 = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
|
||||
test('w.js: ohne Einwilligung nur pv {t,site,path} + bn shown, kein Lesen von referrer/innerWidth', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
|
||||
assert.equal(h.sent[0].u, 'https://project.webklar.com/a/e');
|
||||
assert.deepEqual(h.sent[0].d, { t: 'pv', site: 'abcdef0123456789abcd', path: '/start', ref: 'www.google.com', vp: 'd' });
|
||||
assert.deepEqual(h.sent[1].d, { t: 'bn', site: 'abcdef0123456789abcd', a: 'shown' });
|
||||
assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start' });
|
||||
assert.deepEqual(Object.keys(h.sent[0].d).sort(), ['path', 'site', 't']);
|
||||
assert.deepEqual(h.sent[1].d, { t: 'bn', site: SITE, a: 'shown' });
|
||||
assert.equal(h.reads.referrer, 0);
|
||||
assert.equal(h.reads.innerWidth, 0);
|
||||
h.ck(target()); h.leave();
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
|
||||
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
|
||||
});
|
||||
|
||||
test('core: Banner-Konstanten', () => {
|
||||
assert.match(BANNER_TEXT, /^Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung\./);
|
||||
assert.match(BANNER_TEXT, /Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast\./);
|
||||
assert.match(BANNER_TEXT, /über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen\. Details: $/);
|
||||
assert.equal(BANNER_LINK_LABEL, 'Datenschutz');
|
||||
assert.equal(BANNER_ARIA, 'Einwilligung zur Reichweitenmessung');
|
||||
assert.equal(SETTINGS_LABEL, 'Datenschutz-Einstellungen');
|
||||
});
|
||||
|
||||
test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)', () => {
|
||||
const h = harness();
|
||||
const host = hostEl(h);
|
||||
assert.ok(host, 'Banner im Shadow Root');
|
||||
const dlg = findAll(host.shadow, (n) => n.attrs.role === 'dialog')[0];
|
||||
assert.equal(dlg.attrs['aria-label'], 'Cookie-Einstellungen');
|
||||
assert.equal(dlg.attrs['aria-label'], BANNER_ARIA);
|
||||
const all = findAll(host.shadow, () => true);
|
||||
const text = all.map((n) => n.textContent).join(' ');
|
||||
assert.match(text, /Wir möchten mit deiner Zustimmung anonym auswerten/);
|
||||
assert.ok(all.find((n) => n.tagName === 'P').textContent.startsWith(BANNER_TEXT), 'Text beginnt mit BANNER_TEXT');
|
||||
const a = all.find((n) => n.tagName === 'A');
|
||||
assert.equal(a.attrs.href, '/datenschutz-analyse.html');
|
||||
assert.equal(a.textContent, 'Datenschutz');
|
||||
@@ -244,7 +267,6 @@ test('w.js: Banner-Aufbau (Dialog, Texte, gleiche Buttons, Akzent nur Annehmen)'
|
||||
assert.match(css, /button\{[^}]*background:#0a5a8a[^}]*color:#fff/);
|
||||
assert.match(css, /button\{[^}]*[; ]width:150px/);
|
||||
assert.equal((css.match(/(?<![-\w])width:/g) || []).length, 2, 'eine feste Breite fuer beide + 100% mobil');
|
||||
assert.ok(!/min-width/.test(css));
|
||||
const portal = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'public', 'funktionen.css'), 'utf8');
|
||||
assert.match(portal, /\.an-bn-btn \{[^}]*[; ]width: 150px/);
|
||||
assert.equal((portal.match(/\.an-bn-btn[^{]*\{/g) || []).length, 2, 'Basisregel + mobile Regel');
|
||||
@@ -258,8 +280,8 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
|
||||
const h = harness();
|
||||
const bh = hostEl(h);
|
||||
btn(bh.shadow, 'Annehmen').click();
|
||||
assert.equal(h.types().at(-1), 'bn:accept');
|
||||
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', t: 1_700_000_000_000 });
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
|
||||
assert.deepEqual(JSON.parse(h.store['wk-consent']), { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
|
||||
assert.ok(bh.removed && !hostEl(h));
|
||||
const t = target();
|
||||
h.ck(t);
|
||||
@@ -281,7 +303,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
|
||||
assert.equal(en[0].d.scroll, 55); // (300+800)/2000
|
||||
assert.equal(typeof en[0].d.sec, 'number');
|
||||
// Widerruf ueber Einstellungs-Link
|
||||
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === 'Cookie-Einstellungen')[0];
|
||||
const settings = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
|
||||
assert.ok(settings, 'fester Link');
|
||||
settings.click();
|
||||
assert.ok(btn(hostEl(h).shadow, 'Ablehnen').focused, 'Fokus beim Wiederoeffnen');
|
||||
@@ -296,7 +318,7 @@ test('w.js: Annehmen => bn accept, Speicher, ck/ct/en; Widerruf stoppt sofort',
|
||||
});
|
||||
|
||||
test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
assert.equal(hostEl(h), undefined);
|
||||
h.ck(target());
|
||||
@@ -304,28 +326,132 @@ test('w.js: gespeicherte Einwilligung => kein Banner, kein bn, Tracking an', ()
|
||||
});
|
||||
|
||||
test('w.js: abgelaufene/abgelehnte Einwilligung', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 - 5000 } });
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 - 5000 } });
|
||||
assert.deepEqual(h.types(), []);
|
||||
h.ck(target()); h.leave();
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
const h2 = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 200 * 86400000 } });
|
||||
assert.deepEqual(h.types(), []);
|
||||
const h2 = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 200 * 86400000 } });
|
||||
assert.deepEqual(h2.types(), ['pv', 'bn:shown']);
|
||||
});
|
||||
|
||||
test('w.js: GPC und DNT => nur pv, kein Banner, nie Tracking', () => {
|
||||
for (const o of [{ gpc: true }, { dnt: '1' }, { gpc: true, stored: { v: 1, a: 'accept', t: 1_700_000_000_000 } }]) {
|
||||
test('w.js: GPC und DNT => nichts senden, kein Banner, keine Hooks (T1)', () => {
|
||||
for (const o of [{ gpc: true }, { dnt: '1' }, { dnt: 'yes' }, { gpc: true, stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 } }]) {
|
||||
const h = harness(o);
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
assert.equal(hostEl(h), undefined);
|
||||
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
|
||||
h.ck(target()); h.leave();
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
assert.equal(h.sent.length, 0);
|
||||
assert.equal(h.winL.pageshow, undefined);
|
||||
assert.equal(h.winL.popstate, undefined);
|
||||
assert.equal(h.history.pushState, h.origPush);
|
||||
assert.equal(h.body.children.length, 0, 'kein Banner, kein Einstellungsknopf');
|
||||
h.ck(target()); h.leave(); h.nav('/neu');
|
||||
assert.equal(h.sent.length, 0);
|
||||
assert.equal(h.reads.referrer + h.reads.innerWidth, 0);
|
||||
}
|
||||
});
|
||||
|
||||
test('w.js: gespeichertes Ablehnen => nichts senden, nur Einstellungsknopf; spaeteres Annehmen => bn:accept, pv mit ref+vp', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
|
||||
assert.equal(h.sent.length, 0);
|
||||
assert.equal(h.winL.pageshow, undefined);
|
||||
assert.equal(h.winL.popstate, undefined);
|
||||
assert.equal(h.history.pushState, h.origPush);
|
||||
assert.equal(h.body.children.length, 1);
|
||||
const open = findAll(h.body, (x) => x.tagName === 'BUTTON' && x.textContent === SETTINGS_LABEL)[0];
|
||||
assert.ok(open);
|
||||
open.click();
|
||||
assert.equal(h.sent.length, 0);
|
||||
btn(hostEl(h).shadow, 'Annehmen').click();
|
||||
assert.deepEqual(h.types(), ['bn:accept', 'pv']);
|
||||
assert.deepEqual(h.sent[1].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
|
||||
assert.ok(h.winL.pageshow && h.winL.popstate, 'Hooks nachtraeglich installiert');
|
||||
h.nav('/zwei');
|
||||
assert.equal(h.types().at(-1), 'pv');
|
||||
assert.equal(h.sent.at(-1).d.path, '/zwei');
|
||||
});
|
||||
|
||||
test('w.js: Ablehnen => genau ein bn:reject, danach nichts mehr (auch nicht Seitenwechsel)', () => {
|
||||
const h2 = harness();
|
||||
btn(hostEl(h2).shadow, 'Ablehnen').click();
|
||||
assert.deepEqual(h2.types(), ['pv', 'bn:shown', 'bn:reject']);
|
||||
const n = h2.sent.length;
|
||||
h2.nav('/zwei'); h2.nav('/drei');
|
||||
for (const f of h2.winL.pageshow || []) f({ persisted: true });
|
||||
for (const f of h2.winL.popstate || []) f({});
|
||||
h2.timers.splice(0).forEach((f) => f());
|
||||
h2.ck(target()); h2.leave();
|
||||
for (const f of h2.docL.scroll || []) f({});
|
||||
assert.equal(h2.sent.length, n);
|
||||
assert.equal(h2.types().filter((t) => t === 'bn:reject').length, 1);
|
||||
});
|
||||
|
||||
test('w.js: Seitenwechsel-Timer vor dem Ablehnen sendet nichts', () => {
|
||||
const h = harness();
|
||||
h.win.location.pathname = '/zwei';
|
||||
h.history.pushState(null, '', '/zwei'); // Timer geplant
|
||||
assert.equal(h.timers.length, 1);
|
||||
btn(hostEl(h).shadow, 'Ablehnen').click();
|
||||
const n = h.sent.length;
|
||||
h.timers.splice(0).forEach((f) => f());
|
||||
assert.equal(h.sent.length, n);
|
||||
});
|
||||
|
||||
test('w.js: Ablehnen in anderem Tab (storage) => danach kein pv beim Seitenwechsel', () => {
|
||||
const h = harness();
|
||||
const n = h.sent.length;
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
|
||||
h.storage();
|
||||
h.nav('/zwei');
|
||||
for (const f of h.winL.pageshow || []) f({ persisted: true });
|
||||
assert.equal(h.sent.length, n);
|
||||
});
|
||||
|
||||
test('w.js: Annehmen auf laufender Seite => bn:accept, genau ein cx mit ref+vp; kein zweites cx', () => {
|
||||
const h = harness();
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
|
||||
btn(hostEl(h).shadow, 'Annehmen').click();
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown', 'bn:accept', 'cx']);
|
||||
assert.deepEqual(h.sent.at(-1).d, { t: 'cx', site: SITE, path: '/start', ref: refHost('https://www.google.com/', 'mein.de'), vp: 'd' });
|
||||
// Widerruf, erneut annehmen
|
||||
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
|
||||
btn(hostEl(h).shadow, 'Ablehnen').click();
|
||||
findAll(h.body, (x) => x.textContent === SETTINGS_LABEL)[0].click();
|
||||
btn(hostEl(h).shadow, 'Annehmen').click();
|
||||
assert.equal(h.sent.filter((s) => s.d.t === 'cx').length, 1);
|
||||
});
|
||||
|
||||
test('w.js: gespeichertes Annehmen => erstes pv mit ref+vp, kein cx', () => {
|
||||
const h = harness({ stored: ACC0 });
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
assert.deepEqual(h.sent[0].d, { t: 'pv', site: SITE, path: '/start', ref: 'www.google.com', vp: 'd' });
|
||||
});
|
||||
|
||||
test('w.js: T15 kein sendBeacon, fetch mit keepalive, omit, POST, text/plain', () => {
|
||||
const h = harness();
|
||||
assert.ok(h.fetches.length >= 1);
|
||||
assert.equal(h.beacon.length, 0);
|
||||
for (const f of h.fetches) {
|
||||
assert.equal(f.o.keepalive, true);
|
||||
assert.equal(f.o.credentials, 'omit');
|
||||
assert.equal(f.o.method, 'POST');
|
||||
assert.equal(f.o.headers['Content-Type'], 'text/plain');
|
||||
}
|
||||
});
|
||||
|
||||
test('w.js: T19 Platz unten solange Banner offen; Knopf mindestens 24px', () => {
|
||||
const h = harness({ bannerHeight: 90, bodyPad: '5px' });
|
||||
assert.equal(h.body.style.paddingBottom, '90px');
|
||||
btn(hostEl(h).shadow, 'Ablehnen').click();
|
||||
assert.equal(h.body.style.paddingBottom, '5px');
|
||||
const g = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
|
||||
const css = findAll(g.body, (n) => n.tagName === 'STYLE').map((n) => n.textContent).join('');
|
||||
assert.match(css, /min-width:24px/);
|
||||
assert.match(css, /min-height:32px/);
|
||||
assert.match(css, /padding:8px 12px/);
|
||||
assert.match(css, /font-size:13px/);
|
||||
});
|
||||
|
||||
test('w.js: [data-webklar-consent] oeffnet Banner, kein fester Link', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 }, withConsentLink: true });
|
||||
assert.equal(findAll(h.body, (x) => x.textContent === 'Cookie-Einstellungen').length, 0);
|
||||
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 }, withConsentLink: true });
|
||||
assert.equal(findAll(h.body, (x) => x.textContent === SETTINGS_LABEL).length, 0);
|
||||
assert.equal(hostEl(h), undefined);
|
||||
h.link.click();
|
||||
assert.ok(hostEl(h));
|
||||
@@ -343,14 +469,14 @@ test('w.js: Heatmap-Modus sendet nichts, entfernt Parameter, laedt h.js', () =>
|
||||
});
|
||||
|
||||
test('w.js: SPA-Navigation sendet pv nur bei Pfadwechsel', () => {
|
||||
const h = harness({ gpc: true });
|
||||
h.win.location.pathname = '/start';
|
||||
h.history.pushState(null, '', '/start?x=1'); h.timers.splice(0).forEach((f) => f());
|
||||
assert.equal(h.sent.length, 1);
|
||||
const h = harness();
|
||||
h.nav('/start');
|
||||
assert.equal(h.sent.filter((s) => s.d.t === 'pv').length, 1);
|
||||
h.win.location.pathname = '/neu';
|
||||
h.history.pushState(null, '', '/neu'); h.history.pushState(null, '', '/neu');
|
||||
h.timers.splice(0).forEach((f) => f());
|
||||
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/neu']);
|
||||
assert.deepEqual(h.sent.filter((s) => s.d.t === 'pv').map((s) => s.d.path), ['/start', '/neu']);
|
||||
assert.deepEqual(h.sent.at(-1).d, { t: 'pv', site: SITE, path: '/neu' });
|
||||
});
|
||||
|
||||
test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
|
||||
@@ -361,36 +487,33 @@ test('w.js: wirft nie, auch wenn localStorage/sendBeacon kaputt', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('w.js: DNT yes / msDoNotTrack => nur pv', () => {
|
||||
for (const o of [{ dnt: 'yes' }]) { const h = harness(o); assert.deepEqual(h.types(), ['pv']); }
|
||||
});
|
||||
|
||||
test('w.js: doppelt eingebunden => nur ein pv', () => {
|
||||
const h = harness({ gpc: true });
|
||||
const h = harness();
|
||||
h.rerun();
|
||||
assert.deepEqual(h.types(), ['pv']);
|
||||
assert.deepEqual(h.types(), ['pv', 'bn:shown']);
|
||||
});
|
||||
|
||||
test('w.js: Widerruf in anderem Tab (storage-Ereignis) stoppt Tracking sofort', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
||||
h.ck(target());
|
||||
assert.equal(h.sent.at(-1).d.t, 'ck');
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
|
||||
h.storage();
|
||||
const n = h.sent.length;
|
||||
h.ck(target()); h.leave();
|
||||
assert.equal(h.sent.length, n);
|
||||
assert.equal((h.docL.click || []).length, 1); // nur die Einstellungs-Delegation
|
||||
// Annehmen in anderem Tab startet Tracking
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', t: 1_700_000_000_000 });
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 });
|
||||
h.storage();
|
||||
h.ck(target());
|
||||
assert.equal(h.sent.length, n + 1);
|
||||
assert.equal(h.sent.length, n + 1); // nur ck, kein cx (pv trug ref/vp schon)
|
||||
assert.ok(!h.sent.some((x) => x.d.t === 'cx'));
|
||||
});
|
||||
|
||||
test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereignis)', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
|
||||
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
|
||||
const n = h.sent.length;
|
||||
h.ck(target()); h.leave();
|
||||
assert.equal(h.sent.length, n);
|
||||
@@ -398,13 +521,13 @@ test('w.js: Einwilligung wird vor jedem Senden neu geprueft (ohne storage-Ereign
|
||||
});
|
||||
|
||||
test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
||||
h.win.location.pathname = '/zwei';
|
||||
h.history.pushState(null, '', '/zwei'); h.timers.splice(0).forEach((f) => f());
|
||||
assert.deepEqual(h.sent.map((s) => s.d.path), ['/start', '/zwei']);
|
||||
h.ck(target());
|
||||
assert.equal(h.sent.at(-1).d.path, '/zwei');
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
|
||||
h.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
|
||||
h.storage();
|
||||
const n = h.sent.length;
|
||||
h.leave();
|
||||
@@ -412,7 +535,7 @@ test('w.js: SPA-pv bei akzeptierter Einwilligung; nach Widerruf kein en', () =>
|
||||
});
|
||||
|
||||
test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 } });
|
||||
const h = harness({ stored: { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 } });
|
||||
h.leave();
|
||||
assert.equal(h.sent.filter((s) => s.d.t === 'en').length, 1);
|
||||
for (const f of h.winL.pageshow || []) f({ persisted: false });
|
||||
@@ -424,7 +547,7 @@ test('w.js: bfcache-Rueckkehr sendet neues pv und erlaubt neues en', () => {
|
||||
});
|
||||
|
||||
test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegation', () => {
|
||||
const h = harness({ stored: { v: 1, a: 'reject', t: 1_700_000_000_000 } });
|
||||
const h = harness({ stored: { v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 } });
|
||||
// kein Link beim Laden => fester Link; spaeteres Element oeffnet ebenfalls
|
||||
const late = makeEl('a'); late.setAttribute('data-webklar-consent', '');
|
||||
for (const f of [...(h.docL.click || [])]) f({ type: 'click', target: late, preventDefault() {} });
|
||||
@@ -433,7 +556,7 @@ test('w.js: [data-webklar-consent] spaeter hinzugefuegt funktioniert per Delegat
|
||||
});
|
||||
|
||||
// ---------- Verweildauer bei internem Seitenwechsel ----------
|
||||
const ACC = { v: 1, a: 'accept', t: 1_700_000_000_000 - 1000 };
|
||||
const ACC = { v: 1, a: 'accept', bv: BV, t: 1_700_000_000_000 - 1000 };
|
||||
const lnk = (href, attrs = {}) => { const a = makeEl('a'); a.setAttribute('href', href); for (const k in attrs) a.setAttribute(k, attrs[k]); a.parentNode = makeEl('body'); return a; };
|
||||
const enCount = (h) => h.sent.filter((s) => s.d.t === 'en').length;
|
||||
|
||||
@@ -473,7 +596,7 @@ test('w.js: ohne Einwilligung oder nach Widerruf kein fruehes en', () => {
|
||||
h.ck(lnk('/kontakt'));
|
||||
assert.equal(enCount(h), 0);
|
||||
const g = harness({ stored: ACC });
|
||||
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', t: 1_700_000_000_000 });
|
||||
g.store['wk-consent'] = JSON.stringify({ v: 1, a: 'reject', bv: BV, t: 1_700_000_000_000 });
|
||||
g.ck(lnk('/kontakt'));
|
||||
assert.equal(enCount(g), 0);
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import crypto from 'node:crypto';
|
||||
import { mkdtempSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DatabaseSync } from 'node:sqlite';
|
||||
import { openDb } from '../src/db.js';
|
||||
import { exportSite } from '../scripts/export-site.mjs';
|
||||
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const OTHER = 'zzzzzzzz0123456789zz';
|
||||
const SCRIPT = new URL('../scripts/export-site.mjs', import.meta.url).pathname;
|
||||
|
||||
function setup() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'exp-'));
|
||||
const dbPath = join(dir, 'a.db');
|
||||
const db = openDb(dbPath);
|
||||
for (const k of [KEY, OTHER]) db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(k, 'p', '[]');
|
||||
const ts = Date.UTC(2026, 8, 1, 10);
|
||||
db.prepare("INSERT INTO daily VALUES (?, '2026-08-31','pv_total','',5)").run(KEY);
|
||||
db.prepare("INSERT INTO daily VALUES (?, '2026-08-31','pv_path','/a',2)").run(KEY);
|
||||
db.prepare("INSERT INTO daily VALUES (?, '2026-08-31','pv_total','',9)").run(OTHER);
|
||||
db.prepare("INSERT INTO events_raw (site, ts, type, path, ref) VALUES (?,?, 'pv', '/x', ?)").run(KEY, ts, 'a,"b"\nc');
|
||||
db.prepare("INSERT INTO events_raw (site, ts, type, path) VALUES (?,?, 'pv', '/o')").run(OTHER, ts);
|
||||
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)').run(KEY, ts, '/', 'd', 1.5, 2, '=1+1');
|
||||
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)').run(OTHER, ts, '/', 'd', 1, 2, 'x');
|
||||
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.close();
|
||||
return { dir, dbPath };
|
||||
}
|
||||
const hash = (p) => crypto.createHash('sha256').update(readFileSync(p)).digest('hex');
|
||||
|
||||
test('Export: drei CSV-Dateien mit Kopfzeilen, nur diese Website, Quotes und Formelschutz', () => {
|
||||
const { dir, dbPath } = setup();
|
||||
const before = hash(dbPath);
|
||||
const out = join(dir, 'out');
|
||||
exportSite({ dbPath, siteKey: KEY, outDir: out });
|
||||
assert.deepEqual(readdirSync(out).sort(), [`${KEY}-ereignisse.csv`, `${KEY}-klicks.csv`, `${KEY}-tageswerte.csv`]);
|
||||
const t = readFileSync(join(out, `${KEY}-tageswerte.csv`), 'utf8');
|
||||
assert.equal(t.split('\r\n')[0], 'day,dim,key,count');
|
||||
assert.equal(t.split('\r\n').filter(Boolean).length, 3);
|
||||
const e = readFileSync(join(out, `${KEY}-ereignisse.csv`), 'utf8');
|
||||
assert.equal(e.split('\r\n')[0], 'time,type,path,vp,ref,country,device,visitor,sec,scroll,kind,host');
|
||||
assert.ok(e.includes('2026-09-01T10:00:00.000Z,pv,/x,,"a,""b""\nc"'));
|
||||
assert.ok(!e.includes('/o'));
|
||||
const c = readFileSync(join(out, `${KEY}-klicks.csv`), 'utf8');
|
||||
assert.equal(c.split('\r\n')[0], 'time,path,vp,x,y,sel');
|
||||
assert.equal(c.split('\r\n').filter(Boolean).length, 2);
|
||||
assert.ok(c.includes(",'=1+1"));
|
||||
assert.equal(hash(dbPath), before);
|
||||
});
|
||||
|
||||
test('Export: Datenbank ist nur lesend geöffnet', () => {
|
||||
const { dbPath } = setup();
|
||||
const ro = new DatabaseSync(dbPath, { readOnly: true });
|
||||
assert.throws(() => ro.exec("INSERT INTO sites (siteKey, projectId, domains) VALUES ('q','p','[]')"));
|
||||
ro.close();
|
||||
});
|
||||
|
||||
test('CLI: unbekannter oder ungültiger Schlüssel => Exit 1 ohne Dateien', () => {
|
||||
const { dir, dbPath } = setup();
|
||||
for (const k of ['unbekannt0123456789ab', 'ABC']) {
|
||||
const out = join(dir, 'o-' + k);
|
||||
const r = spawnSync(process.execPath, [SCRIPT, k, '--db', dbPath, '--out', out]);
|
||||
assert.equal(r.status, 1);
|
||||
assert.throws(() => readdirSync(out));
|
||||
}
|
||||
const ok = spawnSync(process.execPath, [SCRIPT, KEY, '--db', dbPath, '--out', join(dir, 'ok')]);
|
||||
assert.equal(ok.status, 0);
|
||||
assert.equal(readdirSync(join(dir, 'ok')).length, 3);
|
||||
});
|
||||
|
||||
test('Export: Formelschutz nur fuer Text (Tab, CR, Minus), Zahlen bleiben unveraendert, Dateien 0600', () => {
|
||||
const { dir, dbPath } = setup();
|
||||
const db = new DatabaseSync(dbPath);
|
||||
const ts = Date.UTC(2026, 8, 1, 10);
|
||||
const ins = db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y, sel) VALUES (?,?,?,?,?,?,?)');
|
||||
ins.run(KEY, ts, '/', 'd', -3, -4.5, '\tcmd');
|
||||
ins.run(KEY, ts, '/', 'd', 1, 2, '\rcmd');
|
||||
ins.run(KEY, ts, '/', 'd', 1, 2, '-cmd');
|
||||
db.close();
|
||||
const out = join(dir, 'out');
|
||||
const files = exportSite({ dbPath, siteKey: KEY, outDir: out });
|
||||
const c = readFileSync(join(out, `${KEY}-klicks.csv`), 'utf8');
|
||||
assert.ok(c.includes(',-3,-4.5,'), 'negative Zahlen ohne Praefix');
|
||||
assert.ok(!c.includes("'-3"));
|
||||
assert.ok(c.includes(",\"'\tcmd\"") || c.includes(",'\tcmd"), 'Tab gesichert');
|
||||
assert.ok(c.includes(`"'\rcmd"`), 'CR gesichert');
|
||||
assert.ok(c.includes(",'-cmd"));
|
||||
for (const f of files) assert.equal(statSync(f).mode & 0o777, 0o600);
|
||||
});
|
||||
@@ -136,3 +136,25 @@ test('OPTIONS-Preflight: CORS-Köpfe nur für erlaubte Origins bekannter Website
|
||||
assert.equal((await pre('abc', 'https://kunde.de')).headers.get('access-control-allow-origin'), null);
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('Heatmap: gleiche Stunde (ts-Gleichstand), Limit schneidet über die niedrigste id ab', async () => {
|
||||
const s = await setup();
|
||||
const ins = s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)');
|
||||
const ts = NOW - 3600000;
|
||||
ins.run(KEY, ts, '/g', 'd', 0.99, 0.99); // niedrigste id
|
||||
s.db.exec('BEGIN');
|
||||
for (let i = 0; i < 5000; i++) ins.run(KEY, ts, '/g', 'd', 0.5, 0.5);
|
||||
s.db.exec('COMMIT');
|
||||
const j = await (await s.get(tok({ path: '/g' }))).json();
|
||||
assert.equal(j.points.length, 5000);
|
||||
assert.ok(!j.points.some((p) => p.x === 0.99));
|
||||
s.close();
|
||||
});
|
||||
|
||||
test('Heatmap: Token-Pfad mit Kennung findet die als :id gespeicherten Klicks', async () => {
|
||||
const s = await setup();
|
||||
s.db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)').run(KEY, NOW - 1000, '/kunde/:id', 'd', 0.4, 0.6);
|
||||
const j = await (await s.get(tok({ path: '/kunde/12345678' }))).json();
|
||||
assert.deepEqual(j, { points: [{ x: 0.4, y: 0.6 }] });
|
||||
s.close();
|
||||
});
|
||||
@@ -350,3 +350,102 @@ test('X-Forwarded-For: rechtester Eintrag zählt für das Mengenlimit', async ()
|
||||
assert.equal(t.rows().length, 121);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('T2: pv ohne ref/vp wird gespeichert (vp NULL, ref NULL), Gerät und Land bleiben', async () => {
|
||||
const t = await setup();
|
||||
await t.post({ t: 'pv', site: KEY, path: '/' });
|
||||
const r = t.rows();
|
||||
assert.equal(r.length, 1);
|
||||
assert.equal(r[0].type, 'pv');
|
||||
assert.equal(r[0].vp, null);
|
||||
assert.equal(r[0].ref, null);
|
||||
assert.equal(r[0].device, 'm');
|
||||
assert.equal(r[0].country, 'DE');
|
||||
await t.post({ t: 'pv', site: KEY, path: '/', vp: 'x' });
|
||||
assert.equal(t.rows().length, 1);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('T2: cx braucht gültiges vp, speichert ref, eigene Domain ergibt NULL', async () => {
|
||||
const t = await setup();
|
||||
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'https://www.google.com/x' });
|
||||
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'google.com', vp: 'x' });
|
||||
assert.equal(t.rows().length, 0);
|
||||
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'https://www.google.com/x', vp: 'd' });
|
||||
await t.post({ t: 'cx', site: KEY, path: '/', ref: 'https://www.kunde.de/x', vp: 'm' });
|
||||
const r = t.rows();
|
||||
assert.equal(r.length, 2);
|
||||
assert.equal(r[0].type, 'cx');
|
||||
assert.equal(r[0].ref, 'www.google.com');
|
||||
assert.equal(r[0].vp, 'd');
|
||||
assert.equal(r[0].country, null);
|
||||
assert.equal(r[0].device, null);
|
||||
assert.equal(r[1].ref, null);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('T3: alle Zeitstempel auf die volle Stunde', async () => {
|
||||
const t = await setup();
|
||||
const hour = Date.UTC(2026, 9, 1, 10, 0, 0);
|
||||
await t.post(pv());
|
||||
await t.post({ t: 'cx', site: KEY, path: '/', vp: 'd' });
|
||||
await t.post({ t: 'en', site: KEY, path: '/', vp: 'd', sec: 5, scroll: 10 });
|
||||
await t.post({ t: 'ct', site: KEY, path: '/', kind: 'tel' });
|
||||
await t.post({ t: 'ck', site: KEY, path: '/', vp: 'd', x: 0.5, y: 0.5 });
|
||||
const r = t.rows();
|
||||
assert.deepEqual(r.map((x) => x.type), ['pv', 'cx', 'en', 'ct']);
|
||||
for (const x of r) assert.equal(x.ts, hour);
|
||||
const c = t.db.prepare('SELECT ts FROM clicks').all();
|
||||
assert.equal(c.length, 1);
|
||||
assert.equal(c[0].ts, hour);
|
||||
assert.equal(t.db.prepare('SELECT COUNT(*) n FROM events_raw WHERE ts % 3600000 != 0').get().n, 0);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('T14: kennungsartige Pfadsegmente werden zu :id, alle Typen', async () => {
|
||||
const t = await setup();
|
||||
const cases = [
|
||||
['/kunde/123456789/rechnung', '/kunde/:id/rechnung'],
|
||||
['/u/3f2b8c1e-5d4a-4e0b-9a7c-1b2c3d4e5f60', '/u/:id'],
|
||||
['/mail/max%40example.com', '/mail/:id'],
|
||||
['/mail/max@example.com', '/mail/:id'],
|
||||
['/preise/2026', '/preise/2026'],
|
||||
['/blog/12345', '/blog/12345'],
|
||||
['/', '/'],
|
||||
['/a/123456?x=1#h', '/a/:id'],
|
||||
];
|
||||
for (const [p] of cases) await t.post(pv({ path: p }));
|
||||
assert.deepEqual(t.rows().map((r) => r.path), cases.map((c) => c[1]));
|
||||
await t.post({ t: 'cx', site: KEY, path: '/kunde/987654321', vp: 'd' });
|
||||
await t.post({ t: 'en', site: KEY, path: '/kunde/987654321', vp: 'd', sec: 1, scroll: 1 });
|
||||
await t.post({ t: 'ct', site: KEY, path: '/kunde/987654321', kind: 'tel' });
|
||||
await t.post({ t: 'ck', site: KEY, path: '/kunde/987654321', vp: 'd', x: 0.1, y: 0.1 });
|
||||
const paths = t.rows().slice(cases.length).map((r) => r.path);
|
||||
assert.deepEqual(paths, ['/kunde/:id', '/kunde/:id', '/kunde/:id']);
|
||||
assert.equal(t.db.prepare('SELECT path FROM clicks').get().path, '/kunde/:id');
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('T14: ungültige Prozent-Kodierung fällt auf den Rohwert zurück', async () => {
|
||||
const t = await setup();
|
||||
await t.post(pv({ path: '/a/%E0%A4%A/123456' }));
|
||||
await t.post(pv({ path: '/a/%E0%A4%A/x' }));
|
||||
await t.post(pv({ path: '/a/%E0%A4%A40/x' }));
|
||||
assert.deepEqual(t.rows().map((r) => r.path), ['/a/%E0%A4%A/:id', '/a/%E0%A4%A/x', '/a/%E0%A4%A40/x']);
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('T3: jede gespeicherte Zeile jedes Typs liegt auf der vollen Stunde (ungerade Uhrzeit)', async () => {
|
||||
const t = await setup({ now: () => Date.UTC(2026, 9, 1, 10, 37, 12, 345) });
|
||||
await t.post({ t: 'bn', site: KEY, a: 'shown' });
|
||||
await t.post(pv());
|
||||
await t.post({ t: 'cx', site: KEY, path: '/', vp: 'd' });
|
||||
await t.post({ t: 'en', site: KEY, path: '/', vp: 'd', sec: 5, scroll: 10 });
|
||||
await t.post({ t: 'ct', site: KEY, path: '/', kind: 'tel' });
|
||||
await t.post({ t: 'ck', site: KEY, path: '/', vp: 'd', x: 0.5, y: 0.5 });
|
||||
const r = t.rows();
|
||||
assert.deepEqual([...new Set(r.map((x) => x.type))].sort(), ['bn', 'ct', 'cx', 'en', 'pv']);
|
||||
for (const x of r) assert.equal(x.ts % 3600000, 0);
|
||||
for (const x of t.db.prepare('SELECT ts FROM clicks').all()) assert.equal(x.ts % 3600000, 0);
|
||||
t.close();
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, statSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { openDb } from '../src/db.js';
|
||||
import { runNightly, dailyCutoffDay, DAILY_MONTHS } from '../src/jobs.js';
|
||||
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const NOW = Date.UTC(2026, 9, 1, 10);
|
||||
const D = 86400 * 1000;
|
||||
|
||||
test('dailyCutoffDay: 25 Kalendermonate zurück, Monatsende begrenzt', () => {
|
||||
assert.equal(DAILY_MONTHS, 25);
|
||||
assert.equal(dailyCutoffDay(Date.UTC(2026, 9, 1, 10)), '2024-09-01');
|
||||
assert.equal(dailyCutoffDay(Date.UTC(2026, 2, 31, 10)), '2024-02-29');
|
||||
});
|
||||
|
||||
function mk() {
|
||||
const db = openDb(':memory:');
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '["a.de"]');
|
||||
return db;
|
||||
}
|
||||
|
||||
test('Tageswerte älter als 25 Monate werden gelöscht, Grenztag bleibt', () => {
|
||||
const db = mk();
|
||||
for (const day of ['2024-08-31', '2024-09-01']) {
|
||||
db.prepare("INSERT INTO daily (site, day, dim, key, count) VALUES (?,?, 'pv_total','',3)").run(KEY, day);
|
||||
db.prepare('INSERT INTO agg_state (site, day) VALUES (?,?)').run(KEY, day);
|
||||
}
|
||||
const r = runNightly(db, { now: NOW });
|
||||
assert.deepEqual(db.prepare('SELECT day FROM daily').all().map((x) => x.day), ['2024-09-01']);
|
||||
assert.deepEqual(db.prepare('SELECT day FROM agg_state').all().map((x) => x.day), ['2024-09-01']);
|
||||
assert.equal(r.dailyDeleted, 1);
|
||||
});
|
||||
|
||||
test('ausgeschaltete Website ohne offSince bekommt offSince, zweiter Lauf ändert nichts', () => {
|
||||
const db = mk();
|
||||
db.prepare('UPDATE sites SET active = 0').run();
|
||||
const r = runNightly(db, { now: NOW });
|
||||
assert.equal(db.prepare('SELECT offSince FROM sites').get().offSince, new Date(NOW).toISOString());
|
||||
assert.equal(r.orphansMarked, 1);
|
||||
runNightly(db, { now: NOW + D });
|
||||
assert.equal(db.prepare('SELECT offSince FROM sites').get().offSince, new Date(NOW).toISOString());
|
||||
});
|
||||
|
||||
test('Website mit offSince > 60 Tage wird weiter komplett gelöscht', () => {
|
||||
const db = mk();
|
||||
db.prepare('UPDATE sites SET active = 0, offSince = ?').run(new Date(NOW - 61 * D).toISOString());
|
||||
const r = runNightly(db, { now: NOW });
|
||||
assert.equal(r.sitesDeleted, 1);
|
||||
assert.equal(db.prepare('SELECT COUNT(*) n FROM sites').get().n, 0);
|
||||
});
|
||||
|
||||
test('Zeilen ohne sites-Eintrag werden gelöscht, andere bleiben', () => {
|
||||
const db = mk();
|
||||
const GONE = 'gonegonegonegonegone';
|
||||
for (const s of [KEY, GONE]) {
|
||||
db.prepare("INSERT INTO events_raw (site, ts, type) VALUES (?,?, 'pv')").run(s, NOW - 1000);
|
||||
db.prepare('INSERT INTO clicks (site, ts, path, vp, x, y) VALUES (?,?,?,?,?,?)').run(s, NOW - 1000, '/', 'd', 1, 1);
|
||||
db.prepare("INSERT INTO daily (site, day, dim, key, count) VALUES (?, '2026-09-01','pv_total','',1)").run(s);
|
||||
db.prepare("INSERT INTO agg_state (site, day) VALUES (?, '2026-09-01')").run(s);
|
||||
}
|
||||
const r = runNightly(db, { now: NOW });
|
||||
for (const t of ['events_raw', 'clicks', 'daily', 'agg_state']) {
|
||||
assert.deepEqual(db.prepare(`SELECT DISTINCT site FROM ${t}`).all().map((x) => x.site), [KEY], t);
|
||||
}
|
||||
assert.ok(r.orphanRowsDeleted >= 4);
|
||||
});
|
||||
|
||||
test('secure_delete: gelöschter Pfad steht nicht mehr in der Datei', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sd-'));
|
||||
const p = join(dir, 'a.db');
|
||||
const db = openDb(p);
|
||||
assert.equal(db.prepare('PRAGMA secure_delete').get().secure_delete, 1);
|
||||
db.prepare("INSERT INTO events_raw (site, ts, type, path) VALUES ('x', 1, 'pv', 'GEHEIMER-PFAD-4711')").run();
|
||||
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
db.exec("DELETE FROM events_raw WHERE path = 'GEHEIMER-PFAD-4711'");
|
||||
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
assert.ok(!readFileSync(p).includes('GEHEIMER-PFAD-4711'));
|
||||
db.close();
|
||||
});
|
||||
|
||||
test('runNightly leert das WAL', () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'wal-'));
|
||||
const p = join(dir, 'a.db');
|
||||
const db = openDb(p);
|
||||
db.prepare('INSERT INTO sites (siteKey, projectId, domains, active) VALUES (?,?,?,1)').run(KEY, 'p', '[]');
|
||||
runNightly(db, { now: NOW });
|
||||
assert.equal(statSync(p + '-wal').size, 0);
|
||||
db.close();
|
||||
});
|
||||
@@ -166,3 +166,44 @@ test('Datei-DB: zweimal öffnen (idempotent) und WAL', () => {
|
||||
assert.equal(d2.prepare('PRAGMA journal_mode').get().journal_mode, 'wal');
|
||||
d2.close();
|
||||
});
|
||||
|
||||
test('POST …/off: 401 ohne Token, 404 unbekannt, setzt active=0 und offSince einmalig', async () => {
|
||||
const t = await setup();
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, null)).status, 401);
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`, undefined, 'falsch')).status, 401);
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 404);
|
||||
assert.equal((await t.call('POST', '/a/admin/sites/kurz/off')).status, 400);
|
||||
await t.call('PUT', `/a/admin/sites/${KEY}`, valid);
|
||||
assert.equal(row(t.db).active, 1);
|
||||
const r = await t.call('POST', `/a/admin/sites/${KEY}/off`);
|
||||
assert.equal(r.status, 200);
|
||||
const s1 = row(t.db);
|
||||
assert.equal(s1.active, 0);
|
||||
assert.match(s1.offSince, /^\d{4}-\d\d-\d\dT/);
|
||||
assert.deepEqual(JSON.parse(s1.domains), ['abc.project.webklar.com']);
|
||||
t.db.prepare('UPDATE sites SET offSince = ? WHERE siteKey = ?').run('2026-01-01T00:00:00.000Z', KEY);
|
||||
assert.equal((await t.call('POST', `/a/admin/sites/${KEY}/off`)).status, 200);
|
||||
assert.equal(row(t.db).offSince, '2026-01-01T00:00:00.000Z');
|
||||
t.close();
|
||||
});
|
||||
|
||||
test('POST …/off: ruft onSitesChanged, danach verwirft /a/e Ereignisse und w.js ist leer', async () => {
|
||||
const db = openDb(':memory:');
|
||||
const pub = createPublicServer({ config: {}, db });
|
||||
const adm = createAdminServer({ config: { adminToken: 't' }, db, onSitesChanged: () => pub.invalidateSites() });
|
||||
await Promise.all([pub, adm].map((x) => new Promise((r) => x.listen(0, '127.0.0.1', r))));
|
||||
const pb = `http://127.0.0.1:${pub.address().port}`;
|
||||
const ab = `http://127.0.0.1:${adm.address().port}`;
|
||||
await fetch(`${ab}/a/admin/sites/${KEY}`, { method: 'PUT', headers: { Authorization: 'Bearer t', 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ projectId: 'p', domains: ['abc.project.webklar.com'], active: true }) });
|
||||
const ev = () => fetch(`${pb}/a/e`, { method: 'POST', headers: { 'Content-Type': 'text/plain', Origin: 'https://abc.project.webklar.com', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0) Chrome/120' },
|
||||
body: JSON.stringify({ t: 'pv', site: KEY, path: '/', vp: 'd' }) });
|
||||
await ev();
|
||||
const before = db.prepare('SELECT COUNT(*) c FROM events_raw').get().c;
|
||||
assert.equal(before, 1);
|
||||
assert.equal((await fetch(`${ab}/a/admin/sites/${KEY}/off`, { method: 'POST', headers: { Authorization: 'Bearer t' } })).status, 200);
|
||||
await ev();
|
||||
assert.equal(db.prepare('SELECT COUNT(*) c FROM events_raw').get().c, before);
|
||||
assert.equal((await (await fetch(`${pb}/a/w.js?s=${KEY}`)).text()).length < 200, true);
|
||||
pub.close(); adm.close();
|
||||
});
|
||||
@@ -204,3 +204,34 @@ test('scheduleNightly: früh feuernder Timer läuft nicht vorzeitig und nie dopp
|
||||
s.stop();
|
||||
assert.equal(pending.length, 0);
|
||||
});
|
||||
|
||||
test('T2: pv ohne Einwilligung + cx: pv_total 1, pv_ref nur {google.com:1}; cx zählt nie pv_total', () => {
|
||||
const { db, ev } = mk();
|
||||
const t = NOW - D;
|
||||
ev({ ts: t, type: 'pv', path: '/', vp: null, ref: null, country: 'DE', device: 'd' });
|
||||
ev({ ts: t, type: 'cx', vp: 'd', ref: 'google.com' });
|
||||
ev({ ts: t, type: 'cx', vp: 'd', ref: null });
|
||||
ev({ ts: t, type: 'pv', path: '/', vp: 'd', ref: null, country: 'DE', device: 'd' });
|
||||
runNightly(db, { now: NOW });
|
||||
const d = '2026-10-09';
|
||||
assert.equal(get(db, d, 'pv_total'), 2);
|
||||
assert.equal(get(db, d, 'pv_ref', 'google.com'), 1);
|
||||
assert.equal(get(db, d, 'pv_ref', ''), 2); // pv mit vp und cx ohne ref
|
||||
const refs = db.prepare("SELECT key FROM daily WHERE site=? AND dim='pv_ref' ORDER BY key").all(KEY).map((r) => r.key);
|
||||
assert.deepEqual(refs, ['', 'google.com']);
|
||||
});
|
||||
|
||||
test('T2: Stats-Endpunkt refs live und aus daily, nie doppelt', async () => {
|
||||
const { db, ev } = mk();
|
||||
ev({ ts: NOW - D, type: 'pv', path: '/', vp: null, country: 'DE', device: 'd' });
|
||||
ev({ ts: NOW - D, type: 'cx', vp: 'd', ref: 'google.com' });
|
||||
const a = await admin(db);
|
||||
const live = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
|
||||
assert.equal(live.totals.pv, 1);
|
||||
assert.deepEqual(live.refs, [{ key: 'google.com', count: 1 }]);
|
||||
runNightly(db, { now: NOW });
|
||||
const agg = await (await a.get(`/a/admin/stats/${KEY}?range=7`)).json();
|
||||
assert.equal(agg.totals.pv, 1);
|
||||
assert.deepEqual(agg.refs, [{ key: 'google.com', count: 1 }]);
|
||||
a.close();
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
# Archiv der Analyse-Texte
|
||||
|
||||
Jede ausgelieferte Fassung bleibt hier nachvollziehbar. `BANNER_VERSION` im Client (`analytics/client/core.js`) und `config.analyse.avvVersion` tragen dieselbe Fassung.
|
||||
|
||||
| Fassung | gültig ab | Banner-Text | Zusatzseite |
|
||||
|---|---|---|---|
|
||||
| 2026-10-02 | offen bis Livegang | [banner-2026-10-02.md](banner-2026-10-02.md) (`BANNER_VERSION` 2026-10-02) | [datenschutz-analyse-2026-10-02.html](datenschutz-analyse-2026-10-02.html) |
|
||||
|
||||
AVV und Leistungsbeschreibung je Fassung: `public/rechtliches/avv-website-analyse-<Fassung>.html|pdf` und `leistungsbeschreibung-website-analyse-<Fassung>.html|pdf`. „Gültig ab“ wird beim Livegang eingetragen.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Banner-Text Fassung 2026-10-02
|
||||
|
||||
Quelle: `analytics/client/core.js` (Konstanten `BANNER_*`). Wortgleich zu `banner-portal-mailtexte.md` § 1.
|
||||
|
||||
- `BANNER_VERSION`: 2026-10-02
|
||||
|
||||
## Fließtext (BANNER_TEXT)
|
||||
|
||||
Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: [Datenschutz](/datenschutz-analyse.html)
|
||||
|
||||
## aria-label (BANNER_ARIA)
|
||||
|
||||
Einwilligung zur Reichweitenmessung
|
||||
|
||||
## Schaltflächen
|
||||
|
||||
„Ablehnen“ · „Annehmen“ (gleich gestaltet, Ablehnen zuerst)
|
||||
|
||||
## Einstellungs-Link (SETTINGS_LABEL)
|
||||
|
||||
Datenschutz-Einstellungen
|
||||
@@ -0,0 +1,84 @@
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Datenschutz – Reichweitenmessung</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#111;background:#fff;max-width:46rem;margin:0 auto;padding:1.5rem 1rem 16rem}
|
||||
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2rem}
|
||||
.stand{color:#555;font-size:.9rem}
|
||||
ul{padding-left:1.25rem}
|
||||
a{color:#0b57d0}
|
||||
.box{border:2px solid #111;border-radius:10px;padding:.25rem 1rem;margin:1.5rem 0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="/">← Zur Startseite</a></p>
|
||||
|
||||
<h1>Datenschutz bei der Reichweitenmessung</h1>
|
||||
<p class="stand">Ergänzung zur Datenschutzerklärung dieser Website · Stand: 02.10.2026</p>
|
||||
<p>Diese Seite beschreibt nur die Reichweitenmessung. Alle übrigen Datenschutzhinweise findest Du in der Datenschutzerklärung dieser Website; Impressum und Datenschutzerklärung erreichst Du über die Startseite.</p>
|
||||
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>{{company}}<br>{{#address}}{{address}}<br>{{/address}}E-Mail: <a href="mailto:{{email}}">{{email}}</a></p>
|
||||
<p>Falls es einen Datenschutzbeauftragten gibt, findest Du seine Kontaktdaten in der Datenschutzerklärung dieser Website.</p>
|
||||
|
||||
<h2>Worum es geht</h2>
|
||||
<p>Wir möchten verstehen, wie diese Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir eine eigene Messung ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf einem Server in Deutschland.</p>
|
||||
<p>Damit die Messung arbeiten kann, lädt Dein Browser bei jedem Seitenaufruf ein Skript von einem Server der WEBklar GbR. Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Deine IP-Adresse und Dein Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Du ablehnst oder „Global Privacy Control“ aktiviert hast. Wir speichern und protokollieren beides nicht.</p>
|
||||
|
||||
<h2>Solange Du im Banner nichts ausgewählt hast</h2>
|
||||
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
|
||||
<ul>
|
||||
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
|
||||
<li>das Land, das wir aus Deiner IP-Adresse ableiten,</li>
|
||||
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
|
||||
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
|
||||
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
|
||||
</ul>
|
||||
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Deiner IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Dich wiedererkennen könnten, speichern wir ohne Deine Einwilligung nicht.</p>
|
||||
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
|
||||
|
||||
<h2>Wenn Du „Ablehnen“ wählst</h2>
|
||||
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Dein Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
|
||||
|
||||
<h2>Wenn Du „Annehmen“ wählst</h2>
|
||||
<p>Dann messen wir zusätzlich:</p>
|
||||
<ul>
|
||||
<li>von welcher Website Du gekommen bist (nur der Domainname),</li>
|
||||
<li>die Größenklasse Deines Browserfensters,</li>
|
||||
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Deiner IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
|
||||
<li>wie lange Du auf einer Seite bleibst (ab dem Laden der Seite) und wie weit Du scrollst,</li>
|
||||
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
|
||||
<li>ob Du auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klickst (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
|
||||
<li>einen Zähler dafür, dass Du „Annehmen“ gewählt hast.</li>
|
||||
</ul>
|
||||
<p>Rechtsgrundlage ist Deine Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
|
||||
|
||||
<h2>Was in Deinem Browser gespeichert wird</h2>
|
||||
<p>Wir speichern Deine Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Deines Browsers (Eintrag „wk-consent“), damit wir Dich nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Du kannst den Eintrag jederzeit selbst löschen, indem Du die Website-Daten in Deinem Browser löschst.</p>
|
||||
|
||||
<h2>Speicherdauer</h2>
|
||||
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten, spätestens rund 60 Tage nach dem Ende der Messung. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
|
||||
|
||||
<h2>Empfänger</h2>
|
||||
<p>Die Messung betreibt für uns die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als Auftragsverarbeiter. WEBklar darf die Daten nur auf unsere Weisung und für unsere Zwecke verwenden. Der Server steht bei der Hetzner Online GmbH in Deutschland, die als weiterer Auftragsverarbeiter eingesetzt wird. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
|
||||
|
||||
<h2>Freiwilligkeit</h2>
|
||||
<p>Du bist weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Du ablehnst oder Deine Einwilligung widerrufst, kannst Du die Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
|
||||
|
||||
<h2>Einwilligung widerrufen</h2>
|
||||
<p>Du kannst Deine Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicke auf „Datenschutz-Einstellungen“ (in der Regel unten rechts auf jeder Seite) und wähle „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
|
||||
|
||||
<div class="box">
|
||||
<h2>Widerspruchsrecht</h2>
|
||||
<p>Du kannst der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Deiner besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählst Du im Banner oder über „Datenschutz-Einstellungen“ „Ablehnen“ oder aktivierst in Deinem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Du kannst Deinen Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
|
||||
</div>
|
||||
|
||||
<h2>Deine Rechte</h2>
|
||||
<p>Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Dir zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle des Webservers noch vorhanden sind (14 Tage). Wenn Du uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Du möchtest, Deine IP-Adresse nennst, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Dir das mit.</p>
|
||||
<p>Mit Deinen Anliegen kannst Du Dich jederzeit an die oben genannte Adresse wenden. Außerdem hast Du das Recht, Dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO).</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,47 +1,54 @@
|
||||
# Auftragsverarbeitungsvertrag Website-Analyse (Art. 28 DSGVO)
|
||||
|
||||
> **Entwurf – vor Verwendung anwaltlich prüfen lassen.** Dieser Text ist keine Rechtsberatung. Stellen mit „Prüfpunkt Anwalt“ sind offen.
|
||||
|
||||
**Fassung 2026-10-01**
|
||||
**Fassung 2026-10-02**
|
||||
|
||||
zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).
|
||||
|
||||
Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Er wird vereinbart, wenn der Kunde im Kundenportal die Funktion „Website-Analyse“ einschaltet und dabei die Zustimmung zu dieser Fassung bestätigt (Haken im Portal; das Portal speichert Fassung, Zeitpunkt und Person). Schaltet ein Mitarbeiter von WEBklar die Funktion auf Wunsch des Kunden ein, bestätigt der Mitarbeiter im System, dass der Kunde diese Fassung des Vertrags bestätigt hat. Gespeichert werden die Fassung, der Zeitpunkt und der Name des Mitarbeiters. Bei Zustimmung im Kundenportal wird die Zustimmung dem Kundenkonto zugeordnet.
|
||||
## Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen
|
||||
|
||||
<!-- Prüfpunkt Anwalt P11: Wirksamer Abschluss durch Portal-Haken (Textform, Art. 28 Abs. 9 DSGVO) und mündliche Bestätigung durch Mitarbeiter; Nachweis der Fassung. Reicht die Bestätigung durch den Mitarbeiter (nur Fassung, Zeitpunkt, Name; keine Textform-Bestätigung des Kunden gespeichert) für den Abschluss? -->
|
||||
<!-- Prüfpunkt Anwalt P6: Rollenverteilung. Kunde = Verantwortlicher, WEBklar = Auftragsverarbeiter; keine gemeinsame Verantwortlichkeit (Art. 26), weil WEBklar die Daten nur für den Kunden verwendet. Prüfen, ob die Auswertung in Aggregaten für WEBklar-Produktverbesserung ausgeschlossen bleiben soll (Ja, siehe Nr. 3). -->
|
||||
(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „Website-Analyse“ einschaltet und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Website-Analyse“ in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.
|
||||
|
||||
(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1.
|
||||
|
||||
(3) Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für die Website-Analyse geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung „Website-Analyse“ vor.
|
||||
|
||||
## 1. Gegenstand und Dauer
|
||||
|
||||
(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Die Leistung ist in der Leistungsbeschreibung „Website-Analyse“ beschrieben.
|
||||
(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung mit Einwilligungsbanner („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung „Website-Analyse“.
|
||||
|
||||
(2) Der Vertrag beginnt mit dem Einschalten der Funktion und gilt, solange die Funktion eingeschaltet ist. Er endet mit dem Ausschalten, spätestens mit dem Ende des Wartungs-Abos, und gilt darüber hinaus, bis alle Daten nach Nummer 9 gelöscht sind.
|
||||
(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Ausschalten, spätestens mit dem Ende des Wartungs-Abos; den Messdienst schaltet WEBklar dann spätestens am folgenden Tag ab. Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.
|
||||
|
||||
## 2. Art und Zweck der Verarbeitung
|
||||
|
||||
(1) Art: Erheben über ein Skript, das WEBklar in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.
|
||||
(1) Art: Erheben über ein Skript, das WEBklar beim Veröffentlichen in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.
|
||||
|
||||
(2) Zweck: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Andere Zwecke verfolgt WEBklar mit diesen Daten nicht. Es findet kein Profiling, keine Werbung und keine Zusammenführung mit anderen Daten statt.
|
||||
(2) Zwecke: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Außerdem schützt WEBklar den Messdienst für den Kunden vor Missbrauch und verfälschten Zahlen (Mengenbegrenzung, Erkennen von Bots).
|
||||
|
||||
(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Besucher. Die Besucher-Kennung dient nur dazu, die Besucher eines Tages zu zählen.
|
||||
|
||||
## 3. Datenarten und Betroffene
|
||||
|
||||
(1) Datenarten ohne Einwilligung des Besuchers: Seitenaufruf mit Pfad (ohne Suchparameter), Domainname der verweisenden Website, Land (abgeleitet aus der IP-Adresse; die IP-Adresse wird nur im Moment der Abfrage verwendet und nicht gespeichert), Geräteklasse (Handy, Tablet, Computer) und Größenklasse des Browserfensters (vp), anonyme Zähler zur Anzeige, Annahme und Ablehnung des Banners.
|
||||
(1) Solange der Besucher im Banner keine Wahl getroffen hat und sein Browser kein Signal nach Absatz 2 Satz 2 sendet: Pfad der aufgerufenen Seite (ohne Suchparameter und Anker), auch bei einem Seitenwechsel ohne Neuladen; Land (aus der IP-Adresse abgeleitet); Geräteklasse (Handy, Tablet oder Computer; aus dem Browser-Kennzeichen abgeleitet); Zeitpunkt (auf die volle Stunde gerundet); ein Zähler dafür, dass das Banner angezeigt wurde.
|
||||
|
||||
(2) Zusätzlich nach Annehmen des Banners durch den Besucher: eine täglich wechselnde pseudonyme Besucher-Kennung (nicht über mehrere Tage verknüpfbar), Verweildauer und Scrolltiefe je Seite, Klickpositionen mit vereinfachtem Elementpfad (Heatmap, keine Formulareingaben), Art des Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit Domainname).
|
||||
(2) Nach „Ablehnen“ sendet das Skript einmalig einen Zähler „abgelehnt“ und danach nichts mehr. Sendet der Browser die Signale „Global Privacy Control“ oder „Do Not Track“, zeigt das Skript kein Banner und sendet nichts.
|
||||
|
||||
(3) Nicht gespeichert werden IP-Adressen und Browser-Kennzeichen (User-Agent); sie werden nur kurzzeitig im Arbeitsspeicher verarbeitet (siehe Anlage). Nicht erhoben werden Cookies, Formularinhalte, Seiteninhalte, Mausbewegungen oder Sitzungsaufzeichnungen. Besondere Kategorien nach Art. 9 DSGVO werden nicht gezielt verarbeitet.
|
||||
(3) Nach „Annehmen“ zusätzlich: Domain der Website, von der der Besucher kam; Größenklasse des Browserfensters; eine täglich wechselnde pseudonyme Besucher-Kennung; Verweildauer (ab dem Laden der Seite) und Scrolltiefe je Seite; Klickpositionen mit einem Elementpfad aus Tag-Namen und Position (ohne Texte, Kennungen und Klassennamen); die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp oder Link auf eine andere Website, dann mit deren Domain); ein Zähler „angenommen“.
|
||||
|
||||
(4) Betroffene: Besucher der Website des Kunden.
|
||||
(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Skripts und bei jeder Messanfrage, auch nach „Ablehnen“. Der Messdienst verarbeitet sie nur kurz im Arbeitsspeicher, um Land, Geräteklasse, Bots, die Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung zu bestimmen. Er speichert und protokolliert sie nicht. Nicht erhoben werden Cookies, Formularinhalte, Seitentexte, Mausbewegungen, Tastatureingaben und Sitzungsaufzeichnungen.
|
||||
|
||||
(5) WEBklar verwendet die Daten ausschließlich für den Kunden und gibt sie nicht an Dritte weiter, außer an Unterauftragsverarbeiter nach Nummer 7.
|
||||
(5) Tageswerte sind Summen je Tag und Merkmal (etwa Aufrufe je Seite, je Land oder je Geräteklasse), ohne Besucher-Kennung. Enthalten die Pfade der Website personenbezogene Angaben, können auch Tageswerte personenbezogen sein (Nummer 11 Abs. 2).
|
||||
|
||||
<!-- Prüfpunkt Anwalt P2: Einordnung der Tages-Kennung (pseudonym oder faktisch anonym, EuGH C-413/23 P) und der Klickpositionen; Konsequenzen für Betroffenenrechte (Nr. 8). -->
|
||||
(6) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht gezielt verarbeitet. Je nach Inhalt der Website können sich aus Pfaden oder Domains aber Rückschlüsse ergeben (Nummer 11 Abs. 3).
|
||||
|
||||
(7) Betroffene: Besucher der Website des Kunden.
|
||||
|
||||
## 4. Weisungen
|
||||
|
||||
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, es sei denn, eine gesetzliche Pflicht verlangt etwas anderes. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es erlaubt.
|
||||
(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.
|
||||
|
||||
(2) Die Weisungen ergeben sich aus diesem Vertrag, der Leistungsbeschreibung und den Einstellungen des Kunden im Kundenportal (Einschalten und Ausschalten; die Akzentfarbe des Banners wird beim Einschalten gewählt). Weitere Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin und darf die Ausführung aussetzen.
|
||||
(2) Mit dem Einschalten weist der Kunde WEBklar an, die Website-Analyse in der Standardkonfiguration nach der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Einschalten, Ausschalten, Akzentfarbe des Banners). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die von der Standardkonfiguration abweichen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde die Funktion ausschalten.
|
||||
|
||||
(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.
|
||||
|
||||
## 5. Vertraulichkeit
|
||||
|
||||
@@ -51,82 +58,81 @@ WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder e
|
||||
|
||||
(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.
|
||||
|
||||
(2) Die Maßnahmen der Anlage 2 zu den Abo-Bedingungen gelten ergänzend.
|
||||
(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.
|
||||
|
||||
## 7. Unterauftragsverarbeiter
|
||||
|
||||
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für die Datensicherung. Ort der Verarbeitung ist ein Rechenzentrum in Deutschland (Falkenstein). Die Daten werden nicht in Drittländer übermittelt.
|
||||
(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für dessen Sicherung. Ort der Verarbeitung ist das Rechenzentrum Falkenstein (Deutschland). Eine Übermittlung in Drittländer findet nicht statt.
|
||||
|
||||
(2) Will WEBklar einen Unterauftragsverarbeiter hinzuziehen oder ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann aus wichtigem datenschutzrechtlichem Grund widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt werden soll.
|
||||
(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse ausschalten und WEBklar sie für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Verlangen des Kunden sofort.
|
||||
|
||||
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf dieselben Datenschutzpflichten, die sich aus diesem Vertrag ergeben.
|
||||
(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).
|
||||
|
||||
(4) Die Länderzuordnung nutzt eine lokal gespeicherte Datenbank („DB-IP Lite Country“, Lizenz CC BY 4.0). Dabei werden keine Daten an den Anbieter der Datenbank übermittelt; er ist kein Unterauftragsverarbeiter.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P13: Gültiger Auftragsverarbeitungsvertrag mit Hetzner besteht (laut Datenschutzerklärung ja); Standort Falkenstein bestätigen. P12: Einordnung DB-IP als Datenbank-Lizenz ohne Datenübermittlung. -->
|
||||
(4) Die Länderzuordnung nutzt eine Datenbank, die WEBklar auf dem eigenen Server speichert („IP to Country Lite“ von DB-IP.com, Lizenz CC BY 4.0). Daten der Besucher werden dabei nicht an DB-IP übermittelt. DB-IP ist kein Unterauftragsverarbeiter.
|
||||
|
||||
## 8. Unterstützung des Kunden, Betroffenenrechte
|
||||
|
||||
(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter und unterstützt ihn bei der Beantwortung, soweit das mit den eingesetzten Systemen möglich ist.
|
||||
(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.
|
||||
|
||||
(2) Die gespeicherten Daten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die über einen Tag hinaus gilt. WEBklar kann einzelne Besucher daher in der Regel nicht identifizieren und Auskunfts-, Berichtigungs- oder Löschverlangen zu einzelnen Besuchen nicht umsetzen, solange die betroffene Person keine zusätzlichen Angaben bereitstellt (Art. 11 DSGVO). Der Kunde weist Betroffene darauf in seiner Datenschutzerklärung hin.
|
||||
(2) Die gespeicherten Messdaten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die länger als einen Tag gilt; Zeitpunkte sind auf die volle Stunde gerundet. Einzelne Besucher kann WEBklar daher in der Regel nicht identifizieren (Art. 11 DSGVO). Ein Abgleich kann aber möglich sein, solange die Zugriffsprotokolle der Website (Anlage 1 und 2 der Abo-Bedingungen, 14 Tage) vorhanden sind. Nennt eine betroffene Person zusätzliche Angaben, etwa Datum, Uhrzeit, besuchte Seiten oder ihre IP-Adresse, prüft WEBklar auf Weisung des Kunden, auch anhand dieser Protokolle, ob eine Zuordnung möglich ist, und teilt ihm das Ergebnis mit.
|
||||
|
||||
(3) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO.
|
||||
(3) Der Messung können Besucher technisch widersprechen (Art. 21 DSGVO): Nach „Ablehnen“ und bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“ sendet das Skript nichts mehr.
|
||||
|
||||
(4) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
|
||||
(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht, und eine kurze Interessenabwägung für die Zählung nach Art. 6 Abs. 1 lit. f DSGVO, die WEBklar mit jeder Fassung dieses Vertrags bereitstellt.
|
||||
|
||||
(5) Der Kunde ist dafür verantwortlich, dass die Besucher seiner Website informiert werden (Art. 13 DSGVO). WEBklar stellt dafür die Zusatzseite „/datenschutz-analyse.html“ bereit. Der Kunde ergänzt seine eigene Datenschutzerklärung um einen Hinweis darauf.
|
||||
(5) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P18: Betroffenenrechte bei nicht identifizierbaren Daten (Art. 11, Art. 12 Abs. 2); Formulierung von Absatz 2. P15: Reicht die Zusatzseite für Art. 13, wenn der Kunde seine Datenschutzerklärung nicht anpasst? -->
|
||||
(6) Zum Nachweis der Einwilligungen bewahrt WEBklar jede Fassung des Banner-Textes und der Zusatzseite mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende der Website-Analyse. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung. Ein Protokoll je Besucher führt WEBklar nicht.
|
||||
|
||||
(7) Die Besucher informiert der Kunde (Art. 13 DSGVO). WEBklar stellt ihm dafür die Zusatzseite „/datenschutz-analyse.html“ als Muster bereit. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar dort einen Hinweis auf die Zusatzseite ein; sonst erhält der Kunde den Hinweis als Textbaustein (Leistungsbeschreibung Nr. 4 Abs. 4).
|
||||
|
||||
## 9. Löschung und Rückgabe
|
||||
|
||||
(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Zusammengefasste Tageswerte ohne Besucher-Kennung bleiben bis zum Ende der Website-Analyse (Ausschalten oder Ende des Wartungs-Abos) und weitere 60 Tage gespeichert; danach löscht WEBklar alle Daten der Website. Eine Kopie der Tageswerte stellt WEBklar dem Kunden auf Verlangen in Textform innerhalb dieser Frist zur Verfügung.
|
||||
(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Der Löschlauf läuft täglich, die Löschung erfolgt also in der Regel spätestens am 31. Tag; fällt ein Lauf aus, holt der nächste ihn nach.
|
||||
|
||||
(2) Ob und wie lange Sicherungskopien Daten der Website-Analyse enthalten, ist noch zu klären (Prüfpunkt P8, siehe Anlage); soweit sie enthalten sind, werden sie im Rahmen der regulären Rotation überschrieben.
|
||||
(2) Tageswerte löscht WEBklar 25 Monate nach dem jeweiligen Tag. Nach dem Ende der Website-Analyse (Ausschalten oder Ende des Wartungs-Abos) löscht WEBklar alle Daten der Website am 61. Tag. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse verlangen; WEBklar stellt sie innerhalb von zwei Wochen als CSV-Datei bereit. Auf Weisung des Kunden löscht WEBklar die Daten früher.
|
||||
|
||||
(3) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
|
||||
(3) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P9: Aufbewahrung der Tageswerte bis Abo-Ende + 60 Tage (Erforderlichkeit, Art. 28 Abs. 3 lit. g DSGVO: Löschung "nach Abschluss der Erbringung"); ggf. kürzer, falls Tageswerte nicht als personenbezogen gelten. P8: Sicherungen (siehe Anlage). -->
|
||||
(4) Gesetzliche Aufbewahrungspflichten bleiben unberührt.
|
||||
|
||||
## 10. Nachweise und Kontrollen
|
||||
|
||||
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Prüfpunkt P13). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
|
||||
WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.
|
||||
|
||||
## 11. Pflichten des Kunden
|
||||
|
||||
Der Kunde ist Verantwortlicher für die Verarbeitung. Er sorgt dafür, dass das Banner nicht entfernt oder verdeckt wird, dass keine weiteren Messwerkzeuge ohne Abstimmung eingebunden werden, und dass seine Datenschutzerklärung auf die Zusatzseite hinweist (Leistungsbeschreibung „Website-Analyse“).
|
||||
(1) Der Kunde ist Verantwortlicher für die Messung. Er entfernt, verdeckt oder umgeht das Banner nicht. Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess- oder Tracking-Werkzeuge ein.
|
||||
|
||||
## 12. Haftung und Schlussbestimmungen
|
||||
(2) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.
|
||||
|
||||
(1) Für die Haftung gilt Art. 82 DSGVO. Im Übrigen gelten die Haftungsregeln der Abo-Bedingungen.
|
||||
(3) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren oder lassen ihre Inhalte auf besondere Kategorien personenbezogener Daten schließen (etwa Gesundheit, Religion oder politische Meinung), gilt Nr. 3 Abs. 4 der Leistungsbeschreibung.
|
||||
|
||||
(2) Bei Widersprüchen geht dieser Vertrag in Datenschutzfragen den Abo-Bedingungen und der Leistungsbeschreibung vor.
|
||||
## 12. Haftung, Änderungen, Schlussbestimmungen
|
||||
|
||||
(3) Änderungen dieses Vertrags richten sich nach § 15 der Abo-Bedingungen; eine neue Fassung wird erst wirksam, wenn der Kunde sie im Portal bestätigt hat.
|
||||
(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.
|
||||
|
||||
(4) Es gilt deutsches Recht.
|
||||
(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P10: Haftungsverweis auf Abo-Bedingungen und Änderungsmechanismus (AGB-Kontrolle). -->
|
||||
(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar die Website-Analyse für seine Website nach Nr. 8 Abs. 2 der Leistungsbeschreibung beenden oder aussetzen. Das Wartungs-Abo bleibt davon unberührt.
|
||||
|
||||
(4) Im Übrigen gilt § 16 der Abo-Bedingungen.
|
||||
|
||||
## Anlage: Technische und organisatorische Maßnahmen der Website-Analyse (Art. 32 DSGVO)
|
||||
|
||||
| Bereich | Maßnahmen |
|
||||
|---|---|
|
||||
| Übertragung | Skript und Messdaten werden nur über TLS (HTTPS) übertragen. |
|
||||
| Keine Cookies | Es werden keine Cookies gesetzt. Auf dem Gerät des Besuchers wird nur die Banner-Auswahl im lokalen Speicher (Eintrag „wk-consent“) gespeichert; sie gilt sechs Monate. |
|
||||
| Datenminimierung | IP-Adresse und Browser-Kennzeichen werden nicht gespeichert und nicht protokolliert. Die IP-Adresse wird nur im Moment der Abfrage verwendet für: Länderzuordnung, Schlüssel der Mengenbegrenzung (Hash mit Tagessalz, nur im Arbeitsspeicher) und, nur nach Einwilligung, die tägliche Besucher-Kennung. Das Browser-Kennzeichen wird nur im Moment der Abfrage verwendet für: Geräteklasse, Bot-Erkennung und, nur nach Einwilligung, die tägliche Besucher-Kennung. Pfade ohne Suchparameter, Elementpfade ohne Kennungen und Klassennamen. Keine Formulareingaben. |
|
||||
| Pseudonymisierung | Die Besucher-Kennung (nur nach Einwilligung) ist ein gekürzter Hash mit Tagessalz. Das Salz liegt nur im Arbeitsspeicher und wird täglich um 00:00 Uhr (Europe/Berlin) neu erzeugt; ältere Kennungen sind danach nicht mehr verknüpfbar. |
|
||||
| Einwilligung | Ohne „Annehmen“ sendet das Skript nur Seitenaufruf und Banner-Zähler. „Global Privacy Control“ und „Do Not Track“ gelten als Ablehnen. |
|
||||
| Zugriffskontrolle | Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem getrennten, internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff nur über das Kundenportal und die Verwaltung von WEBklar. Im Portal sieht jeder Kunde nur die Daten seiner eigenen Website. |
|
||||
| Eingangsprüfung | Messdaten werden nur angenommen, wenn die Website registriert ist und Origin bzw. Referer zu ihren Domains passen; Bots werden verworfen; Größen- und Mengenbegrenzung je Website und Anfrager (nur im Arbeitsspeicher). |
|
||||
| Heatmap-Zugriff | Die Heatmap wird über einen signierten Link abgerufen, der 30 Minuten gültig ist. |
|
||||
| Löschung | Täglicher automatischer Job (02:30 Uhr, Europe/Berlin): Rohdaten und Klicks nach 30 Tagen; Websites, die seit mehr als 60 Tagen abgeschaltet sind, werden vollständig gelöscht. |
|
||||
| Trennung | Daten sind je Website getrennt (Website-Schlüssel); eigener Container und eigenes Datenverzeichnis des Messdienstes; kein Zugriff auf andere Kundendaten. |
|
||||
| Rechenzentrum | Hetzner Online GmbH, Falkenstein (Deutschland). **[Prüfpunkt: Zertifizierung des Rechenzentrums (ISO/IEC 27001) vor Verwendung belegen, bevor sie genannt wird.]** |
|
||||
| Protokolle des Reverse-Proxys | Das Zugriffsprotokoll für die Messdienst-Adresse wird ohne Client-IP geführt. **[Prüfpunkt P7: Einstellung beim Livegang verifizieren.]** |
|
||||
| Datensicherung | Die tägliche Serversicherung (7 Tage) erfasst voraussichtlich auch das Datenverzeichnis des Messdienstes. **[Prüfpunkt P8: ob das Verzeichnis in der Sicherung enthalten ist und wie lange dort gelöschte Daten verbleiben, ist noch zu klären.]** |
|
||||
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nr. 8 Abs. 4), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
|
||||
|
||||
<!-- Prüfpunkt Anwalt P7 und P8: siehe Tabelle (Reverse-Proxy-Protokoll, Sicherung). -->
|
||||
<!-- Prüfpunkt Anwalt P4: Einwilligungsnachweis ist nur technisch (ohne Annehmen sendet das Skript nachweislich nichts Weiteres); es gibt kein Einwilligungsprotokoll je Besucher. Reicht das für Art. 7 Abs. 1 DSGVO? -->
|
||||
| Speicher im Browser | Es werden keine Cookies gesetzt, und der Messdienst wertet keine Cookies aus. Messanfragen sendet das Skript ohne Cookies. Im lokalen Speicher des Browsers liegt nur die Banner-Wahl (Eintrag „wk-consent“ mit Wahl, Zeitpunkt und Fassung des Banner-Textes). Sie gilt 183 Tage; danach und bei einer neuen Fassung des Banner-Textes fragt das Banner erneut. |
|
||||
| Einwilligung | Solange keine Wahl getroffen ist und kein Browser-Signal vorliegt, sendet das Skript nur den Pfad der Seite, auch beim Seitenwechsel ohne Neuladen, und den Zähler „Banner angezeigt“. Nach „Ablehnen“ sendet es einmalig den Zähler „abgelehnt“ und danach nichts mehr, auch nicht aus anderen geöffneten Tabs. Bei „Global Privacy Control“ oder „Do Not Track“ zeigt es kein Banner und sendet nichts. Klick-, Scroll- und Verweildauer-Ereignisse registriert es erst nach „Annehmen“. Die Schaltflächen „Ablehnen“ und „Annehmen“ sind gleich gestaltet. |
|
||||
| Datenminimierung | IP-Adresse und Browser-Kennzeichen werden weder gespeichert noch protokolliert. Der Messdienst verwendet sie nur im Arbeitsspeicher für Land, Geräteklasse, Bot-Erkennung, Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung. Zeitpunkte werden auf die volle Stunde gerundet gespeichert. Pfade werden ohne Suchparameter und Anker gespeichert; Abschnitte, die wie Kennungen aussehen (lange Ziffernfolgen, UUIDs, E-Mail-Adressen), werden vorher ersetzt. Elementpfade bestehen nur aus Tag-Namen und Position. Formulareingaben und Seitentexte werden nicht erfasst. |
|
||||
| Pseudonymisierung | Die Besucher-Kennung (nur nach Einwilligung) ist ein auf 64 Bit gekürzter HMAC-SHA256 aus Website-Schlüssel, IP-Adresse und Browser-Kennzeichen mit einem Tagesschlüssel. Auch der Schlüssel der Mengenbegrenzung wird mit dem Tagesschlüssel gebildet. Der Tagesschlüssel liegt nur im Arbeitsspeicher; die Auslagerung auf die Festplatte ist für den Messdienst gesperrt. Er wird beim ersten Ereignis eines neuen Kalendertags (Europe/Berlin) und bei jedem Neustart neu erzeugt und nie gesichert. Ältere Kennungen lassen sich danach nicht mehr zuordnen. |
|
||||
| Zugriffskontrolle | Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff haben nur das Kundenportal und die Verwaltung von WEBklar. Das Portal zeigt jedem Kunden nur die Daten seiner eigenen Websites. |
|
||||
| Heatmap | Die Heatmap öffnet ein signierter Link, den nur der angemeldete Kunde im Kundenportal erzeugen kann. Der Link gilt 30 Minuten und liefert nur Klickpunkte der gewählten Seite und Ansicht, ohne Zeitpunkte und Kennungen. |
|
||||
| Eingangsprüfung | Messdaten werden nur angenommen, wenn die Website registriert und eingeschaltet ist und Origin bzw. Referer zu ihren Domains passen. Bots werden verworfen. Größe und Menge der Anfragen sind je Website und Absender begrenzt (Zähler nur im Arbeitsspeicher). Für ausgeschaltete Websites liefert der Messdienst ein leeres Skript aus. |
|
||||
| Löschung | Täglicher Löschlauf um 02:30 Uhr (Europe/Berlin): Ereignisse und Klicks nach 30 Tagen, Tageswerte nach 25 Monaten, alle Daten einer Website 60 Tage nach dem Ausschalten. Gelöschte Datensätze werden in der Datenbankdatei überschrieben und aus dem Schreibprotokoll (WAL) entfernt. |
|
||||
| Trennung | Der Messdienst läuft in einem eigenen Container mit eigenem Datenverzeichnis und hat keinen Zugriff auf andere Kundendaten. Alle Datensätze tragen den Website-Schlüssel; Abfragen erfolgen nur je Website. |
|
||||
| Protokolle | Der Reverse-Proxy führt kein Zugriffsprotokoll, und der Messdienst protokolliert keine Anfragen. Wird ein Zugriffsprotokoll eingeschaltet, dann nicht für die Adresse des Messdienstes. Meldungen der Container werden nach 14 Tagen gelöscht. |
|
||||
| Datensicherung | Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). Einen gesonderten Datenbank-Export des Messdienstes gibt es nicht. |
|
||||
| Rechenzentrum | Hetzner Online GmbH, Rechenzentrum Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren. |
|
||||
| Organisation | Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 5), Archiv der Fassungen von Banner-Text und Zusatzseite (Nummer 8 Abs. 6), Überprüfung der Maßnahmen mindestens einmal im Jahr. |
|
||||
@@ -1,75 +1,107 @@
|
||||
# Leistungsbeschreibung Website-Analyse
|
||||
|
||||
> **Entwurf – vor Verwendung anwaltlich prüfen lassen.** Dieser Text ist keine Rechtsberatung.
|
||||
**Fassung 2026-10-02** · Zusatzbedingungen zum Wartungs-Abo
|
||||
|
||||
**Fassung 2026-10-01** · Teil der Abo-Bedingungen (Wartungs-Abo)
|
||||
## 1. Geltung
|
||||
|
||||
## 1. Leistung
|
||||
(1) Diese Leistungsbeschreibung regelt die Funktion „Website-Analyse“ im Wartungs-Abo. Sie wird vereinbart, wenn der Kunde die Funktion einschaltet und ihr zusammen mit dem Auftragsverarbeitungsvertrag Website-Analyse („AVV Website-Analyse“) zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Website-Analyse.
|
||||
|
||||
(1) Die Website-Analyse misst, wie die öffentliche Website des Kunden genutzt wird. Sie ist im Wartungs-Abo ohne Aufpreis enthalten und wird vom Kunden im Kundenportal eingeschaltet.
|
||||
(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für die Website-Analyse diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Website-Analyse.
|
||||
|
||||
(2) WEBklar fügt beim Veröffentlichen der Website ein Messskript und ein Cookie-Banner (Leiste unten, „Ablehnen“ und „Annehmen“ gleichwertig, Akzentfarbe, die beim Einschalten gewählt wird, Link „Cookie-Einstellungen“) ein und erzeugt eine Zusatzseite „/datenschutz-analyse.html“ mit den Datenschutzhinweisen zur Messung (Verantwortlicher: der Kunde).
|
||||
(3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für die Zusatzseite nach Nr. 2 Abs. 2 und den Hinweis in der Datenschutzerklärung nach Nr. 4 Abs. 4. Beides ist Teil der Website-Analyse.
|
||||
|
||||
## 2. Leistung
|
||||
|
||||
(1) Die Website-Analyse misst, wie die öffentliche Website des Kunden genutzt wird. Sie ist im Wartungs-Abo ohne Aufpreis enthalten.
|
||||
|
||||
(2) Beim Veröffentlichen der Website fügt WEBklar Folgendes ein:
|
||||
- ein Messskript in jede HTML-Datei der Website,
|
||||
- ein Einwilligungsbanner am unteren Rand mit den gleich gestalteten Schaltflächen „Ablehnen“ und „Annehmen“ in der Akzentfarbe, die der Kunde beim Einschalten wählt (ist sie für weiße Schrift zu hell, nimmt das Banner Schwarz),
|
||||
- eine Schaltfläche „Datenschutz-Einstellungen“ unten rechts, über die Besucher ihre Wahl jederzeit ändern können (hat die Website ein eigenes Element mit dem Attribut `data-webklar-consent`, übernimmt dieses Element die Aufgabe),
|
||||
- eine Zusatzseite „/datenschutz-analyse.html“ mit den Datenschutzhinweisen zur Messung (Verantwortlicher: der Kunde).
|
||||
|
||||
(3) Gemessen wird:
|
||||
- **ohne Einwilligung des Besuchers:** Seitenaufrufe mit Pfad (ohne Suchparameter), Domainname der verweisenden Website, Land (die IP-Adresse wird nur im Moment der Abfrage verwendet und nicht gespeichert), Geräteklasse sowie anonyme Banner-Zähler;
|
||||
- **nach „Annehmen“ zusätzlich:** eine täglich wechselnde pseudonyme Besucher-Kennung, Verweildauer, Scrolltiefe, Klickpositionen (Heatmap) und die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website).
|
||||
- **solange der Besucher keine Wahl getroffen hat und sein Browser kein Signal nach dem nächsten Spiegelstrich sendet:** Seitenaufrufe mit Pfad (ohne Suchparameter), Land (aus der IP-Adresse, die nicht gespeichert wird), Geräteklasse und ein Zähler dafür, dass das Banner angezeigt wurde;
|
||||
- **bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“:** nichts, und es erscheint kein Banner;
|
||||
- **nach „Ablehnen“:** nichts mehr, außer einmal dem Zähler „abgelehnt“;
|
||||
- **nach „Annehmen“ zusätzlich:** die Domain der Website, von der der Besucher kam, die Größenklasse des Browserfensters, eine täglich wechselnde pseudonyme Besucher-Kennung, Verweildauer, Scrolltiefe, Klickpositionen (Heatmap), die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit deren Domain) und ein Zähler „angenommen“.
|
||||
|
||||
(4) Es werden keine Cookies gesetzt. Gespeichert wird auf dem Gerät des Besuchers nur dessen Banner-Auswahl im lokalen Speicher des Browsers (sechs Monate). Signale „Global Privacy Control“ und „Do Not Track“ gelten als Ablehnen.
|
||||
Die einzelnen Datenarten nennt der AVV Website-Analyse in Nummer 3.
|
||||
|
||||
(5) Die Auswertung (Kennzahlen, Diagramme, Heatmap) steht im Kundenportal zur Verfügung. Die Daten liegen auf Servern der Hetzner Online GmbH in Deutschland; es erfolgt keine Übermittlung in Drittländer. Rohdaten und Klicks werden nach 30 Tagen gelöscht, Tageswerte bis zum Ende der Analyse und weitere 60 Tage.
|
||||
(4) Es werden keine Cookies gesetzt. Gespeichert wird auf dem Gerät des Besuchers nur seine Banner-Wahl im lokalen Speicher des Browsers, für 183 Tage.
|
||||
|
||||
(6) Nicht enthalten sind Sitzungsaufzeichnungen, Auswertung von Formularinhalten, Messung von Ladezeiten und Fehlern, Kampagnen- und Trichteranalyse sowie ein Datenexport (außer nach Nr. 6 Abs. 2).
|
||||
(5) Die Auswertung steht im Kundenportal zur Verfügung: Kennzahlen und Diagramme für die letzten 7, 30 oder 90 Tage und eine Heatmap je Seite und Ansicht (Handy, Tablet, Computer) aus den bis zu 5.000 jüngsten Klicks der letzten 30 Tage. Die Daten liegen auf dem Server von WEBklar bei der Hetzner Online GmbH in Deutschland; sie werden nicht in Drittländer übermittelt. Einzelne Ereignisse und Klicks werden nach 30 Tagen gelöscht, Tageswerte nach 25 Monaten, alle Daten rund 60 Tage nach dem Ende der Website-Analyse (AVV Website-Analyse Nummer 9).
|
||||
|
||||
## 2. Voraussetzungen
|
||||
(6) Die Zahlen sind Näherungswerte. Besucher mit Blockern, abgeschaltetem JavaScript, Browser-Signal oder Ablehnung werden nur eingeschränkt oder gar nicht erfasst. Besucherzahlen, Herkunftsseiten, Verweildauer, Scrolltiefe, Klicks und Kontakt-Klicks stammen nur von Besuchern, die „Annehmen“ gewählt haben.
|
||||
|
||||
(1) Ein aktives Wartungs-Abo. Im Hosting-Abo ist die Website-Analyse nicht enthalten.
|
||||
(7) Nicht enthalten sind Sitzungsaufzeichnungen, die Auswertung von Formularinhalten, die Messung von Ladezeiten und Fehlern, Kampagnen- und Trichteranalysen sowie ein Datenexport außer nach Nr. 7 Abs. 3.
|
||||
|
||||
(2) Die Website ist öffentlich erreichbar und wird bei WEBklar gehostet (Adresse unter project.webklar.com oder eigene Domain, die auf den Server von WEBklar zeigt). Login-geschützte Vorschauseiten werden nicht gemessen.
|
||||
(8) Das Banner fragt nur nach der Einwilligung in die Website-Analyse von WEBklar. Andere einwilligungspflichtige Inhalte der Website, etwa eingebettete Karten, Videos oder Schriften von Drittanbietern, deckt es nicht ab.
|
||||
|
||||
(3) Der Kunde stimmt beim Einschalten dem Auftragsverarbeitungsvertrag Website-Analyse in der aktuellen Fassung zu.
|
||||
## 3. Voraussetzungen
|
||||
|
||||
## 3. Pflichten des Kunden
|
||||
(1) Der Kunde hat ein laufendes Wartungs-Abo. Im Hosting-Abo ist die Website-Analyse nicht enthalten.
|
||||
|
||||
(1) Der Kunde ist Verantwortlicher im Sinne der DSGVO für die Messung auf seiner Website.
|
||||
(2) Die Website ist öffentlich erreichbar und wird bei WEBklar gehostet (Adresse unter project.webklar.com oder eigene Domain, die auf den Server von WEBklar zeigt). Nicht öffentliche Websites und Vorschauen, die nur nach einer Anmeldung erreichbar sind, werden nicht gemessen, ebenso wenig Websites, die im Dev-Modus auf einem Entwicklungsserver laufen. Enthält eine öffentliche Website Bereiche, die erst nach einer Anmeldung zugänglich sind (etwa eine Web-Anwendung mit Kundenkonto), werden auch dort Seitenaufrufe gezählt; dafür gilt Nr. 4 Abs. 5.
|
||||
|
||||
(2) Er entfernt, verdeckt oder umgeht das Banner nicht und verändert das Skript nicht. Er verändert den Banner-Text oder die Gestaltung nur nach Abstimmung mit WEBklar.
|
||||
(3) Auf der Zusatzseite erscheinen als Verantwortlicher die Firma (sonst der Name), die Anschrift, soweit sie im Kundenkonto hinterlegt ist, und die E-Mail-Adresse aus dem Kundenkonto. Das Portal zeigt diese Angaben vor dem Einschalten an. Der Kunde prüft sie und teilt Änderungen mit. Fehlen Firma und Name oder fehlt eine gültige E-Mail-Adresse, lässt sich die Funktion nicht einschalten.
|
||||
|
||||
(3) Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess-, Tracking- oder Marketing-Werkzeuge ein (zum Beispiel Google Analytics, Meta Pixel), weil sie eine eigene Einwilligung und eine Anpassung des Banners erfordern können.
|
||||
(4) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren, schaltet der Kunde die Website-Analyse nicht ein, weil eine Einwilligung über das Banner dann nicht genügt (Art. 8 DSGVO). Lassen Inhalte oder Adressen der Website auf besondere Kategorien personenbezogener Daten schließen, etwa Gesundheit, Religion oder politische Meinung, teilt der Kunde das WEBklar vor dem Einschalten mit. WEBklar darf das Einschalten für eine solche Website ablehnen oder die Website-Analyse dort ausschalten.
|
||||
|
||||
(4) Er pflegt seine Datenschutzerklärung und ergänzt sie um einen Hinweis auf die Zusatzseite „/datenschutz-analyse.html“. Beim Einschalten legt WEBklar dafür ein Ticket an. Der Kunde prüft die Angaben zu Firma, Anschrift und E-Mail-Adresse, die auf der Zusatzseite erscheinen, und teilt Änderungen mit.
|
||||
## 4. Pflichten des Kunden
|
||||
|
||||
(5) Er richtet die Website nicht gezielt an Kinder und verarbeitet über die Website keine besonderen Kategorien personenbezogener Daten in einer Weise, die die Messung berührt, ohne vorher mit WEBklar zu sprechen.
|
||||
(1) Der Kunde ist für die Messung auf seiner Website Verantwortlicher im Sinne der DSGVO.
|
||||
|
||||
(6) Er nutzt die Auswertung nur für eigene Zwecke und versucht nicht, einzelne Besucher zu identifizieren.
|
||||
(2) Er entfernt, verdeckt oder umgeht das Banner nicht und verändert das Skript nicht. Text und Gestaltung des Banners legt WEBklar fest.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P19: Ausschluss von Websites mit Kinderzielgruppe; Datenschutz-Folgenabschätzung (laut Recherche meist nicht nötig). -->
|
||||
(3) Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess-, Tracking- oder Marketing-Werkzeuge ein (zum Beispiel Google Analytics oder Meta Pixel), weil sie eine eigene Einwilligung erfordern können.
|
||||
|
||||
## 4. Verfügbarkeit
|
||||
(4) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einschalten dort einen kurzen Hinweis mit Link auf die Zusatzseite ein und entfernt ihn beim Ausschalten wieder; das wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Hinweis als Textbaustein und fügt ihn selbst ein und entfernt ihn nach dem Ausschalten selbst. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf (etwa „Wir setzen keine Analyse-Werkzeuge ein“), weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.
|
||||
|
||||
(1) Für die Website-Analyse gilt keine Verfügbarkeitszusage. Ausfälle, Wartung oder Fehler der Messung können zu Lücken in den Daten führen; ein Anspruch auf nachträgliche Messung besteht nicht.
|
||||
(5) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.
|
||||
|
||||
(2) Ein Ausfall der Website-Analyse beeinträchtigt die Website des Kunden nicht. Die Website wird weiter ausgeliefert, nur ohne Messung.
|
||||
(6) Er nutzt die Auswertung nur für eigene Zwecke, versucht nicht, einzelne Besucher zu identifizieren, und gibt Heatmap-Links nicht weiter.
|
||||
|
||||
(3) Die Zahlen sind Näherungswerte. Besucher mit Blockern, deaktivierten Skripten oder abgelehnter Einwilligung werden nur eingeschränkt oder nicht erfasst. Zahlen zu Besuchern, Verweildauer, Klicks und Kontakt-Klicks stammen nur von Besuchern, die „Annehmen“ gewählt haben.
|
||||
## 5. Verfügbarkeit und Störungen
|
||||
|
||||
<!-- Prüfpunkt Anwalt P10: Haftungsausschluss/Verfügbarkeit in AGB (§§ 307 ff. BGB), Zusammenspiel mit § 5 und § 13 der Abo-Bedingungen. -->
|
||||
(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für die Messung. Störungen der Messung beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen). Daten, die während einer Störung nicht erfasst wurden, lassen sich nicht nachträglich erheben.
|
||||
|
||||
## 5. Ende der Leistung
|
||||
(2) Fällt der Messdienst aus, wird die Website weiter ausgeliefert, nur ohne Banner und Messung.
|
||||
|
||||
(1) Der Kunde kann die Website-Analyse jederzeit im Kundenportal ausschalten. Mit Ende des Wartungs-Abos (auch bei Wechsel ins Hosting-Abo) endet die Website-Analyse automatisch.
|
||||
(3) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.
|
||||
|
||||
(2) Mit dem Abschalten werden keine Messdaten mehr gespeichert und die Heatmap ist nicht mehr abrufbar. Skript, Banner und Zusatzseite bleiben auf der Website sichtbar, bis die Website das nächste Mal veröffentlicht wird; dann entfernt WEBklar sie. Bei Websites ohne automatische Veröffentlichung geschieht das erst bei einer manuellen Veröffentlichung. WEBklar informiert den Kunden per E-Mail über die Abschaltung.
|
||||
## 6. Hinweis zur Rechtslage
|
||||
|
||||
(3) Die gespeicherten Daten der Website löscht WEBklar 60 Tage nach der Abschaltung (Einzelheiten im Auftragsverarbeitungsvertrag). Der Kunde ergänzt seine Datenschutzerklärung nach der Abschaltung entsprechend.
|
||||
Für die Zählung, solange der Besucher keine Wahl getroffen hat (Nr. 2 Abs. 3), übermittelt das Skript nur die Adresse der aufgerufenen Seite und die Angabe, dass das Banner angezeigt wurde. Land und Geräteklasse bestimmt der Messdienst aus den Daten, die jeder Browser bei einer Anfrage ohnehin mitsendet. Ob auch dafür eine Einwilligung nach § 25 TDDDG nötig ist, haben Gerichte und Aufsichtsbehörden nicht entschieden; die Aufsichtsbehörden legen den Begriff des Zugriffs auf das Endgerät eher weit aus. Ändert sich die Rechtslage, passt WEBklar die Messung an (Nr. 8).
|
||||
|
||||
## 6. Hinweise
|
||||
## 7. Ende der Leistung
|
||||
|
||||
(1) **Keine Rechtsberatung.** Die Zusatzseite und die Hinweise von WEBklar sind Muster und Unterstützung. Sie ersetzen keine Rechtsberatung. WEBklar schuldet keine Prüfung, ob die Website des Kunden insgesamt datenschutzkonform ist. Der Kunde lässt seine Datenschutzerklärung bei Bedarf rechtlich prüfen.
|
||||
(1) Der Kunde kann die Website-Analyse jederzeit im Kundenportal ausschalten; das Portal bestätigt die Abschaltung. Mit dem Ende des Wartungs-Abos endet die Website-Analyse; WEBklar schaltet sie spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf eines Wechsels ins Wartungs-Abo.
|
||||
|
||||
(2) Auf Verlangen in Textform stellt WEBklar dem Kunden vor der Löschung eine Kopie der Tageswerte zur Verfügung.
|
||||
(2) Mit dem Ausschalten speichert der Messdienst keine Messdaten mehr, und die Heatmap ist nicht mehr abrufbar. Das Messskript liefert dann kein Banner mehr aus, sobald die Website einmal mit der aktuellen Fassung des Skripts veröffentlicht wurde. Skript, Zusatzseite und, soweit WEBklar ihn eingefügt hat, den Hinweis in der Datenschutzerklärung entfernt WEBklar mit der nächsten Veröffentlichung der Website. WEBklar löst sie in der Regel sofort aus, spätestens innerhalb von fünf Werktagen.
|
||||
|
||||
(3) Die Länderzuordnung nutzt die Datenbank „DB-IP Lite Country“ von DB-IP.com (CC BY 4.0).
|
||||
(3) Die gespeicherten Daten löscht WEBklar nach Nummer 9 des AVV Website-Analyse. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse als CSV-Datei verlangen.
|
||||
|
||||
<!-- Prüfpunkt Anwalt P17: RDG – Abgrenzung der Mustertexte von Rechtsberatung; Haftung für Mustertext. -->
|
||||
<!-- Prüfpunkt Anwalt P1: Rechtliche Einordnung der Zählung ohne Einwilligung (§ 25 TDDDG, DSK-Lesart); Aussage in Nr. 1 Abs. 3 und Hinweis an den Kunden auf das Restrisiko. -->
|
||||
<!-- Prüfpunkt Anwalt P16: Die Leistungsbeschreibung beschreibt den Einwilligungsnachweis nur technisch; P4 siehe AVV. -->
|
||||
## 8. Änderungen und Einstellung
|
||||
|
||||
(1) WEBklar darf die Technik der Website-Analyse verbessern, wenn der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Website-Analyse.
|
||||
|
||||
(2) WEBklar darf die Website-Analyse für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn
|
||||
- ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Website-Analyse Nummer 12 Abs. 3), oder
|
||||
- eine Sicherheitslücke sich nicht anders beheben lässt.
|
||||
|
||||
Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.
|
||||
|
||||
(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar die Messung ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt die Messung wieder auf, sobald das zulässig und technisch möglich ist.
|
||||
|
||||
(4) Gegenüber Unternehmern darf WEBklar die Website-Analyse außerdem ohne besonderen Grund mit einer Frist von drei Monaten in Textform für alle Kunden einstellen. Der Kunde kann das Wartungs-Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen; die Mindestlaufzeit steht dem nicht entgegen.
|
||||
|
||||
(5) Weil für die Website-Analyse kein Aufpreis berechnet wird, ändert ihre Einstellung den Preis des Wartungs-Abos nicht. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.
|
||||
|
||||
## 9. Hinweise
|
||||
|
||||
(1) **Keine Rechtsberatung.** Die Zusatzseite und der Hinweis in der Datenschutzerklärung sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine Prüfung, ob die Website des Kunden insgesamt datenschutzkonform ist, und eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht. Abstimmungen nach dieser Leistungsbeschreibung betreffen nur die Technik.
|
||||
|
||||
(2) **Nachweis der Einwilligung.** Ein Protokoll je Besucher wird nicht geführt. Als Nachweis dienen die Dokumentation der Technik und das Archiv der Fassungen von Banner-Text und Zusatzseite (AVV Website-Analyse Nummer 8 Abs. 6).
|
||||
|
||||
(3) Die Länderzuordnung nutzt die Datenbank „IP to Country Lite“ von DB-IP.com (Lizenz CC BY 4.0). Im Kundenportal steht dazu unter der Auswertung der Hinweis „Länderdaten: IP-Geolocation by DB-IP“ mit Link auf db-ip.com.
|
||||
@@ -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 <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 <Projekt> 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 <Projekt> 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 <Weg>“, 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
|
||||
@@ -81,3 +81,12 @@ export function barRows(items, labelOf) {
|
||||
const max = Math.max(1, ...list.map((i) => i.count))
|
||||
return list.map((i) => ({ key: i.key, label: labelOf(i.key), count: i.count, pct: Math.round((i.count / max) * 100) }))
|
||||
}
|
||||
|
||||
/** Banner-Text der Portal-Vorschau; muss mit BANNER_TEXT in analytics/client/core.js uebereinstimmen (Paritaetstest). */
|
||||
export const BANNER_PREVIEW_TEXT = 'Um unsere Website zu verbessern, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Mit Deiner Zustimmung messen wir zusätzlich, von welcher Website Du kommst, wie lange Du bleibst, wie weit Du scrollst und wo Du klickst (Heatmap), mit einer täglich wechselnden Kennung. Die Messung betreibt für uns die WEBklar GbR in Deutschland, ohne Cookies und ohne Werbung. Nach „Ablehnen“ zählen wir nur noch, dass Du abgelehnt hast. Deine Zustimmung kannst Du jederzeit über „Datenschutz-Einstellungen“ mit Wirkung für die Zukunft widerrufen. Details: '
|
||||
|
||||
/** Zeilen der Verantwortlichen-Vorschau: Firma (sonst Name), Anschrift falls hinterlegt, E-Mail. Leere Angaben entfallen. */
|
||||
export function controllerLines(controller) {
|
||||
const c = controller || {}
|
||||
return [c.company, c.address, c.email].map((v) => String(v || '').trim()).filter(Boolean)
|
||||
}
|
||||
+27
-17
@@ -3,10 +3,9 @@
|
||||
Buchungsseite (off), Einrichtungsstatus (ordered/setup) oder Editor (active). Styles: funktionen.css.
|
||||
Kundentexte kommen nur ueber esc() bzw. textContent in die Seite. */
|
||||
|
||||
import { bannerColor, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
|
||||
import { bannerColor, BANNER_PREVIEW_TEXT, controllerLines, chartModel, barRows, countryName, flagEmoji, refLabel, DEVICE_LABELS, CONTACT_LABELS } from '/analyse-view.js'
|
||||
|
||||
const PRICE = '30 €'
|
||||
const AVV_URL = '/rechtliches/avv-website-analyse.html'
|
||||
const LEISTUNGSBESCHREIBUNG = '/vertragsunterlagen/funktionen-leistungsbeschreibung.html'
|
||||
const PHOTO_ACCEPT = 'image/jpeg,image/png,image/webp' // iOS wandelt HEIC dann selbst in JPEG um
|
||||
const PHOTO_MAX_BYTES = 10 * 1024 * 1024
|
||||
@@ -592,23 +591,29 @@ function bannerPreview(color) {
|
||||
const btn = `style="background:${c};border-color:${c};color:#fff"`
|
||||
return `
|
||||
<div class="an-bn" role="img" aria-label="Vorschau des Banners auf deiner Website">
|
||||
<p class="an-bn-text">Wir möchten mit deiner Zustimmung anonym auswerten, wie unsere Website genutzt wird (keine Cookies, keine Weitergabe). Details: <span class="an-bn-link">Datenschutz</span></p>
|
||||
<p class="an-bn-text">${esc(BANNER_PREVIEW_TEXT)}<span class="an-bn-link">Datenschutz</span></p>
|
||||
<div class="an-bn-row"><span class="an-bn-btn" ${btn}>Ablehnen</span><span class="an-bn-btn" ${btn}>Annehmen</span></div>
|
||||
</div>`
|
||||
}
|
||||
|
||||
function renderAnalyseActivation(stage, project, reload) {
|
||||
const MISSING_PRIVACY_HINT = '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.'
|
||||
|
||||
// Vorschau der Verantwortlichen-Angaben; sind sie unvollstaendig, bleibt "Analyse einschalten" gesperrt
|
||||
function controllerBlock(controller) {
|
||||
if (controller.ok !== true) return `<p class="error an-missing" id="an-missing" role="alert">${esc(MISSING_PRIVACY_HINT)}</p>`
|
||||
return `<div class="an-ctl" id="an-ctl"><p>Auf der Datenschutz-Zusatzseite Deiner Website erscheinen als Verantwortlicher:</p><p><strong>${controllerLines(controller).map(esc).join(' · ')}</strong></p><p>Stimmt etwas nicht? Schreib uns im Chat, bevor Du einschaltest.</p></div>`
|
||||
}
|
||||
|
||||
export function renderAnalyseActivation(stage, project, reload) {
|
||||
const a = analyseOf(project)
|
||||
const legal = a.legal || {}
|
||||
const controller = a.controller || { ok: false }
|
||||
let color = HEX_RE.test(a.accentColor || '') ? a.accentColor.toLowerCase() : '#111111'
|
||||
stage.innerHTML = `
|
||||
<div class="kb-panel an-panel">
|
||||
<h3>Analyse für ${esc(project.projectName)} einschalten</h3>
|
||||
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen. Wir speichern <strong>keine IP-Adressen</strong> und setzen <strong>keine Cookies</strong>. Alles läuft auf einem eigenen Server in Deutschland.</p>
|
||||
<ul class="kb-checks">
|
||||
<li>Ohne Zustimmung zählen wir nur, wie oft eine Seite aufgerufen wird – anonym, ohne Wiedererkennung.</li>
|
||||
<li>Mit Zustimmung kommen Besucher, Verweildauer, Klicks und Heatmaps dazu.</li>
|
||||
<li>Auf deiner Website erscheint dafür ein Hinweis-Banner mit „Ablehnen“ und „Annehmen“.</li>
|
||||
</ul>
|
||||
<p>Du siehst, wie oft deine Website aufgerufen wird, woher die Besucher kommen und was sie sich ansehen.</p>
|
||||
<p>Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse, ohne Kennung. Wer zustimmt, wird zusätzlich mit Herkunftsseite, Verweildauer, Scrolltiefe und Klicks gemessen. Wir speichern keine IP-Adressen und setzen keine Cookies. Die Messung läuft auf unserem Server bei Hetzner in Deutschland. Wer ablehnt, wird nicht weiter gemessen; gezählt wird nur, dass abgelehnt wurde.</p>
|
||||
<h4 class="an-h4">Farbe des Banners</h4>
|
||||
<div class="an-colors">
|
||||
<input type="color" id="an-color" value="${esc(color)}" aria-label="Farbe wählen">
|
||||
@@ -616,7 +621,8 @@ function renderAnalyseActivation(stage, project, reload) {
|
||||
</div>
|
||||
<p class="an-hint hidden" id="an-hint">Diese Farbe ist für weiße Schrift zu hell. Der Banner nutzt dann Schwarz (#111111).</p>
|
||||
<div id="an-preview"></div>
|
||||
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag (Fassung ${esc(a.avvVersion)}). <a href="${esc(AVV_URL)}" target="_blank" rel="noopener">AVV lesen</a></span></label>
|
||||
${controllerBlock(controller)}
|
||||
<label class="fn-consent"><input type="checkbox" id="an-avv"><span>Ich beauftrage WEBklar mit der Website-Analyse und stimme dem <a href="${esc(legal.avv || '')}" target="_blank" rel="noopener">Auftragsverarbeitungsvertrag Website-Analyse</a> und der <a href="${esc(legal.lb || '')}" target="_blank" rel="noopener">Leistungsbeschreibung Website-Analyse</a> (Fassung ${esc(a.avvVersion)}) zu. Meine Website richtet sich nicht gezielt an Kinder unter 16 Jahren.</span></label>
|
||||
<p class="error hidden" id="an-error" role="alert"></p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-on" disabled>Analyse einschalten</button></div>
|
||||
</div>`
|
||||
@@ -632,9 +638,9 @@ function renderAnalyseActivation(stage, project, reload) {
|
||||
const box = stage.querySelector('#an-avv')
|
||||
const button = stage.querySelector('#an-on')
|
||||
const error = stage.querySelector('#an-error')
|
||||
box.addEventListener('change', () => { button.disabled = !box.checked })
|
||||
box.addEventListener('change', () => { button.disabled = !box.checked || controller.ok !== true })
|
||||
button.addEventListener('click', async () => {
|
||||
if (!box.checked) return
|
||||
if (!box.checked || controller.ok !== true) return
|
||||
button.disabled = true
|
||||
error.classList.add('hidden')
|
||||
try {
|
||||
@@ -643,7 +649,7 @@ function renderAnalyseActivation(stage, project, reload) {
|
||||
} catch (err) {
|
||||
error.textContent = err.message
|
||||
error.classList.remove('hidden')
|
||||
button.disabled = !box.checked
|
||||
button.disabled = !box.checked || controller.ok !== true
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -757,10 +763,10 @@ function renderAnalyseHeat(box, projectId, pages) {
|
||||
}
|
||||
|
||||
// Ausschalten mit Bestaetigung in der Seite (zweiter Klick), ohne Browser-Dialog
|
||||
function renderAnalyseOff(box, projectId, reload) {
|
||||
export function renderAnalyseOff(box, projectId, reload) {
|
||||
box.innerHTML = `
|
||||
<h3>Analyse ausschalten</h3>
|
||||
<p>Danach werden keine neuen Daten mehr gesammelt und der Banner verschwindet von deiner Website. Die bisherigen Daten bleiben noch 60 Tage erhalten.</p>
|
||||
<p>Danach werden keine neuen Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website, in der Regel sofort. Die bisherigen Daten löschen wir nach 60 Tagen; bis zum 46. Tag kannst Du eine Kopie anfordern.</p>
|
||||
<p class="error hidden" id="an-off-error" role="alert"></p>
|
||||
<div class="nw-confirm" id="an-off-actions"><button type="button" class="an-ghost" id="an-off-ask">Analyse ausschalten</button></div>`
|
||||
const actions = box.querySelector('#an-off-actions')
|
||||
@@ -772,7 +778,11 @@ function renderAnalyseOff(box, projectId, reload) {
|
||||
ev.currentTarget.disabled = true
|
||||
try {
|
||||
await request('/api/funktionen/analyse/deactivate', { method: 'POST', body: { projectId } })
|
||||
reload()
|
||||
box.innerHTML = `
|
||||
<h3>Analyse ausgeschaltet</h3>
|
||||
<p id="an-off-done">Die Website-Analyse ist ausgeschaltet. Ab jetzt werden keine Daten mehr gespeichert.</p>
|
||||
<div class="abo-actions"><button type="button" class="abo-btn" id="an-off-ok">OK</button></div>`
|
||||
box.querySelector('#an-off-ok').addEventListener('click', () => reload())
|
||||
} catch (err) {
|
||||
renderAnalyseOff(box, projectId, reload)
|
||||
const e2 = box.querySelector('#an-off-error')
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Auftragsverarbeitungsvertrag Website-Analyse – WEBklar</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||
h3 { font-size: 16.5px; margin: 22px 0 6px; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
.box.widerspruch { border: 2px solid #6d1fd1; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
|
||||
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
|
||||
a { color: #6d1fd1; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 18mm 17mm 18mm;
|
||||
@top-left { content: "WEBklar · AVV Website-Analyse"; font: 8pt sans-serif; color: #888; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
|
||||
body { background: #fff; font-size: 10pt; line-height: 1.45; }
|
||||
main { max-width: none; padding: 0; }
|
||||
nav.top { display: none; }
|
||||
h1 { font-size: 20pt; }
|
||||
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.box, tr, ol.abs > li { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/avv-website-analyse-2026-10-02.html">AVV Website-Analyse</a> · <a href="/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html">Leistungsbeschreibung Website-Analyse</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
|
||||
|
||||
<div class="brand">WEBKLAR</div>
|
||||
<h1>Auftragsverarbeitungsvertrag Website-Analyse (Art. 28 DSGVO)</h1>
|
||||
|
||||
<p class="meta"><strong>Fassung 2026-10-02</strong></p>
|
||||
|
||||
<p>zwischen dem Kunden als Verantwortlichem („Kunde“) und der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, vertreten durch die Gesellschafter Kenso Grimm und Justin Klein, als Auftragsverarbeiter („WEBklar“).</p>
|
||||
|
||||
<h2>Vorbemerkung: Abschluss und Verhältnis zu anderen Verträgen</h2>
|
||||
|
||||
<p>(1) Der Kunde schließt diesen Vertrag, wenn er im Kundenportal die Funktion „Website-Analyse“ einschaltet und dabei per Haken diesem Vertrag und der Leistungsbeschreibung „Website-Analyse“ in der angezeigten Fassung zustimmt. Das Portal speichert die Fassung, den Zeitpunkt, das Kundenkonto und die angemeldete Person. Anschließend sendet WEBklar dem Kunden eine E-Mail, die beide Texte in dieser Fassung als PDF enthält und auf sie verlinkt.</p>
|
||||
|
||||
<p>(2) Ein Mitarbeiter von WEBklar darf die Funktion nur einschalten, wenn der Kunde vorher in Textform (etwa im Chat des Kundenportals oder per E-Mail) erklärt hat, dass er diesem Vertrag und der Leistungsbeschreibung in der aktuellen Fassung zustimmt. WEBklar speichert die Fassung, das Datum und den Weg dieser Erklärung, den Zeitpunkt des Einschaltens und den Namen des Mitarbeiters. Der Kunde erhält die E-Mail nach Absatz 1.</p>
|
||||
|
||||
<p>(3) Dieser Vertrag ergänzt den Auftragsverarbeitungsvertrag in Anlage 1 der Abo-Bedingungen. Für die Website-Analyse geht er Anlage 1 vor; im Übrigen gilt Anlage 1 ergänzend. In Datenschutzfragen geht er den Abo-Bedingungen und der Leistungsbeschreibung „Website-Analyse“ vor.</p>
|
||||
|
||||
<h2>1. Gegenstand und Dauer</h2>
|
||||
|
||||
<p>(1) WEBklar betreibt für die öffentliche Website des Kunden eine Reichweitenmessung mit Einwilligungsbanner („Website-Analyse“) und verarbeitet dabei personenbezogene Daten der Besucher der Website im Auftrag des Kunden. Den Umfang beschreibt die Leistungsbeschreibung „Website-Analyse“.</p>
|
||||
|
||||
<p>(2) Der Vertrag beginnt mit dem Einschalten der Funktion. Er endet mit dem Ausschalten, spätestens mit dem Ende des Wartungs-Abos; den Messdienst schaltet WEBklar dann spätestens am folgenden Tag ab. Die Pflichten aus diesem Vertrag gelten fort, bis alle Daten nach Nummer 9 gelöscht sind.</p>
|
||||
|
||||
<h2>2. Art und Zweck der Verarbeitung</h2>
|
||||
|
||||
<p>(1) Art: Erheben über ein Skript, das WEBklar beim Veröffentlichen in die Website des Kunden einfügt, Übermitteln an den Messdienst von WEBklar, Speichern, Verdichten zu Tageswerten, Auswerten im Kundenportal (Kennzahlen, Heatmap) und Löschen.</p>
|
||||
|
||||
<p>(2) Zwecke: Der Kunde soll erkennen, wie seine Website genutzt wird, um sie zu verbessern. Außerdem schützt WEBklar den Messdienst für den Kunden vor Missbrauch und verfälschten Zahlen (Mengenbegrenzung, Erkennen von Bots).</p>
|
||||
|
||||
<p>(3) WEBklar verwendet die Daten für keine eigenen Zwecke, auch nicht in anonymisierter Form und nicht für Auswertungen über mehrere Kunden. Es findet keine Werbung und keine Zusammenführung mit anderen Daten statt. WEBklar bildet keine Profile einzelner Besucher. Die Besucher-Kennung dient nur dazu, die Besucher eines Tages zu zählen.</p>
|
||||
|
||||
<h2>3. Datenarten und Betroffene</h2>
|
||||
|
||||
<p>(1) Solange der Besucher im Banner keine Wahl getroffen hat und sein Browser kein Signal nach Absatz 2 Satz 2 sendet: Pfad der aufgerufenen Seite (ohne Suchparameter und Anker), auch bei einem Seitenwechsel ohne Neuladen; Land (aus der IP-Adresse abgeleitet); Geräteklasse (Handy, Tablet oder Computer; aus dem Browser-Kennzeichen abgeleitet); Zeitpunkt (auf die volle Stunde gerundet); ein Zähler dafür, dass das Banner angezeigt wurde.</p>
|
||||
|
||||
<p>(2) Nach „Ablehnen“ sendet das Skript einmalig einen Zähler „abgelehnt“ und danach nichts mehr. Sendet der Browser die Signale „Global Privacy Control“ oder „Do Not Track“, zeigt das Skript kein Banner und sendet nichts.</p>
|
||||
|
||||
<p>(3) Nach „Annehmen“ zusätzlich: Domain der Website, von der der Besucher kam; Größenklasse des Browserfensters; eine täglich wechselnde pseudonyme Besucher-Kennung; Verweildauer (ab dem Laden der Seite) und Scrolltiefe je Seite; Klickpositionen mit einem Elementpfad aus Tag-Namen und Position (ohne Texte, Kennungen und Klassennamen); die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp oder Link auf eine andere Website, dann mit deren Domain); ein Zähler „angenommen“.</p>
|
||||
|
||||
<p>(4) IP-Adresse und Browser-Kennzeichen (User-Agent) erhält der Server von WEBklar technisch bei jedem Abruf des Skripts und bei jeder Messanfrage, auch nach „Ablehnen“. Der Messdienst verarbeitet sie nur kurz im Arbeitsspeicher, um Land, Geräteklasse, Bots, die Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung zu bestimmen. Er speichert und protokolliert sie nicht. Nicht erhoben werden Cookies, Formularinhalte, Seitentexte, Mausbewegungen, Tastatureingaben und Sitzungsaufzeichnungen.</p>
|
||||
|
||||
<p>(5) Tageswerte sind Summen je Tag und Merkmal (etwa Aufrufe je Seite, je Land oder je Geräteklasse), ohne Besucher-Kennung. Enthalten die Pfade der Website personenbezogene Angaben, können auch Tageswerte personenbezogen sein (Nummer 11 Abs. 2).</p>
|
||||
|
||||
<p>(6) Besondere Kategorien personenbezogener Daten (Art. 9 DSGVO) werden nicht gezielt verarbeitet. Je nach Inhalt der Website können sich aus Pfaden oder Domains aber Rückschlüsse ergeben (Nummer 11 Abs. 3).</p>
|
||||
|
||||
<p>(7) Betroffene: Besucher der Website des Kunden.</p>
|
||||
|
||||
<h2>4. Weisungen</h2>
|
||||
|
||||
<p>(1) WEBklar verarbeitet die Daten nur auf dokumentierte Weisung des Kunden, auch in Bezug auf eine Übermittlung in ein Drittland oder an eine internationale Organisation. Das gilt nicht, wenn das Recht der Europäischen Union oder Deutschlands WEBklar zu einer Verarbeitung verpflichtet. In diesem Fall teilt WEBklar dem Kunden die Pflicht vorher mit, soweit das Gesetz es nicht verbietet.</p>
|
||||
|
||||
<p>(2) Mit dem Einschalten weist der Kunde WEBklar an, die Website-Analyse in der Standardkonfiguration nach der Leistungsbeschreibung zu betreiben. Weitere Weisungen ergeben sich aus den Einstellungen im Kundenportal (Einschalten, Ausschalten, Akzentfarbe des Banners). Sonstige Weisungen erteilt der Kunde in Textform, etwa im Chat des Kundenportals. Weisungen, die von der Standardkonfiguration abweichen, setzt WEBklar nur um, wenn das technisch vorgesehen ist; sonst kann der Kunde die Funktion ausschalten.</p>
|
||||
|
||||
<p>(3) Hält WEBklar eine Weisung für rechtswidrig, weist es den Kunden unverzüglich darauf hin. WEBklar darf die Ausführung aussetzen, bis der Kunde die Weisung ändert oder die Rechtslage geklärt ist.</p>
|
||||
|
||||
<h2>5. Vertraulichkeit</h2>
|
||||
|
||||
<p>WEBklar setzt nur Personen ein, die zur Vertraulichkeit verpflichtet sind oder einer gesetzlichen Verschwiegenheitspflicht unterliegen.</p>
|
||||
|
||||
<h2>6. Technische und organisatorische Maßnahmen</h2>
|
||||
|
||||
<p>(1) WEBklar trifft die Maßnahmen nach Art. 32 DSGVO, die in der Anlage zu diesem Vertrag beschrieben sind. WEBklar darf sie an den Stand der Technik anpassen, sofern das Schutzniveau nicht sinkt.</p>
|
||||
|
||||
<p>(2) Für den Server gelten ergänzend die Maßnahmen der Anlage 2 der Abo-Bedingungen, soweit die Anlage zu diesem Vertrag nichts anderes regelt.</p>
|
||||
|
||||
<h2>7. Unterauftragsverarbeiter</h2>
|
||||
|
||||
<p>(1) Der Kunde genehmigt den Einsatz der Hetzner Online GmbH, Industriestraße 25, 91710 Gunzenhausen, für den Betrieb des Servers, auf dem der Messdienst läuft, und für dessen Sicherung. Ort der Verarbeitung ist das Rechenzentrum Falkenstein (Deutschland). Eine Übermittlung in Drittländer findet nicht statt.</p>
|
||||
|
||||
<p>(2) Will WEBklar einen weiteren Unterauftragsverarbeiter hinzuziehen oder einen ersetzen, teilt es das dem Kunden mindestens vier Wochen vorher in Textform mit. Der Kunde kann innerhalb von zwei Wochen nach Zugang der Mitteilung aus wichtigem datenschutzrechtlichem Grund in Textform widersprechen. WEBklar setzt den neuen Unterauftragsverarbeiter nicht vor Ablauf dieser Frist ein. Kündigt ein Unterauftragsverarbeiter von WEBklar eine eigene Änderung mit kürzerer Frist an, informiert WEBklar den Kunden unverzüglich, nachdem die Ankündigung eingegangen ist; der Kunde kann dann unverzüglich widersprechen. Einigen sich die Parteien nicht, kann der Kunde die Website-Analyse ausschalten und WEBklar sie für diese Website zu dem Zeitpunkt beenden, zu dem der neue Unterauftragsverarbeiter eingesetzt wird. Die Daten werden dann nach Nummer 9 gelöscht, auf Verlangen des Kunden sofort.</p>
|
||||
|
||||
<p>(3) WEBklar verpflichtet jeden Unterauftragsverarbeiter vertraglich auf die Datenschutzpflichten nach Art. 28 Abs. 4 DSGVO. Bei der Hetzner Online GmbH ist das deren Vertrag zur Auftragsverarbeitung, den WEBklar abgeschlossen hat. Kommt ein Unterauftragsverarbeiter seinen Datenschutzpflichten nicht nach, haftet WEBklar dem Kunden für die Einhaltung dieser Pflichten (Art. 28 Abs. 4 Satz 2 DSGVO).</p>
|
||||
|
||||
<p>(4) Die Länderzuordnung nutzt eine Datenbank, die WEBklar auf dem eigenen Server speichert („IP to Country Lite“ von DB-IP.com, Lizenz CC BY 4.0). Daten der Besucher werden dabei nicht an DB-IP übermittelt. DB-IP ist kein Unterauftragsverarbeiter.</p>
|
||||
|
||||
<h2>8. Unterstützung des Kunden, Betroffenenrechte</h2>
|
||||
|
||||
<p>(1) WEBklar leitet Anfragen betroffener Personen, die an WEBklar gerichtet sind, unverzüglich an den Kunden weiter. WEBklar unterstützt ihn mit geeigneten technischen und organisatorischen Maßnahmen bei der Beantwortung.</p>
|
||||
|
||||
<p>(2) Die gespeicherten Messdaten enthalten keine IP-Adresse, keinen Namen und keine Kennung, die länger als einen Tag gilt; Zeitpunkte sind auf die volle Stunde gerundet. Einzelne Besucher kann WEBklar daher in der Regel nicht identifizieren (Art. 11 DSGVO). Ein Abgleich kann aber möglich sein, solange die Zugriffsprotokolle der Website (Anlage 1 und 2 der Abo-Bedingungen, 14 Tage) vorhanden sind. Nennt eine betroffene Person zusätzliche Angaben, etwa Datum, Uhrzeit, besuchte Seiten oder ihre IP-Adresse, prüft WEBklar auf Weisung des Kunden, auch anhand dieser Protokolle, ob eine Zuordnung möglich ist, und teilt ihm das Ergebnis mit.</p>
|
||||
|
||||
<p>(3) Der Messung können Besucher technisch widersprechen (Art. 21 DSGVO): Nach „Ablehnen“ und bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“ sendet das Skript nichts mehr.</p>
|
||||
|
||||
<p>(4) WEBklar unterstützt den Kunden bei seinen Pflichten aus Art. 32 bis 36 DSGVO. Dazu gehören die Angaben, die er für sein Verzeichnis von Verarbeitungstätigkeiten und für eine etwaige Datenschutz-Folgenabschätzung braucht, und eine kurze Interessenabwägung für die Zählung nach Art. 6 Abs. 1 lit. f DSGVO, die WEBklar mit jeder Fassung dieses Vertrags bereitstellt.</p>
|
||||
|
||||
<p>(5) Verletzungen des Schutzes personenbezogener Daten meldet WEBklar dem Kunden unverzüglich, möglichst innerhalb von 24 Stunden nach Kenntnis, mit den Informationen, die zu diesem Zeitpunkt vorliegen.</p>
|
||||
|
||||
<p>(6) Zum Nachweis der Einwilligungen bewahrt WEBklar jede Fassung des Banner-Textes und der Zusatzseite mit dem Zeitraum ihrer Verwendung auf, bis drei Jahre nach dem Ende der Website-Analyse. WEBklar stellt sie dem Kunden auf Verlangen zur Verfügung. Ein Protokoll je Besucher führt WEBklar nicht.</p>
|
||||
|
||||
<p>(7) Die Besucher informiert der Kunde (Art. 13 DSGVO). WEBklar stellt ihm dafür die Zusatzseite „/datenschutz-analyse.html“ als Muster bereit. Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar dort einen Hinweis auf die Zusatzseite ein; sonst erhält der Kunde den Hinweis als Textbaustein (Leistungsbeschreibung Nr. 4 Abs. 4).</p>
|
||||
|
||||
<h2>9. Löschung und Rückgabe</h2>
|
||||
|
||||
<p>(1) Einzelne Ereignisse und Klickpositionen löscht WEBklar automatisch nach 30 Tagen. Der Löschlauf läuft täglich, die Löschung erfolgt also in der Regel spätestens am 31. Tag; fällt ein Lauf aus, holt der nächste ihn nach.</p>
|
||||
|
||||
<p>(2) Tageswerte löscht WEBklar 25 Monate nach dem jeweiligen Tag. Nach dem Ende der Website-Analyse (Ausschalten oder Ende des Wartungs-Abos) löscht WEBklar alle Daten der Website am 61. Tag. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse verlangen; WEBklar stellt sie innerhalb von zwei Wochen als CSV-Datei bereit. Auf Weisung des Kunden löscht WEBklar die Daten früher.</p>
|
||||
|
||||
<p>(3) Die Daten sind in der täglichen Vollsicherung des Servers enthalten (§ 3 Abs. 5 der Abo-Bedingungen). Gelöschte Daten bleiben dort bis zu sieben Tage erhalten und werden dann überschrieben. Wird eine Sicherung zurückgespielt, holt der tägliche Löschlauf die fälligen Löschungen nach; Löschungen auf Weisung des Kunden wiederholt WEBklar.</p>
|
||||
|
||||
<p>(4) Gesetzliche Aufbewahrungspflichten bleiben unberührt.</p>
|
||||
|
||||
<h2>10. Nachweise und Kontrollen</h2>
|
||||
|
||||
<p>WEBklar stellt dem Kunden alle Informationen zur Verfügung, die zum Nachweis der Pflichten aus Art. 28 DSGVO nötig sind, und ermöglicht Überprüfungen einschließlich Inspektionen durch den Kunden oder einen von ihm beauftragten, zur Verschwiegenheit verpflichteten Prüfer. Sie finden nach Anmeldung mit angemessener Frist (in der Regel 14 Tage) zu den Servicezeiten statt, ohne den Betrieb unzumutbar zu stören. Für das Rechenzentrum genügen in der Regel die Nachweise des Betreibers (Zertifizierung der Hetzner Online GmbH nach ISO/IEC 27001). Für mehr als eine Vor-Ort-Kontrolle pro Jahr kann WEBklar eine angemessene Vergütung verlangen.</p>
|
||||
|
||||
<h2>11. Pflichten des Kunden</h2>
|
||||
|
||||
<p>(1) Der Kunde ist Verantwortlicher für die Messung. Er entfernt, verdeckt oder umgeht das Banner nicht. Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess- oder Tracking-Werkzeuge ein.</p>
|
||||
|
||||
<p>(2) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.</p>
|
||||
|
||||
<p>(3) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren oder lassen ihre Inhalte auf besondere Kategorien personenbezogener Daten schließen (etwa Gesundheit, Religion oder politische Meinung), gilt Nr. 3 Abs. 4 der Leistungsbeschreibung.</p>
|
||||
|
||||
<h2>12. Haftung, Änderungen, Schlussbestimmungen</h2>
|
||||
|
||||
<p>(1) Gegenüber betroffenen Personen gilt Art. 82 DSGVO. Für die Haftung zwischen Kunde und WEBklar gilt § 13 der Abo-Bedingungen.</p>
|
||||
|
||||
<p>(2) Eine neue Fassung dieses Vertrags oder der Leistungsbeschreibung teilt WEBklar dem Kunden mindestens sechs Wochen vor ihrem Wirksamwerden in Textform mit. Sie wird wirksam, wenn der Kunde ihr im Kundenportal oder in Textform zustimmt. Stimmt er nicht zu, gilt die bisherige Fassung weiter.</p>
|
||||
|
||||
<p>(3) Ist die neue Fassung nötig, weil ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form nicht mehr zulässt, und stimmt der Kunde ihr nicht zu, darf WEBklar die Website-Analyse für seine Website nach Nr. 8 Abs. 2 der Leistungsbeschreibung beenden oder aussetzen. Das Wartungs-Abo bleibt davon unberührt.</p>
|
||||
|
||||
<p>(4) Im Übrigen gilt § 16 der Abo-Bedingungen.</p>
|
||||
|
||||
<h2>Anlage: Technische und organisatorische Maßnahmen der Website-Analyse (Art. 32 DSGVO)</h2>
|
||||
|
||||
<table>
|
||||
<thead><tr><th>Bereich</th><th>Maßnahmen</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td>Übertragung</td><td>Skript und Messdaten werden nur über TLS (HTTPS) übertragen.</td></tr>
|
||||
<tr><td>Speicher im Browser</td><td>Es werden keine Cookies gesetzt, und der Messdienst wertet keine Cookies aus. Messanfragen sendet das Skript ohne Cookies. Im lokalen Speicher des Browsers liegt nur die Banner-Wahl (Eintrag „wk-consent“ mit Wahl, Zeitpunkt und Fassung des Banner-Textes). Sie gilt 183 Tage; danach und bei einer neuen Fassung des Banner-Textes fragt das Banner erneut.</td></tr>
|
||||
<tr><td>Einwilligung</td><td>Solange keine Wahl getroffen ist und kein Browser-Signal vorliegt, sendet das Skript nur den Pfad der Seite, auch beim Seitenwechsel ohne Neuladen, und den Zähler „Banner angezeigt“. Nach „Ablehnen“ sendet es einmalig den Zähler „abgelehnt“ und danach nichts mehr, auch nicht aus anderen geöffneten Tabs. Bei „Global Privacy Control“ oder „Do Not Track“ zeigt es kein Banner und sendet nichts. Klick-, Scroll- und Verweildauer-Ereignisse registriert es erst nach „Annehmen“. Die Schaltflächen „Ablehnen“ und „Annehmen“ sind gleich gestaltet.</td></tr>
|
||||
<tr><td>Datenminimierung</td><td>IP-Adresse und Browser-Kennzeichen werden weder gespeichert noch protokolliert. Der Messdienst verwendet sie nur im Arbeitsspeicher für Land, Geräteklasse, Bot-Erkennung, Mengenbegrenzung und, nur nach Einwilligung, die Besucher-Kennung. Zeitpunkte werden auf die volle Stunde gerundet gespeichert. Pfade werden ohne Suchparameter und Anker gespeichert; Abschnitte, die wie Kennungen aussehen (lange Ziffernfolgen, UUIDs, E-Mail-Adressen), werden vorher ersetzt. Elementpfade bestehen nur aus Tag-Namen und Position. Formulareingaben und Seitentexte werden nicht erfasst.</td></tr>
|
||||
<tr><td>Pseudonymisierung</td><td>Die Besucher-Kennung (nur nach Einwilligung) ist ein auf 64 Bit gekürzter HMAC-SHA256 aus Website-Schlüssel, IP-Adresse und Browser-Kennzeichen mit einem Tagesschlüssel. Auch der Schlüssel der Mengenbegrenzung wird mit dem Tagesschlüssel gebildet. Der Tagesschlüssel liegt nur im Arbeitsspeicher; die Auslagerung auf die Festplatte ist für den Messdienst gesperrt. Er wird beim ersten Ereignis eines neuen Kalendertags (Europe/Berlin) und bei jedem Neustart neu erzeugt und nie gesichert. Ältere Kennungen lassen sich danach nicht mehr zuordnen.</td></tr>
|
||||
<tr><td>Zugriffskontrolle</td><td>Die Verwaltungsschnittstelle des Messdienstes (Auswertung, Website-Verwaltung) läuft auf einem internen Port, der nicht öffentlich erreichbar ist, und ist durch ein Zugriffstoken geschützt. Zugriff haben nur das Kundenportal und die Verwaltung von WEBklar. Das Portal zeigt jedem Kunden nur die Daten seiner eigenen Websites.</td></tr>
|
||||
<tr><td>Heatmap</td><td>Die Heatmap öffnet ein signierter Link, den nur der angemeldete Kunde im Kundenportal erzeugen kann. Der Link gilt 30 Minuten und liefert nur Klickpunkte der gewählten Seite und Ansicht, ohne Zeitpunkte und Kennungen.</td></tr>
|
||||
<tr><td>Eingangsprüfung</td><td>Messdaten werden nur angenommen, wenn die Website registriert und eingeschaltet ist und Origin bzw. Referer zu ihren Domains passen. Bots werden verworfen. Größe und Menge der Anfragen sind je Website und Absender begrenzt (Zähler nur im Arbeitsspeicher). Für ausgeschaltete Websites liefert der Messdienst ein leeres Skript aus.</td></tr>
|
||||
<tr><td>Löschung</td><td>Täglicher Löschlauf um 02:30 Uhr (Europe/Berlin): Ereignisse und Klicks nach 30 Tagen, Tageswerte nach 25 Monaten, alle Daten einer Website 60 Tage nach dem Ausschalten. Gelöschte Datensätze werden in der Datenbankdatei überschrieben und aus dem Schreibprotokoll (WAL) entfernt.</td></tr>
|
||||
<tr><td>Trennung</td><td>Der Messdienst läuft in einem eigenen Container mit eigenem Datenverzeichnis und hat keinen Zugriff auf andere Kundendaten. Alle Datensätze tragen den Website-Schlüssel; Abfragen erfolgen nur je Website.</td></tr>
|
||||
<tr><td>Protokolle</td><td>Der Reverse-Proxy führt kein Zugriffsprotokoll, und der Messdienst protokolliert keine Anfragen. Wird ein Zugriffsprotokoll eingeschaltet, dann nicht für die Adresse des Messdienstes. Meldungen der Container werden nach 14 Tagen gelöscht.</td></tr>
|
||||
<tr><td>Datensicherung</td><td>Tägliche Vollsicherung des Servers durch Hetzner; verfügbar sind die letzten sieben Tagessicherungen (§ 3 Abs. 5 der Abo-Bedingungen). Einen gesonderten Datenbank-Export des Messdienstes gibt es nicht.</td></tr>
|
||||
<tr><td>Rechenzentrum</td><td>Hetzner Online GmbH, Rechenzentrum Falkenstein (Deutschland). Hetzner ist nach ISO/IEC 27001 zertifiziert; der Geltungsbereich umfasst die Hosting-Dienstleistungen und die Rechenzentren.</td></tr>
|
||||
<tr><td>Organisation</td><td>Verpflichtung aller Beteiligten auf Vertraulichkeit, Ablauf für Datenschutzverletzungen (Nummer 8 Abs. 5), Archiv der Fassungen von Banner-Text und Zusatzseite (Nummer 8 Abs. 6), Überprüfung der Maßnahmen mindestens einmal im Jahr.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -0,0 +1,164 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Leistungsbeschreibung Website-Analyse – WEBklar</title>
|
||||
<style>
|
||||
:root { color-scheme: light; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin: 0; background: #f7f6f3; color: #1d1d1f; font: 16px/1.6 system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
main { max-width: 780px; margin: 0 auto; padding: 44px 20px 80px; }
|
||||
.brand { font-weight: 800; letter-spacing: 0.12em; font-size: 13px; color: #6d1fd1; }
|
||||
h1 { font-size: 30px; line-height: 1.2; margin: 6px 0 6px; }
|
||||
h2 { font-size: 20px; margin: 36px 0 10px; padding-top: 14px; border-top: 1px solid #e3e0da; }
|
||||
h3 { font-size: 16.5px; margin: 22px 0 6px; }
|
||||
p, li { margin: 6px 0; }
|
||||
ul { padding-left: 24px; }
|
||||
.meta { color: #6b6b70; font-size: 14px; }
|
||||
.box { background: #fff; border: 1px solid #e3e0da; border-radius: 14px; padding: 16px 20px; margin: 18px 0; }
|
||||
.box.widerspruch { border: 2px solid #6d1fd1; }
|
||||
table { border-collapse: collapse; width: 100%; font-size: 15px; }
|
||||
td, th { padding: 6px 10px 6px 0; border-bottom: 1px solid #eee; vertical-align: top; text-align: left; }
|
||||
th { font-size: 13px; text-transform: uppercase; letter-spacing: 0.04em; color: #6b6b70; }
|
||||
a { color: #6d1fd1; }
|
||||
nav.top { font-size: 14px; margin-bottom: 22px; }
|
||||
.offen { background: #fff3cd; padding: 0 4px; border-radius: 4px; }
|
||||
@media print {
|
||||
@page { size: A4; margin: 18mm 17mm 18mm;
|
||||
@top-left { content: "WEBklar · Leistungsbeschreibung Website-Analyse"; font: 8pt sans-serif; color: #888; }
|
||||
@bottom-right { content: "Seite " counter(page) " von " counter(pages); font: 8pt sans-serif; color: #888; } }
|
||||
body { background: #fff; font-size: 10pt; line-height: 1.45; }
|
||||
main { max-width: none; padding: 0; }
|
||||
nav.top { display: none; }
|
||||
h1 { font-size: 20pt; }
|
||||
h2 { font-size: 13pt; margin-top: 16pt; break-after: avoid; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
.box, tr, ol.abs > li { break-inside: avoid; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<main>
|
||||
<nav class="top"><a href="/dashboard.html">← Kundenportal</a> · <a href="/abo-bedingungen.html">Abo-Bedingungen</a> · <a href="/rechtliches/avv-website-analyse-2026-10-02.html">AVV Website-Analyse</a> · <a href="/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html">Leistungsbeschreibung Website-Analyse</a> · <a href="/datenschutz.html">Datenschutzerklärung</a> · <a href="https://webklar.com/impressum">Impressum</a></nav>
|
||||
|
||||
<div class="brand">WEBKLAR</div>
|
||||
<h1>Leistungsbeschreibung Website-Analyse</h1>
|
||||
|
||||
<p class="meta"><strong>Fassung 2026-10-02</strong> · Zusatzbedingungen zum Wartungs-Abo</p>
|
||||
|
||||
<h2>1. Geltung</h2>
|
||||
|
||||
<p>(1) Diese Leistungsbeschreibung regelt die Funktion „Website-Analyse“ im Wartungs-Abo. Sie wird vereinbart, wenn der Kunde die Funktion einschaltet und ihr zusammen mit dem Auftragsverarbeitungsvertrag Website-Analyse („AVV Website-Analyse“) zustimmt. Wie die Zustimmung abläuft, regelt die Vorbemerkung des AVV Website-Analyse.</p>
|
||||
|
||||
<p>(2) Im Übrigen gelten die Abo-Bedingungen. Bei Widersprüchen geht für die Website-Analyse diese Leistungsbeschreibung vor, in Datenschutzfragen der AVV Website-Analyse.</p>
|
||||
|
||||
<p>(3) Leistungsbeschreibung B Nr. 3 der Abo-Bedingungen („Rechtstexte … nicht enthalten“) gilt nicht für die Zusatzseite nach Nr. 2 Abs. 2 und den Hinweis in der Datenschutzerklärung nach Nr. 4 Abs. 4. Beides ist Teil der Website-Analyse.</p>
|
||||
|
||||
<h2>2. Leistung</h2>
|
||||
|
||||
<p>(1) Die Website-Analyse misst, wie die öffentliche Website des Kunden genutzt wird. Sie ist im Wartungs-Abo ohne Aufpreis enthalten.</p>
|
||||
|
||||
<p>(2) Beim Veröffentlichen der Website fügt WEBklar Folgendes ein:</p>
|
||||
|
||||
<ul>
|
||||
<li>ein Messskript in jede HTML-Datei der Website,</li>
|
||||
<li>ein Einwilligungsbanner am unteren Rand mit den gleich gestalteten Schaltflächen „Ablehnen“ und „Annehmen“ in der Akzentfarbe, die der Kunde beim Einschalten wählt (ist sie für weiße Schrift zu hell, nimmt das Banner Schwarz),</li>
|
||||
<li>eine Schaltfläche „Datenschutz-Einstellungen“ unten rechts, über die Besucher ihre Wahl jederzeit ändern können (hat die Website ein eigenes Element mit dem Attribut <code>data-webklar-consent</code>, übernimmt dieses Element die Aufgabe),</li>
|
||||
<li>eine Zusatzseite „/datenschutz-analyse.html“ mit den Datenschutzhinweisen zur Messung (Verantwortlicher: der Kunde).</li>
|
||||
</ul>
|
||||
|
||||
<p>(3) Gemessen wird:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>solange der Besucher keine Wahl getroffen hat und sein Browser kein Signal nach dem nächsten Spiegelstrich sendet:</strong> Seitenaufrufe mit Pfad (ohne Suchparameter), Land (aus der IP-Adresse, die nicht gespeichert wird), Geräteklasse und ein Zähler dafür, dass das Banner angezeigt wurde;</li>
|
||||
<li><strong>bei den Browser-Signalen „Global Privacy Control“ und „Do Not Track“:</strong> nichts, und es erscheint kein Banner;</li>
|
||||
<li><strong>nach „Ablehnen“:</strong> nichts mehr, außer einmal dem Zähler „abgelehnt“;</li>
|
||||
<li><strong>nach „Annehmen“ zusätzlich:</strong> die Domain der Website, von der der Besucher kam, die Größenklasse des Browserfensters, eine täglich wechselnde pseudonyme Besucher-Kennung, Verweildauer, Scrolltiefe, Klickpositionen (Heatmap), die Art von Kontakt-Klicks (Telefon, E-Mail, WhatsApp, Link auf eine andere Website mit deren Domain) und ein Zähler „angenommen“.</li>
|
||||
</ul>
|
||||
|
||||
<p>Die einzelnen Datenarten nennt der AVV Website-Analyse in Nummer 3.</p>
|
||||
|
||||
<p>(4) Es werden keine Cookies gesetzt. Gespeichert wird auf dem Gerät des Besuchers nur seine Banner-Wahl im lokalen Speicher des Browsers, für 183 Tage.</p>
|
||||
|
||||
<p>(5) Die Auswertung steht im Kundenportal zur Verfügung: Kennzahlen und Diagramme für die letzten 7, 30 oder 90 Tage und eine Heatmap je Seite und Ansicht (Handy, Tablet, Computer) aus den bis zu 5.000 jüngsten Klicks der letzten 30 Tage. Die Daten liegen auf dem Server von WEBklar bei der Hetzner Online GmbH in Deutschland; sie werden nicht in Drittländer übermittelt. Einzelne Ereignisse und Klicks werden nach 30 Tagen gelöscht, Tageswerte nach 25 Monaten, alle Daten rund 60 Tage nach dem Ende der Website-Analyse (AVV Website-Analyse Nummer 9).</p>
|
||||
|
||||
<p>(6) Die Zahlen sind Näherungswerte. Besucher mit Blockern, abgeschaltetem JavaScript, Browser-Signal oder Ablehnung werden nur eingeschränkt oder gar nicht erfasst. Besucherzahlen, Herkunftsseiten, Verweildauer, Scrolltiefe, Klicks und Kontakt-Klicks stammen nur von Besuchern, die „Annehmen“ gewählt haben.</p>
|
||||
|
||||
<p>(7) Nicht enthalten sind Sitzungsaufzeichnungen, die Auswertung von Formularinhalten, die Messung von Ladezeiten und Fehlern, Kampagnen- und Trichteranalysen sowie ein Datenexport außer nach Nr. 7 Abs. 3.</p>
|
||||
|
||||
<p>(8) Das Banner fragt nur nach der Einwilligung in die Website-Analyse von WEBklar. Andere einwilligungspflichtige Inhalte der Website, etwa eingebettete Karten, Videos oder Schriften von Drittanbietern, deckt es nicht ab.</p>
|
||||
|
||||
<h2>3. Voraussetzungen</h2>
|
||||
|
||||
<p>(1) Der Kunde hat ein laufendes Wartungs-Abo. Im Hosting-Abo ist die Website-Analyse nicht enthalten.</p>
|
||||
|
||||
<p>(2) Die Website ist öffentlich erreichbar und wird bei WEBklar gehostet (Adresse unter project.webklar.com oder eigene Domain, die auf den Server von WEBklar zeigt). Nicht öffentliche Websites und Vorschauen, die nur nach einer Anmeldung erreichbar sind, werden nicht gemessen, ebenso wenig Websites, die im Dev-Modus auf einem Entwicklungsserver laufen. Enthält eine öffentliche Website Bereiche, die erst nach einer Anmeldung zugänglich sind (etwa eine Web-Anwendung mit Kundenkonto), werden auch dort Seitenaufrufe gezählt; dafür gilt Nr. 4 Abs. 5.</p>
|
||||
|
||||
<p>(3) Auf der Zusatzseite erscheinen als Verantwortlicher die Firma (sonst der Name), die Anschrift, soweit sie im Kundenkonto hinterlegt ist, und die E-Mail-Adresse aus dem Kundenkonto. Das Portal zeigt diese Angaben vor dem Einschalten an. Der Kunde prüft sie und teilt Änderungen mit. Fehlen Firma und Name oder fehlt eine gültige E-Mail-Adresse, lässt sich die Funktion nicht einschalten.</p>
|
||||
|
||||
<p>(4) Richtet sich die Website auch gezielt an Kinder unter 16 Jahren, schaltet der Kunde die Website-Analyse nicht ein, weil eine Einwilligung über das Banner dann nicht genügt (Art. 8 DSGVO). Lassen Inhalte oder Adressen der Website auf besondere Kategorien personenbezogener Daten schließen, etwa Gesundheit, Religion oder politische Meinung, teilt der Kunde das WEBklar vor dem Einschalten mit. WEBklar darf das Einschalten für eine solche Website ablehnen oder die Website-Analyse dort ausschalten.</p>
|
||||
|
||||
<h2>4. Pflichten des Kunden</h2>
|
||||
|
||||
<p>(1) Der Kunde ist für die Messung auf seiner Website Verantwortlicher im Sinne der DSGVO.</p>
|
||||
|
||||
<p>(2) Er entfernt, verdeckt oder umgeht das Banner nicht und verändert das Skript nicht. Text und Gestaltung des Banners legt WEBklar fest.</p>
|
||||
|
||||
<p>(3) Er bindet ohne Abstimmung mit WEBklar keine weiteren Mess-, Tracking- oder Marketing-Werkzeuge ein (zum Beispiel Google Analytics oder Meta Pixel), weil sie eine eigene Einwilligung erfordern können.</p>
|
||||
|
||||
<p>(4) Liegt die Datenschutzerklärung in der von WEBklar betreuten Website, fügt WEBklar beim Einschalten dort einen kurzen Hinweis mit Link auf die Zusatzseite ein und entfernt ihn beim Ausschalten wieder; das wird nicht auf das Stundenkontingent angerechnet. Liegt sie woanders, etwa bei einem externen Generator, erhält der Kunde den Hinweis als Textbaustein und fügt ihn selbst ein und entfernt ihn nach dem Ausschalten selbst. Für den übrigen Inhalt seiner Datenschutzerklärung bleibt der Kunde verantwortlich (§ 6 Abs. 3 der Abo-Bedingungen). Fällt WEBklar beim Einfügen ein offensichtlicher Widerspruch auf (etwa „Wir setzen keine Analyse-Werkzeuge ein“), weist WEBklar den Kunden darauf hin; eine Pflicht zur Prüfung der Datenschutzerklärung entsteht daraus nicht.</p>
|
||||
|
||||
<p>(5) Enthalten die Pfade seiner Website personenbezogene Angaben, etwa Namen, Kundennummern oder E-Mail-Adressen, teilt der Kunde das WEBklar vor dem Einschalten mit.</p>
|
||||
|
||||
<p>(6) Er nutzt die Auswertung nur für eigene Zwecke, versucht nicht, einzelne Besucher zu identifizieren, und gibt Heatmap-Links nicht weiter.</p>
|
||||
|
||||
<h2>5. Verfügbarkeit und Störungen</h2>
|
||||
|
||||
<p>(1) Das Verfügbarkeitsziel nach § 5 der Abo-Bedingungen gilt für die Website, nicht für die Messung. Störungen der Messung beheben wir in angemessener Frist (§ 12 der Abo-Bedingungen). Daten, die während einer Störung nicht erfasst wurden, lassen sich nicht nachträglich erheben.</p>
|
||||
|
||||
<p>(2) Fällt der Messdienst aus, wird die Website weiter ausgeliefert, nur ohne Banner und Messung.</p>
|
||||
|
||||
<p>(3) Die gesetzlichen Rechte des Kunden sowie §§ 12 und 13 der Abo-Bedingungen bleiben unberührt.</p>
|
||||
|
||||
<h2>6. Hinweis zur Rechtslage</h2>
|
||||
|
||||
<p>Für die Zählung, solange der Besucher keine Wahl getroffen hat (Nr. 2 Abs. 3), übermittelt das Skript nur die Adresse der aufgerufenen Seite und die Angabe, dass das Banner angezeigt wurde. Land und Geräteklasse bestimmt der Messdienst aus den Daten, die jeder Browser bei einer Anfrage ohnehin mitsendet. Ob auch dafür eine Einwilligung nach § 25 TDDDG nötig ist, haben Gerichte und Aufsichtsbehörden nicht entschieden; die Aufsichtsbehörden legen den Begriff des Zugriffs auf das Endgerät eher weit aus. Ändert sich die Rechtslage, passt WEBklar die Messung an (Nr. 8).</p>
|
||||
|
||||
<h2>7. Ende der Leistung</h2>
|
||||
|
||||
<p>(1) Der Kunde kann die Website-Analyse jederzeit im Kundenportal ausschalten; das Portal bestätigt die Abschaltung. Mit dem Ende des Wartungs-Abos endet die Website-Analyse; WEBklar schaltet sie spätestens am folgenden Tag ab und informiert den Kunden per E-Mail. Das gilt auch bei einem Wechsel ins Hosting-Abo und beim Widerruf eines Wechsels ins Wartungs-Abo.</p>
|
||||
|
||||
<p>(2) Mit dem Ausschalten speichert der Messdienst keine Messdaten mehr, und die Heatmap ist nicht mehr abrufbar. Das Messskript liefert dann kein Banner mehr aus, sobald die Website einmal mit der aktuellen Fassung des Skripts veröffentlicht wurde. Skript, Zusatzseite und, soweit WEBklar ihn eingefügt hat, den Hinweis in der Datenschutzerklärung entfernt WEBklar mit der nächsten Veröffentlichung der Website. WEBklar löst sie in der Regel sofort aus, spätestens innerhalb von fünf Werktagen.</p>
|
||||
|
||||
<p>(3) Die gespeicherten Daten löscht WEBklar nach Nummer 9 des AVV Website-Analyse. Bis zum 46. Tag nach dem Ende kann der Kunde eine Kopie der Tageswerte und der noch vorhandenen Einzelereignisse als CSV-Datei verlangen.</p>
|
||||
|
||||
<h2>8. Änderungen und Einstellung</h2>
|
||||
|
||||
<p>(1) WEBklar darf die Technik der Website-Analyse verbessern, wenn der Leistungsumfang nicht schlechter wird (§ 3 Abs. 7 der Abo-Bedingungen). Ändert sich dabei, welche Daten erhoben werden, gilt Nummer 12 des AVV Website-Analyse.</p>
|
||||
|
||||
<p>(2) WEBklar darf die Website-Analyse für eine Website mit einer Frist von vier Wochen beenden oder für alle Kunden mit einer Frist von drei Monaten einstellen, wenn</p>
|
||||
|
||||
<ul>
|
||||
<li>ein Gesetz, eine Gerichtsentscheidung oder eine Vorgabe einer Aufsichtsbehörde den Betrieb in der bisherigen Form unzulässig macht oder nur mit wesentlich anderer Technik erlaubt und der Kunde einer dafür nötigen neuen Fassung nicht zustimmt (AVV Website-Analyse Nummer 12 Abs. 3), oder</li>
|
||||
<li>eine Sicherheitslücke sich nicht anders beheben lässt.</li>
|
||||
</ul>
|
||||
|
||||
<p>Die Mitteilung erfolgt in Textform und nennt den Grund, die Merkmale der Änderung, den Zeitpunkt und bei Verbrauchern das Recht, den Vertrag nach § 327r Abs. 3 BGB zu beenden, mit seiner Frist.</p>
|
||||
|
||||
<p>(3) Ist der Weiterbetrieb aus einem Grund nach Absatz 2 schon vor Ablauf der Frist nicht zulässig, darf WEBklar die Messung ohne Vorankündigung aussetzen, solange das nötig ist. WEBklar informiert den Kunden darüber unverzüglich in Textform mit dem Grund und nimmt die Messung wieder auf, sobald das zulässig und technisch möglich ist.</p>
|
||||
|
||||
<p>(4) Gegenüber Unternehmern darf WEBklar die Website-Analyse außerdem ohne besonderen Grund mit einer Frist von drei Monaten in Textform für alle Kunden einstellen. Der Kunde kann das Wartungs-Abo dann innerhalb von 30 Tagen nach der Mitteilung zum Zeitpunkt der Einstellung kündigen; die Mindestlaufzeit steht dem nicht entgegen.</p>
|
||||
|
||||
<p>(5) Weil für die Website-Analyse kein Aufpreis berechnet wird, ändert ihre Einstellung den Preis des Wartungs-Abos nicht. Gesetzliche Rechte von Verbrauchern bei Änderungen digitaler Produkte (§ 327r BGB) bleiben unberührt.</p>
|
||||
|
||||
<h2>9. Hinweise</h2>
|
||||
|
||||
<p>(1) <strong>Keine Rechtsberatung.</strong> Die Zusatzseite und der Hinweis in der Datenschutzerklärung sind Mustertexte für die Standardkonfiguration. WEBklar sorgt dafür, dass sie die Technik richtig beschreiben, und passt sie an, wenn sich die Technik ändert. Eine Prüfung, ob die Website des Kunden insgesamt datenschutzkonform ist, und eine rechtliche Beratung im Einzelfall schuldet WEBklar nicht. Abstimmungen nach dieser Leistungsbeschreibung betreffen nur die Technik.</p>
|
||||
|
||||
<p>(2) <strong>Nachweis der Einwilligung.</strong> Ein Protokoll je Besucher wird nicht geführt. Als Nachweis dienen die Dokumentation der Technik und das Archiv der Fassungen von Banner-Text und Zusatzseite (AVV Website-Analyse Nummer 8 Abs. 6).</p>
|
||||
|
||||
<p>(3) Die Länderzuordnung nutzt die Datenbank „IP to Country Lite“ von DB-IP.com (Lizenz CC BY 4.0). Im Kundenportal steht dazu unter der Auswertung der Hinweis „Länderdaten: IP-Geolocation by DB-IP“ mit Link auf db-ip.com.</p>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
@@ -17,7 +17,7 @@ export const isValidBase = (b) => typeof b === 'string' && /^https:\/\/[^\s"'<>]
|
||||
|
||||
export function scriptTag(analytics, baseUrl) {
|
||||
// Attributwerte nur aus geprüften Eingaben (Regex), daher ohne Maskierung sicher.
|
||||
return `<script data-wk-analytics src="${baseUrl}w.js" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
|
||||
return `<script data-wk-analytics src="${baseUrl}w.js?s=${analytics.siteKey}" data-site="${analytics.siteKey}" data-color="${analytics.color}" defer></script>`;
|
||||
}
|
||||
|
||||
// Spannen, in denen Marker nichts zu suchen haben: Kommentare, script, style, textarea, template (ohne Verschachtelungs-Logik).
|
||||
|
||||
@@ -6,55 +6,79 @@
|
||||
<meta name="robots" content="noindex">
|
||||
<title>Datenschutz – Reichweitenmessung</title>
|
||||
<style>
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#111;max-width:46rem;margin:0 auto;padding:1.5rem 1rem 4rem}
|
||||
body{font-family:system-ui,-apple-system,Segoe UI,Roboto,sans-serif;line-height:1.6;color:#111;background:#fff;max-width:46rem;margin:0 auto;padding:1.5rem 1rem 16rem}
|
||||
h1{font-size:1.6rem}h2{font-size:1.15rem;margin-top:2rem}
|
||||
.stand{color:#555;font-size:.9rem}
|
||||
ul{padding-left:1.25rem}
|
||||
a{color:#0b57d0}
|
||||
.box{border:2px solid #111;border-radius:10px;padding:.25rem 1rem;margin:1.5rem 0}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<p><a href="/">← Zur Startseite</a></p>
|
||||
|
||||
<h1>Datenschutz bei der Reichweitenmessung</h1>
|
||||
<p class="stand">Ergänzung zur Datenschutzerklärung dieser Website · Stand: 01.10.2026</p>
|
||||
<p class="stand">Ergänzung zur Datenschutzerklärung dieser Website · Stand: 02.10.2026</p>
|
||||
<p>Diese Seite beschreibt nur die Reichweitenmessung. Alle übrigen Datenschutzhinweise findest Du in der Datenschutzerklärung dieser Website; Impressum und Datenschutzerklärung erreichst Du über die Startseite.</p>
|
||||
|
||||
<h2>Verantwortlicher</h2>
|
||||
<p>{{company}}<br>{{#address}}{{address}}<br>{{/address}}E-Mail: <a href="mailto:{{email}}">{{email}}</a></p>
|
||||
<p>Falls es einen Datenschutzbeauftragten gibt, findest Du seine Kontaktdaten in der Datenschutzerklärung dieser Website.</p>
|
||||
|
||||
<h2>Worum es geht</h2>
|
||||
<p>Wir möchten verstehen, wie diese Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo bleiben sie hängen? Es gibt dabei keine Werbung, kein Profiling und keine Weitergabe an Werbenetzwerke. Die Messung setzt keine Cookies und bindet keine Dienste von Google, Meta oder anderen Drittanbietern ein. Die Messung läuft auf Servern in Deutschland.</p>
|
||||
<p>Wir möchten verstehen, wie diese Website genutzt wird, um sie zu verbessern: Welche Seiten werden gelesen, woher kommen die Besucher, wo wird geklickt? Dafür nutzen wir eine eigene Messung ohne Cookies und ohne Dienste von Google, Meta oder anderen Werbenetzwerken. Wir nutzen die Daten nicht für Werbung und bilden keine Profile einzelner Besucher. Die Messung läuft auf einem Server in Deutschland.</p>
|
||||
<p>Damit die Messung arbeiten kann, lädt Dein Browser bei jedem Seitenaufruf ein Skript von einem Server der WEBklar GbR. Dabei erhält dieser Server, wie bei jeder Datei, die eine Website lädt, technisch Deine IP-Adresse und Dein Browser-Kennzeichen (User-Agent). Das gilt auch, wenn Du ablehnst oder „Global Privacy Control“ aktiviert hast. Wir speichern und protokollieren beides nicht.</p>
|
||||
|
||||
<h2>Wenn Du nichts oder „Ablehnen“ auswählst</h2>
|
||||
<p>Dann zählen wir nur, dass eine Seite aufgerufen wurde. Dabei werden gespeichert:</p>
|
||||
<h2>Solange Du im Banner nichts ausgewählt hast</h2>
|
||||
<p>Dann zählen wir den Aufruf der Seite. Das Skript übermittelt dafür nur die Adresse der Seite und die Angabe, dass das Banner angezeigt wurde. Gespeichert werden:</p>
|
||||
<ul>
|
||||
<li>der Seitenpfad (ohne Suchbegriffe oder Parameter hinter dem „?“),</li>
|
||||
<li>die Website, von der Du kommst (nur der Domainname, falls es nicht diese Website selbst ist),</li>
|
||||
<li>das Land, abgeleitet aus Deiner IP-Adresse,</li>
|
||||
<li>die Geräteklasse (Handy, Tablet, Computer) aus dem Browser-Kennzeichen und zusätzlich die Größenklasse des Browserfensters, und</li>
|
||||
<li>anonyme Zähler dafür, ob das Banner angezeigt, angenommen oder abgelehnt wurde.</li>
|
||||
<li>das Land, das wir aus Deiner IP-Adresse ableiten,</li>
|
||||
<li>die Geräteklasse (Handy, Tablet oder Computer), die wir aus dem Browser-Kennzeichen ableiten,</li>
|
||||
<li>der Zeitpunkt, auf die volle Stunde gerundet, und</li>
|
||||
<li>ein Zähler dafür, dass das Banner angezeigt wurde.</li>
|
||||
</ul>
|
||||
<p>Deine IP-Adresse wird nur für den Augenblick der Abfrage verwendet, um das Land zu bestimmen. Sie wird nicht gespeichert, ebenso wenig Dein Browser-Kennzeichen (User-Agent). Ohne Einwilligung gibt es keine Kennung, mit der wir Dich wiedererkennen könnten. Dasselbe gilt, wenn Dein Browser das Signal „Global Privacy Control“ oder „Do Not Track“ sendet: Wir behandeln das wie ein „Ablehnen“ und zeigen das Banner nicht an.</p>
|
||||
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen und das Banner korrekt zu betreiben. Auf Informationen in Deinem Endgerät greifen wir dafür nach unserer Einschätzung nicht im Sinne von § 25 TDDDG zu.</p>
|
||||
<p>IP-Adresse und Browser-Kennzeichen verwenden wir nur kurz im Arbeitsspeicher: um Land und Geräteklasse zu bestimmen, um automatische Abrufe durch Programme (Bots) auszusortieren und um zu verhindern, dass die Messung durch massenhafte Anfragen verfälscht wird. Für diese Mengenbegrenzung bilden wir aus Deiner IP-Adresse einen Prüfwert (Hash), den wir nur im Arbeitsspeicher halten und nicht speichern. Eine Kennung, mit der wir Dich wiedererkennen könnten, speichern wir ohne Deine Einwilligung nicht.</p>
|
||||
<p>Rechtsgrundlage ist Art. 6 Abs. 1 lit. f DSGVO. Unser berechtigtes Interesse ist, die Nutzung unserer Website in Zahlen zu kennen, das Banner zu betreiben und die Messung vor Missbrauch zu schützen.</p>
|
||||
|
||||
<h2>Wenn Du „Ablehnen“ wählst</h2>
|
||||
<p>Dann zählen wir einmal, dass abgelehnt wurde, und das Skript sendet danach nichts mehr, auch keine Seitenaufrufe. Sendet Dein Browser das Signal „Global Privacy Control“ oder „Do Not Track“, zeigen wir das Banner gar nicht an und zählen nichts.</p>
|
||||
|
||||
<h2>Wenn Du „Annehmen“ wählst</h2>
|
||||
<p>Dann messen wir zusätzlich:</p>
|
||||
<ul>
|
||||
<li>eine Besucher-Kennung, die täglich wechselt (aus IP-Adresse und Browser-Kennzeichen errechnet, nicht zurückrechenbar und nicht über mehrere Tage verknüpfbar; IP-Adresse und Browser-Kennzeichen selbst werden nicht gespeichert),</li>
|
||||
<li>wie lange Du auf einer Seite bleibst und wie weit Du scrollst,</li>
|
||||
<li>wo auf der Seite geklickt wird (Klickposition und die Art des angeklickten Elements, für eine „Heatmap“; keine Eingaben in Formulare) und</li>
|
||||
<li>ob Du auf Telefonnummer, E-Mail-Adresse, WhatsApp-Link oder einen Link zu einer anderen Website klickst.</li>
|
||||
<li>von welcher Website Du gekommen bist (nur der Domainname),</li>
|
||||
<li>die Größenklasse Deines Browserfensters,</li>
|
||||
<li>eine Besucher-Kennung, die täglich wechselt. Wir berechnen sie aus IP-Adresse und Browser-Kennzeichen mit einem Schlüssel, der jeden Tag neu erzeugt und nicht gespeichert wird. Am selben Tag könnten wir die Kennung damit nachrechnen. Nach dem Wechsel des Schlüssels lässt sie sich nicht mehr aus Deiner IP-Adresse berechnen und nicht mit Kennungen anderer Tage verknüpfen,</li>
|
||||
<li>wie lange Du auf einer Seite bleibst (ab dem Laden der Seite) und wie weit Du scrollst,</li>
|
||||
<li>wo auf der Seite geklickt wird: die Klickposition und den Aufbau des angeklickten Elements, ohne Texte und ohne Eingaben in Formulare (für eine „Heatmap“),</li>
|
||||
<li>ob Du auf eine Telefonnummer, eine E-Mail-Adresse, einen WhatsApp-Link oder einen Link zu einer anderen Website klickst (dann mit deren Domainname, nicht mit der Nummer oder Adresse selbst), und</li>
|
||||
<li>einen Zähler dafür, dass Du „Annehmen“ gewählt hast.</li>
|
||||
</ul>
|
||||
<p>Rechtsgrundlage ist Deine Einwilligung nach Art. 6 Abs. 1 lit. a DSGVO und § 25 Abs. 1 TDDDG. Außerdem speichern wir Deine Auswahl in Deinem Browser (lokaler Speicher, Eintrag „wk-consent“), damit wir Dich nicht bei jedem Besuch erneut fragen müssen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt sechs Monate, danach fragen wir erneut.</p>
|
||||
<p>Rechtsgrundlage ist Deine Einwilligung nach § 25 Abs. 1 TDDDG und Art. 6 Abs. 1 lit. a DSGVO.</p>
|
||||
|
||||
<h2>Was in Deinem Browser gespeichert wird</h2>
|
||||
<p>Wir speichern Deine Auswahl („Annehmen“ oder „Ablehnen“) mit Datum und der Fassung des Banner-Textes im lokalen Speicher Deines Browsers (Eintrag „wk-consent“), damit wir Dich nicht bei jedem Seitenaufruf erneut fragen. Das ist ohne Einwilligung zulässig (§ 25 Abs. 2 Nr. 2 TDDDG). Die Auswahl gilt 183 Tage, danach fragen wir erneut. Du kannst den Eintrag jederzeit selbst löschen, indem Du die Website-Daten in Deinem Browser löschst.</p>
|
||||
|
||||
<h2>Speicherdauer</h2>
|
||||
<p>Einzelne Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte ohne Personenbezug, die wir bis zum Ende der Messung und weitere 60 Tage aufbewahren. Danach werden auch sie gelöscht.</p>
|
||||
<p>Einzelne Seitenaufrufe, Ereignisse und Klickpositionen löschen wir nach 30 Tagen. Danach bleiben nur zusammengefasste Tageswerte, also Summen je Tag, etwa Aufrufe je Seite oder je Land, ohne Besucher-Kennung. Wir löschen sie nach 25 Monaten, spätestens rund 60 Tage nach dem Ende der Messung. Unser Dienstleister sichert den Server täglich; gelöschte Daten bleiben dort bis zu sieben Tage erhalten.</p>
|
||||
|
||||
<h2>Empfänger</h2>
|
||||
<p>Die Messung wird für uns von der WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als Auftragsverarbeiter betrieben. WEBklar darf die Daten nur auf unsere Weisung und für unsere Zwecke verwenden. Die Server stehen bei der Hetzner Online GmbH (Rechenzentrum in Deutschland), die als weiterer Auftragsverarbeiter eingesetzt wird. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
|
||||
<p>Die Messung betreibt für uns die WEBklar GbR, Am Schwimmbad 10, 67722 Winnweiler, als Auftragsverarbeiter. WEBklar darf die Daten nur auf unsere Weisung und für unsere Zwecke verwenden. Der Server steht bei der Hetzner Online GmbH in Deutschland, die als weiterer Auftragsverarbeiter eingesetzt wird. Eine Übermittlung in Länder außerhalb der EU bzw. des EWR findet nicht statt.</p>
|
||||
|
||||
<h2>Widerruf</h2>
|
||||
<p>Du kannst Deine Entscheidung jederzeit ändern oder Deine Einwilligung widerrufen: Klicke dazu auf den Link „Cookie-Einstellungen“ (in der Regel unten rechts auf jeder Seite oder über das dafür vorgesehene Element der Website) und wähle „Ablehnen“. Der Widerruf gilt ab dann für die Zukunft. Die Rechtmäßigkeit der bis dahin erfolgten Verarbeitung bleibt unberührt.</p>
|
||||
<h2>Freiwilligkeit</h2>
|
||||
<p>Du bist weder gesetzlich noch vertraglich verpflichtet, uns diese Daten bereitzustellen. Wenn Du ablehnst oder Deine Einwilligung widerrufst, kannst Du die Website trotzdem uneingeschränkt nutzen. Eine automatisierte Entscheidungsfindung findet nicht statt.</p>
|
||||
|
||||
<h2>Einwilligung widerrufen</h2>
|
||||
<p>Du kannst Deine Einwilligung jederzeit mit Wirkung für die Zukunft widerrufen: Klicke auf „Datenschutz-Einstellungen“ (in der Regel unten rechts auf jeder Seite) und wähle „Ablehnen“. Die Rechtmäßigkeit der Verarbeitung bis zum Widerruf bleibt unberührt.</p>
|
||||
|
||||
<div class="box">
|
||||
<h2>Widerspruchsrecht</h2>
|
||||
<p>Du kannst der Zählung nach Art. 6 Abs. 1 lit. f DSGVO jederzeit aus Gründen widersprechen, die sich aus Deiner besonderen Situation ergeben (Art. 21 DSGVO). Am einfachsten wählst Du im Banner oder über „Datenschutz-Einstellungen“ „Ablehnen“ oder aktivierst in Deinem Browser das Signal „Global Privacy Control“. Dann sendet das Skript keine Daten mehr. Du kannst Deinen Widerspruch auch an die oben genannte E-Mail-Adresse richten.</p>
|
||||
</div>
|
||||
|
||||
<h2>Deine Rechte</h2>
|
||||
<p>Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). Du kannst der Verarbeitung nach Art. 6 Abs. 1 lit. f DSGVO aus Gründen, die sich aus Deiner besonderen Situation ergeben, jederzeit widersprechen (Art. 21 DSGVO). Da wir Dich anhand der gespeicherten Daten nicht wiedererkennen können, können wir Auskunft, Berichtigung oder Löschung zu einzelnen Besuchen in der Regel nicht vornehmen, es sei denn, Du stellst uns zusätzliche Angaben bereit, mit denen wir Dich identifizieren können (Art. 11, Art. 12 Abs. 2 DSGVO). Du hast außerdem das Recht, Dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren. Wende Dich für Deine Anliegen an die oben genannte Adresse.</p>
|
||||
<p>Du hast das Recht auf Auskunft, Berichtigung, Löschung, Einschränkung der Verarbeitung und Datenübertragbarkeit (Art. 15 bis 18 und 20 DSGVO). In den Messdaten speichern wir keine Namen, keine IP-Adressen und keine Kennung, die länger als einen Tag gilt, und wir runden Zeitpunkte auf die volle Stunde. Deshalb können wir gespeicherte Messdaten in der Regel nicht Dir zuordnen (Art. 11 DSGVO). Anders kann es sein, solange die Zugriffsprotokolle des Webservers noch vorhanden sind (14 Tage). Wenn Du uns Datum, ungefähre Uhrzeit, besuchte Seiten und, wenn Du möchtest, Deine IP-Adresse nennst, prüfen wir, ob eine Zuordnung möglich ist. Ist das nicht möglich, teilen wir Dir das mit.</p>
|
||||
<p>Mit Deinen Anliegen kannst Du Dich jederzeit an die oben genannte Adresse wenden. Außerdem hast Du das Recht, Dich bei einer Datenschutz-Aufsichtsbehörde zu beschweren (Art. 77 DSGVO).</p>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11,7 +11,7 @@ import { createJobs } from '../src/jobs.js';
|
||||
const BASE = 'https://project.webklar.com/a/';
|
||||
const KEY = 'abcdef0123456789abcd';
|
||||
const A = (o = {}) => ({ siteKey: KEY, color: '#2b6cb0', privacy: { company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }, ...o });
|
||||
const TAG = `<script data-wk-analytics src="${BASE}w.js" data-site="${KEY}" data-color="#2b6cb0" defer></script>`;
|
||||
const TAG = `<script data-wk-analytics src="${BASE}w.js?s=${KEY}" data-site="${KEY}" data-color="#2b6cb0" defer></script>`;
|
||||
const dir = () => mkdtempSync(join(tmpdir(), 'inj-'));
|
||||
const put = (d, f, c) => { mkdirSync(join(d, f, '..'), { recursive: true }); writeFileSync(join(d, f), c); };
|
||||
const get = (d, f) => readFileSync(join(d, f), 'utf8');
|
||||
@@ -81,7 +81,9 @@ test('Datenschutzseite: Platzhalter maskiert, Skript auch dort, Pflichtabschnitt
|
||||
assert.ok(p.includes('A "B" <C>') && p.includes('x'y@z.de'));
|
||||
assert.ok(!p.includes('{{'));
|
||||
assert.equal(count(p, 'data-wk-analytics'), 1);
|
||||
for (const s of ['Verantwortlicher', 'Zweck', 'Einwilligung', 'Rechtsgrundlage', 'Auftragsverarbeiter', 'Hetzner', 'Speicherdauer', 'Cookie-Einstellungen', 'Rechte']) assert.ok(p.includes(s), s);
|
||||
for (const s of ['Verantwortlicher', 'Zweck', 'Einwilligung', 'Rechtsgrundlage', 'Auftragsverarbeiter', 'Hetzner', 'Speicherdauer', 'Datenschutz-Einstellungen', 'Rechte']) assert.ok(p.includes(s), s);
|
||||
assert.ok(p.includes('Stand: 02.10.2026'));
|
||||
assert.ok(!p.includes('Cookie-Einstellungen'));
|
||||
});
|
||||
|
||||
test('ohne analytics: nichts geändert, keine Seite', async () => {
|
||||
|
||||
+111
-30
@@ -1,10 +1,11 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Website-Analyse: legt die Analyse-Attribute an der Collection portalFeatures an.
|
||||
* Idempotent: vorhandene Attribute werden uebersprungen.
|
||||
* Website-Analyse: legt die Analyse-Attribute an der Collection portalFeatures an und die
|
||||
* Collection analyseConsents (Einwilligungs-Historie, nur Server-Key). Idempotent: Vorhandenes
|
||||
* wird uebersprungen. Vor dem Einschalten der Analyse (Livegang) ausfuehren.
|
||||
* Usage:
|
||||
* node scripts/analyse-setup.mjs --check (nur melden, nichts anlegen)
|
||||
* node scripts/analyse-setup.mjs (fehlende Attribute anlegen)
|
||||
* node scripts/analyse-setup.mjs (Fehlendes anlegen)
|
||||
*/
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { config } from '../server/config.js'
|
||||
@@ -18,16 +19,42 @@ export const ANALYSE_ATTRIBUTES = [
|
||||
{ key: 'offSince', type: 'string', size: 40, required: false },
|
||||
{ key: 'activatedMailAt', type: 'string', size: 40, required: false },
|
||||
{ key: 'privacyTicketId', type: 'string', size: 40, required: false },
|
||||
{ key: 'avvConsentDate', type: 'string', size: 10, required: false },
|
||||
{ key: 'avvConsentChannel', type: 'string', size: 10, required: false },
|
||||
{ key: 'removalTicketId', type: 'string', size: 40, required: false },
|
||||
{ key: 'publishTicketId', type: 'string', size: 40, required: false },
|
||||
// Zielgruppen-Bestaetigung (nicht an Kinder unter 16): bestaetigt + Fassung des Bestaetigungstextes
|
||||
{ key: 'audienceConfirmed', type: 'boolean', required: false },
|
||||
{ key: 'audienceTextVersion', type: 'string', size: 20, required: false },
|
||||
]
|
||||
|
||||
/** Echter Appwrite-Client (REST) fuer die Collection portalFeatures. */
|
||||
const str = (key, size) => ({ key, type: 'string', size, required: false })
|
||||
|
||||
/** Einwilligungs-Historie: nur anfuegen, nie aendern; kein Client-Zugriff. */
|
||||
export const CONSENT_COLLECTION_NAME = 'analyseConsents'
|
||||
export const CONSENT_ATTRIBUTES = [
|
||||
str('customerId', 36),
|
||||
str('projectId', 36),
|
||||
str('siteKey', 40),
|
||||
str('event', 8),
|
||||
str('avvVersion', 20),
|
||||
str('via', 8),
|
||||
str('acceptedAt', 40),
|
||||
str('acceptedBy', 100),
|
||||
str('textformDate', 10),
|
||||
str('textformChannel', 10),
|
||||
{ key: 'audienceConfirmed', type: 'boolean', required: false },
|
||||
str('audienceTextVersion', 20),
|
||||
]
|
||||
export const CONSENT_INDEXES = [{ key: 'projectId_idx', type: 'key', attributes: ['projectId'] }]
|
||||
|
||||
/** Echter Appwrite-Client (REST); jede Methode nimmt die Collection-ID als ersten Parameter. */
|
||||
export function createAppwriteClient() {
|
||||
const { endpoint, projectId, databaseId, apiKey } = config.appwrite
|
||||
const collectionId = config.collections.portalFeatures
|
||||
const base = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections/${collectionId}`
|
||||
const db = `${endpoint.replace(/\/$/, '')}/databases/${databaseId}/collections`
|
||||
|
||||
async function aw(path, { method = 'GET', body } = {}) {
|
||||
const response = await fetch(`${base}${path}`, {
|
||||
const response = await fetch(`${db}${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -37,60 +64,114 @@ export function createAppwriteClient() {
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
const data = await response.json().catch(() => ({}))
|
||||
if (!response.ok) throw new Error(data.message || `Appwrite ${response.status}`)
|
||||
if (!response.ok) {
|
||||
const err = new Error(data.message || `Appwrite ${response.status}`)
|
||||
err.status = response.status
|
||||
throw err
|
||||
}
|
||||
return data
|
||||
}
|
||||
// Ohne limit liefert Appwrite nur 25 Eintraege
|
||||
const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }))
|
||||
|
||||
return {
|
||||
async listAttributes() {
|
||||
// Ohne limit liefert Appwrite nur 25 Attribute - die Sammlung hat mehr
|
||||
const q = encodeURIComponent(JSON.stringify({ method: 'limit', values: [200] }))
|
||||
const data = await aw(`/attributes?queries[]=${q}`)
|
||||
return data.attributes || []
|
||||
async collectionExists(id) {
|
||||
try {
|
||||
await aw(`/${id}`)
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err.status === 404) return false
|
||||
throw err
|
||||
}
|
||||
},
|
||||
async createAttribute({ type, ...body }) {
|
||||
await aw(`/attributes/${type}`, { method: 'POST', body })
|
||||
/** Legt die Collection ohne Rechte an (nur Server-Key); true = neu angelegt. */
|
||||
async ensureCollection(id, name) {
|
||||
if (await this.collectionExists(id)) return false
|
||||
await aw('', { method: 'POST', body: { collectionId: id, name, permissions: [], documentSecurity: false } })
|
||||
return true
|
||||
},
|
||||
async waitForAttribute(key, maxSec = 30) {
|
||||
async listAttributes(id) {
|
||||
return (await aw(`/${id}/attributes?queries[]=${q}`)).attributes || []
|
||||
},
|
||||
async createAttribute(id, { type, ...body }) {
|
||||
await aw(`/${id}/attributes/${type}`, { method: 'POST', body })
|
||||
},
|
||||
async waitForAttribute(id, key, maxSec = 30) {
|
||||
for (let i = 0; i < maxSec; i += 1) {
|
||||
const attr = await aw(`/attributes/${key}`).catch(() => null)
|
||||
const attr = await aw(`/${id}/attributes/${key}`).catch(() => null)
|
||||
if (attr?.status === 'available') return
|
||||
await new Promise((r) => setTimeout(r, 1000))
|
||||
}
|
||||
throw new Error(`Attribut ${key} wurde nicht verfuegbar`)
|
||||
},
|
||||
async listIndexes(id) {
|
||||
return (await aw(`/${id}/indexes?queries[]=${q}`)).indexes || []
|
||||
},
|
||||
async createIndex(id, index) {
|
||||
await aw(`/${id}/indexes`, { method: 'POST', body: index })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function main({ check = false, client, log = console.log } = {}) {
|
||||
const api = client || createAppwriteClient()
|
||||
const existing = new Set((await api.listAttributes()).map((a) => a.key))
|
||||
const missing = ANALYSE_ATTRIBUTES.filter((a) => !existing.has(a.key))
|
||||
const featuresId = config.collections.portalFeatures
|
||||
const consentsId = config.collections.analyseConsents
|
||||
const missing = []
|
||||
|
||||
for (const attr of ANALYSE_ATTRIBUTES) {
|
||||
if (existing.has(attr.key)) log(`= Attribut ${attr.key} (existiert)`)
|
||||
else if (check) log(`- Attribut ${attr.key} FEHLT`)
|
||||
// Attribute einer Collection abgleichen; `exists` false = Collection fehlt (alle Attribute fehlen)
|
||||
async function syncAttributes(collectionId, attributes, exists) {
|
||||
const have = exists ? new Set((await api.listAttributes(collectionId)).map((a) => a.key)) : new Set()
|
||||
const absent = attributes.filter((a) => !have.has(a.key))
|
||||
for (const attr of attributes) {
|
||||
if (have.has(attr.key)) log(`= ${collectionId}.${attr.key} (existiert)`)
|
||||
else if (check) log(`- ${collectionId}.${attr.key} FEHLT`)
|
||||
}
|
||||
return absent
|
||||
}
|
||||
|
||||
const featureAbsent = await syncAttributes(featuresId, ANALYSE_ATTRIBUTES, true)
|
||||
featureAbsent.forEach((a) => missing.push({ ...a, collection: featuresId }))
|
||||
|
||||
const consentsExist = await api.collectionExists(consentsId)
|
||||
if (!consentsExist) log(`${check ? '-' : '+'} Collection ${consentsId}${check ? ' FEHLT' : ''}`)
|
||||
const consentAbsent = await syncAttributes(consentsId, CONSENT_ATTRIBUTES, consentsExist)
|
||||
consentAbsent.forEach((a) => missing.push({ ...a, collection: consentsId }))
|
||||
const haveIndexes = consentsExist ? new Set((await api.listIndexes(consentsId)).map((i) => i.key)) : new Set()
|
||||
const indexAbsent = CONSENT_INDEXES.filter((i) => !haveIndexes.has(i.key))
|
||||
if (check) indexAbsent.forEach((i) => log(`- Index ${consentsId}.${i.key} FEHLT`))
|
||||
|
||||
if (check) {
|
||||
log(missing.length ? `${missing.length} Attribut(e) fehlen.` : 'Alle Attribute vorhanden.')
|
||||
return { missing }
|
||||
const open = missing.length + indexAbsent.length + (consentsExist ? 0 : 1)
|
||||
log(open ? `${open} Eintrag/Eintraege fehlen.` : 'Alles vorhanden.')
|
||||
return { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent }
|
||||
}
|
||||
|
||||
for (const attr of missing) {
|
||||
await api.createAttribute(attr)
|
||||
log(`+ Attribut ${attr.key}`)
|
||||
for (const attr of featureAbsent) {
|
||||
await api.createAttribute(featuresId, attr)
|
||||
log(`+ Attribut ${featuresId}.${attr.key}`)
|
||||
}
|
||||
if (!consentsExist) await api.ensureCollection(consentsId, CONSENT_COLLECTION_NAME)
|
||||
for (const attr of consentAbsent) {
|
||||
await api.createAttribute(consentsId, attr)
|
||||
log(`+ Attribut ${consentsId}.${attr.key}`)
|
||||
}
|
||||
for (const attr of featureAbsent) await api.waitForAttribute(featuresId, attr.key)
|
||||
for (const attr of consentAbsent) await api.waitForAttribute(consentsId, attr.key)
|
||||
for (const index of indexAbsent) {
|
||||
await api.createIndex(consentsId, index)
|
||||
log(`+ Index ${consentsId}.${index.key}`)
|
||||
}
|
||||
for (const attr of missing) await api.waitForAttribute(attr.key)
|
||||
log('Fertig.')
|
||||
return { missing }
|
||||
return { missing, missingCollection: !consentsExist, missingIndexes: indexAbsent }
|
||||
}
|
||||
|
||||
const invokedDirectly = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
if (invokedDirectly) {
|
||||
const check = process.argv.includes('--check')
|
||||
main({ check })
|
||||
.then(({ missing }) => process.exit(check && missing.length ? 1 : 0))
|
||||
.then(({ missing, missingCollection, missingIndexes }) =>
|
||||
process.exit(check && (missing.length || missingCollection || missingIndexes.length) ? 1 : 0))
|
||||
.catch((e) => {
|
||||
console.error(e.message)
|
||||
process.exit(1)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Erzeugt die Druck-/PDF-Fassungen der Vertragsunterlagen aus den HTML-Quellen.
|
||||
# Laeuft auf dem Host (braucht Chrome/Chromium), die PDFs werden eingecheckt.
|
||||
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh
|
||||
# Usage: CHROME=/pfad/zu/chrome scripts/build-legal-pdfs.sh [all|analyse]
|
||||
# analyse = nur AVV und Leistungsbeschreibung der Website-Analyse (Fassung ANALYSE_VERSION), andere PDFs bleiben unveraendert
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
CHROME="${CHROME:-$(ls -d ~/.cache/ms-playwright/chromium-*/chrome-linux64/chrome 2>/dev/null | tail -1)}"
|
||||
@@ -13,7 +14,21 @@ pdf() { # $1 = Quelle (HTML), $2 = Ziel (PDF)
|
||||
echo "-> $2"
|
||||
}
|
||||
|
||||
pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf
|
||||
# Fassung 3 (public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf, Archiv unter public/vertragsunterlagen/v3/) wird nicht neu erzeugt
|
||||
pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf
|
||||
pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf
|
||||
MODE="${1:-all}"
|
||||
ANALYSE_VERSION="${ANALYSE_VERSION:-2026-10-02}"
|
||||
|
||||
case "$MODE" in
|
||||
analyse)
|
||||
pdf "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/avv-website-analyse-$ANALYSE_VERSION.pdf"
|
||||
pdf "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.html" "public/rechtliches/leistungsbeschreibung-website-analyse-$ANALYSE_VERSION.pdf"
|
||||
;;
|
||||
all)
|
||||
pdf docs/leitfaden-empfehlungsgeber.html docs/WEBklar-Leitfaden-Empfehlungsgeber.pdf
|
||||
# Fassung 3 (public/WEBklar-Wartungs-Abo-Vertragsunterlagen.pdf, Archiv unter public/vertragsunterlagen/v3/) wird nicht neu erzeugt
|
||||
pdf public/abo-bedingungen.html public/WEBklar-Abo-Vertragsunterlagen.pdf
|
||||
pdf public/datenschutz.html docs/WEBklar-Datenschutzerklaerung.pdf
|
||||
;;
|
||||
*)
|
||||
echo "Usage: $0 [all|analyse]"; exit 1
|
||||
;;
|
||||
esac
|
||||
+2
-1
@@ -143,6 +143,7 @@ export const config = {
|
||||
customerPortalAccess: process.env.APPWRITE_COLLECTION_CUSTOMER_PORTAL_ACCESS || 'customerPortalAccess',
|
||||
websiteProjects: process.env.APPWRITE_COLLECTION_WEBSITE_PROJECTS || 'websiteProjects',
|
||||
portalFeatures: process.env.APPWRITE_COLLECTION_PORTAL_FEATURES || 'portalFeatures',
|
||||
analyseConsents: process.env.APPWRITE_COLLECTION_ANALYSE_CONSENTS || 'analyseConsents',
|
||||
workorders: process.env.APPWRITE_COLLECTION_WORKORDERS || 'workorders',
|
||||
worksheets: process.env.APPWRITE_COLLECTION_WORKSHEETS || 'worksheets',
|
||||
employees: process.env.APPWRITE_COLLECTION_EMPLOYEES || 'employees',
|
||||
@@ -355,7 +356,7 @@ export const config = {
|
||||
url: process.env.ANALYTICS_URL || 'http://webklar-analytics:8091',
|
||||
adminToken: process.env.ANALYTICS_ADMIN_TOKEN || '',
|
||||
heatSecret: process.env.ANALYTICS_HEAT_SECRET || '',
|
||||
avvVersion: '2026-10-01',
|
||||
avvVersion: '2026-10-02',
|
||||
},
|
||||
|
||||
invoiceNinja: {
|
||||
|
||||
+8
-1
@@ -22,7 +22,7 @@ import { purgeExpiredUploads } from './services/takeoverCredits.js'
|
||||
import { purgePendingChatUploads } from './services/chatAttachments.js'
|
||||
import { purgeUnclaimedNewsImages } from './services/newsImages.js'
|
||||
import { runActivation } from './services/siteFeatures.js'
|
||||
import { runAboCheck } from './services/analyse.js'
|
||||
import { runAboCheck, runAnalyseReconcile } from './services/analyse.js'
|
||||
import giteaWebhookRoutes from './routes/webhook/gitea.js'
|
||||
import pushTimeRoutes from './routes/pushTime.js'
|
||||
import adminGiteaRoutes from './routes/admin/gitea.js'
|
||||
@@ -36,6 +36,7 @@ import adminPreviewOpenRoutes from './routes/admin/previewOpen.js'
|
||||
import newsRoutes from './routes/news.js'
|
||||
import publicNewsRoutes from './routes/publicNews.js'
|
||||
import adminAnalyseRoutes from './routes/admin/analyse.js'
|
||||
import { analyseLegalRouter } from './services/analyseLegal.js'
|
||||
import adminSwitchLinkRoutes from './routes/admin/switchLink.js'
|
||||
import adminFinanzenRoutes from './routes/admin/finanzen.js'
|
||||
|
||||
@@ -110,6 +111,8 @@ app.use('/embed/', (_req, res, next) => {
|
||||
res.set('Access-Control-Allow-Origin', '*')
|
||||
next()
|
||||
})
|
||||
// Adressen ohne Fassung: aktuelle geprüfte Fassung der Analyse-Rechtstexte (vor express.static)
|
||||
app.use('/rechtliches', analyseLegalRouter())
|
||||
app.use(express.static(publicDir))
|
||||
|
||||
app.get('/dashboard.html', (req, res, next) => {
|
||||
@@ -179,6 +182,10 @@ const server = app.listen(config.port, '0.0.0.0', () => {
|
||||
runAboCheck({ now: new Date() })
|
||||
.then((r) => r.deactivated.length && console.log(`[analyse] ${r.deactivated.length} Analyse(n) ohne Abo beendet`))
|
||||
.catch((err) => console.warn('[analyse] Abo-Prüfung fehlgeschlagen:', err.message))
|
||||
// danach der Abgleich: gelöschte Projekte/Kunden, geänderte Domains, Messdienst für beendete Analysen, fehlende Tickets
|
||||
.then(() => runAnalyseReconcile({ now: new Date() }))
|
||||
.then((r) => r.ended.length && console.log(`[analyse] Abgleich: ${r.ended.length} Analyse(n) beendet`))
|
||||
.catch((err) => console.warn('[analyse] Abgleich fehlgeschlagen:', err.message))
|
||||
setTimeout(analyseAboCheck, 2 * 60 * 1000).unref()
|
||||
setInterval(analyseAboCheck, 24 * 60 * 60 * 1000).unref()
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
Betreff: Deine Website-Analyse ist aktiv – {{projekt}}
|
||||
Absender: „WEBklar“ <Portal-Postfach>
|
||||
Empfänger: Kunde (E-Mail des Kundenkontos) · BCC intern
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}
|
||||
Platzhalter: {{kunde_name}}, {{projekt}}, {{portal_url}}, {{fassung}}, {{zeitpunkt}}, {{person}}, {{datum}}, {{weg}}, {{avv_url}}, {{lb_url}}
|
||||
Optionale Blöcke (zwischen WENN-/ENDE-Kommentaren; bei Nichtzutreffen samt Inhalt entfernen): portal_weg, textform_weg
|
||||
-->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
@@ -33,8 +34,15 @@ a{color:#111111;}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">✓ Website-Analyse</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse ist aktiv</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist eingeschaltet. Besucher sehen jetzt einen Hinweis-Banner, Du findest die Auswertung im Kundenportal. Wir ergänzen Deine Datenschutzerklärung um einen Hinweis auf die Analyse und melden uns, sobald das erledigt ist.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist eingeschaltet. Das Einwilligungsbanner erscheint mit der nächsten Veröffentlichung Deiner Website. Die lösen wir jetzt aus; in der Regel ist es nach wenigen Minuten da. Die Auswertung findest Du im Kundenportal. Wir fügen in Deine Datenschutzerklärung einen Hinweis auf die Zusatzseite ein und melden uns, sobald das erledigt ist. Liegt Deine Datenschutzerklärung nicht bei uns, schicken wir Dir den Textbaustein im Chat.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;">{{projekt}}</td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Vereinbart</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><a href="{{avv_url}}" style="color:#111111;">Auftragsverarbeitungsvertrag Website-Analyse</a> und <a href="{{lb_url}}" style="color:#111111;">Leistungsbeschreibung Website-Analyse</a>, Fassung {{fassung}} (beide als PDF im Anhang und verlinkt)</td></tr>
|
||||
<!-- WENN portal_weg -->
|
||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Zugestimmt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{zeitpunkt}} durch {{person}}</td></tr>
|
||||
<!-- ENDE portal_weg -->
|
||||
<!-- WENN textform_weg -->
|
||||
<tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Zugestimmt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">in Textform am {{datum}} per {{weg}}</td></tr>
|
||||
<!-- ENDE textform_weg -->
|
||||
</table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Auswertung öffnen →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
</table>
|
||||
|
||||
@@ -33,7 +33,7 @@ a{color:#111111;}
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td class="px" style="padding:44px 40px 8px 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td style="background:#e4f2e6;border-radius:100px;padding:6px 12px;font-family:Arial,Helvetica,sans-serif;font-size:12px;font-weight:bold;color:#1d5b2c;">Website-Analyse</td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:16px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:28px;line-height:34px;mso-line-height-rule:exactly;font-weight:bold;letter-spacing:-0.5px;color:#111111;">Deine Website-Analyse wurde beendet</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website ist beendet, weil Dein Wartungs-Abo beendet wurde. Der Banner und die Messung sind von Deiner Website entfernt. Die bisher gesammelten Daten werden nach 60 Tagen gelöscht.</td></tr>
|
||||
<tr><td class="px mut" style="padding:14px 40px 0 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:25px;mso-line-height-rule:exactly;color:#4a4843;">Hallo {{kunde_name}},<br>die Website-Analyse für Deine Website wurde beendet, weil Dein Wartungs-Abo beendet wurde. Ab jetzt werden keine Daten mehr gespeichert, und das Banner erscheint nicht mehr. Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website. Den Hinweis in Deiner Datenschutzerklärung entfernen wir ebenfalls, wenn sie bei uns liegt; sonst entferne ihn bitte selbst. Die bisher gesammelten Daten löschen wir 60 Tage nach dem Ende. Möchtest Du bis dahin eine Kopie, schreib uns bis zum 46. Tag im Chat.</td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:Arial,Helvetica,sans-serif;font-size:14px;line-height:21px;mso-line-height-rule:exactly;"><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;border-bottom:1px solid #ecebe6;color:#6b6964;">Funktion</td><td class="val ink rowline" valign="top" style="padding:12px 0;border-bottom:1px solid #ecebe6;color:#111111;"><strong>Website-Analyse</strong></td></tr><tr><td class="lbl mut rowline" width="170" valign="top" style="width:170px;padding:12px 12px 12px 0;color:#6b6964;">Projekt</td><td class="val ink rowline" valign="top" style="padding:12px 0;color:#111111;">{{projekt}}</td></tr></table></td></tr>
|
||||
<tr><td class="px" style="padding:28px 40px 0 40px;"><table role="presentation" cellpadding="0" cellspacing="0" border="0"><tr><td class="btn" bgcolor="#111111" style="background:#111111;border-radius:10px;"><a href="{{portal_url}}" style="display:block;padding:14px 26px;font-family:Arial,Helvetica,sans-serif;font-size:15px;font-weight:bold;color:#ffffff;text-decoration:none;">Zum Kundenportal →</a></td></tr></table></td></tr>
|
||||
<tr><td class="px ink" style="padding:28px 40px 44px 40px;font-family:Arial,Helvetica,sans-serif;font-size:16px;line-height:24px;color:#111111;">Viele Grüße<br><strong>Dein WEBklar-Team</strong></td></tr>
|
||||
|
||||
@@ -1048,6 +1048,15 @@ async function withdrawOrder(sub, d, receivedAt) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (inTime && type === 'wechsel') {
|
||||
// Ohne Wartungs-Abo gibt es keine Website-Analyse: Messdienst abschalten (der Widerruf scheitert nie daran)
|
||||
try {
|
||||
const { endAnalyseForCustomer } = await import('../services/analyse.js')
|
||||
await endAnalyseForCustomer(sub.customerId, { reason: 'widerruf_wechsel', mail: true, now: receivedAt })
|
||||
} catch (err) {
|
||||
console.error(`[abo] Analyse nach Widerruf des Wechsels (Abo ${sub.$id}) nicht beendet:`, err.message)
|
||||
}
|
||||
}
|
||||
const event = await recordDeclarationEvent(sub, 'widerruf', receivedAt, { order: d.order, eventId: ids[0] ?? null, eventIds: ids, inTime }, 'offen')
|
||||
return {
|
||||
sub,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Admin-API (WOMS): Website-Analyse je Projekt ein-/ausschalten. AVV wird mündlich bestätigt.
|
||||
// Admin-API (WOMS): Website-Analyse je Projekt ein-/ausschalten. Einschalten nur mit Zustimmung des Kunden in Textform (Datum + Weg).
|
||||
import { Router } from 'express'
|
||||
import { config } from '../../config.js'
|
||||
import { getDocument } from '../../services/appwriteAdmin.js'
|
||||
import { requireAppwriteStaff } from '../../middleware/appwriteStaff.js'
|
||||
import { activateAnalyse, analyseEligibility, deactivateAnalyse, ELIGIBILITY_MESSAGES } from '../../services/analyse.js'
|
||||
import { activateAnalyse, analyseEligibility, controllerView, deactivateAnalyse, ELIGIBILITY_MESSAGES, validateTextform } from '../../services/analyse.js'
|
||||
import { getFeatureDoc } from '../../services/siteFeatures.js'
|
||||
|
||||
const router = Router()
|
||||
@@ -26,9 +26,10 @@ router.get('/:projectId', async (req, res) => {
|
||||
throw err
|
||||
}
|
||||
const customerId = project.customerId || ''
|
||||
const [doc, eligibility] = await Promise.all([
|
||||
const [doc, eligibility, controller] = await Promise.all([
|
||||
customerId ? getFeatureDoc(customerId, project.$id, 'analyse') : null,
|
||||
analyseEligibility(customerId, project.$id),
|
||||
controllerView(customerId),
|
||||
])
|
||||
res.json({
|
||||
enabled: config.analyse.enabled,
|
||||
@@ -37,6 +38,9 @@ router.get('/:projectId', async (req, res) => {
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
avvAcceptedAt: doc?.avvAcceptedAt || null,
|
||||
avvAcceptedBy: doc?.avvAcceptedBy || null,
|
||||
avvConsentDate: doc?.avvConsentDate || null,
|
||||
avvConsentChannel: doc?.avvConsentChannel || null,
|
||||
privacy: { ok: controller.ok, missing: controller.missing },
|
||||
activatedAt: doc?.activatedAt || null,
|
||||
offSince: doc?.offSince || null,
|
||||
eligible: eligibility.ok,
|
||||
@@ -50,10 +54,15 @@ router.get('/:projectId', async (req, res) => {
|
||||
router.post('/:projectId', async (req, res) => {
|
||||
try {
|
||||
if (!config.analyse.enabled) return res.status(409).json({ error: 'Website-Analyse ist noch nicht freigeschaltet' })
|
||||
const { on, avvConfirmedVerbally } = req.body || {}
|
||||
const { on, textformDate, textformChannel, audienceConfirmed, avvConfirmedVerbally } = req.body || {}
|
||||
if (typeof on !== 'boolean') return res.status(400).json({ error: 'on muss true oder false sein' })
|
||||
if (on && avvConfirmedVerbally !== true) {
|
||||
return res.status(400).json({ error: 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat' })
|
||||
let textform
|
||||
if (on) {
|
||||
if (avvConfirmedVerbally !== undefined) return res.status(400).json({ error: 'Zustimmung in Textform nötig' })
|
||||
if (audienceConfirmed !== true) {
|
||||
return res.status(400).json({ error: 'Bitte bestätigen, dass die Website sich nicht gezielt an Kinder unter 16 Jahren richtet' })
|
||||
}
|
||||
textform = validateTextform({ date: textformDate, channel: textformChannel })
|
||||
}
|
||||
let project
|
||||
try {
|
||||
@@ -71,6 +80,8 @@ router.post('/:projectId', async (req, res) => {
|
||||
projectId: project.$id,
|
||||
consent: { accepted: true, avvVersion: config.analyse.avvVersion },
|
||||
by,
|
||||
textform,
|
||||
audienceConfirmed,
|
||||
})
|
||||
: await deactivateAnalyse({ customerId, projectId: project.$id, by })
|
||||
res.json({ ok: true, state: doc?.state || 'off' })
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router } from 'express'
|
||||
import { legalPaths } from '../services/analyseLegal.js'
|
||||
import { config } from '../config.js'
|
||||
import { getDocument } from '../services/appwriteAdmin.js'
|
||||
import { getSessionCustomerId, requireSession } from '../middleware/session.js'
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
analyseHosts,
|
||||
isDevPreview,
|
||||
analyseEligibility,
|
||||
controllerView,
|
||||
deactivateAnalyse,
|
||||
} from '../services/analyse.js'
|
||||
|
||||
@@ -36,12 +38,22 @@ function fail(res, err, fallback) {
|
||||
return res.status(500).json({ error: fallback })
|
||||
}
|
||||
|
||||
// Handelnde Person: bei Admin-Sitzung (adminName/adminEmail, siehe auth.js) der Admin, sonst der Kunde
|
||||
const actingPerson = (session) =>
|
||||
session.adminName || session.adminEmail
|
||||
? { name: session.adminName, email: session.adminEmail }
|
||||
: { name: session.name, email: session.email }
|
||||
|
||||
const ANALYSE_OFF = 'Website-Analyse ist noch nicht freigeschaltet'
|
||||
const ANALYSE_ERROR = 'Die Website-Analyse konnte gerade nicht geändert werden. Bitte versuch es später noch einmal oder schreib uns im Chat.'
|
||||
|
||||
async function analyseView(customerId, projectId) {
|
||||
if (!config.analyse.enabled) return { comingSoon: true }
|
||||
const [doc, eligibility] = await Promise.all([getFeatureDoc(customerId, projectId, 'analyse'), analyseEligibility(customerId, projectId)])
|
||||
const [doc, eligibility, controller] = await Promise.all([
|
||||
getFeatureDoc(customerId, projectId, 'analyse'),
|
||||
analyseEligibility(customerId, projectId),
|
||||
controllerView(customerId),
|
||||
])
|
||||
let devMode = false
|
||||
if (doc?.state === 'active') {
|
||||
const project = eligibility.project || (await getDocument(config.collections.websiteProjects, projectId).catch(() => null))
|
||||
@@ -55,6 +67,8 @@ async function analyseView(customerId, projectId) {
|
||||
reason: eligibility.reason || null,
|
||||
reasonText: eligibility.reason ? ELIGIBILITY_MESSAGES[eligibility.reason] : null,
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
legal: legalPaths(config.analyse.avvVersion),
|
||||
controller,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,6 +191,7 @@ router.post('/analyse/activate', analyseGuard, async (req, res) => {
|
||||
accentColor,
|
||||
consent,
|
||||
by: 'customer',
|
||||
actor: actingPerson(req.session),
|
||||
})
|
||||
res.json({ ok: true, state: doc.state })
|
||||
} catch (err) {
|
||||
@@ -193,6 +208,7 @@ router.post('/analyse/deactivate', analyseGuard, async (req, res) => {
|
||||
customerId: getSessionCustomerId(req),
|
||||
projectId: analyseProjectId(req.body?.projectId),
|
||||
by: 'customer',
|
||||
actor: actingPerson(req.session),
|
||||
})
|
||||
res.json({ ok: true, state: doc?.state || 'off' })
|
||||
} catch (err) {
|
||||
|
||||
+391
-43
@@ -2,20 +2,28 @@
|
||||
import { config } from '../config.js'
|
||||
import { getDocument, listDocuments, updateDocument, Query } from './appwriteAdmin.js'
|
||||
import { analyticsClient } from './analyticsClient.js'
|
||||
import { analysePrivacyData } from './analysePrivacy.js'
|
||||
import { recordConsent } from './analyseConsent.js'
|
||||
import { withLocks } from './keyedLock.js'
|
||||
import { sendCustomerMail } from './mailer.js'
|
||||
import { triggerRebuild } from './previewRebuild.js'
|
||||
import { ensureFeatureDoc, getFeatureDoc, newSiteKey, stateFields, assertOwnProject } from './siteFeatures.js'
|
||||
import { ACTIVE_STATUSES, getCurrentSubscription } from './stripeBilling.js'
|
||||
import { createFeatureTicket } from './ticketAdmin.js'
|
||||
import { berlinDateTime } from './berlinTime.js'
|
||||
import { legalPaths, legalPdfAttachments } from './analyseLegal.js'
|
||||
import { privacyTicketDetails, publishTicketDetails, removalTicketDetails, publishReasonText } from './analyseTexts.js'
|
||||
|
||||
const FEATURE_KEY = 'analyse'
|
||||
const SITE_KEY_RE = /^[a-z0-9]{16,40}$/
|
||||
const COLOR_RE = /^#[0-9a-f]{6}$/
|
||||
const DEFAULT_COLOR = '#111111'
|
||||
const CHANNELS = new Set(['chat', 'email'])
|
||||
export const MISSING_PRIVACY_MESSAGE =
|
||||
'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.'
|
||||
|
||||
/** Austauschbar fuer Tests: Sammeldienst-Client, Neubau und Ticket. */
|
||||
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket }
|
||||
export const analyseDeps = { client: analyticsClient, rebuild: triggerRebuild, ticket: createFeatureTicket, recordConsent, legalPdfs: legalPdfAttachments, saveRemovalTicketId: (docId, ticketId) => updateDocument(config.collections.portalFeatures, docId, { removalTicketId: ticketId }) }
|
||||
|
||||
function httpError(status, message) {
|
||||
const err = new Error(message)
|
||||
@@ -91,17 +99,80 @@ async function customerOf(customerId) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Verantwortlichen-Angaben des Kunden (Vorschau im Portal, Sperre beim Einschalten). */
|
||||
export async function controllerView(customerId) {
|
||||
const { ok, company, address, email, missing } = analysePrivacyData(await customerOf(customerId))
|
||||
return { company, address, email, ok, missing }
|
||||
}
|
||||
|
||||
/** "Name <E-Mail>" (oder was davon da ist), max. 100 Zeichen */
|
||||
const personLabel = (p) => (p?.name && p?.email ? `${p.name} <${p.email}>` : p?.name || p?.email || '').slice(0, 100)
|
||||
|
||||
const berlinToday = (now) => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(now)
|
||||
|
||||
/**
|
||||
* Zustimmung des Kunden in Textform (Mitarbeiter-Weg): Datum JJJJ-MM-TT (echt, nicht nach heute in Berlin,
|
||||
* nicht vor der Fassung) und Weg chat/email. Wirft 400, sonst { date, channel }.
|
||||
*/
|
||||
export function validateTextform(textform, { today = berlinToday(new Date()), minDate = config.analyse.avvVersion } = {}) {
|
||||
const date = String(textform?.date ?? '')
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date)
|
||||
const real = m && new Date(Date.UTC(+m[1], +m[2] - 1, +m[3])).toISOString().slice(0, 10) === date
|
||||
if (!real) throw httpError(400, 'Datum der Zustimmung fehlt oder ist ungültig')
|
||||
if (date > today) throw httpError(400, 'Das Datum der Zustimmung liegt in der Zukunft')
|
||||
if (date < minDate) throw httpError(400, `Das Datum der Zustimmung liegt vor der Fassung ${minDate}`)
|
||||
const channel = String(textform?.channel ?? '')
|
||||
if (!CHANNELS.has(channel)) throw httpError(400, 'Weg der Zustimmung fehlt: Chat oder E-Mail')
|
||||
return { date, channel }
|
||||
}
|
||||
|
||||
/** Neubau anstossen; { ok, reason } mit reason kein_repo | preview_aus | fehler. Wirft nie. */
|
||||
async function rebuildSafe(project) {
|
||||
if (!project.repoFullName || project.previewMode === 'off') return
|
||||
if (!project.repoFullName) return { ok: false, reason: 'kein_repo' }
|
||||
if (project.previewMode === 'off') return { ok: false, reason: 'preview_aus' }
|
||||
try {
|
||||
await analyseDeps.rebuild(project)
|
||||
const result = await analyseDeps.rebuild(project)
|
||||
if (result?.error) throw new Error(String(result.error))
|
||||
return { ok: true }
|
||||
} catch (err) {
|
||||
// Zustand bleibt gespeichert, der naechste Neubau holt nach
|
||||
console.warn(`[analyse] Neubau für Projekt ${project.$id} fehlgeschlagen:`, err.message)
|
||||
return { ok: false, reason: 'fehler' }
|
||||
}
|
||||
}
|
||||
|
||||
async function mailSafe(customerId, template, project) {
|
||||
const absolute = (path) => `${config.publicBaseUrl}${path}`
|
||||
const projectName = (project) => project.projectName || project.subdomain || ''
|
||||
const WEG = { chat: 'Chat im Kundenportal', email: 'E-Mail' }
|
||||
|
||||
function zeitpunkt(iso) {
|
||||
const at = new Date(iso)
|
||||
if (Number.isNaN(at.getTime())) return ''
|
||||
const { date, time } = berlinDateTime(at)
|
||||
return `${date}, ${time.slice(0, 2)}:${time.slice(2)} Uhr`
|
||||
}
|
||||
|
||||
const germanDate = (iso) => String(iso || '').split('-').reverse().join('.')
|
||||
|
||||
/** K18-Werte und -Bloecke aus dem Portal-Dokument (Nachweis der Zustimmung). */
|
||||
function k18Parts(doc) {
|
||||
const textform = Boolean(doc.avvConsentDate)
|
||||
const paths = legalPaths(doc.avvVersion || config.analyse.avvVersion)
|
||||
return {
|
||||
values: {
|
||||
fassung: doc.avvVersion || config.analyse.avvVersion,
|
||||
zeitpunkt: zeitpunkt(doc.avvAcceptedAt),
|
||||
person: doc.avvAcceptedBy || '',
|
||||
datum: germanDate(doc.avvConsentDate),
|
||||
weg: WEG[doc.avvConsentChannel] || '',
|
||||
avv_url: absolute(paths.avv),
|
||||
lb_url: absolute(paths.lb),
|
||||
},
|
||||
blocks: { portal_weg: !textform, textform_weg: textform },
|
||||
}
|
||||
}
|
||||
|
||||
async function mailSafe(customerId, template, project, { values = {}, blocks = {}, attachments = [] } = {}) {
|
||||
try {
|
||||
const customer = await customerOf(customerId)
|
||||
if (!customer?.email) return false
|
||||
@@ -109,9 +180,12 @@ async function mailSafe(customerId, template, project) {
|
||||
template,
|
||||
values: {
|
||||
kunde_name: customer.name || '',
|
||||
projekt: project.projectName || project.subdomain || '',
|
||||
projekt: projectName(project),
|
||||
portal_url: `${config.publicBaseUrl}/dashboard.html#analyse`,
|
||||
...values,
|
||||
},
|
||||
blocks,
|
||||
attachments,
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
@@ -120,28 +194,107 @@ async function mailSafe(customerId, template, project) {
|
||||
}
|
||||
}
|
||||
|
||||
function privacyTicket(customerId, project, siteKey) {
|
||||
const name = project.projectName || project.subdomain || ''
|
||||
function privacyTicket(customerId, project, siteKey, rebuild) {
|
||||
const name = projectName(project)
|
||||
const domains = analyseHosts(project)
|
||||
const rebuildNote = rebuild && !rebuild.ok ? `${publishReasonText(rebuild.reason)} Dafür gibt es ein eigenes Ticket „Website-Analyse von ${name} veröffentlichen“.` : ''
|
||||
return analyseDeps.ticket({
|
||||
customerId,
|
||||
project,
|
||||
featureKey: FEATURE_KEY,
|
||||
siteKey,
|
||||
topic: `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`,
|
||||
details: [
|
||||
`Funktion: ${FEATURE_KEY}`,
|
||||
`Projekt: ${name}`,
|
||||
`Domain(s): ${domains.join(', ')}`,
|
||||
`Zusatzseite: https://${domains[domains.length - 1]}/datenschutz-analyse.html`,
|
||||
'Satz + Link in die bestehende Datenschutzerklärung einfügen',
|
||||
].join('\n'),
|
||||
details: privacyTicketDetails({ name, domains, rebuildNote }),
|
||||
})
|
||||
}
|
||||
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer' }) {
|
||||
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) {
|
||||
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen')
|
||||
function publishTicket(customerId, project, siteKey, reason) {
|
||||
const name = projectName(project)
|
||||
return analyseDeps.ticket({
|
||||
customerId,
|
||||
project,
|
||||
featureKey: FEATURE_KEY,
|
||||
siteKey,
|
||||
topic: `Website-Analyse von ${name} veröffentlichen`,
|
||||
details: publishTicketDetails({ name, domains: analyseHosts(project), reason }),
|
||||
})
|
||||
}
|
||||
|
||||
const removalTopic = (project) => `Website-Analyse von ${projectName(project)} entfernen`
|
||||
/** Zeitpunkt dieser Abschaltung: kennzeichnet das Entfernen-Ticket (ein Ticket je Abschaltung). */
|
||||
const offMarker = (doc) => String(doc.offSince || doc.disabledAt || '')
|
||||
|
||||
function removalTicket(customerId, project, siteKey, offSince) {
|
||||
const name = projectName(project)
|
||||
return analyseDeps.ticket({
|
||||
customerId,
|
||||
project,
|
||||
featureKey: FEATURE_KEY,
|
||||
siteKey,
|
||||
topic: removalTopic(project),
|
||||
details: removalTicketDetails({ name, domains: analyseHosts(project), offSince }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Schon angelegtes Entfernen-Ticket dieser Abschaltung (Speichern der ID war gescheitert) oder null. Suchfehler werfen: dann lieber kein zweites Ticket. */
|
||||
async function findRemovalTicket(customerId, project, marker) {
|
||||
if (!marker) return null
|
||||
const tickets = await listDocuments(config.collections.workorders, [Query.equal('customerId', customerId), Query.limit(1000)])
|
||||
return tickets.find((t) => t.topic === removalTopic(project) && String(t.details || '').includes(`Abgeschaltet: ${marker}`)) || null
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernen-Ticket fuer die aktuelle Abschaltung sicherstellen (idempotent): vorhandene ID gilt, sonst ein bereits
|
||||
* angelegtes Ticket uebernehmen, sonst neu anlegen. Gibt das (ggf. aktualisierte) Dokument zurueck. Wirft bei Fehlern.
|
||||
*/
|
||||
async function ensureRemovalTicket(doc, project) {
|
||||
if (doc.removalTicketId) return doc
|
||||
const marker = offMarker(doc)
|
||||
const found = await findRemovalTicket(doc.customerId, project, marker)
|
||||
const ticketId = found ? found.$id : (await removalTicket(doc.customerId, project, doc.siteKey || '', marker)).ticketId
|
||||
const saved = await analyseDeps.saveRemovalTicketId(doc.$id, ticketId)
|
||||
return saved || { ...doc, removalTicketId: ticketId }
|
||||
}
|
||||
|
||||
async function projectOf(projectId) {
|
||||
try {
|
||||
return await getDocument(config.collections.websiteProjects, projectId)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return null
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/** K18 mit beiden PDFs; sind sie nicht lesbar, geht nichts raus und der Marker bleibt leer (spaetere Wiederholung). */
|
||||
async function sendK18(doc, project) {
|
||||
let attachments = null
|
||||
try {
|
||||
attachments = await analyseDeps.legalPdfs(doc.avvVersion || config.analyse.avvVersion)
|
||||
} catch (err) {
|
||||
console.error(`[analyse] K18-Anhänge für Projekt ${doc.projectId} nicht lesbar:`, err.message)
|
||||
}
|
||||
if (attachments && (await mailSafe(doc.customerId, 'K18', project, { ...k18Parts(doc), attachments }))) {
|
||||
return updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
|
||||
}
|
||||
return doc
|
||||
}
|
||||
|
||||
export async function activateAnalyse({ customerId, projectId, accentColor, consent, by = 'customer', actor, textform, audienceConfirmed, now = new Date() }) {
|
||||
const viaCustomer = by === 'customer'
|
||||
let accepted
|
||||
let proof = { date: '', channel: '' }
|
||||
if (viaCustomer) {
|
||||
if (!consent || consent.accepted !== true || consent.avvVersion !== config.analyse.avvVersion) {
|
||||
throw httpError(400, 'Bitte den Auftragsverarbeitungsvertrag bestätigen')
|
||||
}
|
||||
// Name und E-Mail der handelnden Person (bei Admin-Sitzung der Admin, siehe Route)
|
||||
accepted = personLabel(actor)
|
||||
if (!accepted) throw httpError(400, 'Bitte melde Dich neu an')
|
||||
} else {
|
||||
if (audienceConfirmed !== true) throw httpError(400, 'Bitte bestätigen, dass die Website sich nicht gezielt an Kinder unter 16 Jahren richtet')
|
||||
const t = validateTextform(textform, { today: berlinToday(now) })
|
||||
proof = { date: t.date, channel: t.channel }
|
||||
accepted = String(by).slice(0, 100)
|
||||
}
|
||||
const color = accentColor === undefined || accentColor === null || accentColor === '' ? DEFAULT_COLOR : String(accentColor).toLowerCase()
|
||||
if (!COLOR_RE.test(color)) throw httpError(400, 'Ungültige Akzentfarbe')
|
||||
@@ -160,21 +313,59 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
|
||||
let doc = existing
|
||||
let changed = false
|
||||
if (existing.state !== 'active') {
|
||||
// Ohne Verantwortlichen-Angaben bliebe die Zusatzseite leer: nicht einschalten (kein Skript, kein Ticket, keine Mail)
|
||||
if (!analysePrivacyData(await customerOf(customerId)).ok) throw httpError(409, MISSING_PRIVACY_MESSAGE)
|
||||
const siteKey = SITE_KEY_RE.test(existing.siteKey || '') ? existing.siteKey : newSiteKey()
|
||||
// Erst beim Sammeldienst anmelden: schlaegt das fehl, bleibt das Dokument unveraendert (erneut versuchbar)
|
||||
await analyseDeps.client.putSite(siteKey, { projectId, domains: analyseDomains(project), active: true })
|
||||
const now = new Date().toISOString()
|
||||
doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
|
||||
...stateFields('active'),
|
||||
const at = now.toISOString()
|
||||
const entry = {
|
||||
customerId,
|
||||
projectId,
|
||||
siteKey,
|
||||
accentColor: color,
|
||||
avvVersion: consent.avvVersion,
|
||||
avvAcceptedAt: now,
|
||||
avvAcceptedBy: String(by || 'customer').slice(0, 100),
|
||||
activatedAt: now,
|
||||
offSince: '',
|
||||
activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18
|
||||
})
|
||||
event: 'on',
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
via: viaCustomer ? 'portal' : 'woms',
|
||||
acceptedAt: at,
|
||||
acceptedBy: accepted,
|
||||
textformDate: proof.date,
|
||||
textformChannel: proof.channel,
|
||||
audienceConfirmed: true, // Kunden-Haken enthaelt die Zielgruppen-Aussage; Mitarbeiter bestaetigen sie getrennt
|
||||
audienceTextVersion: config.analyse.avvVersion,
|
||||
}
|
||||
let updated = false
|
||||
try {
|
||||
doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
|
||||
...stateFields('active'),
|
||||
siteKey,
|
||||
accentColor: color,
|
||||
avvVersion: entry.avvVersion,
|
||||
avvAcceptedAt: at,
|
||||
avvAcceptedBy: accepted,
|
||||
avvConsentDate: proof.date,
|
||||
avvConsentChannel: proof.channel,
|
||||
audienceConfirmed: true,
|
||||
audienceTextVersion: entry.audienceTextVersion,
|
||||
activatedAt: at,
|
||||
offSince: '',
|
||||
activatedMailAt: '', // jede neue Aktivierung bekommt ihre K18
|
||||
privacyTicketId: '', // und ihre Tickets
|
||||
removalTicketId: '',
|
||||
publishTicketId: '',
|
||||
})
|
||||
updated = true
|
||||
// Nachweis erst nach dem Dokument: scheitert er, wird alles zurueckgenommen (kein "on" ohne aktives Dokument)
|
||||
await analyseDeps.recordConsent(entry)
|
||||
} catch (err) {
|
||||
await analyseDeps.client
|
||||
.putSite(siteKey, { projectId, domains: analyseDomains(project), active: false })
|
||||
.catch((e) => console.warn(`[analyse] Rücknahme für Projekt ${projectId} fehlgeschlagen:`, e.message))
|
||||
if (updated) {
|
||||
await updateDocument(config.collections.portalFeatures, existing.$id, { ...stateFields(existing.state || 'off'), offSince: existing.offSince || '' })
|
||||
.catch((e) => console.warn(`[analyse] Zurücksetzen für Projekt ${projectId} fehlgeschlagen:`, e.message))
|
||||
}
|
||||
throw err
|
||||
}
|
||||
changed = true
|
||||
} else if (existing.accentColor !== color) {
|
||||
doc = await updateDocument(config.collections.portalFeatures, existing.$id, { accentColor: color })
|
||||
@@ -185,19 +376,66 @@ export async function activateAnalyse({ customerId, projectId, accentColor, cons
|
||||
// Ticket vor Mail, Marker erst nach Erfolg => je genau ein Ticket und eine K18.
|
||||
const needTicket = !doc.privacyTicketId
|
||||
const needMail = !doc.activatedMailAt
|
||||
// Erst Neubau, dann Tickets: Das Ergebnis steht im Datenschutz-Ticket und entscheidet ueber das Veroeffentlichen-Ticket
|
||||
const rebuild = changed || needTicket || needMail ? await rebuildSafe(project) : null
|
||||
if (needTicket) {
|
||||
const { ticketId } = await privacyTicket(customerId, project, doc.siteKey)
|
||||
const { ticketId } = await privacyTicket(customerId, project, doc.siteKey, rebuild)
|
||||
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { privacyTicketId: ticketId })
|
||||
}
|
||||
if (changed || needTicket || needMail) await rebuildSafe(project)
|
||||
if (needMail && (await mailSafe(customerId, 'K18', project))) {
|
||||
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { activatedMailAt: new Date().toISOString() })
|
||||
if (rebuild && !rebuild.ok && !doc.publishTicketId) {
|
||||
const { ticketId } = await publishTicket(customerId, project, doc.siteKey, rebuild.reason)
|
||||
doc = await updateDocument(config.collections.portalFeatures, doc.$id, { publishTicketId: ticketId })
|
||||
}
|
||||
if (needMail) doc = await sendK18(doc, project)
|
||||
return { doc, changed: changed || needTicket || needMail }
|
||||
})
|
||||
}
|
||||
|
||||
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer' }) {
|
||||
/**
|
||||
* Gemeinsamer Kern aller Beendigungswege: Messdienst abschalten (stopSite), Dokument auf off + offSince,
|
||||
* Historie, optional Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Fehler beim Messdienst werfen
|
||||
* (Dokument bleibt dann unveraendert, der Abgleich holt nach); Historie, Neubau und Ticket nie.
|
||||
*/
|
||||
export async function endAnalyse({ doc, project, reason, now = new Date(), by = 'system', actor, tickets = true, rebuild = true }) {
|
||||
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
|
||||
const updated = await updateDocument(config.collections.portalFeatures, doc.$id, {
|
||||
...stateFields('off'),
|
||||
offSince: now.toISOString(),
|
||||
})
|
||||
try {
|
||||
await analyseDeps.recordConsent({
|
||||
customerId: doc.customerId,
|
||||
projectId: doc.projectId,
|
||||
siteKey: doc.siteKey || '',
|
||||
event: 'off',
|
||||
avvVersion: config.analyse.avvVersion,
|
||||
via: by === 'customer' || by === 'system' ? 'portal' : 'woms',
|
||||
acceptedAt: now.toISOString(),
|
||||
acceptedBy: (by === 'customer' && personLabel(actor)) || String(by).slice(0, 100),
|
||||
textformDate: '',
|
||||
textformChannel: '',
|
||||
})
|
||||
} catch (err) {
|
||||
// Ausschalten darf nie am Nachweis scheitern
|
||||
console.warn(`[analyse] Historie (aus) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
console.log(`[analyse] Projekt ${doc.projectId} beendet (${reason || 'ausgeschaltet'})`)
|
||||
let result = updated
|
||||
if (project) {
|
||||
if (rebuild) await rebuildSafe(project)
|
||||
if (tickets && (await customerOf(doc.customerId).catch(() => null))) {
|
||||
try {
|
||||
result = await ensureRemovalTicket(updated, project)
|
||||
} catch (err) {
|
||||
// Ausschalten darf nie am Ticket scheitern; der taegliche Abgleich holt es nach
|
||||
console.warn(`[analyse] Entfernen-Ticket für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
return { doc: result, changed: true }
|
||||
}
|
||||
|
||||
export async function deactivateAnalyse({ customerId, projectId, now = new Date(), by = 'customer', actor }) {
|
||||
return withLocks([`analyse:${projectId}`], async () => {
|
||||
const project = await loadOwnProject(customerId, projectId)
|
||||
if (!project) throw httpError(403, ELIGIBILITY_MESSAGES.nicht_dein_projekt)
|
||||
@@ -205,18 +443,32 @@ export async function deactivateAnalyse({ customerId, projectId, now = new Date(
|
||||
if (!existing || existing.state === 'off') return { doc: existing, changed: false }
|
||||
// Von uns gesperrt: der Kunde darf daran nichts aendern (nur Mitarbeiter)
|
||||
if (existing.state === 'disabled' && by === 'customer') return { doc: existing, changed: false }
|
||||
if (existing.siteKey) {
|
||||
await analyseDeps.client.putSite(existing.siteKey, { projectId, domains: analyseDomains(project), active: false })
|
||||
}
|
||||
const doc = await updateDocument(config.collections.portalFeatures, existing.$id, {
|
||||
...stateFields('off'),
|
||||
offSince: now.toISOString(),
|
||||
})
|
||||
await rebuildSafe(project)
|
||||
return { doc, changed: true }
|
||||
return endAnalyse({ doc: existing, project, reason: 'ausgeschaltet', now, by, actor })
|
||||
})
|
||||
}
|
||||
|
||||
const featureDocsOf = (extra = []) => listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), ...extra, Query.limit(1000)])
|
||||
|
||||
/**
|
||||
* Alle aktiven Analysen eines Kunden beenden (Kunde geloescht, Widerruf Wechsel). Ein Fehler bei einem Dokument
|
||||
* stoppt die uebrigen nicht; was bleibt, beendet der taegliche Abgleich. Bei mail:true bekommt der Kunde K19.
|
||||
*/
|
||||
export async function endAnalyseForCustomer(customerId, { reason, mail = false, now = new Date() } = {}) {
|
||||
const gone = reason === 'kunde_geloescht'
|
||||
const ended = []
|
||||
for (const doc of await featureDocsOf([Query.equal('customerId', customerId), Query.equal('state', 'active')])) {
|
||||
try {
|
||||
const project = await projectOf(doc.projectId).catch(() => null)
|
||||
await withLocks([`analyse:${doc.projectId}`], () => endAnalyse({ doc, project, reason, now, by: 'system', tickets: !gone, rebuild: !gone }))
|
||||
ended.push(doc.$id)
|
||||
if (mail) await mailSafe(customerId, 'K19', project || {})
|
||||
} catch (err) {
|
||||
console.error(`[analyse] Beenden (${reason}) für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
return { ended }
|
||||
}
|
||||
|
||||
/** Taeglich: aktive Analyse ohne laufendes Wartungs-Abo wird beendet, Kunde bekommt K19. */
|
||||
export async function runAboCheck({ now = new Date() } = {}) {
|
||||
const docs = await listDocuments(config.collections.portalFeatures, [
|
||||
@@ -229,7 +481,7 @@ export async function runAboCheck({ now = new Date() } = {}) {
|
||||
try {
|
||||
// Ein Lesefehler (Stripe/Appwrite) beendet nie eine Analyse
|
||||
if (await hasWartungsAbo(doc.customerId)) continue
|
||||
const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now })
|
||||
const { changed } = await deactivateAnalyse({ customerId: doc.customerId, projectId: doc.projectId, now, by: 'system' })
|
||||
if (!changed) continue
|
||||
deactivated.push(doc.$id)
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId).catch(() => ({}))
|
||||
@@ -240,3 +492,99 @@ export async function runAboCheck({ now = new Date() } = {}) {
|
||||
}
|
||||
return { checked: docs.length, deactivated }
|
||||
}
|
||||
|
||||
const INELIGIBLE = (project) => (project.isPublic !== true ? 'nicht_oeffentlich' : !project.subdomain ? 'keine_subdomain' : null)
|
||||
|
||||
/** Nach einer Aenderung am Projekt (Subdomain, Oeffentlichkeit): Berechtigung weg => beenden, sonst Domains nachziehen. */
|
||||
export async function analyseOnProjectChanged(projectId) {
|
||||
const project = await projectOf(projectId)
|
||||
const docs = await featureDocsOf([Query.equal('projectId', projectId), Query.equal('state', 'active')])
|
||||
for (const doc of docs) {
|
||||
await withLocks([`analyse:${projectId}`], async () => {
|
||||
if (!project) return endAnalyse({ doc, project: null, reason: 'projekt_geloescht', tickets: false, rebuild: false })
|
||||
const reason = INELIGIBLE(project)
|
||||
if (reason) return endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich' })
|
||||
const domains = analyseDomains(project)
|
||||
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId, domains, active: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sperre/Loeschfrist (siteFeatures.disable): Messdienst per stopSite abschalten (auch ohne Projekt), offSince setzen,
|
||||
* Neubau und Entfernen-Ticket (nur mit Projekt und Kunde). Wirft nie.
|
||||
*/
|
||||
export async function analyseDisabled(doc) {
|
||||
try {
|
||||
if (doc.siteKey) await analyseDeps.client.stopSite(doc.siteKey)
|
||||
} catch (err) {
|
||||
console.warn(`[analyse] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
try {
|
||||
let current = doc
|
||||
if (!doc.offSince && doc.siteKey) {
|
||||
current = await updateDocument(config.collections.portalFeatures, doc.$id, { offSince: doc.disabledAt || new Date().toISOString() })
|
||||
}
|
||||
const project = await projectOf(doc.projectId).catch(() => null)
|
||||
if (!project || !current.siteKey || !(await customerOf(doc.customerId).catch(() => null))) return
|
||||
await rebuildSafe(project)
|
||||
await ensureRemovalTicket(current, project)
|
||||
} catch (err) {
|
||||
console.warn(`[analyse] Nacharbeit nach Sperre für ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Taeglicher Abgleich (nach runAboCheck), je Analyse-Dokument unabhaengig:
|
||||
* - active: Projekt oder Kunde weg => beenden (ohne Ticket/Neubau); nicht mehr berechtigt => beenden; sonst Domains
|
||||
* nachziehen (putSite) und K18 wiederholen, falls sie fehlt;
|
||||
* - off/disabled: Messdienst abschalten (idempotent) und fehlendes Entfernen-Ticket nachholen (nur Abschaltungen ab der
|
||||
* Fassung, also nach Livegang).
|
||||
*/
|
||||
export async function runAnalyseReconcile({ now = new Date() } = {}) {
|
||||
const docs = await featureDocsOf()
|
||||
const stats = { checked: docs.length, ended: [], stopped: [], tickets: [] }
|
||||
for (const doc of docs) {
|
||||
try {
|
||||
if (doc.state === 'active') {
|
||||
await withLocks([`analyse:${doc.projectId}`], async () => {
|
||||
const project = await projectOf(doc.projectId)
|
||||
if (!project) {
|
||||
await endAnalyse({ doc, project: null, reason: 'projekt_geloescht', now, tickets: false, rebuild: false })
|
||||
return stats.ended.push(doc.$id)
|
||||
}
|
||||
if (!(await customerOf(doc.customerId))) {
|
||||
await endAnalyse({ doc, project, reason: 'kunde_geloescht', now, tickets: false, rebuild: false })
|
||||
return stats.ended.push(doc.$id)
|
||||
}
|
||||
if (INELIGIBLE(project)) {
|
||||
await endAnalyse({ doc, project, reason: 'projekt_nicht_mehr_oeffentlich', now })
|
||||
return stats.ended.push(doc.$id)
|
||||
}
|
||||
const domains = analyseDomains(project)
|
||||
if (domains.length && doc.siteKey) await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains, active: true })
|
||||
if (!doc.activatedMailAt) await sendK18(doc, project)
|
||||
})
|
||||
} else if ((doc.state === 'off' || doc.state === 'disabled') && SITE_KEY_RE.test(doc.siteKey || '')) {
|
||||
// Gleiche Sperre wie Ein-/Ausschalten, Dokument nachlesen: eine inzwischen neu aktivierte Analyse bleibt an
|
||||
await withLocks([`analyse:${doc.projectId}`], async () => {
|
||||
const fresh = await listDocuments(config.collections.portalFeatures, [Query.equal('featureKey', FEATURE_KEY), Query.equal('projectId', doc.projectId), Query.limit(1)])
|
||||
const cur = fresh[0]
|
||||
if (!cur || cur.state === 'active' || !SITE_KEY_RE.test(cur.siteKey || '')) return
|
||||
await analyseDeps.client.stopSite(cur.siteKey)
|
||||
stats.stopped.push(cur.$id)
|
||||
if (!cur.removalTicketId && offMarker(cur) >= config.analyse.avvVersion) {
|
||||
const project = await projectOf(cur.projectId)
|
||||
if (project && (await customerOf(cur.customerId))) {
|
||||
await ensureRemovalTicket(cur, project)
|
||||
stats.tickets.push(cur.$id)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[analyse] Abgleich für Projekt ${doc.projectId} fehlgeschlagen:`, err.message)
|
||||
}
|
||||
}
|
||||
return stats
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
// Website-Analyse: Einwilligungs-Historie je Projekt (Collection analyseConsents).
|
||||
// Nur anfuegen und lesen: es gibt bewusst keine Aenderungs- und Loeschfunktion.
|
||||
import { config } from '../config.js'
|
||||
import { createDocument, listDocuments, Query } from './appwriteAdmin.js'
|
||||
|
||||
/** @param {{customerId,projectId,siteKey,event,avvVersion,via,acceptedAt,acceptedBy,textformDate,textformChannel}} entry */
|
||||
export async function recordConsent(entry) {
|
||||
return createDocument(config.collections.analyseConsents, entry)
|
||||
}
|
||||
|
||||
/** Alle Eintraege eines Projekts, aelteste zuerst. */
|
||||
export async function listConsents(projectId) {
|
||||
const docs = await listDocuments(config.collections.analyseConsents, [Query.equal('projectId', projectId), Query.limit(500)])
|
||||
return [...docs].sort((a, b) => String(a.acceptedAt).localeCompare(String(b.acceptedAt)) || String(a.$createdAt).localeCompare(String(b.$createdAt)))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
// Veroeffentlichte Rechtstexte der Website-Analyse (je Fassung feste Dateien unter public/rechtliches/)
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import { Router } from 'express'
|
||||
import { config } from '../config.js'
|
||||
|
||||
const publicDir = fileURLToPath(new URL('../../public/', import.meta.url))
|
||||
const VERSION_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
function checkVersion(version) {
|
||||
if (!VERSION_RE.test(String(version))) throw new Error('Analyse: ungültige Fassung')
|
||||
return String(version)
|
||||
}
|
||||
|
||||
export function legalPaths(version) {
|
||||
const v = checkVersion(version)
|
||||
return {
|
||||
avv: `/rechtliches/avv-website-analyse-${v}.html`,
|
||||
lb: `/rechtliches/leistungsbeschreibung-website-analyse-${v}.html`,
|
||||
}
|
||||
}
|
||||
|
||||
export function legalPdfFiles(version) {
|
||||
const v = checkVersion(version)
|
||||
return {
|
||||
avv: { filename: `AVV-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `avv-website-analyse-${v}.pdf`) },
|
||||
lb: { filename: `Leistungsbeschreibung-Website-Analyse-${v}.pdf`, path: join(publicDir, 'rechtliches', `leistungsbeschreibung-website-analyse-${v}.pdf`) },
|
||||
}
|
||||
}
|
||||
|
||||
/** Beide PDFs als Mail-Anhaenge; wirft, wenn eine Datei fehlt oder nicht lesbar ist. */
|
||||
export async function legalPdfAttachments(version) {
|
||||
const files = legalPdfFiles(version)
|
||||
return Promise.all(
|
||||
[files.avv, files.lb].map(async (f) => ({
|
||||
filename: f.filename,
|
||||
content: await readFile(f.path),
|
||||
contentType: 'application/pdf',
|
||||
}))
|
||||
)
|
||||
}
|
||||
|
||||
/** Adressen ohne Fassung liefern immer die aktuelle geprüfte Fassung (config.analyse.avvVersion). */
|
||||
export function analyseLegalRouter() {
|
||||
const router = Router()
|
||||
const send = (key) => (_req, res, next) => {
|
||||
const file = join(publicDir, legalPaths(config.analyse.avvVersion)[key])
|
||||
res.sendFile(file, (err) => err && next(err.code === 'ENOENT' ? undefined : err))
|
||||
}
|
||||
router.get('/avv-website-analyse.html', send('avv'))
|
||||
router.get('/leistungsbeschreibung-website-analyse.html', send('lb'))
|
||||
return router
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// Website-Analyse: Angaben des Verantwortlichen fuer die Datenschutz-Zusatzseite aus dem Kundendatensatz.
|
||||
// Rein, ohne Imports. Einziger Regelsatz: Portal-Sperre, Portal-Vorschau und Runner-Auftrag nutzen ihn.
|
||||
|
||||
const clean = (v) => String(v ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
|
||||
/**
|
||||
* @returns {{ ok: boolean, company: string, address: string, email: string, missing: Array<'firma'|'email'> }}
|
||||
*/
|
||||
export function analysePrivacyData(customer) {
|
||||
const c = customer || {}
|
||||
const company = (clean(c.companyName) || clean(c.name)).slice(0, 200).trim()
|
||||
const address = [clean(c.street), clean(`${c.postalCode || ''} ${c.city || ''}`)].filter(Boolean).join(', ').slice(0, 300).trim()
|
||||
const rawEmail = clean(c.email)
|
||||
const email = rawEmail.slice(0, 200)
|
||||
const missing = []
|
||||
if (!company) missing.push('firma')
|
||||
if (rawEmail.length > 200 || !EMAIL_RE.test(rawEmail)) missing.push('email')
|
||||
return { ok: missing.length === 0, company, address, email, missing }
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
// Geprüfte Texte der Website-Analyse für WOMS-Tickets (Quelle: banner-portal-mailtexte.md, Fassung 2026-10-02)
|
||||
// § 6 (Tickets) und § 7 (Textbaustein für die Datenschutzerklärung des Kunden), 1:1 übernommen.
|
||||
// "veröffentlichen"-Ticket und Grundtexte sind NEU und bewusst kurz.
|
||||
|
||||
export const PRIVACY_SNIPPET_DU = [
|
||||
'Reichweitenmessung',
|
||||
'Wir messen mit einer eigenen Reichweitenmessung ohne Cookies, wie unsere Website genutzt wird. Solange Du im Banner nichts ausgewählt hast, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Weitere Daten, etwa von welcher Website Du kommst, Verweildauer und Klicks, erfassen wir nur mit Deiner Einwilligung. Die Messung betreibt für uns die WEBklar GbR als Auftragsverarbeiter auf einem Server in Deutschland. Einzelheiten, Rechtsgrundlagen, Speicherdauer und Deine Rechte, auch Dein Widerspruchsrecht, findest Du unter [Datenschutz bei der Reichweitenmessung](/datenschutz-analyse.html).',
|
||||
].join('\n')
|
||||
|
||||
export const PRIVACY_SNIPPET_SIE = [
|
||||
'Reichweitenmessung',
|
||||
'Wir messen mit einer eigenen Reichweitenmessung ohne Cookies, wie unsere Website genutzt wird. Solange Sie im Banner nichts ausgewählt haben, zählen wir Seitenaufrufe mit Land und Gerätetyp, ohne Kennung. Weitere Daten, etwa von welcher Website Sie kommen, Verweildauer und Klicks, erfassen wir nur mit Ihrer Einwilligung. Die Messung betreibt für uns die WEBklar GbR als Auftragsverarbeiter auf einem Server in Deutschland. Einzelheiten, Rechtsgrundlagen, Speicherdauer und Ihre Rechte, auch Ihr Widerspruchsrecht, finden Sie unter [Datenschutz bei der Reichweitenmessung](/datenschutz-analyse.html).',
|
||||
].join('\n')
|
||||
|
||||
const FEATURE = 'Funktion: analyse'
|
||||
|
||||
const headLines = ({ name, domains }) => [FEATURE, `Projekt: ${name}`, `Domain(s): ${domains.join(', ')}`]
|
||||
|
||||
const lastDomain = (domains) => domains[domains.length - 1]
|
||||
|
||||
/** Ticket beim Einschalten: Textbaustein in die Datenschutzerklärung (Quelle § 6 „Beim Einschalten“). */
|
||||
export function privacyTicketDetails({ name, domains, rebuildNote = '' }) {
|
||||
return [
|
||||
...headLines({ name, domains }),
|
||||
`Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`,
|
||||
...(rebuildNote ? [`Veröffentlichung: ${rebuildNote}`] : []),
|
||||
'',
|
||||
'1. Textbaustein (siehe unten) in die Datenschutzerklärung der Website einfügen, Link auf /datenschutz-analyse.html.',
|
||||
'2. Fällt dabei ein offensichtlicher Widerspruch auf (etwa „Wir setzen keine Analyse-Werkzeuge ein“), den Kunden im Chat darauf hinweisen. Die Datenschutzerklärung wird nicht inhaltlich geprüft und ohne Auftrag nicht weiter geändert.',
|
||||
'3. Liegt die Datenschutzerklärung nicht in unserem Repo (externer Generator): Textbaustein per Chat an den Kunden senden.',
|
||||
'4. Nicht auf das Stundenkontingent anrechnen (Leistungsbeschreibung Website-Analyse Nr. 4 Abs. 4).',
|
||||
'',
|
||||
'Textbaustein (Du-Fassung):',
|
||||
PRIVACY_SNIPPET_DU,
|
||||
'',
|
||||
'Textbaustein (Sie-Fassung, für Datenschutzerklärungen, die siezen):',
|
||||
PRIVACY_SNIPPET_SIE,
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Ticket beim Ausschalten (auch Abo-Ende): Quelle § 6 „Beim Ausschalten“. */
|
||||
export function removalTicketDetails({ name, domains, offSince = '' }) {
|
||||
return [
|
||||
...headLines({ name, domains }),
|
||||
`Zusatzseite: https://${lastDomain(domains)}/datenschutz-analyse.html`,
|
||||
'',
|
||||
'1. Prüfen, ob die Website neu veröffentlicht wurde (Skript und Zusatzseite weg). Sonst neu veröffentlichen, spätestens innerhalb von fünf Werktagen.',
|
||||
'2. Hinweis auf die Reichweitenmessung aus der Datenschutzerklärung entfernen, wenn sie in unserem Repo liegt; sonst den Kunden im Chat bitten, ihn zu entfernen.',
|
||||
'3. Nicht auf das Stundenkontingent anrechnen.',
|
||||
...(offSince ? ['', `Abgeschaltet: ${offSince}`] : []), // Kennzeichen je Abschaltung (verhindert doppelte Tickets)
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
const PUBLISH_REASONS = {
|
||||
kein_repo: 'Dem Projekt ist kein Repo zugeordnet, die Website wurde nicht automatisch neu veröffentlicht.',
|
||||
preview_aus: 'Die Vorschau/Veröffentlichung ist für das Projekt ausgeschaltet, die Website wurde nicht automatisch neu veröffentlicht.',
|
||||
fehler: 'Der automatische Neubau der Website ist fehlgeschlagen.',
|
||||
}
|
||||
|
||||
export const publishReasonText = (reason) => PUBLISH_REASONS[reason] || PUBLISH_REASONS.fehler
|
||||
|
||||
/** NEU: Ticket „Website-Analyse veröffentlichen“, wenn der automatische Neubau beim Einschalten nicht lief. */
|
||||
export function publishTicketDetails({ name, domains, reason }) {
|
||||
return [
|
||||
...headLines({ name, domains }),
|
||||
`Grund: ${publishReasonText(reason)}`,
|
||||
'',
|
||||
'Website neu veröffentlichen, damit das Analyse-Skript (Banner) und die Zusatzseite /datenschutz-analyse.html erscheinen.',
|
||||
'Nicht auf das Stundenkontingent anrechnen.',
|
||||
].join('\n')
|
||||
}
|
||||
@@ -47,6 +47,15 @@ export function createAnalyticsClient(opts = {}) {
|
||||
putSite(siteKey, { projectId, domains, active }) {
|
||||
return request('PUT', sitePath(siteKey), { projectId, domains, active })
|
||||
},
|
||||
/** Beenden ohne Domainliste (active=0, offSince einmalig); unbekannter Schluessel zaehlt als erledigt. */
|
||||
async stopSite(siteKey) {
|
||||
try {
|
||||
return await request('POST', `${sitePath(siteKey)}/off`)
|
||||
} catch (err) {
|
||||
if (err.status === 404) return { ok: true, missing: true }
|
||||
throw err
|
||||
}
|
||||
},
|
||||
deleteSite(siteKey) {
|
||||
return request('DELETE', sitePath(siteKey))
|
||||
},
|
||||
|
||||
@@ -274,6 +274,14 @@ export async function deleteCustomerWithPortalAccess(customerId) {
|
||||
throw error
|
||||
}
|
||||
|
||||
// Website-Analyse zuerst abschalten (kein Ticket, keine Mail); Fehler halten das Loeschen nicht auf, der taegliche Abgleich holt nach
|
||||
try {
|
||||
const { endAnalyseForCustomer } = await import('./analyse.js')
|
||||
await endAnalyseForCustomer(customerId, { reason: 'kunde_geloescht', mail: false })
|
||||
} catch (err) {
|
||||
console.error(`[customerAdmin] Analyse von Kunde ${customerId} nicht beendet:`, err.message)
|
||||
}
|
||||
|
||||
const portalAccess = await getPortalAccessByCustomerId(customerId)
|
||||
if (portalAccess) {
|
||||
await deleteDocument(
|
||||
|
||||
@@ -517,6 +517,14 @@ export async function updateWebsiteProject(projectId, { projectName, subdomain,
|
||||
console.error('[updateWebsiteProject] TLS-Sync fehlgeschlagen:', err.message)
|
||||
}
|
||||
|
||||
// Website-Analyse: Berechtigung/Domains neu pruefen (dynamischer Import wegen Zyklus); Fehler aendern das Ergebnis nicht
|
||||
try {
|
||||
const { analyseOnProjectChanged } = await import('./analyse.js')
|
||||
await analyseOnProjectChanged(projectId)
|
||||
} catch (err) {
|
||||
console.error('[updateWebsiteProject] Analyse-Abgleich fehlgeschlagen:', err.message)
|
||||
}
|
||||
|
||||
return {
|
||||
project: updated,
|
||||
deploy: deployResult,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { giteaFetch, splitRepo } from './giteaClient.js'
|
||||
import { envKey, decryptEnv, maskLog } from './previewEnv.js'
|
||||
import { saveRunLog } from './previewLogs.js'
|
||||
import * as runnerClient from './previewRunnerClient.js'
|
||||
import { analysePrivacyData } from './analysePrivacy.js'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
@@ -730,17 +731,14 @@ async function buildAnalytics(project) {
|
||||
} catch (err) {
|
||||
if (err.status !== 404) throw err
|
||||
}
|
||||
const clean = (v) => String(v ?? '').replace(/[\x00-\x1f\x7f]/g, ' ').replace(/\s+/g, ' ').trim()
|
||||
const company = clean(customer?.companyName || customer?.name).slice(0, 200).trim()
|
||||
const email = clean(customer?.email)
|
||||
if (!company || !email || email.length > 200 || !email.includes('@')) {
|
||||
const privacy = analysePrivacyData(customer)
|
||||
if (!privacy.ok) {
|
||||
console.warn(`[previewDeploy] Analyse fuer Projekt ${project.$id} uebersprungen: Firma oder E-Mail fehlt/ungueltig`)
|
||||
return null
|
||||
}
|
||||
const address = [clean(customer.street), clean(`${customer.postalCode || ''} ${customer.city || ''}`)].filter(Boolean).join(', ').slice(0, 300).trim()
|
||||
const stored = String(feature.accentColor || '').toLowerCase()
|
||||
const color = /^#[0-9a-f]{6}$/.test(stored) ? stored : '#111111'
|
||||
return { siteKey: feature.siteKey, color, privacy: { company, address, email } }
|
||||
return { siteKey: feature.siteKey, color, privacy: { company: privacy.company, address: privacy.address, email: privacy.email } }
|
||||
} catch (err) {
|
||||
console.warn(`[previewDeploy] Analyse-Abfrage fuer Projekt ${project?.$id} fehlgeschlagen: ${err.message}`)
|
||||
return null
|
||||
|
||||
@@ -302,13 +302,12 @@ export async function disable(docId, { by, adminNote } = {}) {
|
||||
return doc
|
||||
}
|
||||
|
||||
// Analyse gesperrt: Sammeldienst nimmt keine Daten mehr an. Best effort, Zustand ist schon gespeichert.
|
||||
// Analyse gesperrt (auch Loeschfrist): Sammeldienst abschalten, Neubau und Entfernen-Ticket. Best effort, Zustand ist schon gespeichert.
|
||||
// Dynamischer Import: analyse.js importiert dieses Modul.
|
||||
async function stopAnalyseCollecting(doc) {
|
||||
try {
|
||||
const { analyseDeps, analyseDomains } = await import('./analyse.js')
|
||||
const project = await getDocument(config.collections.websiteProjects, doc.projectId)
|
||||
await analyseDeps.client.putSite(doc.siteKey, { projectId: doc.projectId, domains: analyseDomains(project), active: false })
|
||||
const { analyseDisabled } = await import('./analyse.js')
|
||||
await analyseDisabled(doc)
|
||||
} catch (err) {
|
||||
console.warn(`[siteFeatures] Sammeldienst für ${doc.projectId} nicht abgeschaltet:`, err.message)
|
||||
}
|
||||
|
||||
+540
-29
@@ -7,11 +7,13 @@ import { schemaKeys } from './support/schema.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
// Das Setup-Skript laedt config.js: erst nach useTestEnv() importieren, daher die Attributnamen hier fest
|
||||
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt']
|
||||
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId', 'audienceConfirmed', 'audienceTextVersion']
|
||||
const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel', 'audienceConfirmed', 'audienceTextVersion']
|
||||
const aw = await startAppwriteMock({
|
||||
schema: {
|
||||
portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS],
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
analyseConsents: CONSENT_KEYS,
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
@@ -20,7 +22,9 @@ const { config } = await import('../server/config.js')
|
||||
const { wartungDoc, hosting2Doc } = await import('./support/fixtures.js')
|
||||
const an = await import('../server/services/analyse.js')
|
||||
const sf = await import('../server/services/siteFeatures.js')
|
||||
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const cl = await import('../server/services/analyseConsent.js')
|
||||
const priv = await import('../server/services/analysePrivacy.js')
|
||||
const { updateDocument, deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
|
||||
const mails = mailSink()
|
||||
@@ -31,27 +35,40 @@ after(() => {
|
||||
})
|
||||
|
||||
const puts = []
|
||||
const stops = []
|
||||
const rebuilds = []
|
||||
an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }) }
|
||||
an.analyseDeps.client = { putSite: async (siteKey, body) => puts.push({ siteKey, ...body }), stopSite: async (siteKey) => stops.push(siteKey) }
|
||||
an.analyseDeps.rebuild = async (project) => rebuilds.push(project.$id)
|
||||
|
||||
const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion })
|
||||
const actor = { name: 'Kunde Eins', email: 'eins@example.invalid' }
|
||||
const cust = (extra = {}) => ({ consent: consent(), actor, ...extra })
|
||||
const textform = { date: '2026-10-02', channel: 'email' }
|
||||
const NOW = new Date('2026-10-05T10:00:00.000Z')
|
||||
const consents = (p) => cl.listConsents(p)
|
||||
const analyseTickets = () => [...aw.docs('workorders').values()].filter((t) => String(t.topic).includes('Hinweis auf Analyse'))
|
||||
const topics = () => [...aw.docs('workorders').values()].map((t) => String(t.topic))
|
||||
const doc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
|
||||
|
||||
aw.seed('customers', { $id: 'c1', name: 'Kunde Eins', email: 'eins@example.invalid', location: 'Berlin' })
|
||||
aw.seed('customers', { $id: 'c2', name: 'Kunde Zwei', email: 'zwei@example.invalid' })
|
||||
aw.seed('customers', { $id: 'c4', name: '', email: 'vier@example.invalid' })
|
||||
aw.seed('customers', { $id: 'c5', name: 'Kunde Fuenf', email: 'x' })
|
||||
aw.seed('customers', { $id: 'c3', name: 'Kunde Drei', email: 'drei@example.invalid' })
|
||||
aw.seed('subscriptions', wartungDoc)
|
||||
aw.seed('subscriptions', hosting2Doc)
|
||||
for (const c of ['c4', 'c5']) aw.seed('subscriptions', { ...wartungDoc, $id: `sub_w_${c}`, stripeSubscriptionId: `sub_w_${c}`, customerId: c })
|
||||
aw.seed('websiteProjects', { $id: 'p1', customerId: 'c1', projectName: 'Bäckerei', subdomain: 'baeckerei', liveDomain: 'https://Baeckerei.DE/start', isPublic: true, repoFullName: 'WEBklar/baeckerei' })
|
||||
aw.seed('websiteProjects', { $id: 'p2', customerId: 'c1', projectName: 'Privat', subdomain: 'privat', isPublic: false })
|
||||
aw.seed('websiteProjects', { $id: 'p3', customerId: 'c1', projectName: 'Ohne Sub', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'p4', customerId: 'c2', projectName: 'Hosting-Kunde', subdomain: 'hosting', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'p11', customerId: 'c4', projectName: 'Ohne Name', subdomain: 'ohnename', isPublic: true, repoFullName: 'WEBklar/ohnename' })
|
||||
aw.seed('websiteProjects', { $id: 'p12', customerId: 'c5', projectName: 'Schlechte Mail', subdomain: 'schlechtemail', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'p5', customerId: 'c3', projectName: 'Ohne Abo', subdomain: 'ohneabo', isPublic: true })
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0
|
||||
stops.length = 0
|
||||
rebuilds.length = 0
|
||||
mails.sent.length = 0
|
||||
})
|
||||
@@ -67,19 +84,19 @@ test('Berechtigung: Wartungs-Abo, öffentlich, Subdomain, Eigentum', async () =>
|
||||
})
|
||||
|
||||
test('Einschalten abgelehnt: Hosting-Abo, kein Abo, nicht öffentlich, AVV fehlt, Farbe', async () => {
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c2', projectId: 'p4', consent: consent() }), { status: 403 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', consent: consent() }), { status: 403 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', consent: consent() }), { status: 409 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c2', projectId: 'p4', ...cust() }), { status: 403 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c3', projectId: 'p5', ...cust() }), { status: 403 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p2', ...cust() }), { status: 409 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1' }), { status: 400, message: 'Bitte den Auftragsverarbeitungsvertrag bestätigen' })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: true, avvVersion: 'alt' } }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: { accepted: false, avvVersion: config.analyse.avvVersion } }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: 'rot', consent: consent() }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: 'rot', ...cust() }), { status: 400 })
|
||||
assert.equal(puts.length, 0)
|
||||
assert.equal(analyseTickets().length, 0)
|
||||
})
|
||||
|
||||
test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () => {
|
||||
const { doc: d, changed } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() })
|
||||
const { doc: d, changed } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', ...cust() })
|
||||
assert.equal(changed, true)
|
||||
assert.equal(d.featureKey, 'analyse')
|
||||
assert.equal(d.state, 'active')
|
||||
@@ -87,7 +104,7 @@ test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () =
|
||||
assert.match(d.siteKey, /^[a-z0-9]{16,40}$/)
|
||||
assert.equal(d.accentColor, '#1a73e8')
|
||||
assert.equal(d.avvVersion, config.analyse.avvVersion)
|
||||
assert.equal(d.avvAcceptedBy, 'customer')
|
||||
assert.equal(d.avvAcceptedBy, 'Kunde Eins <eins@example.invalid>')
|
||||
assert.ok(d.avvAcceptedAt && d.activatedAt)
|
||||
assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'p1', domains: ['baeckerei.project.webklar.com', 'baeckerei.de', 'www.baeckerei.de'], active: true }])
|
||||
assert.deepEqual(rebuilds, ['p1'])
|
||||
@@ -97,25 +114,41 @@ test('Einschalten: Dokument, Sammeldienst, Neubau, Ticket, Mail K18', async () =
|
||||
assert.match(tickets[0].details, /Funktion: analyse/)
|
||||
assert.match(tickets[0].details, /Domain\(s\): baeckerei\.project\.webklar\.com, baeckerei\.de/)
|
||||
assert.match(tickets[0].details, /https:\/\/baeckerei\.de\/datenschutz-analyse\.html/)
|
||||
assert.match(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/)
|
||||
assert.doesNotMatch(tickets[0].details, /Satz \+ Link in die bestehende Datenschutzerklärung einfügen/)
|
||||
for (const part of ['Textbaustein', 'offensichtlicher Widerspruch', 'externer Generator', 'Stundenkontingent', 'Wir messen mit einer eigenen Reichweitenmessung ohne Cookies', 'Solange Sie im Banner nichts ausgewählt haben', 'Solange Du im Banner nichts ausgewählt hast']) {
|
||||
assert.ok(tickets[0].details.includes(part), part)
|
||||
}
|
||||
assert.ok(tickets[0].details.length < 8000, 'passt in workorders.details (8000)')
|
||||
assert.equal(topics().filter((t) => t.includes('veröffentlichen')).length, 0, 'Neubau lief: kein Veröffentlichen-Ticket')
|
||||
assert.equal((await doc('c1', 'p1')).privacyTicketId, tickets[0].$id)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.equal(mails.sent[0].subject, 'Deine Website-Analyse ist aktiv – Bäckerei')
|
||||
assert.deepEqual(mails.sent[0].to.map((t) => t.address), ['eins@example.invalid'])
|
||||
const k18 = mails.sent[0]
|
||||
assert.deepEqual(k18.attachments.map((a) => a.filename), ['AVV-Website-Analyse-2026-10-02.pdf', 'Leistungsbeschreibung-Website-Analyse-2026-10-02.pdf'])
|
||||
for (const a of k18.attachments) assert.equal(Buffer.from(a.content, 'base64').subarray(0, 4).toString(), '%PDF')
|
||||
assert.match(k18.html, /2026-10-02/)
|
||||
assert.ok(k18.html.includes(`${config.publicBaseUrl}/rechtliches/avv-website-analyse-2026-10-02.html`))
|
||||
assert.ok(k18.html.includes(`${config.publicBaseUrl}/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html`))
|
||||
assert.match(k18.html, /durch Kunde Eins <eins@example\.invalid>/)
|
||||
assert.match(k18.html, /\d\d\.\d\d\.\d{4}, \d\d:\d\d Uhr/)
|
||||
assert.match(k18.text, /Das Einwilligungsbanner erscheint mit der nächsten Veröffentlichung/)
|
||||
assert.doesNotMatch(k18.html, /in Textform am/)
|
||||
})
|
||||
|
||||
test('Doppelt einschalten: kein zweites Ticket, keine zweite Mail, nur Farbe', async () => {
|
||||
const before = await doc('c1', 'p1')
|
||||
const same = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', consent: consent() })
|
||||
const same = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#1a73e8', ...cust() })
|
||||
assert.equal(same.changed, false)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', consent: consent() })
|
||||
const recolored = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', accentColor: '#CC0000', ...cust() })
|
||||
assert.equal(recolored.doc.accentColor, '#cc0000')
|
||||
assert.equal(recolored.doc.siteKey, before.siteKey)
|
||||
assert.deepEqual(rebuilds, ['p1'])
|
||||
assert.equal(analyseTickets().length, 1)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal(puts.length, 0)
|
||||
assert.equal((await consents('p1')).length, 1, 'Farbwechsel/zweites Aktivieren schreibt keine Historie')
|
||||
})
|
||||
|
||||
test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts', async () => {
|
||||
@@ -125,22 +158,24 @@ test('Ausschalten: off, offSince, Sammeldienst inaktiv, Neubau; erneut = nichts'
|
||||
assert.equal(d.state, 'off')
|
||||
assert.equal(d.enabled, false)
|
||||
assert.equal(d.offSince, '2026-10-05T10:00:00.000Z')
|
||||
assert.equal(puts.length, 1)
|
||||
assert.equal(puts[0].active, false)
|
||||
assert.equal(puts.length, 0)
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
assert.deepEqual(rebuilds, ['p1'])
|
||||
assert.equal((await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p1', now })).changed, false)
|
||||
await assert.rejects(an.deactivateAnalyse({ customerId: 'c2', projectId: 'p1' }), { status: 403 })
|
||||
})
|
||||
|
||||
test('Wieder einschalten: gleicher siteKey, offSince leer, kein neues Ticket', async () => {
|
||||
test('Wieder einschalten: gleicher siteKey, offSince leer, neues Ticket und Historie', async () => {
|
||||
const before = await doc('c1', 'p1')
|
||||
const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso' })
|
||||
const { doc: d } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p1', consent: consent(), by: 'Kenso', textform, audienceConfirmed: true, now: NOW })
|
||||
assert.equal(d.siteKey, before.siteKey)
|
||||
assert.equal(d.state, 'active')
|
||||
assert.equal(d.offSince, '')
|
||||
assert.equal(d.accentColor, '#111111')
|
||||
assert.equal(d.avvAcceptedBy, 'Kenso')
|
||||
assert.equal(analyseTickets().length, 1)
|
||||
assert.equal(d.avvConsentDate, '2026-10-02')
|
||||
assert.equal(d.avvConsentChannel, 'email')
|
||||
assert.equal(analyseTickets().length, 2, 'neues Ticket nach Aus/An')
|
||||
})
|
||||
|
||||
test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async () => {
|
||||
@@ -160,6 +195,11 @@ test('Abo-Ende-Job: aktive Analyse ohne Wartungs-Abo wird beendet, K19', async (
|
||||
assert.equal(d.offSince, now.toISOString())
|
||||
}
|
||||
assert.deepEqual(mails.sent.map((m) => m.subject).sort(), ['Deine Website-Analyse wurde beendet – Hosting-Kunde', 'Deine Website-Analyse wurde beendet – Ohne Abo'])
|
||||
for (const m of mails.sent) {
|
||||
assert.match(m.text, /keine Daten mehr gespeichert/)
|
||||
assert.match(m.text, /46\. Tag/)
|
||||
assert.doesNotMatch(m.text, /Der Banner und die Messung sind von Deiner Website entfernt/)
|
||||
}
|
||||
mails.sent.length = 0
|
||||
assert.equal((await an.runAboCheck({ now })).deactivated.length, 0, 'zweiter Lauf: nichts mehr zu tun')
|
||||
assert.equal(mails.sent.length, 0)
|
||||
@@ -185,20 +225,19 @@ test('selbstheilend: Ticket scheitert einmal, Wiederholung holt Ticket, Neubau u
|
||||
return original(...args)
|
||||
}
|
||||
const before = analyseTickets().length
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() }), /Ticketsystem down/)
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() }), /Ticketsystem down/)
|
||||
assert.equal((await doc('c1', 'p6')).state, 'active')
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() })
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
assert.ok(r.doc.privacyTicketId && r.doc.activatedMailAt)
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.deepEqual(rebuilds, ['p6'])
|
||||
assert.deepEqual(rebuilds, ['p6', 'p6'], 'Neubau kommt vor dem Ticket, die Wiederholung baut erneut')
|
||||
assert.equal(mails.sent.length, 1)
|
||||
// dritter Aufruf: nichts mehr offen
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', consent: consent() })
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p6', ...cust() })
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.deepEqual(rebuilds, ['p6'])
|
||||
assert.deepEqual(rebuilds, ['p6', 'p6'])
|
||||
assert.equal(mails.sent.length, 1)
|
||||
an.analyseDeps.ticket = original
|
||||
})
|
||||
@@ -207,7 +246,7 @@ test('putSite scheitert: Dokument bleibt unverändert', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p7', customerId: 'c1', projectName: 'Down', subdomain: 'down', isPublic: true })
|
||||
const ok = an.analyseDeps.client
|
||||
an.analyseDeps.client = { putSite: async () => { throw Object.assign(new Error('Auswertung nicht erreichbar'), { status: 502 }) } }
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p7', consent: consent() }), { status: 502 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p7', ...cust() }), { status: 502 })
|
||||
assert.equal(await doc('c1', 'p7') === null || (await doc('c1', 'p7')).state === 'off', true)
|
||||
assert.equal(analyseTickets().filter((t) => t.details.includes('Down')).length, 0)
|
||||
an.analyseDeps.client = ok
|
||||
@@ -217,7 +256,7 @@ test('Neubau scheitert: Analyse bleibt aktiv, kein Fehler', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p8', customerId: 'c1', projectName: 'Bau', subdomain: 'bau', isPublic: true, repoFullName: 'WEBklar/bau' })
|
||||
const ok = an.analyseDeps.rebuild
|
||||
an.analyseDeps.rebuild = async () => { throw new Error('Runner weg') }
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p8', consent: consent() })
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p8', ...cust() })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
assert.equal(mails.sent.length, 1)
|
||||
an.analyseDeps.rebuild = ok
|
||||
@@ -228,8 +267,8 @@ test('parallel einschalten: ein Ticket, eine Mail', async () => {
|
||||
const before = analyseTickets().length
|
||||
mails.sent.length = 0
|
||||
await Promise.all([
|
||||
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }),
|
||||
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', consent: consent() }),
|
||||
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', ...cust() }),
|
||||
an.activateAnalyse({ customerId: 'c1', projectId: 'p9', ...cust() }),
|
||||
])
|
||||
assert.equal(analyseTickets().length, before + 1)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
@@ -239,8 +278,8 @@ test('gesperrte Analyse: Kunde 403, Mitarbeiter darf', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p10', customerId: 'c1', projectName: 'Gesperrt', subdomain: 'gesperrt', isPublic: true })
|
||||
const g = await sf.ensureFeatureDoc('c1', 'p10', 'analyse')
|
||||
await sf.disable(g.$id, {})
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' })
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso' })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p10', ...cust() }), { status: 403, message: 'Analyse wurde gesperrt – bitte melde dich bei uns' })
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p10', consent: consent(), by: 'Kenso', textform, audienceConfirmed: true, now: NOW })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
})
|
||||
|
||||
@@ -254,3 +293,475 @@ test('analyseDomains: www-/Apex-Gegenstück der Live-Domain, ohne Doppelte, max.
|
||||
// gespeicherter Host bleibt die Live-Domain (Heatmap-Link, Ticket)
|
||||
assert.deepEqual(an.analyseHosts({ subdomain: 'x', liveDomain: 'example.de' }), ['x.project.webklar.com', 'example.de'])
|
||||
})
|
||||
|
||||
// ---- Rechtsrunde Task 5: Verantwortlichen-Angaben, Historie, Textform ----
|
||||
|
||||
test('analysePrivacyData: Firma/Name, E-Mail, Bereinigung, Adresse', () => {
|
||||
const d = priv.analysePrivacyData
|
||||
assert.deepEqual(d({ companyName: '', name: '', email: 'a@b.de' }).missing, ['firma'])
|
||||
const nameOnly = d({ name: 'Max Muster', email: 'a@b.de' })
|
||||
assert.equal(nameOnly.company, 'Max Muster')
|
||||
assert.equal(nameOnly.ok, true)
|
||||
assert.deepEqual(nameOnly.missing, [])
|
||||
assert.deepEqual(d({ name: 'Max', email: 'abc' }).missing, ['email'])
|
||||
assert.deepEqual(d({ name: '', email: '' }).missing, ['firma', 'email'])
|
||||
assert.deepEqual(d(null).missing, ['firma', 'email'])
|
||||
assert.equal(d({ companyName: 'Muster\nGmbH\t& Co', email: 'a@b.de' }).company, 'Muster GmbH & Co')
|
||||
assert.equal(d({ name: 'M', email: 'a@b.de', street: 'Weg 1' }).address, 'Weg 1')
|
||||
assert.equal(d({ name: 'M', email: 'a@b.de', street: 'Weg 1', postalCode: '12345', city: 'Berlin' }).address, 'Weg 1, 12345 Berlin')
|
||||
assert.equal(d({ name: 'x'.repeat(300), email: 'a@b.de' }).company.length, 200)
|
||||
assert.deepEqual(d({ name: 'M', email: `${'a'.repeat(200)}@b.de` }).missing, ['email'])
|
||||
assert.deepEqual(d({ name: 'M', email: 'a b@c.de' }).missing, ['email'])
|
||||
})
|
||||
|
||||
test('validateTextform: Datum, Zukunft, Fassung, Weg', () => {
|
||||
const ctx = { today: '2026-10-05', minDate: '2026-10-02' }
|
||||
const bad = (t) => assert.throws(() => an.validateTextform(t, ctx), { status: 400 })
|
||||
bad({ date: '2026-10-01', channel: 'chat' })
|
||||
bad({ date: '2026-10-06', channel: 'chat' })
|
||||
bad({ date: '2026-02-30', channel: 'chat' })
|
||||
bad({ date: '02.10.2026', channel: 'chat' })
|
||||
bad({ date: '2026-10-02', channel: 'telefon' })
|
||||
bad({ date: '2026-10-02' })
|
||||
bad(undefined)
|
||||
assert.deepEqual(an.validateTextform({ date: '2026-10-02', channel: 'email' }, ctx), { date: '2026-10-02', channel: 'email' })
|
||||
assert.deepEqual(an.validateTextform({ date: '2026-10-05', channel: 'chat' }, ctx), { date: '2026-10-05', channel: 'chat' })
|
||||
})
|
||||
|
||||
test('Einschalten ohne Firma/Name oder mit ungültiger E-Mail: 409, nichts passiert', async () => {
|
||||
const before = analyseTickets().length
|
||||
for (const [c, p] of [['c4', 'p11'], ['c5', 'p12']]) {
|
||||
await assert.rejects(an.activateAnalyse({ customerId: c, projectId: p, ...cust() }), {
|
||||
status: 409,
|
||||
message: '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.',
|
||||
})
|
||||
assert.equal((await doc(c, p)).state, 'off')
|
||||
}
|
||||
assert.equal(puts.length, 0)
|
||||
assert.equal(analyseTickets().length, before)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal((await consents('p11')).length, 0)
|
||||
})
|
||||
|
||||
test('Kunde ohne actor-Namen oder Mitarbeiter ohne Textform: 400', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p13', customerId: 'c1', projectName: 'Nachweis', subdomain: 'nachweis', isPublic: true })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent() }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent(), actor: {} }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', consent: consent(), by: 'Kenso', now: NOW }), { status: 400 })
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', by: 'Kenso', textform, now: NOW }), { status: 400 }) // ohne Zielgruppen-Bestätigung
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p13', by: 'Kenso', audienceConfirmed: true, textform: { date: '2026-10-01', channel: 'chat' }, now: NOW }), { status: 400 })
|
||||
assert.equal(puts.length, 0)
|
||||
assert.equal((await consents('p13')).length, 0)
|
||||
})
|
||||
|
||||
test('Historie: Kunde, Aus, An durch Mitarbeiter in zeitlicher Folge; Portal-Dokument gleich', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p14', customerId: 'c1', projectName: 'Historie', subdomain: 'historie', isPublic: true })
|
||||
const { doc: d1 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() })
|
||||
let h = await consents('p14')
|
||||
assert.equal(h.length, 1)
|
||||
assert.equal(h[0].event, 'on')
|
||||
assert.equal(h[0].via, 'portal')
|
||||
assert.equal(h[0].avvVersion, '2026-10-02')
|
||||
assert.equal(h[0].acceptedBy, 'Kunde Eins <eins@example.invalid>')
|
||||
assert.equal(h[0].customerId, 'c1')
|
||||
assert.equal(h[0].siteKey, d1.siteKey)
|
||||
assert.match(h[0].acceptedAt, /^\d{4}-\d\d-\d\dT[\d:.]+Z$/)
|
||||
assert.equal(h[0].acceptedAt, d1.avvAcceptedAt)
|
||||
assert.equal(d1.avvAcceptedBy, 'Kunde Eins <eins@example.invalid>')
|
||||
assert.equal(d1.audienceConfirmed, true)
|
||||
assert.equal(d1.audienceTextVersion, '2026-10-02')
|
||||
assert.equal(h[0].audienceConfirmed, true)
|
||||
assert.equal(h[0].audienceTextVersion, '2026-10-02')
|
||||
assert.equal(d1.avvConsentDate, '')
|
||||
assert.equal(d1.avvConsentChannel, '')
|
||||
// Farbwechsel und erneutes Aktivieren: keine neue Zeile
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', accentColor: '#222222', ...cust() })
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', ...cust() })
|
||||
assert.equal((await consents('p14')).length, 1)
|
||||
// Marker zurücksetzen beim Wiedereinschalten
|
||||
await updateDocument(config.collections.portalFeatures, d1.$id, { removalTicketId: 'r1', publishTicketId: 'u1' })
|
||||
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p14', now: NOW, actor })
|
||||
const { doc: d3 } = await an.activateAnalyse({ customerId: 'c1', projectId: 'p14', consent: consent(), by: 'Kenso', textform, audienceConfirmed: true, now: NOW })
|
||||
assert.equal(d3.privacyTicketId && d3.privacyTicketId !== d1.privacyTicketId, true, 'neues Datenschutz-Ticket')
|
||||
assert.equal(d3.removalTicketId, '')
|
||||
assert.ok(d3.publishTicketId && d3.publishTicketId !== 'u1', 'p14 ohne Repo: neues Veröffentlichen-Ticket statt altem Marker')
|
||||
h = await consents('p14')
|
||||
assert.deepEqual(h.map((x) => x.event), ['on', 'off', 'on'])
|
||||
assert.deepEqual(h.map((x) => x.via), ['portal', 'portal', 'woms'])
|
||||
assert.equal(h[1].acceptedBy, 'Kunde Eins <eins@example.invalid>')
|
||||
assert.equal(h[2].acceptedBy, 'Kenso')
|
||||
assert.equal(h[2].textformDate, '2026-10-02')
|
||||
assert.equal(h[2].textformChannel, 'email')
|
||||
assert.equal(h[2].audienceConfirmed, true)
|
||||
assert.equal(d3.audienceConfirmed, true)
|
||||
assert.equal(d3.avvConsentDate, '2026-10-02')
|
||||
assert.equal(d3.avvAcceptedBy, 'Kenso')
|
||||
})
|
||||
|
||||
test('analyseConsent exportiert nur recordConsent und listConsents', () => {
|
||||
assert.deepEqual(Object.keys(cl).sort(), ['listConsents', 'recordConsent'])
|
||||
})
|
||||
|
||||
test('recordConsent scheitert: putSite wird zurückgenommen, Dokument bleibt aus', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p15', customerId: 'c1', projectName: 'Rollback', subdomain: 'rollback', isPublic: true })
|
||||
const prev = an.analyseDeps.recordConsent
|
||||
an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') }
|
||||
await assert.rejects(an.activateAnalyse({ customerId: 'c1', projectId: 'p15', ...cust() }), /Historie weg/)
|
||||
an.analyseDeps.recordConsent = prev
|
||||
assert.equal(puts.length, 2)
|
||||
assert.equal(puts[0].active, true)
|
||||
assert.equal(puts[1].active, false)
|
||||
assert.equal((await doc('c1', 'p15')).state, 'off', 'Dokument zurückgesetzt')
|
||||
assert.equal((await doc('c1', 'p15')).offSince, '')
|
||||
assert.equal(analyseTickets().filter((t) => t.details.includes('Rollback')).length, 0)
|
||||
})
|
||||
|
||||
test('Ausschalten schreibt off-Historie; Fehler dort verhindert das Ausschalten nicht', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p16', customerId: 'c1', projectName: 'Aus', subdomain: 'aus', isPublic: true })
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() })
|
||||
const prev = an.analyseDeps.recordConsent
|
||||
an.analyseDeps.recordConsent = async () => { throw new Error('Historie weg') }
|
||||
const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor })
|
||||
an.analyseDeps.recordConsent = prev
|
||||
assert.equal(r.doc.state, 'off')
|
||||
assert.deepEqual((await consents('p16')).map((x) => x.event), ['on'])
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p16', ...cust() })
|
||||
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p16', now: NOW, actor })
|
||||
assert.deepEqual((await consents('p16')).map((x) => x.event), ['on', 'on', 'off'])
|
||||
})
|
||||
|
||||
const staff = (extra = {}) => ({ consent: consent(), by: 'Kenso', textform: { date: '2026-10-03', channel: 'email' }, audienceConfirmed: true, now: NOW, ...extra })
|
||||
|
||||
test('K18 Mitarbeiter-Weg: in Textform am 03.10.2026 per E-Mail', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p20', customerId: 'c1', projectName: 'Textform', subdomain: 'textform', isPublic: true, repoFullName: 'WEBklar/textform' })
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p20', ...staff() })
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.match(mails.sent[0].html, /in Textform am 03\.10\.2026 per E-Mail/)
|
||||
assert.doesNotMatch(mails.sent[0].html, /Uhr/)
|
||||
})
|
||||
|
||||
test('K18: PDFs nicht lesbar => keine Mail, Marker leer, Aktivierung gültig, spätere Wiederholung sendet', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p21', customerId: 'c1', projectName: 'OhnePdf', subdomain: 'ohnepdf', isPublic: true, repoFullName: 'WEBklar/ohnepdf' })
|
||||
const { legalPdfAttachments } = await import('../server/services/analyseLegal.js')
|
||||
const ok = an.analyseDeps.legalPdfs
|
||||
an.analyseDeps.legalPdfs = () => legalPdfAttachments('1999-01-01')
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: 'p21', ...cust() })
|
||||
assert.equal(r.doc.state, 'active')
|
||||
assert.equal(r.doc.activatedMailAt, '')
|
||||
assert.equal(mails.sent.length, 0)
|
||||
an.analyseDeps.legalPdfs = ok
|
||||
const again = await an.activateAnalyse({ customerId: 'c1', projectId: 'p21', ...cust() })
|
||||
assert.ok(again.doc.activatedMailAt)
|
||||
assert.equal(mails.sent.length, 1)
|
||||
})
|
||||
|
||||
test('Entfernen-Ticket: Kunde, Mitarbeiter, zweimal = ein Ticket, Aus/An/Aus = zwei', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p22', customerId: 'c1', projectName: 'Bäckerei Aus', subdomain: 'baeckereiaus', isPublic: true, repoFullName: 'WEBklar/aus' })
|
||||
const removal = () => topics().filter((t) => t === 'Website-Analyse von Bäckerei Aus entfernen')
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...cust() })
|
||||
assert.equal(removal().length, 0)
|
||||
const off1 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, actor })
|
||||
assert.ok(off1.doc.removalTicketId)
|
||||
await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: NOW, actor })
|
||||
assert.equal(removal().length, 1)
|
||||
const ticket = [...aw.docs('workorders').values()].find((t) => t.topic === 'Website-Analyse von Bäckerei Aus entfernen')
|
||||
assert.match(ticket.details, /fünf Werktagen/)
|
||||
assert.match(ticket.details, /Stundenkontingent/)
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p22', ...staff() })
|
||||
const off2 = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p22', now: new Date(NOW.getTime() + 3600000), by: 'Kenso' })
|
||||
assert.equal(removal().length, 2)
|
||||
assert.notEqual(off2.doc.removalTicketId, off1.doc.removalTicketId)
|
||||
})
|
||||
|
||||
test('Entfernen-Ticket: Ticketfehler verhindert das Ausschalten nicht', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p23', customerId: 'c1', projectName: 'TicketFehler', subdomain: 'ticketfehler', isPublic: true, repoFullName: 'WEBklar/tf' })
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: 'p23', ...cust() })
|
||||
const original = an.analyseDeps.ticket
|
||||
an.analyseDeps.ticket = async () => { throw new Error('Ticketsystem down') }
|
||||
const r = await an.deactivateAnalyse({ customerId: 'c1', projectId: 'p23', now: NOW, actor })
|
||||
an.analyseDeps.ticket = original
|
||||
assert.equal(r.doc.state, 'off')
|
||||
assert.equal(r.changed, true)
|
||||
})
|
||||
|
||||
test('Abo-Ende über runAboCheck: Entfernen-Ticket genau einmal', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p24', customerId: 'c3', projectName: 'Abo Ende', subdomain: 'aboende', isPublic: true, repoFullName: 'WEBklar/aboende' })
|
||||
const d = await sf.ensureFeatureDoc('c3', 'p24', 'analyse')
|
||||
await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active'), accentColor: '#111111' })
|
||||
await an.runAboCheck({ now: NOW })
|
||||
await an.runAboCheck({ now: NOW })
|
||||
assert.equal(topics().filter((t) => t === 'Website-Analyse von Abo Ende entfernen').length, 1)
|
||||
})
|
||||
|
||||
test('rebuildSafe-Ergebnis: kein_repo, preview_aus, fehler => Veröffentlichen-Ticket; Erfolg => keins', async () => {
|
||||
const cases = [
|
||||
['p30', 'KeinRepo', {}, 'kein_repo'],
|
||||
['p31', 'PreviewAus', { repoFullName: 'WEBklar/pa', previewMode: 'off' }, 'preview_aus'],
|
||||
['p32', 'BauFehler', { repoFullName: 'WEBklar/bf' }, 'fehler'],
|
||||
['p33', 'BauErgebnisFehler', { repoFullName: 'WEBklar/bef' }, 'fehler'],
|
||||
['p34', 'BauOk', { repoFullName: 'WEBklar/bo' }, null],
|
||||
]
|
||||
const original = an.analyseDeps.rebuild
|
||||
for (const [id, name, extra, reason] of cases) {
|
||||
aw.seed('websiteProjects', { $id: id, customerId: 'c1', projectName: name, subdomain: name.toLowerCase(), isPublic: true, ...extra })
|
||||
an.analyseDeps.rebuild = async (p) => {
|
||||
rebuilds.push(p.$id)
|
||||
if (id === 'p32') throw new Error('Runner weg')
|
||||
if (id === 'p33') return { error: 'Build kaputt' }
|
||||
return { ok: true }
|
||||
}
|
||||
rebuilds.length = 0
|
||||
const r = await an.activateAnalyse({ customerId: 'c1', projectId: id, ...cust() })
|
||||
const pub = [...aw.docs('workorders').values()].filter((t) => t.topic === `Website-Analyse von ${name} veröffentlichen`)
|
||||
assert.equal(pub.length, reason ? 1 : 0, name)
|
||||
if (reason) {
|
||||
assert.equal(r.doc.publishTicketId, pub[0].$id)
|
||||
assert.ok(pub[0].details.includes(reason === 'kein_repo' ? 'kein Repo' : reason === 'preview_aus' ? 'ausgeschaltet' : 'fehlgeschlagen'), name)
|
||||
const privacy = [...aw.docs('workorders').values()].find((t) => t.topic === `Datenschutzerklärung von ${name} um Hinweis auf Analyse ergänzen`)
|
||||
assert.match(privacy.details, /Veröffentlichung:/)
|
||||
}
|
||||
if (id === 'p30' || id === 'p31') assert.deepEqual(rebuilds, [], 'Rebuild-Stub nicht aufgerufen')
|
||||
await an.activateAnalyse({ customerId: 'c1', projectId: id, ...cust() })
|
||||
assert.equal([...aw.docs('workorders').values()].filter((t) => t.topic === `Website-Analyse von ${name} veröffentlichen`).length, reason ? 1 : 0, 'genau eines je Aktivierung')
|
||||
}
|
||||
an.analyseDeps.rebuild = original
|
||||
})
|
||||
|
||||
// ---- Task 9: alle Beendigungswege ----
|
||||
const FULL = { avvVersion: '2026-10-02', avvAcceptedAt: '2026-10-03T08:00:00.000Z', avvAcceptedBy: 'x', activatedAt: '2026-10-03T08:00:00.000Z', activatedMailAt: '2026-10-03T08:00:01.000Z', privacyTicketId: 'pt' }
|
||||
async function seedFeature(customerId, projectId, state, extra = {}) {
|
||||
const d = await sf.ensureFeatureDoc(customerId, projectId, 'analyse')
|
||||
return updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields(state), accentColor: '#111111', ...FULL, ...extra })
|
||||
}
|
||||
const removalTopics = (name) => topics().filter((t) => t === `Website-Analyse von ${name} entfernen`)
|
||||
|
||||
test('Kunde gelöscht: endAnalyseForCustomer stoppt jeden aktiven Schlüssel, off, kein Ticket, keine Mail, Fehler werden geschluckt', async () => {
|
||||
aw.seed('customers', { $id: 'k1', name: 'Weg Eins', email: 'weg1@example.invalid' })
|
||||
aw.seed('websiteProjects', { $id: 'k1p1', customerId: 'k1', projectName: 'KP1', subdomain: 'kp1', isPublic: true, repoFullName: 'WEBklar/kp1' })
|
||||
aw.seed('websiteProjects', { $id: 'k1p2', customerId: 'k1', projectName: 'KP2', subdomain: 'kp2', isPublic: true })
|
||||
const a = await seedFeature('k1', 'k1p1', 'active')
|
||||
const b = await seedFeature('k1', 'k1p2', 'active')
|
||||
const real = an.analyseDeps.client.stopSite
|
||||
an.analyseDeps.client.stopSite = async (k) => { stops.push(k); if (k === b.siteKey) throw new Error('Sammeldienst weg') }
|
||||
const r = await an.endAnalyseForCustomer('k1', { reason: 'kunde_geloescht', mail: false })
|
||||
an.analyseDeps.client.stopSite = real
|
||||
assert.deepEqual(stops.sort(), [a.siteKey, b.siteKey].sort())
|
||||
assert.equal((await doc('k1', 'k1p1')).state, 'off')
|
||||
assert.ok((await doc('k1', 'k1p1')).offSince)
|
||||
assert.equal((await doc('k1', 'k1p2')).state, 'active', 'Fehler bei einem Dokument: Abgleich holt nach')
|
||||
assert.deepEqual(r.ended, [a.$id])
|
||||
assert.equal(removalTopics('KP1').length, 0)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
test('Abgleich: Projekt gelöscht, Kunde gelöscht => stopSite, off, offSince, kein Ticket', async () => {
|
||||
aw.seed('customers', { $id: 'k2', name: 'Weg Zwei', email: 'weg2@example.invalid' })
|
||||
aw.seed('websiteProjects', { $id: 'k2p', customerId: 'k2', projectName: 'KP3', subdomain: 'kp3', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'k3p', customerId: 'kgibtsnicht', projectName: 'KP4', subdomain: 'kp4', isPublic: true })
|
||||
const gone = await seedFeature('k2', 'k2p', 'active')
|
||||
const orphan = await seedFeature('kgibtsnicht', 'k3p', 'active')
|
||||
await deleteDocument(config.collections.websiteProjects, 'k2p')
|
||||
const now = new Date('2026-11-02T03:00:00.000Z')
|
||||
await an.runAnalyseReconcile({ now })
|
||||
assert.ok(stops.includes(gone.siteKey) && stops.includes(orphan.siteKey))
|
||||
for (const [c, p] of [['k2', 'k2p'], ['kgibtsnicht', 'k3p']]) {
|
||||
const d = await doc(c, p)
|
||||
assert.equal(d.state, 'off')
|
||||
assert.equal(d.offSince, now.toISOString())
|
||||
}
|
||||
assert.equal(removalTopics('KP3').length + removalTopics('KP4').length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
})
|
||||
|
||||
test('Abgleich: geänderte liveDomain => putSite mit neuer Domainliste; off-Dokument => stopSite; K18-Wiederholung genau einmal', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k4p', customerId: 'c1', projectName: 'KP5', subdomain: 'kp5', liveDomain: 'neu-domain.de', isPublic: true })
|
||||
const live = await seedFeature('c1', 'k4p', 'active', { activatedMailAt: '' })
|
||||
aw.seed('websiteProjects', { $id: 'k5p', customerId: 'c1', projectName: 'KP6', subdomain: 'kp6', isPublic: true })
|
||||
const off = await seedFeature('c1', 'k5p', 'off', { offSince: '2026-10-03T09:00:00.000Z', removalTicketId: 'vorhanden' })
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.deepEqual(puts.filter((p) => p.siteKey === live.siteKey), [{ siteKey: live.siteKey, projectId: 'k4p', domains: ['kp5.project.webklar.com', 'neu-domain.de', 'www.neu-domain.de'], active: true }])
|
||||
assert.ok(stops.includes(off.siteKey))
|
||||
assert.equal(mails.sent.length, 1)
|
||||
assert.ok((await doc('c1', 'k4p')).activatedMailAt)
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(mails.sent.length, 1, 'Marker gesetzt: keine zweite K18')
|
||||
})
|
||||
|
||||
test('Domain/Öffentlichkeit: analyseOnProjectChanged beendet bei nicht öffentlich (Ticket), putSite bei neuer Subdomain', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k6p', customerId: 'c1', projectName: 'KP7', subdomain: 'kp7', isPublic: true, repoFullName: 'WEBklar/kp7' })
|
||||
const d = await seedFeature('c1', 'k6p', 'active')
|
||||
await updateDocument(config.collections.websiteProjects, 'k6p', { subdomain: 'kp7neu' })
|
||||
await an.analyseOnProjectChanged('k6p')
|
||||
assert.deepEqual(puts, [{ siteKey: d.siteKey, projectId: 'k6p', domains: ['kp7neu.project.webklar.com'], active: true }])
|
||||
assert.equal(stops.length, 0)
|
||||
puts.length = 0
|
||||
await updateDocument(config.collections.websiteProjects, 'k6p', { isPublic: false })
|
||||
await an.analyseOnProjectChanged('k6p')
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
const after = await doc('c1', 'k6p')
|
||||
assert.equal(after.state, 'off')
|
||||
assert.ok(after.offSince)
|
||||
assert.ok(after.removalTicketId)
|
||||
assert.equal(removalTopics('KP7').length, 1)
|
||||
assert.deepEqual(rebuilds, ['k6p'])
|
||||
await an.analyseOnProjectChanged('k6p') // kein aktives Dokument mehr
|
||||
assert.equal(removalTopics('KP7').length, 1)
|
||||
await an.analyseOnProjectChanged('gibts-nicht')
|
||||
})
|
||||
|
||||
test('Löschfrist/Sperre: siteFeatures.disable beendet Sammeldienst, baut neu und legt Entfernen-Ticket genau einmal an', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k7p', customerId: 'c1', projectName: 'KP8', subdomain: 'kp8', isPublic: true, repoFullName: 'WEBklar/kp8' })
|
||||
const d = await seedFeature('c1', 'k7p', 'active')
|
||||
const r = await sf.disable(d.$id, { adminNote: 'Abgeschaltet: Aufbewahrungsfrist abgelaufen' })
|
||||
assert.equal(r.state, 'disabled')
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
assert.equal(puts.length, 0)
|
||||
const after = await doc('c1', 'k7p')
|
||||
assert.ok(after.offSince)
|
||||
assert.ok(after.removalTicketId)
|
||||
assert.deepEqual(rebuilds, ['k7p'])
|
||||
assert.equal(removalTopics('KP8').length, 1)
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(removalTopics('KP8').length, 1)
|
||||
assert.equal((await doc('c1', 'k7p')).state, 'disabled')
|
||||
})
|
||||
|
||||
test('Löschfrist ohne Projekt: stopSite, kein Ticket, kein Neubau, kein Fehler', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k8p', customerId: 'c1', projectName: 'KP9', subdomain: 'kp9', isPublic: true })
|
||||
const d = await seedFeature('c1', 'k8p', 'active')
|
||||
await deleteDocument(config.collections.websiteProjects, 'k8p')
|
||||
await sf.disable(d.$id, { adminNote: 'x' })
|
||||
assert.deepEqual(stops, [d.siteKey])
|
||||
assert.equal(removalTopics('KP9').length, 0)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
})
|
||||
|
||||
test('Abgleich holt fehlendes Entfernen-Ticket nach (off/disabled), idempotent; Altfälle vor der Fassung nicht', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k9p', customerId: 'c1', projectName: 'KP10', subdomain: 'kp10', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'k10p', customerId: 'c1', projectName: 'KP11', subdomain: 'kp11', isPublic: true })
|
||||
aw.seed('websiteProjects', { $id: 'k11p', customerId: 'c1', projectName: 'KP12', subdomain: 'kp12', isPublic: true })
|
||||
await seedFeature('c1', 'k9p', 'off', { offSince: '2026-10-04T09:00:00.000Z' })
|
||||
await seedFeature('c1', 'k10p', 'disabled', { offSince: '', disabledAt: '2026-10-04T09:00:00.000Z' })
|
||||
await seedFeature('c1', 'k11p', 'off', { offSince: '2026-09-20T09:00:00.000Z' })
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(removalTopics('KP10').length, 1)
|
||||
assert.equal(removalTopics('KP11').length, 1)
|
||||
assert.equal(removalTopics('KP12').length, 0, 'vor Livegang beendet')
|
||||
assert.ok((await doc('c1', 'k9p')).removalTicketId)
|
||||
})
|
||||
|
||||
test('Ticket angelegt, Speichern der ID scheiterte: Abgleich übernimmt das vorhandene Ticket (kein zweites)', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'k12p', customerId: 'c1', projectName: 'KP13', subdomain: 'kp13', isPublic: true })
|
||||
const off = '2026-10-04T09:00:00.000Z'
|
||||
await seedFeature('c1', 'k12p', 'off', { offSince: off })
|
||||
// Zustand nach einem Fehler beim Speichern: Ticket fuer genau diese Abschaltung existiert, removalTicketId fehlt
|
||||
const orig = an.analyseDeps.ticket
|
||||
let created = null
|
||||
an.analyseDeps.ticket = async (a) => (created = await orig(a))
|
||||
const realUpdate = an.analyseDeps.saveRemovalTicketId
|
||||
an.analyseDeps.saveRemovalTicketId = async () => { throw new Error('Appwrite weg') }
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
an.analyseDeps.saveRemovalTicketId = realUpdate
|
||||
an.analyseDeps.ticket = orig
|
||||
assert.ok(created, 'Ticket entstand')
|
||||
assert.ok(!(await doc('c1', 'k12p')).removalTicketId, 'ID nicht gespeichert')
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal(removalTopics('KP13').length, 1)
|
||||
assert.equal((await doc('c1', 'k12p')).removalTicketId, created.ticketId)
|
||||
})
|
||||
|
||||
test('Aus/An/Aus: zweite Abschaltung bekommt trotz offenem ersten Ticket ein eigenes (Marker je Abschaltung)', async () => {
|
||||
const tickets = [...aw.docs('workorders').values()].filter((t) => t.topic === 'Website-Analyse von Bäckerei Aus entfernen')
|
||||
assert.equal(tickets.length, 2)
|
||||
assert.notEqual(tickets[0].details, tickets[1].details)
|
||||
})
|
||||
|
||||
test('deleteCustomerWithPortalAccess: Analyse aus (kein Ticket, keine Mail), Löschen gelingt auch wenn stopSite wirft', async () => {
|
||||
const { deleteCustomerWithPortalAccess } = await import('../server/services/customerAdmin.js')
|
||||
aw.seed('customers', { $id: 'kd1', name: 'Löschen Eins', email: 'loe1@example.invalid' })
|
||||
aw.seed('customers', { $id: 'kd2', name: 'Löschen Zwei', email: 'loe2@example.invalid' })
|
||||
aw.seed('websiteProjects', { $id: 'kd1p', customerId: 'kd1', projectName: 'KD1', subdomain: 'kd1', isPublic: true, repoFullName: 'WEBklar/kd1' })
|
||||
aw.seed('websiteProjects', { $id: 'kd2p', customerId: 'kd2', projectName: 'KD2', subdomain: 'kd2', isPublic: true })
|
||||
const a = await seedFeature('kd1', 'kd1p', 'active')
|
||||
const b = await seedFeature('kd2', 'kd2p', 'active')
|
||||
await deleteCustomerWithPortalAccess('kd1')
|
||||
assert.deepEqual(stops, [a.siteKey])
|
||||
assert.equal((await doc('kd1', 'kd1p')).state, 'off')
|
||||
assert.equal(removalTopics('KD1').length, 0)
|
||||
assert.equal(mails.sent.length, 0)
|
||||
assert.equal(rebuilds.length, 0)
|
||||
assert.equal(aw.docs('customers').has('kd1'), false)
|
||||
const real = an.analyseDeps.client.stopSite
|
||||
an.analyseDeps.client.stopSite = async () => { throw new Error('weg') }
|
||||
await deleteCustomerWithPortalAccess('kd2')
|
||||
an.analyseDeps.client.stopSite = real
|
||||
assert.equal(aw.docs('customers').has('kd2'), false)
|
||||
assert.equal((await doc('kd2', 'kd2p')).state, 'active', 'bleibt für den Abgleich (Kunde fehlt)')
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
assert.equal((await doc('kd2', 'kd2p')).state, 'off')
|
||||
assert.ok(stops.includes(b.siteKey))
|
||||
})
|
||||
|
||||
// ---- Fix round 1 ----
|
||||
test('Abgleich off-Zweig: wird das Dokument nach dem Lesen wieder aktiv, bleibt der Messdienst an (Sperre + Nachlesen)', async () => {
|
||||
const { withLocks } = await import('../server/services/keyedLock.js')
|
||||
aw.seed('websiteProjects', { $id: 'f1p', customerId: 'c1', projectName: 'F1', subdomain: 'f1', isPublic: true })
|
||||
const d = await seedFeature('c1', 'f1p', 'off', { offSince: '2026-10-04T09:00:00.000Z', removalTicketId: 'x' })
|
||||
let run
|
||||
await withLocks(['analyse:f1p'], async () => {
|
||||
run = an.runAnalyseReconcile({ now: NOW })
|
||||
await new Promise((r) => setTimeout(r, 150)) // Abgleich hat gelesen und wartet auf die Sperre
|
||||
await updateDocument(config.collections.portalFeatures, d.$id, { ...sf.stateFields('active') })
|
||||
})
|
||||
await run
|
||||
assert.ok(!stops.includes(d.siteKey), 'frisch aktivierte Analyse nicht abgeschaltet')
|
||||
assert.equal((await doc('c1', 'f1p')).state, 'active')
|
||||
})
|
||||
|
||||
test('Abgleich: Appwrite-Lesefehler (nicht 404) beendet nichts', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'f2p', customerId: 'c1', projectName: 'F2', subdomain: 'f2', isPublic: true })
|
||||
const d = await seedFeature('c1', 'f2p', 'active')
|
||||
const real = globalThis.fetch
|
||||
globalThis.fetch = async (url, opts) => {
|
||||
if (String(url).includes('/collections/websiteProjects/documents/f2p')) return new Response(JSON.stringify({ message: 'kaputt' }), { status: 500, headers: { 'content-type': 'application/json' } })
|
||||
return real(url, opts)
|
||||
}
|
||||
try {
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
} finally {
|
||||
globalThis.fetch = real
|
||||
}
|
||||
assert.ok(!stops.includes(d.siteKey))
|
||||
assert.equal((await doc('c1', 'f2p')).state, 'active')
|
||||
})
|
||||
|
||||
test('Abgleich: nicht öffentliches Projekt / ohne Subdomain => beendet mit Entfernen-Ticket, ohne K19', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'f3p', customerId: 'c1', projectName: 'F3', subdomain: 'f3', isPublic: false, repoFullName: 'WEBklar/f3' })
|
||||
aw.seed('websiteProjects', { $id: 'f4p', customerId: 'c1', projectName: 'F4', isPublic: true })
|
||||
const a = await seedFeature('c1', 'f3p', 'active')
|
||||
const b = await seedFeature('c1', 'f4p', 'active')
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
for (const [p, name, key] of [['f3p', 'F3', a.siteKey], ['f4p', 'F4', b.siteKey]]) {
|
||||
const d = await doc('c1', p)
|
||||
assert.equal(d.state, 'off')
|
||||
assert.ok(stops.includes(key))
|
||||
assert.equal(removalTopics(name).length, 1)
|
||||
assert.ok(d.removalTicketId)
|
||||
}
|
||||
assert.equal(mails.sent.filter((m) => /beendet/.test(m.subject)).length, 0)
|
||||
})
|
||||
|
||||
test('Abgleich: stopSite wirft => Dokument bleibt unverändert aktiv', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'f5p', customerId: 'c1', projectName: 'F5', subdomain: 'f5', isPublic: false })
|
||||
const d = await seedFeature('c1', 'f5p', 'active')
|
||||
const real = an.analyseDeps.client.stopSite
|
||||
an.analyseDeps.client.stopSite = async () => { throw new Error('Messdienst weg') }
|
||||
await an.runAnalyseReconcile({ now: NOW })
|
||||
an.analyseDeps.client.stopSite = real
|
||||
const after = await doc('c1', 'f5p')
|
||||
assert.equal(after.state, 'active')
|
||||
assert.ok(!after.offSince)
|
||||
assert.equal(removalTopics('F5').length, 0)
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { test, after } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import express, { Router } from 'express'
|
||||
import { useTestEnv } from './support/testEnv.js'
|
||||
|
||||
useTestEnv({ appwriteUrl: 'http://127.0.0.1:1', stripeUrl: 'http://127.0.0.1:1' })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp } = await import('./support/testApp.js')
|
||||
const { analyseLegalRouter, legalPaths, legalPdfFiles, legalPdfAttachments } = await import('../server/services/analyseLegal.js')
|
||||
const core = await import('../analytics/client/core.js')
|
||||
|
||||
const root = (rel) => new URL(`../${rel}`, import.meta.url)
|
||||
const V = '2026-10-02'
|
||||
// wie in server/index.js: erst die Router für Adressen ohne Fassung, dann express.static
|
||||
const rechtliches = Router()
|
||||
rechtliches.use(analyseLegalRouter())
|
||||
rechtliches.use(express.static(fileURLToPath(new URL('../public/rechtliches/', import.meta.url))))
|
||||
const app = await startTestApp({ '/rechtliches': rechtliches })
|
||||
after(() => app.close())
|
||||
|
||||
const fetchBytes = async (path) => {
|
||||
const r = await fetch(app.url + path)
|
||||
return { status: r.status, body: Buffer.from(await r.arrayBuffer()) }
|
||||
}
|
||||
|
||||
test('legalPaths und legalPdfFiles nennen feste Adressen und Dateinamen', () => {
|
||||
assert.deepEqual(legalPaths(V), {
|
||||
avv: `/rechtliches/avv-website-analyse-${V}.html`,
|
||||
lb: `/rechtliches/leistungsbeschreibung-website-analyse-${V}.html`,
|
||||
})
|
||||
const f = legalPdfFiles(V)
|
||||
assert.equal(f.avv.filename, `AVV-Website-Analyse-${V}.pdf`)
|
||||
assert.equal(f.lb.filename, `Leistungsbeschreibung-Website-Analyse-${V}.pdf`)
|
||||
assert.throws(() => legalPaths('../x'))
|
||||
})
|
||||
|
||||
test('Adressen ohne Fassung liefern die Bytes der Fassung aus config.analyse.avvVersion', async () => {
|
||||
assert.equal(config.analyse.avvVersion, V)
|
||||
const a = await fetchBytes('/rechtliches/avv-website-analyse.html')
|
||||
assert.equal(a.status, 200)
|
||||
assert.deepEqual(a.body, readFileSync(root(`public/rechtliches/avv-website-analyse-${V}.html`)))
|
||||
const l = await fetchBytes('/rechtliches/leistungsbeschreibung-website-analyse.html')
|
||||
assert.equal(l.status, 200)
|
||||
assert.deepEqual(l.body, readFileSync(root(`public/rechtliches/leistungsbeschreibung-website-analyse-${V}.html`)))
|
||||
})
|
||||
|
||||
test('versionierte Adresse 200, unbekannte Fassung 404', async () => {
|
||||
assert.equal((await fetchBytes(`/rechtliches/avv-website-analyse-${V}.html`)).status, 200)
|
||||
assert.equal((await fetchBytes(`/rechtliches/leistungsbeschreibung-website-analyse-${V}.html`)).status, 200)
|
||||
assert.equal((await fetchBytes('/rechtliches/avv-website-analyse-2026-01-01.html')).status, 404)
|
||||
})
|
||||
|
||||
test('PDFs: beginnen mit %PDF, > 20 KB, Anhänge mit richtigen Dateinamen', async () => {
|
||||
const att = await legalPdfAttachments(V)
|
||||
assert.equal(att.length, 2)
|
||||
assert.deepEqual(att.map((a) => a.filename), [`AVV-Website-Analyse-${V}.pdf`, `Leistungsbeschreibung-Website-Analyse-${V}.pdf`])
|
||||
for (const a of att) {
|
||||
assert.equal(a.contentType, 'application/pdf')
|
||||
assert.equal(a.content.subarray(0, 4).toString(), '%PDF')
|
||||
assert.ok(a.content.length > 20 * 1024)
|
||||
}
|
||||
await assert.rejects(legalPdfAttachments('2026-01-01'))
|
||||
})
|
||||
|
||||
test('Banner-Archiv: Text und aria-label wortgleich zu core.js, README nennt BANNER_VERSION', () => {
|
||||
const md = readFileSync(root(`docs/rechtliches/archiv/banner-${V}.md`), 'utf8')
|
||||
assert.ok(md.includes(core.BANNER_TEXT))
|
||||
assert.ok(md.includes(core.BANNER_ARIA))
|
||||
assert.equal(core.BANNER_VERSION, V)
|
||||
assert.ok(md.includes('BANNER_VERSION'))
|
||||
assert.ok(readFileSync(root('docs/rechtliches/archiv/README.md'), 'utf8').includes('BANNER_VERSION'))
|
||||
assert.deepEqual(
|
||||
readFileSync(root(`docs/rechtliches/archiv/datenschutz-analyse-${V}.html`)),
|
||||
readFileSync(root('runner/src/templates/datenschutz-analyse.html'))
|
||||
)
|
||||
})
|
||||
+85
-21
@@ -6,14 +6,16 @@ import { useTestEnv } from './support/testEnv.js'
|
||||
import { schemaKeys } from './support/schema.js'
|
||||
import { mailSink } from './support/mailSink.js'
|
||||
|
||||
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt']
|
||||
const ANALYSE_KEYS = ['accentColor', 'avvVersion', 'avvAcceptedAt', 'avvAcceptedBy', 'activatedAt', 'offSince', 'privacyTicketId', 'activatedMailAt', 'avvConsentDate', 'avvConsentChannel', 'removalTicketId', 'publishTicketId', 'audienceConfirmed', 'audienceTextVersion']
|
||||
const CONSENT_KEYS = ['customerId', 'projectId', 'siteKey', 'event', 'avvVersion', 'via', 'acceptedAt', 'acceptedBy', 'textformDate', 'textformChannel', 'audienceConfirmed', 'audienceTextVersion']
|
||||
const aw = await startAppwriteMock({
|
||||
schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions') },
|
||||
schema: { portalFeatures: [...schemaKeys('portalFeatures'), ...ANALYSE_KEYS], subscriptions: schemaKeys('subscriptions'), analyseConsents: CONSENT_KEYS },
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
useTestEnv({ appwriteUrl: aw.url, stripeUrl: st.url })
|
||||
const { config } = await import('../server/config.js')
|
||||
const { startTestApp, get, rawGet, post } = await import('./support/testApp.js')
|
||||
const { legalPaths } = await import('../server/services/analyseLegal.js')
|
||||
const { startTestApp, get, rawGet, post, sessionCookie } = await import('./support/testApp.js')
|
||||
const { customerCookie, wartungDoc, seedFixtures } = await import('./support/fixtures.js')
|
||||
const { default: funktionenRoutes } = await import('../server/routes/funktionen.js')
|
||||
const { default: adminAnalyseRoutes } = await import('../server/routes/admin/analyse.js')
|
||||
@@ -31,11 +33,13 @@ after(() => {
|
||||
})
|
||||
|
||||
const puts = []
|
||||
const stops = []
|
||||
const statsCalls = []
|
||||
let statsFail = false
|
||||
const realClient = createAnalyticsClient({ heatSecret: 'h'.repeat(32), adminToken: 't' })
|
||||
an.analyseDeps.client = {
|
||||
putSite: async (siteKey, body) => puts.push({ siteKey, ...body }),
|
||||
stopSite: async (siteKey) => stops.push(siteKey),
|
||||
getStats: async (siteKey, range) => {
|
||||
statsCalls.push({ siteKey, range })
|
||||
if (statsFail) throw Object.assign(new Error('x'), { status: 502 })
|
||||
@@ -46,6 +50,8 @@ an.analyseDeps.client = {
|
||||
an.analyseDeps.rebuild = async () => {}
|
||||
config.analyse.enabled = true
|
||||
config.analyse.heatSecret = 'h'.repeat(32)
|
||||
// Die Fassung 2026-10-02 liegt (je nach Uhr) noch in der Zukunft: Textform-Mindestdatum fuer die Tests auf frueh setzen
|
||||
config.analyse.avvVersion = '2000-01-01'
|
||||
|
||||
seedFixtures(aw)
|
||||
aw.seed('subscriptions', wartungDoc)
|
||||
@@ -60,10 +66,14 @@ const cookie = customerCookie('c1')
|
||||
const A = '/api/funktionen/analyse'
|
||||
const consent = () => ({ accepted: true, avvVersion: config.analyse.avvVersion })
|
||||
const adminPost = (id, body, jwt = 'jwt-admin') => post(`/api/admin/analyse/${id}`, body, { headers: { Authorization: `Bearer ${jwt}` } })
|
||||
const todayBerlin = () => new Intl.DateTimeFormat('sv-SE', { timeZone: 'Europe/Berlin' }).format(new Date())
|
||||
const tf = (extra = {}) => ({ textformDate: todayBerlin(), textformChannel: 'chat', audienceConfirmed: true, ...extra })
|
||||
const consentsOf = (p) => [...aw.docs('analyseConsents').values()].filter((d) => d.projectId === p).sort((a, b) => a.$seq - b.$seq)
|
||||
const featureDoc = (c, p) => sf.getFeatureDoc(c, p, 'analyse')
|
||||
|
||||
beforeEach(() => {
|
||||
puts.length = 0
|
||||
stops.length = 0
|
||||
statsCalls.length = 0
|
||||
statsFail = false
|
||||
config.analyse.enabled = true
|
||||
@@ -89,7 +99,7 @@ test('Schalter aus: alle Analyse-Routen 409', async () => {
|
||||
post(`${A}/deactivate`, { projectId: 'p1' }, { cookie }),
|
||||
rawGet(`${A}/stats?projectId=p1`, { cookie }),
|
||||
post(`${A}/heatmap-link`, { projectId: 'p1', path: '/', vp: 'd' }, { cookie }),
|
||||
adminPost('p1', { on: true, avvConfirmedVerbally: true }),
|
||||
adminPost('p1', { on: true, ...tf() }),
|
||||
]) {
|
||||
const res = await r
|
||||
assert.equal(res.status, 409)
|
||||
@@ -102,7 +112,7 @@ test('GET /api/funktionen: analyse-Zustand je Projekt, mit und ohne Schalter', a
|
||||
const body = await get('/api/funktionen', { cookie })
|
||||
const p = (id) => body.projects.find((x) => x.projectId === id)
|
||||
assert.equal(p('p1').features.news.state, 'off')
|
||||
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion })
|
||||
assert.deepEqual(p('p1').analyse, { state: 'off', devMode: false, accentColor: '#111111', eligible: true, reason: null, reasonText: null, avvVersion: config.analyse.avvVersion, legal: legalPaths(config.analyse.avvVersion), controller: { company: 'Kunde Eins', address: '', email: 'k@example.invalid', ok: true, missing: [] } })
|
||||
assert.equal(p('p2').analyse.eligible, false)
|
||||
assert.equal(p('p2').analyse.reason, 'nicht_oeffentlich')
|
||||
assert.equal(p('p2').analyse.reasonText, an.ELIGIBILITY_MESSAGES.nicht_oeffentlich)
|
||||
@@ -125,7 +135,7 @@ test('Aktivieren und Deaktivieren (Kunde)', async () => {
|
||||
assert.equal(view.accentColor, '#112233')
|
||||
const off = await post(`${A}/deactivate`, { projectId: 'p1' }, { cookie })
|
||||
assert.deepEqual(off.body, { ok: true, state: 'off' })
|
||||
assert.equal(puts[1].active, false)
|
||||
assert.equal(stops.length, 1)
|
||||
const notElig = await post(`${A}/activate`, { projectId: 'p2', consent: consent() }, { cookie })
|
||||
assert.equal(notElig.status, 409)
|
||||
})
|
||||
@@ -189,18 +199,37 @@ test('Heatmap-Link: Prüfung, URL, Token', async () => {
|
||||
|
||||
test('Admin: 401, 400, 200', async () => {
|
||||
assert.equal((await post('/api/admin/analyse/p6', { on: true })).status, 401)
|
||||
assert.equal((await adminPost('p6', { on: true, avvConfirmedVerbally: true }, 'jwt-kunde')).status, 403)
|
||||
const noAvv = await adminPost('p6', { on: true })
|
||||
assert.equal(noAvv.status, 400)
|
||||
assert.equal(noAvv.body.error, 'Bitte bestätigen, dass der Kunde den AVV bestätigt hat')
|
||||
assert.equal((await adminPost('p6', { on: true, ...tf() }, 'jwt-kunde')).status, 403)
|
||||
const noTf = await adminPost('p6', { on: true, audienceConfirmed: true, textformChannel: 'chat' })
|
||||
assert.equal(noTf.status, 400)
|
||||
assert.equal((await adminPost('p6', { on: true, ...tf({ textformDate: undefined }) })).status, 400)
|
||||
assert.equal((await adminPost('p6', { on: true, ...tf({ textformChannel: undefined }) })).status, 400)
|
||||
assert.equal((await adminPost('p6', { on: true, ...tf({ textformChannel: 'telefon' }) })).status, 400)
|
||||
assert.equal((await adminPost('p6', { on: true, ...tf({ textformDate: '1999-01-01' }) })).status, 400, 'vor der Fassung')
|
||||
assert.equal((await adminPost('p6', { on: true, ...tf({ audienceConfirmed: undefined }) })).status, 400)
|
||||
const old = await adminPost('p6', { on: true, avvConfirmedVerbally: true, ...tf() })
|
||||
assert.equal(old.status, 400)
|
||||
assert.equal(old.body.error, 'Zustimmung in Textform nötig')
|
||||
assert.equal(await featureDoc('c1', 'p6'), null, 'nichts angelegt')
|
||||
assert.equal((await adminPost('p6', { on: 'ja' })).status, 400)
|
||||
assert.equal((await adminPost('nix', { on: true, avvConfirmedVerbally: true })).status, 404)
|
||||
const on = await adminPost('p6', { on: true, avvConfirmedVerbally: true })
|
||||
assert.equal((await adminPost('nix', { on: true, ...tf() })).status, 404)
|
||||
const on = await adminPost('p6', { on: true, ...tf() })
|
||||
assert.equal(on.status, 200)
|
||||
assert.deepEqual(on.body, { ok: true, state: 'active' })
|
||||
const d = await featureDoc('c1', 'p6')
|
||||
assert.equal(d.avvAcceptedBy, 'kenso@webklar.com')
|
||||
assert.equal(d.avvVersion, config.analyse.avvVersion)
|
||||
assert.equal(d.avvConsentDate, todayBerlin())
|
||||
assert.equal(d.avvConsentChannel, 'chat')
|
||||
const h = consentsOf('p6')
|
||||
assert.equal(h.length, 1)
|
||||
assert.equal(h[0].via, 'woms')
|
||||
assert.equal(h[0].acceptedBy, 'kenso@webklar.com')
|
||||
assert.equal(h[0].audienceConfirmed, true)
|
||||
assert.equal(h[0].audienceTextVersion, config.analyse.avvVersion)
|
||||
assert.equal(d.audienceConfirmed, true)
|
||||
assert.equal(h[0].textformDate, todayBerlin())
|
||||
assert.equal(h[0].textformChannel, 'chat')
|
||||
const off = await adminPost('p6', { on: false })
|
||||
assert.deepEqual(off.body, { ok: true, state: 'off' })
|
||||
})
|
||||
@@ -217,9 +246,12 @@ test('Admin: Lesezugriff GET', async () => {
|
||||
assert.equal(off.body.eligible, true)
|
||||
assert.equal(off.body.reasonText, null)
|
||||
assert.equal(off.body.avvVersion, config.analyse.avvVersion)
|
||||
await adminPost('p1', { on: true, avvConfirmedVerbally: true })
|
||||
await adminPost('p1', { on: true, ...tf() })
|
||||
const on = await adminGet('p1')
|
||||
assert.equal(on.body.state, 'active')
|
||||
assert.equal(on.body.avvConsentDate, todayBerlin())
|
||||
assert.equal(on.body.avvConsentChannel, 'chat')
|
||||
assert.deepEqual(on.body.privacy, { ok: true, missing: [] })
|
||||
assert.equal(on.body.avvAcceptedBy, 'kenso@webklar.com')
|
||||
assert.ok(on.body.activatedAt)
|
||||
assert.ok(!('siteKey' in on.body) && !JSON.stringify(on.body).includes((await featureDoc('c1', 'p1')).siteKey))
|
||||
@@ -230,7 +262,7 @@ test('Admin: Lesezugriff GET', async () => {
|
||||
})
|
||||
|
||||
test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async () => {
|
||||
await adminPost('p6', { on: true, avvConfirmedVerbally: true })
|
||||
await adminPost('p6', { on: true, ...tf() })
|
||||
const cur = await featureDoc('c1', 'p6')
|
||||
await sf.disable(cur.$id, { by: 'Test' })
|
||||
puts.length = 0
|
||||
@@ -241,18 +273,19 @@ test('Fix a: Kunde ändert gesperrtes Dokument nicht, Mitarbeiter schon', async
|
||||
assert.equal(staff.doc.state, 'off')
|
||||
})
|
||||
|
||||
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst active:false', async () => {
|
||||
await adminPost('p6', { on: true, avvConfirmedVerbally: true })
|
||||
test('Fix b: Abschalten durch Mitarbeiter meldet Sammeldienst per stopSite', async () => {
|
||||
await adminPost('p6', { on: true, ...tf() })
|
||||
const cur = await featureDoc('c1', 'p6')
|
||||
puts.length = 0
|
||||
stops.length = 0
|
||||
await sf.disable(cur.$id, { by: 'Test' })
|
||||
assert.equal(puts.length, 1)
|
||||
assert.deepEqual(puts[0], { siteKey: cur.siteKey, projectId: 'p6', domains: ['neu.project.webklar.com'], active: false })
|
||||
assert.deepEqual(stops, [cur.siteKey])
|
||||
assert.equal(puts.length, 0)
|
||||
// Ausfall des Sammeldienstes verhindert das Abschalten nicht
|
||||
const real = an.analyseDeps.client.putSite
|
||||
an.analyseDeps.client.putSite = async () => { throw new Error('weg') }
|
||||
const real = an.analyseDeps.client.stopSite
|
||||
an.analyseDeps.client.stopSite = async () => { throw new Error('weg') }
|
||||
const again = await sf.disable(cur.$id, { by: 'Test' })
|
||||
an.analyseDeps.client.putSite = real
|
||||
an.analyseDeps.client.stopSite = real
|
||||
assert.equal(again.state, 'disabled')
|
||||
})
|
||||
|
||||
@@ -275,3 +308,34 @@ test('Härtung: ungültige Hosts, Pfadzeichen, Fehler bei einem Projekt', async
|
||||
assert.equal(bad.body.error, 'Keine gültige Domain für die Analyse')
|
||||
assert.equal(puts.length, before)
|
||||
})
|
||||
|
||||
test('Kunden-Aktivierung schreibt Historie mit Sitzungsnamen; fehlende Angaben: controller + 409', async () => {
|
||||
aw.seed('customers', { $id: 'c9', appwriteUserId: 'u9', email: 'neun', name: '', status: 'active', portalAccessEnabled: true })
|
||||
aw.seed('websiteProjects', { $id: 'p9', customerId: 'c9', projectName: 'Leer', subdomain: 'leer', isPublic: true })
|
||||
aw.seed('subscriptions', { ...wartungDoc, $id: 'sub_w_c9', stripeSubscriptionId: 'sub_w_c9', customerId: 'c9' })
|
||||
const c9 = sessionCookie({ customerId: 'c9', appwriteUserId: 'u9', name: 'Neun', email: 'neun' })
|
||||
const view = (await get('/api/funktionen', { cookie: c9 })).projects.find((x) => x.projectId === 'p9').analyse
|
||||
assert.deepEqual(view.controller, { company: '', address: '', email: 'neun', ok: false, missing: ['firma', 'email'] })
|
||||
const r = await post(`${A}/activate`, { projectId: 'p9', consent: consent() }, { cookie: c9 })
|
||||
assert.equal(r.status, 409)
|
||||
assert.match(r.body.error, /fehlen in Deinen Kundendaten Firma oder Name/)
|
||||
assert.equal(consentsOf('p9').length, 0)
|
||||
const before = consentsOf('p3').length
|
||||
await post(`${A}/activate`, { projectId: 'p3', consent: consent() }, { cookie })
|
||||
const h = consentsOf('p3')
|
||||
assert.equal(h.length, before + 1)
|
||||
assert.equal(h.at(-1).acceptedBy, 'Kunde Eins <k@example.invalid>')
|
||||
assert.equal(h.at(-1).audienceConfirmed, true)
|
||||
assert.equal(h.at(-1).via, 'portal')
|
||||
const admin = (await rawGet('/api/admin/analyse/p9', { headers: { Authorization: 'Bearer jwt-admin' } })).body
|
||||
assert.deepEqual(admin.privacy, { ok: false, missing: ['firma', 'email'] })
|
||||
})
|
||||
|
||||
test('Admin-Sitzung (Impersonation): handelnde Person ist der Admin, nicht der Kunde', async () => {
|
||||
aw.seed('websiteProjects', { $id: 'p20', customerId: 'c1', projectName: 'Imp', subdomain: 'imp', isPublic: true })
|
||||
const c = sessionCookie({ customerId: 'c1', appwriteUserId: 'u1', name: 'Kunde Eins', email: 'k@example.invalid', adminName: 'Kenso', adminEmail: 'kenso@webklar.com' })
|
||||
assert.equal((await post(`${A}/activate`, { projectId: 'p20', consent: consent() }, { cookie: c })).status, 200)
|
||||
const h = consentsOf('p20')
|
||||
assert.equal(h[0].acceptedBy, 'Kenso <kenso@webklar.com>')
|
||||
assert.equal((await featureDoc('c1', 'p20')).avvAcceptedBy, 'Kenso <kenso@webklar.com>')
|
||||
})
|
||||
+68
-21
@@ -1,24 +1,37 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { ANALYSE_ATTRIBUTES, main } from '../scripts/analyse-setup.mjs'
|
||||
import { ANALYSE_ATTRIBUTES, CONSENT_ATTRIBUTES, CONSENT_INDEXES, main } from '../scripts/analyse-setup.mjs'
|
||||
import { config } from '../server/config.js'
|
||||
|
||||
function mockClient(existing = []) {
|
||||
const created = []
|
||||
function mockClient({ attrs = [], consentsExist = false, consentAttrs = [], indexes = [] } = {}) {
|
||||
const calls = { attrs: [], collections: [], indexes: [] }
|
||||
return {
|
||||
created,
|
||||
async listAttributes() {
|
||||
return existing.map((key) => ({ key }))
|
||||
calls,
|
||||
async collectionExists() {
|
||||
return consentsExist
|
||||
},
|
||||
async createAttribute(attr) {
|
||||
created.push(attr.key)
|
||||
async ensureCollection(id, name) {
|
||||
calls.collections.push({ id, name })
|
||||
return true
|
||||
},
|
||||
async listAttributes(id) {
|
||||
return (id === config.collections.portalFeatures ? attrs : consentAttrs).map((key) => ({ key }))
|
||||
},
|
||||
async createAttribute(id, attr) {
|
||||
calls.attrs.push(`${id}.${attr.key}`)
|
||||
},
|
||||
async waitForAttribute() {},
|
||||
async listIndexes() {
|
||||
return indexes.map((key) => ({ key }))
|
||||
},
|
||||
async createIndex(id, index) {
|
||||
calls.indexes.push(`${id}.${index.key}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
test('Schema: 8 Attribute mit Groessen, alle optional', () => {
|
||||
const sizes = Object.fromEntries(ANALYSE_ATTRIBUTES.map((a) => [a.key, a.size]))
|
||||
test('Schema: 14 Attribute mit Groessen, alle optional', () => {
|
||||
const sizes = Object.fromEntries(ANALYSE_ATTRIBUTES.filter((a) => a.type === 'string').map((a) => [a.key, a.size]))
|
||||
assert.deepEqual(sizes, {
|
||||
accentColor: 7,
|
||||
avvVersion: 20,
|
||||
@@ -28,23 +41,57 @@ test('Schema: 8 Attribute mit Groessen, alle optional', () => {
|
||||
offSince: 40,
|
||||
privacyTicketId: 40,
|
||||
activatedMailAt: 40,
|
||||
avvConsentDate: 10,
|
||||
avvConsentChannel: 10,
|
||||
removalTicketId: 40,
|
||||
publishTicketId: 40,
|
||||
audienceTextVersion: 20,
|
||||
})
|
||||
assert.ok(ANALYSE_ATTRIBUTES.every((a) => a.required === false && a.type === 'string'))
|
||||
assert.equal(ANALYSE_ATTRIBUTES.length, 14)
|
||||
assert.equal(ANALYSE_ATTRIBUTES.find((a) => a.key === 'audienceConfirmed').type, 'boolean')
|
||||
assert.ok(ANALYSE_ATTRIBUTES.every((a) => a.required === false))
|
||||
})
|
||||
|
||||
test('main legt nur fehlende Attribute an', async () => {
|
||||
const client = mockClient(['accentColor'])
|
||||
test('Schema: Historien-Collection analyseConsents', () => {
|
||||
assert.equal(config.collections.analyseConsents, 'analyseConsents')
|
||||
const sizes = Object.fromEntries(CONSENT_ATTRIBUTES.filter((a) => a.type === 'string').map((a) => [a.key, a.size]))
|
||||
assert.deepEqual(sizes, {
|
||||
customerId: 36, projectId: 36, siteKey: 40, event: 8, avvVersion: 20, via: 8,
|
||||
acceptedAt: 40, acceptedBy: 100, textformDate: 10, textformChannel: 10, audienceTextVersion: 20,
|
||||
})
|
||||
assert.equal(CONSENT_ATTRIBUTES.find((a) => a.key === 'audienceConfirmed').type, 'boolean')
|
||||
assert.deepEqual(CONSENT_INDEXES.map((i) => i.attributes), [['projectId']])
|
||||
})
|
||||
|
||||
test('main legt nur fehlende Attribute an und die fehlende Collection samt Index', async () => {
|
||||
const client = mockClient({ attrs: ['accentColor'] })
|
||||
const result = await main({ client, log: () => {} })
|
||||
assert.equal(client.created.length, 7)
|
||||
assert.ok(!client.created.includes('accentColor'))
|
||||
assert.equal(result.missing.length, 7)
|
||||
assert.equal(client.calls.attrs.filter((k) => k.startsWith('portalFeatures.')).length, 13)
|
||||
assert.ok(!client.calls.attrs.includes('portalFeatures.accentColor'))
|
||||
assert.equal(client.calls.attrs.filter((k) => k.startsWith('analyseConsents.')).length, 12)
|
||||
assert.deepEqual(client.calls.collections, [{ id: 'analyseConsents', name: 'analyseConsents' }])
|
||||
assert.deepEqual(client.calls.indexes, ['analyseConsents.projectId_idx'])
|
||||
assert.equal(result.missing.length, 25)
|
||||
})
|
||||
|
||||
test('main --check legt nichts an', async () => {
|
||||
const client = mockClient(['accentColor'])
|
||||
test('main ueberspringt vorhandene Collection, Attribute und Index', async () => {
|
||||
const client = mockClient({
|
||||
attrs: ANALYSE_ATTRIBUTES.map((a) => a.key),
|
||||
consentsExist: true,
|
||||
consentAttrs: CONSENT_ATTRIBUTES.map((a) => a.key),
|
||||
indexes: ['projectId_idx'],
|
||||
})
|
||||
const result = await main({ client, log: () => {} })
|
||||
assert.deepEqual(client.calls, { attrs: [], collections: [], indexes: [] })
|
||||
assert.equal(result.missing.length, 0)
|
||||
})
|
||||
|
||||
test('main --check legt nichts an und meldet missing', async () => {
|
||||
const client = mockClient({ attrs: ['accentColor'] })
|
||||
const result = await main({ check: true, client, log: () => {} })
|
||||
assert.equal(client.created.length, 0)
|
||||
assert.equal(result.missing.length, 7)
|
||||
assert.deepEqual(client.calls, { attrs: [], collections: [], indexes: [] })
|
||||
assert.equal(result.missing.length, 25)
|
||||
assert.equal(result.missingCollection, true)
|
||||
})
|
||||
|
||||
test('Konfiguration: analyse ist standardmaessig aus', () => {
|
||||
@@ -52,5 +99,5 @@ test('Konfiguration: analyse ist standardmaessig aus', () => {
|
||||
assert.equal(config.analyse.url, 'http://webklar-analytics:8091')
|
||||
assert.equal(config.analyse.adminToken, '')
|
||||
assert.equal(config.analyse.heatSecret, '')
|
||||
assert.equal(config.analyse.avvVersion, '2026-10-01')
|
||||
assert.equal(config.analyse.avvVersion, '2026-10-02')
|
||||
})
|
||||
@@ -1,8 +1,8 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { contrastOk } from '../analytics/client/core.js'
|
||||
import { bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js'
|
||||
import { contrastOk, BANNER_TEXT } from '../analytics/client/core.js'
|
||||
import { BANNER_PREVIEW_TEXT, controllerLines, bannerColor, chartModel, countryName, flagEmoji, niceMax, shortDay, barRows, refLabel } from '../public/analyse-view.js'
|
||||
|
||||
test('contrast und bannerColor stimmen mit core.js ueberein', () => {
|
||||
for (const c of ['#000000', '#ffffff', '#1d4ed8', '#047857', '#b91c1c', '#7c3aed', '#c2410c', '#ffcc00', '#9d3dfe', 'x', '#12']) {
|
||||
@@ -57,3 +57,13 @@ test('Portal: Analyse-Ansicht ist verdrahtet', () => {
|
||||
const fn = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8')
|
||||
assert.doesNotMatch(fn.slice(fn.indexOf('Analyse ---')), /\bconfirm\(/)
|
||||
})
|
||||
|
||||
test('Portal-Vorschau: Banner-Text ist der des Clients', () => {
|
||||
assert.equal(BANNER_PREVIEW_TEXT, BANNER_TEXT)
|
||||
})
|
||||
|
||||
test('controllerLines: Firma, Anschrift falls vorhanden, E-Mail', () => {
|
||||
assert.deepEqual(controllerLines({ company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }), ['Muster GmbH', 'Weg 1, 12345 Ort', 'info@muster.de'])
|
||||
assert.deepEqual(controllerLines({ company: 'Muster GmbH', address: '', email: 'info@muster.de' }), ['Muster GmbH', 'info@muster.de'])
|
||||
assert.deepEqual(controllerLines(undefined), [])
|
||||
})
|
||||
@@ -92,3 +92,15 @@ test('heatToken: von der Analytics-Verifikation akzeptiert, 30 Min gueltig', ()
|
||||
test('heatToken ohne heatSecret => 503', () => {
|
||||
assert.throws(() => mk({ heatSecret: '' }).heatToken({ siteKey: 'abcdefghij123456', path: '/', vp: 'd' }), (e) => e.status === 503)
|
||||
})
|
||||
|
||||
test('stopSite: POST /off mit Bearer, 404 zaehlt als erledigt, andere Fehler werfen', async () => {
|
||||
reset()
|
||||
await mk().stopSite('abcdefghij123456')
|
||||
assert.equal(calls[0].method, 'POST')
|
||||
assert.equal(calls[0].url, '/a/admin/sites/abcdefghij123456/off')
|
||||
assert.equal(calls[0].auth, 'Bearer tok-secret')
|
||||
reset(() => ({ status: 404, body: { error: 'x' } }))
|
||||
assert.deepEqual(await mk().stopSite('abcdefghij123456'), { ok: true, missing: true })
|
||||
reset(() => ({ status: 500, body: {} }))
|
||||
await assert.rejects(mk().stopSite('abcdefghij123456'), (e) => e.status === 500)
|
||||
})
|
||||
@@ -60,3 +60,15 @@ test('Vertrag: putSite -> Ereignisse -> getStats -> heatToken -> deleteSite', as
|
||||
await assert.rejects(client.getStats(SITE, '7'), (e) => e.status === 404)
|
||||
assert.equal(db.prepare('SELECT COUNT(*) AS n FROM events_raw').get().n, 0)
|
||||
})
|
||||
|
||||
test('Vertrag: stopSite setzt active=0 und offSince, unbekannt zaehlt als erledigt', async () => {
|
||||
const KEY2 = 'fedcba9876543210fedc'
|
||||
await client.putSite(KEY2, { projectId: 'p2', domains: [DOMAIN], active: true })
|
||||
await client.stopSite(KEY2)
|
||||
const row = db.prepare('SELECT active, offSince FROM sites WHERE siteKey = ?').get(KEY2)
|
||||
assert.equal(row.active, 0)
|
||||
assert.ok(row.offSince)
|
||||
await client.stopSite(KEY2) // zweiter Aufruf aendert nichts
|
||||
assert.equal(db.prepare('SELECT offSince FROM sites WHERE siteKey = ?').get(KEY2).offSince, row.offSince)
|
||||
assert.deepEqual(await client.stopSite('00000000000000000000'), { ok: true, missing: true })
|
||||
})
|
||||
+27
-6
@@ -2,13 +2,35 @@ import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
|
||||
const { config } = await import('../server/config.js')
|
||||
const root = (rel) => new URL(`../${rel}`, import.meta.url)
|
||||
|
||||
test('AVV Website-Analyse: Entwurf ist nicht öffentlich (geprüfte Fassung kommt mit der Rechtsrunde)', () => {
|
||||
const V = '2026-10-02'
|
||||
const FILES = [`public/rechtliches/avv-website-analyse-${V}.html`, `public/rechtliches/leistungsbeschreibung-website-analyse-${V}.html`]
|
||||
|
||||
test('Analyse-Rechtstexte: versionierte Dateien tragen die Fassung, keine Entwurfs- oder Prüfhinweise, keine Kommentare', () => {
|
||||
for (const f of FILES) {
|
||||
assert.ok(existsSync(root(f)), f)
|
||||
const html = readFileSync(root(f), 'utf8')
|
||||
assert.ok(html.includes(`Fassung ${V}`), f)
|
||||
assert.doesNotMatch(html, /<!--/, f)
|
||||
assert.doesNotMatch(html, /Entwurf/, f)
|
||||
assert.doesNotMatch(html, /Prüfpunkt/, f)
|
||||
}
|
||||
assert.match(readFileSync(root(FILES[0]), 'utf8'), /<h2>12\. Haftung/)
|
||||
})
|
||||
|
||||
test('Entwurfsseite ohne Fassung ist nicht mehr als Datei vorhanden', () => {
|
||||
assert.ok(!existsSync(root('public/rechtliches/avv-website-analyse.html')))
|
||||
assert.ok(!existsSync(root('public/rechtliches/leistungsbeschreibung-website-analyse.html')))
|
||||
})
|
||||
|
||||
test('Portal: keine alten Formulierungen, AVV-Adresse kommt aus a.legal', () => {
|
||||
const fn = readFileSync(root('public/funktionen.js'), 'utf8')
|
||||
assert.equal(fn.match(/const AVV_URL = '([^']+)'/)?.[1], '/rechtliches/avv-website-analyse.html')
|
||||
for (const old of ['anonym', 'Cookie-Einstellungen', 'Die bisherigen Daten bleiben noch 60 Tage erhalten', 'Verarbeitung der Analysedaten gemäß Auftragsverarbeitungsvertrag', "AVV_URL = '/rechtliches/avv-website-analyse.html'"]) {
|
||||
assert.ok(!fn.includes(old), old)
|
||||
}
|
||||
assert.doesNotMatch(fn, /AVV_URL/)
|
||||
assert.match(fn, /a\.legal/)
|
||||
})
|
||||
|
||||
test('Portal: Dev-Modus-Hinweis an der Analyse-Kachel', () => {
|
||||
@@ -18,7 +40,6 @@ test('Portal: Dev-Modus-Hinweis an der Analyse-Kachel', () => {
|
||||
})
|
||||
|
||||
test('Texte: Akzentfarbe wird beim Einschalten gewählt (kein späteres Portal-Setting behauptet)', () => {
|
||||
for (const f of ['docs/rechtliches/avv-website-analyse.md', 'docs/rechtliches/leistungsbeschreibung-website-analyse.md']) {
|
||||
assert.match(readFileSync(root(f), 'utf8'), /beim Einschalten gewählt/, f)
|
||||
}
|
||||
// Geprüfte Fassung 2026-10-02: nur die Leistungsbeschreibung sagt es so; die AVV nennt die Akzentfarbe unter den Einstellungen
|
||||
assert.match(readFileSync(root('docs/rechtliches/leistungsbeschreibung-website-analyse.md'), 'utf8'), /beim Einschalten wählt/)
|
||||
})
|
||||
@@ -11,7 +11,7 @@ const viewUrl = pathToFileURL(new URL('../public/analyse-view.js', import.meta.u
|
||||
const src = readFileSync(new URL('../public/funktionen.js', import.meta.url), 'utf8').replace("'/analyse-view.js'", `'${viewUrl}'`)
|
||||
const file = join(dir, 'funktionen.mjs')
|
||||
writeFileSync(file, src)
|
||||
const { initFunktionen } = await import(pathToFileURL(file).href)
|
||||
const { initFunktionen, initAnalyse, renderAnalyseOff } = await import(pathToFileURL(file).href)
|
||||
|
||||
const news = { state: 'off' }
|
||||
const project = (id, analyse) => ({ projectId: id, projectName: `P ${id}`, liveUrl: '', features: { news }, analyse })
|
||||
@@ -46,3 +46,89 @@ test('Kacheln rendern ohne Fehler: News, Analyse comingSoon/aus/aktiv/gesperrt',
|
||||
assert.match(tile(disabled, 'analyse'), /Bitte melde dich bei uns/)
|
||||
assert.doesNotMatch(disabled, /Dev-Modus/)
|
||||
})
|
||||
|
||||
// ---- Analyse-Einschalten und Ausschalten (ohne DOM: Attrappen, die innerHTML und Klick-Handler festhalten)
|
||||
function fakeEl() {
|
||||
const handlers = {}
|
||||
return { innerHTML: '', disabled: false, checked: false, value: '', dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, handlers, addEventListener(ev, fn) { handlers[ev] = fn }, querySelector: () => null, querySelectorAll: () => [] }
|
||||
}
|
||||
function fakeStage() {
|
||||
const els = {}
|
||||
const get = (sel) => (els[sel] ||= fakeEl())
|
||||
return { els, innerHTML: '', querySelector: get, querySelectorAll: () => [] }
|
||||
}
|
||||
const offProject = (analyse) => ({ projectId: 'p1', projectName: 'Muster', analyse: { state: 'off', eligible: true, accentColor: '#111111', avvVersion: '2026-10-02', ...analyse } })
|
||||
const LEGAL = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }
|
||||
const CTRL = { ok: true, company: 'Muster GmbH', address: 'Weg 1, 12345 Ort', email: 'info@muster.de' }
|
||||
|
||||
async function renderActivation(analyse) {
|
||||
globalThis.fetch = async () => ({ ok: true, status: 200, json: async () => ({ projects: [offProject(analyse)] }) })
|
||||
const root = fakeEl()
|
||||
const stage = fakeStage()
|
||||
root.querySelector = (sel) => (sel === '#an-stage' ? stage : null)
|
||||
await initAnalyse(root)
|
||||
return stage
|
||||
}
|
||||
|
||||
test('Einschalten: zwei Links aus legal, Fassung, Verantwortlicher, Kinder-Satz, Banner-Vorschau', async () => {
|
||||
const stage = await renderActivation({ legal: LEGAL, controller: CTRL })
|
||||
const html = stage.innerHTML
|
||||
assert.ok(html.includes(`href="${LEGAL.avv}"`))
|
||||
assert.ok(html.includes(`href="${LEGAL.lb}"`))
|
||||
assert.match(html, /Fassung 2026-10-02/)
|
||||
assert.match(html, /Muster GmbH/)
|
||||
assert.match(html, /info@muster\.de/)
|
||||
assert.match(html, /Stimmt etwas nicht\? Schreib uns im Chat, bevor Du einschaltest\./)
|
||||
assert.match(html, /nicht gezielt an Kinder unter 16 Jahren/)
|
||||
assert.match(html, /Ohne Zustimmung der Besucher zählen wir nur Seitenaufrufe mit Land und Geräteklasse/)
|
||||
assert.doesNotMatch(html, /anonym/)
|
||||
const preview = stage.els['#an-preview'].innerHTML
|
||||
assert.match(preview, /Ablehnen/)
|
||||
assert.match(preview, /Annehmen/)
|
||||
assert.match(preview, /Datenschutz-Einstellungen/)
|
||||
// Haken schaltet den Knopf frei
|
||||
const box = stage.els['#an-avv']
|
||||
box.checked = true
|
||||
box.handlers.change()
|
||||
assert.equal(stage.els['#an-on'].disabled, false)
|
||||
})
|
||||
|
||||
test('Einschalten: Firma und Anschrift werden maskiert', async () => {
|
||||
const stage = await renderActivation({ legal: LEGAL, controller: { ...CTRL, company: 'A<b>&Co' } })
|
||||
assert.match(stage.innerHTML, /A<b>&Co/)
|
||||
assert.doesNotMatch(stage.innerHTML, /A<b>/)
|
||||
})
|
||||
|
||||
test('Einschalten: ohne Verantwortlichen-Angaben gesperrt, Hinweis sichtbar, kein Schreib-uns-Hinweis der Vorschau', async () => {
|
||||
const stage = await renderActivation({ legal: LEGAL, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } })
|
||||
assert.match(stage.innerHTML, /fehlen in Deinen Kundendaten Firma oder Name und eine gültige E-Mail-Adresse/)
|
||||
assert.doesNotMatch(stage.innerHTML, /bevor Du einschaltest/)
|
||||
const box = stage.els['#an-avv']
|
||||
box.checked = true
|
||||
box.handlers.change()
|
||||
assert.equal(stage.els['#an-on'].disabled, true)
|
||||
})
|
||||
|
||||
test('Ausschalten: Hinweis vorher, nach "Ja, ausschalten" Bestätigung, erst "OK" lädt neu', async () => {
|
||||
const calls = []
|
||||
globalThis.fetch = async (url) => { calls.push(url); return { ok: true, status: 200, json: async () => ({}) } }
|
||||
const box = fakeEl()
|
||||
const actions = fakeEl()
|
||||
const yes = fakeEl()
|
||||
const no = fakeEl()
|
||||
const ok = fakeEl()
|
||||
const ask = fakeEl()
|
||||
box.querySelector = (sel) => ({ '#an-off-actions': actions, '#an-off-ask': ask, '#an-off-ok': ok, '#an-off-error': fakeEl() })[sel]
|
||||
actions.querySelector = (sel) => ({ '#an-off-yes': yes, '#an-off-no': no })[sel]
|
||||
let reloads = 0
|
||||
renderAnalyseOff(box, 'p1', () => { reloads++ })
|
||||
assert.match(box.innerHTML, /Skript und Zusatzseite entfernen wir mit der nächsten Veröffentlichung Deiner Website/)
|
||||
assert.match(box.innerHTML, /bis zum 46\. Tag kannst Du eine Kopie anfordern/)
|
||||
ask.handlers.click()
|
||||
await yes.handlers.click({ currentTarget: yes })
|
||||
assert.deepEqual(calls, ['/api/funktionen/analyse/deactivate'])
|
||||
assert.match(box.innerHTML, /Die Website-Analyse ist ausgeschaltet\. Ab jetzt werden keine Daten mehr gespeichert\./)
|
||||
assert.equal(reloads, 0)
|
||||
ok.handlers.click()
|
||||
assert.equal(reloads, 1)
|
||||
})
|
||||
@@ -30,6 +30,10 @@ before(async () => {
|
||||
{ repoFullName: 'knso/alt', projectName: 'alt', subdomain: 'alt' },
|
||||
{ repoFullName: 'knso/synced-sub', projectName: 'synced-sub', status: 'synced', subdomain: 'synced-sub' },
|
||||
{ repoFullName: 'knso/lead', projectName: 'lead', status: 'deployed', subdomain: 'lead' },
|
||||
{ $id: 'wp_an', repoFullName: 'knso/alt', projectName: 'Analyse-Projekt', subdomain: 'analyseprojekt', isPublic: true, projectType: 'website' },
|
||||
],
|
||||
portalFeatures: [
|
||||
{ $id: 'wp_an_feat', customerId: 'cx', projectId: 'wp_an', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wpansitekey01234567' },
|
||||
],
|
||||
},
|
||||
})
|
||||
@@ -111,3 +115,30 @@ test('Repo-Liste hat eine Obergrenze', async () => {
|
||||
assert.equal(repos.length, 2000)
|
||||
assert.equal(capGitea.calls.filter((c) => c.path === '/api/v1/repos/search').length, 40)
|
||||
})
|
||||
|
||||
test('updateWebsiteProject: neue Subdomain => putSite mit neuer Domain; nicht öffentlich => stopSite; Hook-Fehler ändert das Ergebnis nicht', async () => {
|
||||
const { updateWebsiteProject } = await import('../server/services/giteaAdmin.js')
|
||||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||||
const calls = []
|
||||
const real = analyseDeps.client
|
||||
analyseDeps.client = {
|
||||
putSite: async (k, b) => calls.push(['put', k, b]),
|
||||
stopSite: async (k) => calls.push(['stop', k]),
|
||||
}
|
||||
try {
|
||||
const r1 = await updateWebsiteProject('wp_an', { subdomain: 'analyseneu', projectType: 'website' })
|
||||
assert.equal(r1.subdomain, 'analyseneu')
|
||||
assert.deepEqual(calls, [['put', 'wpansitekey01234567', { projectId: 'wp_an', domains: ['analyseneu.project.webklar.com'], active: true }]])
|
||||
calls.length = 0
|
||||
const r2 = await updateWebsiteProject('wp_an', { projectType: 'preview' })
|
||||
assert.equal(r2.isPublic, false)
|
||||
assert.deepEqual(calls, [['stop', 'wpansitekey01234567']])
|
||||
const { updateDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
await updateDocument(config.collections.portalFeatures, 'wp_an_feat', { state: 'active', enabled: true })
|
||||
analyseDeps.client = { putSite: async () => { throw new Error('weg') }, stopSite: async () => { throw new Error('weg') } }
|
||||
const r3 = await updateWebsiteProject('wp_an', { subdomain: 'nochneu', projectType: 'website' })
|
||||
assert.equal(r3.subdomain, 'nochneu')
|
||||
} finally {
|
||||
analyseDeps.client = real
|
||||
}
|
||||
})
|
||||
@@ -403,6 +403,13 @@ test('Analyse: Standardfarbe, Firma fällt auf Name zurück, Adresse optional',
|
||||
assert.equal(body.analytics.privacy.address, '')
|
||||
})
|
||||
|
||||
test('Analyse: ungültige E-Mail ⇒ null, nur Name reicht als Firma', async (t) => {
|
||||
t.mock.method(console, 'warn', () => {})
|
||||
assert.equal((await submitFor(await analyseProject({ customer: { email: 'abc' } }))).analytics, null)
|
||||
const body = await submitFor(await analyseProject({ customer: { companyName: '', name: 'Nur Name' } }))
|
||||
assert.equal(body.analytics.privacy.company, 'Nur Name')
|
||||
})
|
||||
|
||||
test('Analyse: aus ⇒ null', async () => {
|
||||
assert.equal((await submitFor(await analyseProject({ feature: { state: 'off', enabled: false } }))).analytics, null)
|
||||
assert.equal((await submitFor(await analyseProject({ noFeature: true }))).analytics, null)
|
||||
|
||||
@@ -15,6 +15,7 @@ const aw = await startAppwriteMock({
|
||||
subscriptions: schemaKeys('subscriptions'),
|
||||
contractEvents: schemaKeys('contractEvents'),
|
||||
hourCredits: schemaKeys('hourCredits'),
|
||||
portalFeatures: [...schemaKeys('portalFeatures'), 'siteKey', 'offSince', 'activatedAt', 'accentColor', 'removalTicketId'],
|
||||
},
|
||||
})
|
||||
const st = await startFakeStripe()
|
||||
@@ -28,6 +29,7 @@ const { withdrawalDeadline } = await import('../server/services/deadlines.js')
|
||||
const { deleteDocument } = await import('../server/services/appwriteAdmin.js')
|
||||
const { setMailTransportForTests } = await import('../server/services/mailer.js')
|
||||
|
||||
const { analyseDeps } = await import('../server/services/analyse.js')
|
||||
const app = await startTestApp({ '/api/abo': aboRoutes })
|
||||
// Mails landen in der Senke statt bei SMTP (F25): K03/K04 an den Kunden, T08/T09 ans Team sind pruefbar
|
||||
const mails = mailSink()
|
||||
@@ -396,6 +398,61 @@ test('Widerruf Wechsel in der Frist: Ereignis widerrufen, kein automatischer Pre
|
||||
assert.doesNotMatch(t09().text, /Abo in Stripe sofort beenden/)
|
||||
})
|
||||
|
||||
// Analyse hat nur mit Wartungs-Abo Bestand: der Widerruf des Wechsels beendet sie (Messdienst aus, K19)
|
||||
async function withActiveAnalyse(fn) {
|
||||
const stops = []
|
||||
const real = analyseDeps.client
|
||||
analyseDeps.client = { stopSite: async (k) => { stops.push(k) } }
|
||||
aw.seed('portalFeatures', { $id: 'wf_feat', customerId: 'c1', projectId: 'wf_p', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'wfsitekey0123456789' })
|
||||
try {
|
||||
return await fn(stops)
|
||||
} finally {
|
||||
analyseDeps.client = real
|
||||
}
|
||||
}
|
||||
const wechselIn = async (days) => {
|
||||
const consentAt = daysAgo(days)
|
||||
await recordContractEvent({
|
||||
customerId: 'c1', subscriptionId: 'sub_h', type: 'wechsel', productKey: 'wartung',
|
||||
details: { from: 'hosting', switchedAt: consentAt, effectiveAt: consentAt, paidAt: consentAt },
|
||||
consent: { ...consent, consentAt }, status: 'erledigt',
|
||||
})
|
||||
}
|
||||
|
||||
test('Widerruf Wechsel in der Frist beendet die Analyse des Kunden (stopSite, off, K19); Fehler lässt den Widerruf auf 200', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(3)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200)
|
||||
assert.deepEqual(stops, ['wfsitekey0123456789'])
|
||||
const d = aw.docs('portalFeatures').get('wf_feat')
|
||||
assert.equal(d.state, 'off')
|
||||
assert.ok(d.offSince)
|
||||
assert.ok(bySubject('Deine Website-Analyse wurde beendet'))
|
||||
})
|
||||
// wirft der Messdienst, bleibt der Widerruf 200
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(3)
|
||||
await withActiveAnalyse(async () => {
|
||||
analyseDeps.client = { stopSite: async () => { throw new Error('Messdienst weg') } }
|
||||
const res = await withdraw({ order: 'wechsel' })
|
||||
assert.equal(res.status, 200)
|
||||
assert.equal((await events('wechsel'))[0].status, 'widerrufen')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf Wechsel nach Fristende oder Zusatzspeicher ändert die Analyse nicht', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
await wechselIn(30)
|
||||
await withActiveAnalyse(async (stops) => {
|
||||
assert.equal((await withdraw({ order: 'wechsel' })).status, 200)
|
||||
assert.equal((await withdraw({ order: 'zusatzspeicher' })).status, 200)
|
||||
assert.deepEqual(stops, [])
|
||||
assert.equal(aw.docs('portalFeatures').get('wf_feat').state, 'active')
|
||||
})
|
||||
})
|
||||
|
||||
test('Widerruf nach Fristende: 200, nichts geändert', async () => {
|
||||
await resetSubH({ units: 0 })
|
||||
const consentAt = daysAgo(30)
|
||||
|
||||
@@ -315,13 +315,13 @@ test('retention: Analyse-Dokument schaltet auch den Sammeldienst ab', async () =
|
||||
aw.seed('portalFeatures', { $id: 'ra_feat', customerId: 'ra', projectId: 'rap', featureKey: 'analyse', state: 'active', enabled: true, siteKey: 'abcdefghij234567' })
|
||||
const puts = []
|
||||
const ok = analyseDeps.client
|
||||
analyseDeps.client = { putSite: async (k, b) => { puts.push([k, b]) } }
|
||||
analyseDeps.client = { putSite: async (k, b) => { puts.push(['put', k, b]) }, stopSite: async (k) => { puts.push(['stop', k]) } }
|
||||
try {
|
||||
const plan = await findDueDeletions(JAN_2026)
|
||||
assert.ok(plan.news.find((n) => n.customerId === 'ra').featureIds.includes('ra_feat'))
|
||||
const result = await applyDeletions(plan)
|
||||
assert.deepEqual(result.failed, [])
|
||||
assert.deepEqual(puts, [['abcdefghij234567', { projectId: 'rap', domains: ['altprojekt.project.webklar.com'], active: false }]])
|
||||
assert.deepEqual(puts, [['stop', 'abcdefghij234567']])
|
||||
assert.equal([...aw.docs('portalFeatures').values()].find((d) => d.$id === 'ra_feat').state, 'disabled')
|
||||
} finally {
|
||||
analyseDeps.client = ok
|
||||
|
||||
@@ -85,7 +85,12 @@ export const consent = {
|
||||
|
||||
/** Kunden-Session; Standard c1. */
|
||||
export const customerCookie = (customerId = 'c1') =>
|
||||
sessionCookie({ customerId, appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}` })
|
||||
sessionCookie({
|
||||
customerId,
|
||||
appwriteUserId: customers[customerId]?.appwriteUserId || `u_${customerId}`,
|
||||
name: customers[customerId]?.name || '', // wie beim echten Login (auth.js)
|
||||
email: customers[customerId]?.email || '',
|
||||
})
|
||||
|
||||
/** Admin-Session (Ticketsystem-Mitarbeiter im Portal), F11. */
|
||||
export const adminCookie = () =>
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
// Playwright-Check Analyse-Panel (Einschalten-Dialog + aktives Panel): statischer Server fuer public/ + gemockte API.
|
||||
// Nicht Teil von npm test. Aufruf: node test/ui/analyse.ui.mjs [Ausgabeordner]
|
||||
import http from 'node:http'
|
||||
import { readFile, mkdir } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { chromium } from '/root/.npm/_npx/db89d7302a373f10/node_modules/playwright-core/index.mjs'
|
||||
|
||||
const pub = path.join(path.dirname(fileURLToPath(import.meta.url)), '../../public')
|
||||
const SHOTS = process.argv[2] || '/root/reports/website-analyse-2026-10-01'
|
||||
await mkdir(SHOTS, { recursive: true })
|
||||
const TYPES = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', '.webmanifest': 'application/json' }
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, 'http://x')
|
||||
try {
|
||||
const file = path.join(pub, url.pathname === '/' ? 'dashboard.html' : url.pathname)
|
||||
const data = await readFile(file)
|
||||
res.writeHead(200, { 'content-type': TYPES[path.extname(file)] || 'application/octet-stream' })
|
||||
res.end(data)
|
||||
} catch { res.writeHead(404); res.end('nicht gefunden') }
|
||||
})
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
|
||||
const base = `http://127.0.0.1:${server.address().port}`
|
||||
|
||||
const legal = { avv: '/rechtliches/avv-website-analyse-2026-10-02.html', lb: '/rechtliches/leistungsbeschreibung-website-analyse-2026-10-02.html' }
|
||||
const controller = { ok: true, company: 'Bäckerei Berg GmbH', address: 'Hauptstraße 1, 12345 Musterstadt', email: 'info@baeckerei-berg.example' }
|
||||
const base1 = { projectId: 'p1', projectName: 'Bäckerei Berg', liveUrl: 'https://baeckerei.example', features: { news: { state: 'off' } } }
|
||||
const SCENARIOS = {
|
||||
off: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
|
||||
missing: { ...base1, analyse: { state: 'off', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller: { ok: false, company: '', address: '', email: '', missing: ['firma'] } } },
|
||||
active: { ...base1, analyse: { state: 'active', eligible: true, accentColor: '#1d4ed8', avvVersion: '2026-10-02', legal, controller } },
|
||||
}
|
||||
const days = Array.from({ length: 14 }, (_, i) => ({ day: `2026-09-${String(17 + i).padStart(2, '0')}`, pv: 20 + ((i * 7) % 13) * 4 }))
|
||||
const stats = {
|
||||
totals: { pv: 612, visitors: 214, contacts: 18, consentRate: 0.62 }, series: days,
|
||||
pages: [{ key: '/', count: 300 }, { key: '/kontakt', count: 120 }], refs: [{ key: '', count: 200 }, { key: 'google.com', count: 90 }],
|
||||
countries: [{ key: 'DE', count: 500 }], devices: [{ key: 'm', count: 380 }, { key: 'd', count: 230 }], contacts: [{ key: 'tel', count: 12 }],
|
||||
banner: { shown: 300, accept: 186, reject: 114 },
|
||||
}
|
||||
let scenario = 'off'
|
||||
const browser = await chromium.launch()
|
||||
let failed = 0
|
||||
const check = (name, ok) => { console.log(`${ok ? 'OK ' : 'FAIL'} ${name}`); if (!ok) failed += 1 }
|
||||
try {
|
||||
for (const [label, viewport] of [['desktop', { width: 1280, height: 900 }], ['phone', { width: 390, height: 844 }]]) {
|
||||
const ctx = await browser.newContext({ viewport })
|
||||
const page = await ctx.newPage()
|
||||
const errors = []
|
||||
page.on('pageerror', (e) => errors.push(e.message))
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const p = new URL(route.request().url()).pathname
|
||||
const json = (b) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(b) })
|
||||
if (p === '/api/auth/me') return json({ authenticated: true, role: 'customer', customer: { id: 'c1', code: '1001', name: 'Berta Berg', email: 'b@example.test' } })
|
||||
if (p === '/api/projects') return json({ projects: [{ id: 'p1', name: 'Bäckerei Berg', status: 'live' }] })
|
||||
if (p === '/api/features') return json({ features: [] })
|
||||
if (p === '/api/activity') return json({ tickets: [] })
|
||||
if (p === '/api/funktionen') return json({ projects: [SCENARIOS[scenario]] })
|
||||
if (p === '/api/funktionen/analyse/stats') return json(stats)
|
||||
if (p === '/api/funktionen/analyse/deactivate') return json({ ok: true })
|
||||
return json({})
|
||||
})
|
||||
const open = async (sc) => {
|
||||
scenario = sc
|
||||
await page.goto('about:blank')
|
||||
await page.goto(`${base}/dashboard.html#analyse`)
|
||||
await page.waitForSelector('#analyse-body .kb-panel')
|
||||
await page.waitForTimeout(900)
|
||||
}
|
||||
const shot = async (name) => {
|
||||
const height = await page.evaluate(() => Math.min(document.documentElement.scrollHeight, 3200))
|
||||
await page.setViewportSize({ width: viewport.width, height: Math.max(viewport.height, height) })
|
||||
await page.waitForTimeout(150)
|
||||
await page.screenshot({ path: `${SHOTS}/task-7-${label}-${name}.png` })
|
||||
await page.setViewportSize(viewport)
|
||||
}
|
||||
const noScroll = async (n) => check(`${label} kein_horizontaler_scroll ${n}`, await page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth))
|
||||
|
||||
await open('off')
|
||||
const text = await page.textContent('#analyse-body')
|
||||
check(`${label} verantwortlicher`, text.includes('Bäckerei Berg GmbH') && text.includes('info@baeckerei-berg.example'))
|
||||
check(`${label} links`, (await page.$$eval('#analyse-body a[href^="/rechtliches/"]', (a) => a.map((x) => x.getAttribute('href')))).join() === `${legal.avv},${legal.lb}`)
|
||||
check(`${label} knopf_gesperrt_ohne_haken`, await page.isDisabled('#an-on'))
|
||||
await page.check('#an-avv')
|
||||
check(`${label} knopf_frei_mit_haken`, !(await page.isDisabled('#an-on')))
|
||||
await noScroll('einschalten')
|
||||
await shot('einschalten')
|
||||
|
||||
await open('missing')
|
||||
await page.check('#an-avv')
|
||||
check(`${label} gesperrt_ohne_angaben`, (await page.isDisabled('#an-on')) && Boolean(await page.$('#an-missing')))
|
||||
|
||||
await open('active')
|
||||
await page.waitForSelector('.an-kpis')
|
||||
await noScroll('aktiv')
|
||||
await shot('aktiv')
|
||||
await page.click('#an-off-ask')
|
||||
await page.click('#an-off-yes')
|
||||
await page.waitForSelector('#an-off-done')
|
||||
check(`${label} ausschalten_bestaetigung`, (await page.textContent('#an-off-done')).includes('Ab jetzt werden keine Daten mehr gespeichert'))
|
||||
await shot('ausgeschaltet')
|
||||
check(`${label} keine_js_fehler`, errors.length === 0)
|
||||
await ctx.close()
|
||||
}
|
||||
} finally {
|
||||
await browser.close()
|
||||
server.close()
|
||||
}
|
||||
process.exit(failed ? 1 : 0)
|
||||
Reference in new issue
Block a user