feat(agent): migrate compliance-check results to banner + documents (M1-M5)
After a compliance-check run finishes, the user can now apply the
extracted vendor inventory directly to their own:
- CookieBanner config (admin /sdk/einwilligungen)
- Cookie-Policy / VVT-Register / Privacy-Policy templates
(admin /sdk/document-generator)
Backend:
- migration_to_banner.py: vendor list -> CookieBannerConfig with
ESSENTIAL/PERFORMANCE/PERSONALIZATION/EXTERNAL_MEDIA buckets +
review flags (broken opt-out URLs, missing expiry, no cookies listed)
- migration_to_document.py: vendor list -> pre-fills for 3 doc
templates, recipient-type aware (INTERNAL/GROUP/PROCESSOR/CONTROLLER)
- agent_migration_routes.py: GET /banner-preview, /document-preview,
/summary keyed on check_id
- compliance_audit_log: new check_payloads table persists cmp_vendors +
extracted_profile so the preview survives an app restart
- tests: 9 mapper units + 4 endpoint integration tests
Frontend:
- MigrationPanel.tsx: modal showing banner-config diff + document
pre-fills, plus links into the existing editors
- ComplianceCheckTab.tsx: replaces standalone audit link with the
panel; net -3 lines, stays at the 500-cap
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/banner-preview
|
||||
* -> backend GET /api/compliance/agent/migration/<checkId>/banner-preview
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const qs = request.nextUrl.searchParams.toString()
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/banner-preview${qs ? `?${qs}` : ''}`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Banner-Preview fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/document-preview
|
||||
* -> backend GET /api/compliance/agent/migration/<checkId>/document-preview
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/document-preview`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Dokument-Preview fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* Proxy: GET /api/sdk/v1/agent/migration/<checkId>/summary
|
||||
* -> backend GET /api/compliance/agent/migration/<checkId>/summary
|
||||
*/
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_API_URL || 'http://backend-compliance:8002'
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: { checkId: string } },
|
||||
) {
|
||||
const url = `${BACKEND_URL}/api/compliance/agent/migration/${params.checkId}/summary`
|
||||
try {
|
||||
const resp = await fetch(url, { signal: AbortSignal.timeout(15000) })
|
||||
const data = await resp.json()
|
||||
return NextResponse.json(data, { status: resp.status })
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: 'Migrations-Summary fehlgeschlagen' },
|
||||
{ status: 503 },
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import React, { useState, useCallback } from 'react'
|
||||
import { ChecklistView } from './ChecklistView'
|
||||
import { DocumentRow } from './DocumentRow'
|
||||
import { MigrationPanel } from './MigrationPanel'
|
||||
|
||||
const DOCUMENT_TYPES = [
|
||||
{ id: 'dse', label: 'DSI (Datenschutzinformation)', required: true },
|
||||
@@ -455,21 +456,14 @@ export function ComplianceCheckTab() {
|
||||
|
||||
<ChecklistView results={results.results} />
|
||||
|
||||
{/* Email status + Full-audit link */}
|
||||
<div className="mt-3 flex items-center justify-between gap-3">
|
||||
{results.email_status && (
|
||||
<div className="text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
{results.check_id && (
|
||||
<a href={`/sdk/agent/audit/${results.check_id}`} target="_blank" rel="noopener"
|
||||
className="text-xs text-blue-700 hover:text-blue-900 underline">
|
||||
Voll-Audit oeffnen (alle MCs) →
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{/* Email + Migration + Full-audit */}
|
||||
{results.email_status && (
|
||||
<div className="mt-3 text-xs text-gray-500 flex items-center gap-2">
|
||||
<span className={`w-2 h-2 rounded-full ${results.email_status === 'sent' ? 'bg-green-400' : 'bg-gray-300'}`} />
|
||||
E-Mail: {results.email_status === 'sent' ? 'Gesendet' : results.email_status}
|
||||
</div>
|
||||
)}
|
||||
{results.check_id && <MigrationPanel checkId={results.check_id} />}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
interface BannerFlag {
|
||||
level: 'ERROR' | 'WARNING' | 'INFO'
|
||||
vendor: string
|
||||
issue: string
|
||||
message: string
|
||||
}
|
||||
|
||||
interface BannerPreview {
|
||||
config: { categories: { id: string; cookies: { name: string }[] }[] }
|
||||
flags: BannerFlag[]
|
||||
summary: {
|
||||
vendors_total: number
|
||||
vendors_with_no_cookies: number
|
||||
cookies_total: number
|
||||
categories: Record<string, number>
|
||||
flags_error: number
|
||||
flags_warning: number
|
||||
flags_info: number
|
||||
}
|
||||
}
|
||||
|
||||
interface DocumentPreview {
|
||||
check_id: string
|
||||
vendor_count: number
|
||||
templates: Record<string, {
|
||||
templateType: string
|
||||
initialContent: string
|
||||
suggested_template_search?: string
|
||||
}>
|
||||
}
|
||||
|
||||
type Mode = 'banner' | 'documents'
|
||||
|
||||
export function MigrationPanel({ checkId }: { checkId: string }) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [mode, setMode] = useState<Mode>('banner')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [banner, setBanner] = useState<BannerPreview | null>(null)
|
||||
const [docs, setDocs] = useState<DocumentPreview | null>(null)
|
||||
|
||||
async function loadPreview(next: Mode) {
|
||||
setMode(next)
|
||||
setOpen(true)
|
||||
setError(null)
|
||||
setLoading(true)
|
||||
try {
|
||||
const path = next === 'banner'
|
||||
? `/api/sdk/v1/agent/migration/${checkId}/banner-preview`
|
||||
: `/api/sdk/v1/agent/migration/${checkId}/document-preview`
|
||||
const r = await fetch(path)
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`)
|
||||
const data = await r.json()
|
||||
if (next === 'banner') setBanner(data)
|
||||
else setDocs(data)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Preview-Ladefehler')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mt-3 flex items-center justify-between gap-3 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => loadPreview('banner')}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-purple-50 text-purple-700 border border-purple-200 hover:bg-purple-100">
|
||||
Cookie-Banner uebernehmen
|
||||
</button>
|
||||
<button onClick={() => loadPreview('documents')}
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-lg bg-amber-50 text-amber-700 border border-amber-200 hover:bg-amber-100">
|
||||
Dokumente vorbefuellen
|
||||
</button>
|
||||
</div>
|
||||
<a href={`/sdk/agent/audit/${checkId}`} target="_blank" rel="noopener"
|
||||
className="text-xs text-blue-700 hover:text-blue-900 underline">
|
||||
Voll-Audit oeffnen (alle MCs) →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50 bg-black/40 flex items-start justify-center p-6 overflow-y-auto">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-3xl p-6 mt-12">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900">
|
||||
{mode === 'banner' ? 'Cookie-Banner Migration' : 'Dokument-Vorbefuellung'}
|
||||
</h3>
|
||||
<button onClick={() => setOpen(false)}
|
||||
className="text-gray-400 hover:text-gray-600 text-xl leading-none">×</button>
|
||||
</div>
|
||||
|
||||
{loading && <div className="text-sm text-gray-500">Lade Preview ...</div>}
|
||||
{error && <div className="text-sm text-red-600">Fehler: {error}</div>}
|
||||
|
||||
{!loading && !error && mode === 'banner' && banner && (
|
||||
<BannerPreviewBody data={banner} />
|
||||
)}
|
||||
|
||||
{!loading && !error && mode === 'documents' && docs && (
|
||||
<DocumentPreviewBody data={docs} />
|
||||
)}
|
||||
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button onClick={() => setOpen(false)}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-gray-200 hover:bg-gray-50">
|
||||
Schliessen
|
||||
</button>
|
||||
<a href={mode === 'banner' ? '/sdk/einwilligungen' : '/sdk/document-generator'}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-purple-600 text-white hover:bg-purple-700">
|
||||
Im Editor oeffnen
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BannerPreviewBody({ data }: { data: BannerPreview }) {
|
||||
const { summary, flags, config } = data
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat label="Anbieter" value={summary.vendors_total} />
|
||||
<Stat label="Cookies" value={summary.cookies_total} />
|
||||
<Stat label="Kategorien" value={Object.values(summary.categories).filter(n => n > 0).length} />
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Stat label="Fehler" value={summary.flags_error} tone="red" />
|
||||
<Stat label="Warnungen" value={summary.flags_warning} tone="amber" />
|
||||
<Stat label="Hinweise" value={summary.flags_info} tone="gray" />
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-1">Kategorien</h4>
|
||||
<ul className="text-xs text-gray-600 space-y-0.5">
|
||||
{config.categories.map(c => (
|
||||
<li key={c.id}>{c.id}: {c.cookies.length} Cookie(s)</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{flags.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-medium text-gray-700 mb-1">Pruefpunkte</h4>
|
||||
<ul className="text-xs space-y-0.5 max-h-48 overflow-y-auto">
|
||||
{flags.map((f, i) => (
|
||||
<li key={i} className={f.level === 'ERROR' ? 'text-red-700' : f.level === 'WARNING' ? 'text-amber-700' : 'text-gray-600'}>
|
||||
[{f.level}] {f.vendor}: {f.message}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DocumentPreviewBody({ data }: { data: DocumentPreview }) {
|
||||
return (
|
||||
<div className="space-y-4 text-sm">
|
||||
<div className="text-xs text-gray-600">
|
||||
{data.vendor_count} Anbieter werden in {Object.keys(data.templates).length} Vorlagen eingespielt.
|
||||
</div>
|
||||
{Object.entries(data.templates).map(([key, tpl]) => (
|
||||
<div key={key} className="border border-gray-200 rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<h4 className="font-medium text-gray-800">{tpl.templateType}</h4>
|
||||
{tpl.suggested_template_search && (
|
||||
<span className="text-xs text-gray-500">Vorschlag: {tpl.suggested_template_search}</span>
|
||||
)}
|
||||
</div>
|
||||
<pre className="text-xs bg-gray-50 rounded p-2 max-h-48 overflow-auto whitespace-pre-wrap">
|
||||
{tpl.initialContent.slice(0, 1200)}{tpl.initialContent.length > 1200 ? '\n…' : ''}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label, value, tone = 'gray' }: { label: string; value: number; tone?: 'red' | 'amber' | 'gray' }) {
|
||||
const color = tone === 'red' ? 'text-red-700' : tone === 'amber' ? 'text-amber-700' : 'text-gray-800'
|
||||
return (
|
||||
<div className="border border-gray-200 rounded-lg p-2 text-center">
|
||||
<div className={`text-lg font-semibold ${color}`}>{value}</div>
|
||||
<div className="text-xs text-gray-500">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user