feat: 6 Dokumentations-Module auf 100% — VVT Backend, Filter, PDF-Export

Phase 1 — VVT Backend (localStorage → API):
- migrations/006_vvt.sql: Neue Tabellen (vvt_organization, vvt_activities, vvt_audit_log)
- compliance/db/vvt_models.py: SQLAlchemy-Models für alle VVT-Tabellen
- compliance/api/vvt_routes.py: Vollständiger CRUD-Router (10 Endpoints)
- compliance/api/__init__.py: VVT-Router registriert
- compliance/api/schemas.py: VVT Pydantic-Schemas ergänzt
- app/(sdk)/sdk/vvt/page.tsx: API-Client + camelCase↔snake_case Mapping,
  localStorage durch persistente DB-Calls ersetzt (POST/PUT/DELETE/GET)
- tests/test_vvt_routes.py: 18 Tests (alle grün)

Phase 3 — Document Generator PDF-Export:
- document-generator/page.tsx: "Als PDF exportieren"-Button funktioniert jetzt
  via window.print() + Print-Window mit korrektem HTML
- Fallback-Banner wenn Template-Service (breakpilot-core) nicht erreichbar

Phase 4 — Source Policy erweiterte Filter:
- SourcesTab.tsx: source_type-Filter (Rechtlich / Leitlinien / Vorlagen / etc.)
- PIIRulesTab.tsx: category-Filter (E-Mail / Telefon / IBAN / etc.)
- source_policy_router.py: Backend-Endpoints unterstützen jetzt source_type
  und category als Query-Parameter
- requirements.txt: reportlab==4.2.5 ergänzt (fehlende Audit-PDF-Dependency)

Phase 2 — Training (Migration-Skripte):
- scripts/apply_training_migrations.sh: SSH-Skript für Mac Mini
- scripts/apply_vvt_migration.sh: Vollständiges Deploy-Skript für VVT

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-02 17:14:58 +01:00
parent 7cc420bd9e
commit 34fc8dc654
14 changed files with 1332 additions and 65 deletions

View File

@@ -454,6 +454,13 @@ export default function DocumentGeneratorPage() {
</button>
</StepHeader>
{/* Service unreachable warning */}
{!isLoading && !status && (
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm text-amber-800">
<strong>Template-Service nicht erreichbar.</strong> Stellen Sie sicher, dass breakpilot-core läuft (<code>curl -sf http://macmini:8099/health</code>). Das Suchen und Zusammenstellen von Vorlagen ist erst nach Verbindung möglich.
</div>
)}
{/* Status Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="bg-white rounded-xl border border-gray-200 p-6">
@@ -737,6 +744,24 @@ export default function DocumentGeneratorPage() {
Kopieren
</button>
<button
onClick={() => {
const printWindow = window.open('', '_blank')
if (!printWindow) return
let content = combinedContent
for (const [key, value] of Object.entries(placeholderValues)) {
if (value) {
content = content.replace(new RegExp(key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g'), value)
}
}
const attributions = selectedTemplateObjects
.filter(t => t.attributionRequired && t.attributionText)
.map(t => `<li>${t.attributionText}</li>`)
.join('')
printWindow.document.write(`<!DOCTYPE html><html lang="de"><head><meta charset="UTF-8"><title>Datenschutzdokument</title><style>body{font-family:Arial,sans-serif;max-width:800px;margin:40px auto;padding:0 20px;line-height:1.6;color:#222}h2{border-bottom:1px solid #ccc;padding-bottom:8px;margin-top:32px}hr{border:none;border-top:1px solid #eee;margin:24px 0}.attribution{font-size:12px;color:#666;margin-top:48px;border-top:1px solid #ddd;padding-top:16px}@media print{body{margin:0}}</style></head><body><div style="white-space:pre-wrap">${content.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;')}</div>${attributions ? `<div class="attribution"><strong>Quellenangaben:</strong><ul>${attributions}</ul></div>` : ''}</body></html>`)
printWindow.document.close()
printWindow.focus()
setTimeout(() => printWindow.print(), 500)
}}
className="px-6 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
>
Als PDF exportieren

View File

@@ -47,25 +47,149 @@ import type { ProfilingAnswers } from '@/lib/sdk/vvt-profiling'
type Tab = 'verzeichnis' | 'editor' | 'generator' | 'export'
const STORAGE_KEY = 'bp_vvt'
const PROFILING_STORAGE_KEY = 'bp_vvt_profiling'
interface VVTData {
activities: VVTActivity[]
orgHeader: VVTOrganizationHeader
profilingAnswers: ProfilingAnswers
// =============================================================================
// API CLIENT
// =============================================================================
const VVT_API_BASE = '/api/sdk/v1/compliance/vvt'
function activityFromApi(raw: any): VVTActivity {
return {
id: raw.id,
vvtId: raw.vvt_id,
name: raw.name || '',
description: raw.description || '',
purposes: raw.purposes || [],
legalBases: raw.legal_bases || [],
dataSubjectCategories: raw.data_subject_categories || [],
personalDataCategories: raw.personal_data_categories || [],
recipientCategories: raw.recipient_categories || [],
thirdCountryTransfers: raw.third_country_transfers || [],
retentionPeriod: raw.retention_period || { description: '' },
tomDescription: raw.tom_description || '',
businessFunction: raw.business_function || 'other',
systems: raw.systems || [],
deploymentModel: raw.deployment_model || 'cloud',
dataSources: raw.data_sources || [],
dataFlows: raw.data_flows || [],
protectionLevel: raw.protection_level || 'MEDIUM',
dpiaRequired: raw.dpia_required || false,
structuredToms: raw.structured_toms || { accessControl: [], confidentiality: [], integrity: [], availability: [], separation: [] },
status: raw.status || 'DRAFT',
responsible: raw.responsible || '',
owner: raw.owner || '',
createdAt: raw.created_at || new Date().toISOString(),
updatedAt: raw.updated_at || raw.created_at || new Date().toISOString(),
}
}
function loadData(): VVTData {
if (typeof window === 'undefined') return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} }
try {
const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return JSON.parse(stored)
} catch { /* ignore */ }
return { activities: [], orgHeader: createDefaultOrgHeader(), profilingAnswers: {} }
function activityToApi(act: VVTActivity): Record<string, unknown> {
return {
vvt_id: act.vvtId,
name: act.name,
description: act.description,
purposes: act.purposes,
legal_bases: act.legalBases,
data_subject_categories: act.dataSubjectCategories,
personal_data_categories: act.personalDataCategories,
recipient_categories: act.recipientCategories,
third_country_transfers: act.thirdCountryTransfers,
retention_period: act.retentionPeriod,
tom_description: act.tomDescription,
business_function: act.businessFunction,
systems: act.systems,
deployment_model: act.deploymentModel,
data_sources: act.dataSources,
data_flows: act.dataFlows,
protection_level: act.protectionLevel,
dpia_required: act.dpiaRequired,
structured_toms: act.structuredToms,
status: act.status,
responsible: act.responsible,
owner: act.owner,
}
}
function saveData(data: VVTData) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data))
function orgHeaderFromApi(raw: any): VVTOrganizationHeader {
return {
organizationName: raw.organization_name || '',
industry: raw.industry || '',
locations: raw.locations || [],
employeeCount: raw.employee_count || 0,
dpoName: raw.dpo_name || '',
dpoContact: raw.dpo_contact || '',
vvtVersion: raw.vvt_version || '1.0',
lastReviewDate: raw.last_review_date || '',
nextReviewDate: raw.next_review_date || '',
reviewInterval: raw.review_interval || 'annual',
}
}
function orgHeaderToApi(org: VVTOrganizationHeader): Record<string, unknown> {
return {
organization_name: org.organizationName,
industry: org.industry,
locations: org.locations,
employee_count: org.employeeCount,
dpo_name: org.dpoName,
dpo_contact: org.dpoContact,
vvt_version: org.vvtVersion,
last_review_date: org.lastReviewDate || null,
next_review_date: org.nextReviewDate || null,
review_interval: org.reviewInterval,
}
}
async function apiListActivities(): Promise<VVTActivity[]> {
const res = await fetch(`${VVT_API_BASE}/activities`)
if (!res.ok) throw new Error(`GET activities failed: ${res.status}`)
const data = await res.json()
return data.map(activityFromApi)
}
async function apiGetOrganization(): Promise<VVTOrganizationHeader | null> {
const res = await fetch(`${VVT_API_BASE}/organization`)
if (!res.ok) return null
const data = await res.json()
if (!data) return null
return orgHeaderFromApi(data)
}
async function apiCreateActivity(act: VVTActivity): Promise<VVTActivity> {
const res = await fetch(`${VVT_API_BASE}/activities`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activityToApi(act)),
})
if (!res.ok) throw new Error(`POST activity failed: ${res.status}`)
return activityFromApi(await res.json())
}
async function apiUpdateActivity(id: string, act: VVTActivity): Promise<VVTActivity> {
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(activityToApi(act)),
})
if (!res.ok) throw new Error(`PUT activity failed: ${res.status}`)
return activityFromApi(await res.json())
}
async function apiDeleteActivity(id: string): Promise<void> {
const res = await fetch(`${VVT_API_BASE}/activities/${id}`, { method: 'DELETE' })
if (!res.ok) throw new Error(`DELETE activity failed: ${res.status}`)
}
async function apiUpsertOrganization(org: VVTOrganizationHeader): Promise<VVTOrganizationHeader> {
const res = await fetch(`${VVT_API_BASE}/organization`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(orgHeaderToApi(org)),
})
if (!res.ok) throw new Error(`PUT organization failed: ${res.status}`)
return orgHeaderFromApi(await res.json())
}
// =============================================================================
@@ -84,34 +208,45 @@ export default function VVTPage() {
const [sortBy, setSortBy] = useState<'name' | 'date' | 'status'>('name')
const [generatorStep, setGeneratorStep] = useState(1)
const [generatorPreview, setGeneratorPreview] = useState<VVTActivity[] | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [apiError, setApiError] = useState<string | null>(null)
// Load from localStorage
// Load profiling answers from localStorage (UI state only)
useEffect(() => {
const data = loadData()
setActivities(data.activities)
setOrgHeader(data.orgHeader)
setProfilingAnswers(data.profilingAnswers)
try {
const stored = localStorage.getItem(PROFILING_STORAGE_KEY)
if (stored) setProfilingAnswers(JSON.parse(stored))
} catch { /* ignore */ }
}, [])
// Save to localStorage on change
const persist = useCallback((acts: VVTActivity[], org: VVTOrganizationHeader, prof: ProfilingAnswers) => {
saveData({ activities: acts, orgHeader: org, profilingAnswers: prof })
// Load activities + org header from API
useEffect(() => {
async function loadFromApi() {
setIsLoading(true)
setApiError(null)
try {
const [acts, org] = await Promise.all([
apiListActivities(),
apiGetOrganization(),
])
setActivities(acts)
if (org) setOrgHeader(org)
} catch (err) {
setApiError('Fehler beim Laden der VVT-Daten. Bitte Verbindung prüfen.')
console.error('VVT API load error:', err)
} finally {
setIsLoading(false)
}
}
loadFromApi()
}, [])
const updateActivities = useCallback((acts: VVTActivity[]) => {
setActivities(acts)
persist(acts, orgHeader, profilingAnswers)
}, [orgHeader, profilingAnswers, persist])
const updateOrgHeader = useCallback((org: VVTOrganizationHeader) => {
setOrgHeader(org)
persist(activities, org, profilingAnswers)
}, [activities, profilingAnswers, persist])
const updateProfilingAnswers = useCallback((prof: ProfilingAnswers) => {
setProfilingAnswers(prof)
persist(activities, orgHeader, prof)
}, [activities, orgHeader, persist])
try {
localStorage.setItem(PROFILING_STORAGE_KEY, JSON.stringify(prof))
} catch { /* ignore */ }
}, [])
// Computed stats
const activeCount = activities.filter(a => a.status === 'APPROVED').length
@@ -147,6 +282,14 @@ export default function VVTPage() {
{ id: 'export', label: 'Export & Compliance' },
]
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-purple-600"></div>
</div>
)
}
return (
<div className="space-y-6">
<StepHeader
@@ -157,6 +300,12 @@ export default function VVTPage() {
tips={stepInfo.tips}
/>
{apiError && (
<div className="bg-red-50 border border-red-200 rounded-lg p-4 text-sm text-red-700">
{apiError}
</div>
)}
{/* Tab Navigation */}
<div className="flex items-center gap-1 bg-gray-100 p-1 rounded-lg">
{tabs.map(t => (
@@ -193,14 +342,28 @@ export default function VVTPage() {
sortBy={sortBy}
setSortBy={setSortBy}
onEdit={(id) => { setEditingId(id); setTab('editor') }}
onNew={() => {
onNew={async () => {
const vvtId = generateVVTId(activities.map(a => a.vvtId))
const newAct = createEmptyActivity(vvtId)
updateActivities([...activities, newAct])
setEditingId(newAct.id)
setTab('editor')
try {
const created = await apiCreateActivity(newAct)
setActivities(prev => [...prev, created])
setEditingId(created.id)
setTab('editor')
} catch (err) {
setApiError('Fehler beim Anlegen der Verarbeitung.')
console.error(err)
}
}}
onDelete={async (id) => {
try {
await apiDeleteActivity(id)
setActivities(prev => prev.filter(a => a.id !== id))
} catch (err) {
setApiError('Fehler beim Löschen der Verarbeitung.')
console.error(err)
}
}}
onDelete={(id) => updateActivities(activities.filter(a => a.id !== id))}
/>
)}
@@ -208,12 +371,13 @@ export default function VVTPage() {
<TabEditor
activity={editingActivity}
activities={activities}
onSave={(updated) => {
const idx = activities.findIndex(a => a.id === updated.id)
if (idx >= 0) {
const copy = [...activities]
copy[idx] = { ...updated, updatedAt: new Date().toISOString() }
updateActivities(copy)
onSave={async (updated) => {
try {
const saved = await apiUpdateActivity(updated.id, updated)
setActivities(prev => prev.map(a => a.id === saved.id ? saved : a))
} catch (err) {
setApiError('Fehler beim Speichern der Verarbeitung.')
console.error(err)
}
}}
onBack={() => setTab('verzeichnis')}
@@ -229,8 +393,17 @@ export default function VVTPage() {
setAnswers={updateProfilingAnswers}
preview={generatorPreview}
setPreview={setGeneratorPreview}
onAdoptAll={(newActivities) => {
updateActivities([...activities, ...newActivities])
onAdoptAll={async (newActivities) => {
const created: VVTActivity[] = []
for (const act of newActivities) {
try {
const saved = await apiCreateActivity(act)
created.push(saved)
} catch (err) {
console.error('Failed to create activity from generator:', err)
}
}
if (created.length > 0) setActivities(prev => [...prev, ...created])
setGeneratorPreview(null)
setGeneratorStep(1)
setTab('verzeichnis')
@@ -242,7 +415,15 @@ export default function VVTPage() {
<TabExport
activities={activities}
orgHeader={orgHeader}
onUpdateOrgHeader={updateOrgHeader}
onUpdateOrgHeader={async (org) => {
try {
const saved = await apiUpsertOrganization(org)
setOrgHeader(saved)
} catch (err) {
setApiError('Fehler beim Speichern der Organisationsdaten.')
console.error(err)
}
}}
/>
)}
</div>

View File

@@ -56,6 +56,9 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Category filter
const [categoryFilter, setCategoryFilter] = useState('')
// Test panel
const [testText, setTestText] = useState('')
const [testResult, setTestResult] = useState<PIITestResult | null>(null)
@@ -77,12 +80,14 @@ export function PIIRulesTab({ apiBase, onUpdate }: PIIRulesTabProps) {
useEffect(() => {
fetchRules()
}, [])
}, [categoryFilter])
const fetchRules = async () => {
try {
setLoading(true)
const res = await fetch(`${apiBase}/pii-rules`)
const params = new URLSearchParams()
if (categoryFilter) params.append('category', categoryFilter)
const res = await fetch(`${apiBase}/pii-rules?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
const data = await res.json()
@@ -321,17 +326,29 @@ Meine IBAN ist DE89 3704 0044 0532 0130 00."
</div>
{/* Rules List Header */}
<div className="flex justify-between items-center mb-4">
<div className="flex flex-wrap justify-between items-center gap-3 mb-4">
<h3 className="font-semibold text-slate-900">PII-Erkennungsregeln</h3>
<button
onClick={() => setIsNewRule(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Regel
</button>
<div className="flex gap-3 items-center">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Alle Kategorien</option>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>{c.label}</option>
))}
</select>
<button
onClick={() => setIsNewRule(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Neue Regel
</button>
</div>
</div>
{/* Rules Table */}

View File

@@ -51,6 +51,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const [searchTerm, setSearchTerm] = useState('')
const [licenseFilter, setLicenseFilter] = useState('')
const [statusFilter, setStatusFilter] = useState<'all' | 'active' | 'inactive'>('all')
const [sourceTypeFilter, setSourceTypeFilter] = useState('')
// Edit modal
const [editingSource, setEditingSource] = useState<AllowedSource | null>(null)
@@ -69,7 +70,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
useEffect(() => {
fetchSources()
}, [licenseFilter, statusFilter])
}, [licenseFilter, statusFilter, sourceTypeFilter])
const fetchSources = async () => {
try {
@@ -77,6 +78,7 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
const params = new URLSearchParams()
if (licenseFilter) params.append('license', licenseFilter)
if (statusFilter !== 'all') params.append('active_only', statusFilter === 'active' ? 'true' : 'false')
if (sourceTypeFilter) params.append('source_type', sourceTypeFilter)
const res = await fetch(`${apiBase}/sources?${params}`)
if (!res.ok) throw new Error('Fehler beim Laden')
@@ -230,6 +232,18 @@ export function SourcesTab({ apiBase, onUpdate }: SourcesTabProps) {
<option value="active">Aktiv</option>
<option value="inactive">Inaktiv</option>
</select>
<select
value={sourceTypeFilter}
onChange={(e) => setSourceTypeFilter(e.target.value)}
className="px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent"
>
<option value="">Alle Typen</option>
<option value="legal">Rechtlich</option>
<option value="guidance">Leitlinien</option>
<option value="template">Vorlagen</option>
<option value="technical">Technisch</option>
<option value="other">Sonstige</option>
</select>
<button
onClick={() => setIsNewSource(true)}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 flex items-center gap-2"