From 3332eb0bf9ae6423d0783747222cfc5e1e1ee35d Mon Sep 17 00:00:00 2001 From: Benjamin Admin Date: Thu, 11 Jun 2026 00:51:25 +0200 Subject: [PATCH] feat(agent): Cookie-Result-View + Check-Historie aus Snapshots Snapshot-getriebene Result-Views, entkoppelt vom Live-Check: - CookieResultView: laedt cmp_vendors aus einem Snapshot (kein Re-Crawl), KPIs (Anbieter/Cookies/Marketing/Drittland) + Empfaenger-Gruppen (Eigene/AVV/Joint-Controller) + aufklappbare Vendor->Cookie-Tabelle. - Historie (/sdk/agent/snapshots): alle gespeicherten Checks, jederzeit oeffnbar (DSB/Mitarbeiter) + Detail-Seite je Snapshot. - Next.js-Proxys fuer GET /snapshots (Liste) + /snapshots/{id} (einzeln). BMW-Snapshot 4603d15b: 83 Vendors / 780 Cookies. Library-Abgleich (cookie_knowledge_db.lookup_cookie) folgt als Phase B. Co-Authored-By: Claude Opus 4.7 --- .../v1/agent/snapshots/[snapshotId]/route.ts | 34 ++++ .../app/api/sdk/v1/agent/snapshots/route.ts | 33 ++++ .../agent/_components/CookieResultView.tsx | 177 ++++++++++++++++++ .../__tests__/CookieResultView.test.tsx | 47 +++++ .../sdk/agent/snapshots/[snapshotId]/page.tsx | 56 ++++++ .../app/sdk/agent/snapshots/page.tsx | 71 +++++++ 6 files changed, 418 insertions(+) create mode 100644 admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/route.ts create mode 100644 admin-compliance/app/api/sdk/v1/agent/snapshots/route.ts create mode 100644 admin-compliance/app/sdk/agent/_components/CookieResultView.tsx create mode 100644 admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx create mode 100644 admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx create mode 100644 admin-compliance/app/sdk/agent/snapshots/page.tsx diff --git a/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/route.ts b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/route.ts new file mode 100644 index 00000000..4f4475f3 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/snapshots/[snapshotId]/route.ts @@ -0,0 +1,34 @@ +/** + * Snapshot-Proxy + * GET /api/sdk/v1/agent/snapshots/{snapshotId} + * → backend /api/compliance/agent/snapshots/{snapshotId} + * + * Liefert die persistierten Roh-Daten eines Checks (cmp_vendors + Cookies + + * banner_result) — Basis für den Cookie-Result-View OHNE Re-Crawl. + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = + process.env.BACKEND_API_URL || process.env.BACKEND_URL || + 'http://backend-compliance:8002' + +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ snapshotId: string }> }, +) { + const { snapshotId } = await params + try { + const response = await fetch( + `${BACKEND_URL}/api/compliance/agent/snapshots/${snapshotId}`, + { signal: AbortSignal.timeout(60_000) }, + ) + const data = await response.json() + return NextResponse.json(data, { status: response.status }) + } catch { + return NextResponse.json( + { error: 'Snapshot-Laden zum Backend fehlgeschlagen' }, + { status: 503 }, + ) + } +} diff --git a/admin-compliance/app/api/sdk/v1/agent/snapshots/route.ts b/admin-compliance/app/api/sdk/v1/agent/snapshots/route.ts new file mode 100644 index 00000000..152d6143 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/agent/snapshots/route.ts @@ -0,0 +1,33 @@ +/** + * Snapshot-Liste (Historie) + * GET /api/sdk/v1/agent/snapshots?domain=&limit= + * → backend /api/compliance/agent/snapshots + * + * Ohne domain: alle letzten Snapshots (Historie zum Durchklicken). + */ + +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = + process.env.BACKEND_API_URL || process.env.BACKEND_URL || + 'http://backend-compliance:8002' + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url) + const domain = searchParams.get('domain') || '' + const limit = searchParams.get('limit') || '50' + try { + const response = await fetch( + `${BACKEND_URL}/api/compliance/agent/snapshots` + + `?domain=${encodeURIComponent(domain)}&limit=${encodeURIComponent(limit)}`, + { signal: AbortSignal.timeout(30_000) }, + ) + const data = await response.json() + return NextResponse.json(data, { status: response.status }) + } catch { + return NextResponse.json( + { error: 'Snapshot-Liste zum Backend fehlgeschlagen', snapshots: [] }, + { status: 503 }, + ) + } +} diff --git a/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx b/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx new file mode 100644 index 00000000..6e70ddfc --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/CookieResultView.tsx @@ -0,0 +1,177 @@ +'use client' + +/** + * CookieResultView — strukturierte Cookie-/Vendor-Auswertung aus einem + * gespeicherten Snapshot (cmp_vendors), OHNE Re-Crawl. + * + * KPIs + Empfänger-Gruppen (Eigene / Auftragsverarbeiter / Joint Controller — + * wie im Audit-Mail-VVT) + aufklappbare Vendor→Cookie-Tabelle. Verarbeitet + * Mengen (780 Cookies bei BMW): Vendors gruppiert, Cookies on-demand. + */ + +import React, { useMemo, useState } from 'react' + +export interface SnapshotCookie { + name: string + expiry?: string + purpose?: string + is_third_party?: boolean + functional_role?: string +} + +export interface SnapshotVendor { + name: string + cookies?: SnapshotCookie[] + category?: string + country?: string + recipient_type?: string + compliance_score?: number + compliance_flags?: string[] + opt_out_ok?: boolean +} + +interface Snapshot { + id: string + site_domain?: string + created_at?: string + cmp_vendors?: SnapshotVendor[] +} + +const ROLE_LABEL: Record = { + unknown: 'Unbekannt', ad_pixel: 'Werbe-Pixel', auth_token: 'Auth-Token', + preference: 'Präferenz', visitor_id: 'Besucher-ID', consent_state: 'Consent', + tracking: 'Tracking', +} +const CAT_COLOR: Record = { + necessary: 'bg-green-100 text-green-700', functional: 'bg-blue-100 text-blue-700', + statistics: 'bg-amber-100 text-amber-700', marketing: 'bg-red-100 text-red-700', +} +const EEA = new Set([ + 'DE','FR','IE','NL','AT','BE','BG','HR','CY','CZ','DK','EE','FI','GR','HU', + 'IT','LV','LT','LU','MT','PL','PT','RO','SK','SI','ES','SE','IS','LI','NO', +]) +const GROUPS = [ + { key: 'own', label: 'Eigene Verarbeitungen (VVT, Art. 30)', test: (r: string) => !r || r === 'INTERNAL' || r === 'GROUP' || r === 'CONTROLLER' }, + { key: 'proc', label: 'Auftragsverarbeiter (AVV, Art. 28)', test: (r: string) => r === 'PROCESSOR' }, + { key: 'joint', label: 'Joint Controller (Art. 26)', test: (r: string) => r === 'JOINT_CONTROLLER' }, + { key: 'other', label: 'Sonstige Empfänger', test: () => true }, +] + +function scoreColor(s?: number): string { + if (s == null) return 'text-gray-400' + return s >= 80 ? 'text-green-700' : s >= 50 ? 'text-amber-700' : 'text-red-700' +} + +function Tile({ label, value, tone }: { label: string; value: React.ReactNode; tone: string }) { + return ( +
+
{value}
+
{label}
+
+ ) +} + +function VendorRow({ v }: { v: SnapshotVendor }) { + const [open, setOpen] = useState(false) + const cookies = v.cookies || [] + const cat = (v.category || '').toLowerCase() + const drittland = !!v.country && !EEA.has((v.country || '').toUpperCase()) + return ( +
+ + {open && cookies.length > 0 && ( +
+ + + + + + + + + + + {cookies.map((c, i) => ( + + + + + + + ))} + +
CookieRolleZweckLaufzeit
{c.name}{ROLE_LABEL[c.functional_role || 'unknown'] || c.functional_role}{c.purpose ? c.purpose.slice(0, 60) : kein Zweck}{c.expiry || '—'}
+
+ )} +
+ ) +} + +export function CookieResultView({ snapshot }: { snapshot: Snapshot }) { + const vendors = snapshot.cmp_vendors || [] + const stats = useMemo(() => { + const cookies = vendors.reduce((n, v) => n + (v.cookies?.length || 0), 0) + const marketing = vendors.filter(v => (v.category || '').toLowerCase() === 'marketing').length + const drittland = vendors.filter(v => v.country && !EEA.has(v.country.toUpperCase())).length + return { cookies, marketing, drittland } + }, [vendors]) + + const grouped = useMemo(() => GROUPS.map(g => ({ + ...g, + vendors: vendors + .filter(v => GROUPS.find(gg => gg.test((v.recipient_type || '').toUpperCase()))?.key === g.key) + .sort((a, b) => (a.compliance_score ?? 100) - (b.compliance_score ?? 100)), + })).filter(g => g.vendors.length > 0), [vendors]) + + return ( +
+
+

+ Cookie-Auswertung — {snapshot.site_domain || 'Snapshot'} +

+

+ aus gespeichertem Snapshot (kein Re-Crawl) ·{' '} + {snapshot.created_at ? snapshot.created_at.slice(0, 19).replace('T', ' ') : ''} +

+
+ +
+ + + 0 ? 'text-red-700' : 'text-gray-800'} /> + 0 ? 'text-amber-700' : 'text-gray-800'} /> +
+ + {grouped.map(g => ( +
+
+ {g.label} ({g.vendors.length}) +
+
+ {g.vendors.map((v, i) => )} +
+
+ ))} +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx b/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx new file mode 100644 index 00000000..76faa354 --- /dev/null +++ b/admin-compliance/app/sdk/agent/_components/__tests__/CookieResultView.test.tsx @@ -0,0 +1,47 @@ +import { describe, it, expect } from 'vitest' +import { render, screen, fireEvent } from '@testing-library/react' + +import { CookieResultView } from '../CookieResultView' + +const SNAP = { + id: 'abc', + site_domain: 'bmw.de', + created_at: '2026-06-10T22:16:11', + cmp_vendors: [ + { + name: 'Salesforce', category: 'necessary', country: 'US', + recipient_type: 'PROCESSOR', compliance_score: 91, + cookies: [ + { name: 'LSKey-c$Policy', functional_role: 'consent_state', purpose: '', expiry: '1 Jahr' }, + { name: 'sid', functional_role: 'auth_token', purpose: 'Login', expiry: 'Session' }, + ], + }, + { + name: 'BMW AG — eShop', category: 'necessary', country: '', + recipient_type: 'INTERNAL', compliance_score: 100, + cookies: [{ name: 'x', functional_role: 'preference', purpose: 'Sprache' }], + }, + ], +} + +describe('CookieResultView', () => { + it('zeigt KPIs + Empfänger-Gruppen aus dem Snapshot', () => { + render() + expect(screen.getByText(/Cookie-Auswertung/)).toBeInTheDocument() + // 2 Anbieter, 3 Cookies gesamt + expect(screen.getByText('Anbieter')).toBeInTheDocument() + expect(screen.getByText('Cookies gesamt')).toBeInTheDocument() + expect(screen.getByText('3')).toBeInTheDocument() + // Gruppen: Eigene + Auftragsverarbeiter + expect(screen.getByText(/Eigene Verarbeitungen/)).toBeInTheDocument() + expect(screen.getByText(/Auftragsverarbeiter/)).toBeInTheDocument() + expect(screen.getByText('Salesforce')).toBeInTheDocument() + }) + + it('klappt einen Vendor auf und zeigt die Cookies', () => { + render() + fireEvent.click(screen.getByText('Salesforce')) + expect(screen.getByText('LSKey-c$Policy')).toBeInTheDocument() + expect(screen.getByText(/kein Zweck/)).toBeInTheDocument() // leerer purpose + }) +}) diff --git a/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx new file mode 100644 index 00000000..f48fdca9 --- /dev/null +++ b/admin-compliance/app/sdk/agent/snapshots/[snapshotId]/page.tsx @@ -0,0 +1,56 @@ +'use client' + +/** + * Snapshot-Detail — öffnet einen gespeicherten Check aus der Historie und + * zeigt die Ergebnis-Views aus den Rohdaten (kein Re-Crawl). Aktuell: + * Cookie-Auswertung. Impressum/AGB/… folgen als weitere Module hier. + */ + +import React, { use as useUnwrap, useEffect, useState } from 'react' +import Link from 'next/link' + +import { CookieResultView } from '../../_components/CookieResultView' + +export default function SnapshotDetail( + { params }: { params: Promise<{ snapshotId: string }> }, +) { + const { snapshotId } = useUnwrap(params) + const [snap, setSnap] = useState(null) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + let cancelled = false + fetch(`/api/sdk/v1/agent/snapshots/${snapshotId}`) + .then(r => r.json()) + .then(d => { + if (cancelled) return + if (d?.error) setError(d.error) + else setSnap(d) + }) + .catch(e => { if (!cancelled) setError(String(e)) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [snapshotId]) + + const hasCookies = (snap?.cmp_vendors?.length ?? 0) > 0 + + return ( +
+ + ‹ Zurück zur Historie + + {loading ? ( +
Lade Snapshot…
+ ) : error || !snap ? ( +
Snapshot nicht gefunden.
+ ) : hasCookies ? ( + + ) : ( +
+ Dieser Snapshot enthält keine Cookie-/Vendor-Daten. +
+ )} +
+ ) +} diff --git a/admin-compliance/app/sdk/agent/snapshots/page.tsx b/admin-compliance/app/sdk/agent/snapshots/page.tsx new file mode 100644 index 00000000..479a1716 --- /dev/null +++ b/admin-compliance/app/sdk/agent/snapshots/page.tsx @@ -0,0 +1,71 @@ +'use client' + +/** + * Check-Historie — listet gespeicherte Snapshots (alle Sites/Module). + * Ein DSB/Mitarbeiter kann jeden früheren Check öffnen, ohne neuen Check + * zu starten. Daten kommen aus den Snapshot-Rohdaten. + */ + +import React, { useEffect, useState } from 'react' +import Link from 'next/link' + +interface SnapMeta { + id: string + check_id?: string + site_domain?: string + site_label?: string + created_at?: string + replay_count?: number +} + +export default function SnapshotHistory() { + const [snaps, setSnaps] = useState([]) + const [loading, setLoading] = useState(true) + + useEffect(() => { + let cancelled = false + fetch('/api/sdk/v1/agent/snapshots?limit=50') + .then(r => r.json()) + .then(d => { if (!cancelled) setSnaps(d.snapshots || []) }) + .catch(() => { if (!cancelled) setSnaps([]) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, []) + + return ( +
+
+

Check-Historie

+

+ Frühere Compliance-Checks aus gespeicherten Snapshots — jederzeit + ansehbar, ohne neuen Check zu starten. +

+
+ + {loading ? ( +
Lade Historie…
+ ) : snaps.length === 0 ? ( +
Keine gespeicherten Checks gefunden.
+ ) : ( +
+ {snaps.map(s => ( + + + {s.site_label || s.site_domain || 'unbekannt'} + + {s.site_domain} + + {(s.created_at || '').slice(0, 16).replace('T', ' ')} + + + + ))} +
+ )} +
+ ) +}