feat: Consent-Service Module nach Compliance migriert (DSR, E-Mail-Templates, Legal Docs, Banner)
All checks were successful
CI / go-lint (push) Has been skipped
CI / python-lint (push) Has been skipped
CI / nodejs-lint (push) Has been skipped
CI / test-go-ai-compliance (push) Successful in 36s
CI / test-python-backend-compliance (push) Successful in 31s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 18s

5-Phasen-Migration: Go consent-service Proxies durch native Python/FastAPI ersetzt.

Phase 1 — DSR (Betroffenenrechte): 6 Tabellen, 30 Endpoints, Frontend-API umgestellt
Phase 2 — E-Mail-Templates: 5 Tabellen, 20 Endpoints, neues Frontend, SDK_STEPS erweitert
Phase 3 — Legal Documents Extension: User Consents, Audit Log, Cookie-Kategorien
Phase 4 — Banner Consent: Device-Consents, Site-Configs, Kategorien, Vendors
Phase 5 — Cleanup: DSR-Proxy aus main.py entfernt, Frontend-URLs aktualisiert

148 neue Tests (50 + 47 + 26 + 25), alle bestanden.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-05 00:36:24 +01:00
parent 2211cb9349
commit b7c1a5da1a
23 changed files with 7146 additions and 542 deletions

View File

@@ -536,7 +536,7 @@ export default function ConsentManagementPage() {
// Try to get DSR count
try {
const dsrRes = await fetch('/api/sdk/v1/dsgvo/dsr', {
const dsrRes = await fetch('/api/sdk/v1/compliance/dsr', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
@@ -558,7 +558,7 @@ export default function ConsentManagementPage() {
async function loadGDPRData() {
try {
const res = await fetch('/api/sdk/v1/dsgvo/dsr', {
const res = await fetch('/api/sdk/v1/compliance/dsr', {
headers: {
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '',
'X-User-ID': localStorage.getItem('bp_user_id') || '',

View File

@@ -541,7 +541,7 @@ function DSRDetailPanel({
}
const handleExportPDF = () => {
window.open(`/api/sdk/v1/dsgvo/dsr/${request.id}/export`, '_blank')
window.open(`/api/sdk/v1/compliance/dsr/${request.id}/export`, '_blank')
}
return (

View File

@@ -0,0 +1,825 @@
'use client'
import React, { useState, useEffect, useCallback } from 'react'
import { useSDK } from '@/lib/sdk'
import { StepHeader, STEP_EXPLANATIONS } from '@/components/sdk/StepHeader'
// =============================================================================
// TYPES
// =============================================================================
interface EmailTemplate {
id: string
template_type: string
name: string
description: string | null
category: string
is_active: boolean
sort_order: number
variables: string[]
latest_version?: TemplateVersion | null
created_at: string | null
updated_at: string | null
}
interface TemplateVersion {
id: string
template_id: string
version: string
language: string
subject: string
body_html: string
body_text: string | null
status: string
submitted_at: string | null
published_at: string | null
created_at: string | null
}
interface TemplateType {
type: string
name: string
category: string
variables: string[]
}
interface SendLog {
id: string
template_type: string
recipient: string
subject: string
status: string
sent_at: string | null
}
interface Settings {
sender_name: string
sender_email: string
reply_to: string | null
logo_url: string | null
primary_color: string
secondary_color: string
footer_text: string
company_name: string | null
company_address: string | null
}
type TabId = 'templates' | 'editor' | 'settings' | 'logs'
const API_BASE = '/api/sdk/v1/compliance/email-templates'
function getHeaders(): HeadersInit {
if (typeof window === 'undefined') return { 'Content-Type': 'application/json' }
return {
'Content-Type': 'application/json',
'X-Tenant-ID': localStorage.getItem('bp_tenant_id') || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e',
'X-User-ID': localStorage.getItem('bp_user_id') || '',
}
}
// =============================================================================
// CATEGORY CONFIG
// =============================================================================
const CATEGORIES: Record<string, { label: string; color: string; bgColor: string }> = {
general: { label: 'Allgemein', color: 'text-gray-700', bgColor: 'bg-gray-100' },
dsr: { label: 'Betroffenenrechte', color: 'text-blue-700', bgColor: 'bg-blue-100' },
consent: { label: 'Einwilligung', color: 'text-green-700', bgColor: 'bg-green-100' },
breach: { label: 'Datenpanne', color: 'text-red-700', bgColor: 'bg-red-100' },
vendor: { label: 'Dienstleister', color: 'text-purple-700', bgColor: 'bg-purple-100' },
training: { label: 'Schulung', color: 'text-orange-700', bgColor: 'bg-orange-100' },
}
const STATUS_BADGE: Record<string, { label: string; color: string }> = {
draft: { label: 'Entwurf', color: 'bg-gray-100 text-gray-700' },
review: { label: 'Pruefung', color: 'bg-yellow-100 text-yellow-700' },
approved: { label: 'Genehmigt', color: 'bg-blue-100 text-blue-700' },
published: { label: 'Publiziert', color: 'bg-green-100 text-green-700' },
}
// =============================================================================
// PAGE
// =============================================================================
export default function EmailTemplatesPage() {
const sdk = useSDK()
const [activeTab, setActiveTab] = useState<TabId>('templates')
const [templates, setTemplates] = useState<EmailTemplate[]>([])
const [templateTypes, setTemplateTypes] = useState<TemplateType[]>([])
const [settings, setSettings] = useState<Settings | null>(null)
const [logs, setLogs] = useState<SendLog[]>([])
const [logsTotal, setLogsTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
// Editor state
const [selectedTemplate, setSelectedTemplate] = useState<EmailTemplate | null>(null)
const [editorSubject, setEditorSubject] = useState('')
const [editorHtml, setEditorHtml] = useState('')
const [editorVersion, setEditorVersion] = useState<TemplateVersion | null>(null)
const [saving, setSaving] = useState(false)
const [previewHtml, setPreviewHtml] = useState<string | null>(null)
// Settings form
const [settingsForm, setSettingsForm] = useState<Settings | null>(null)
const [savingSettings, setSavingSettings] = useState(false)
// =============================================================================
// DATA LOADING
// =============================================================================
const loadTemplates = useCallback(async () => {
try {
const url = selectedCategory ? `${API_BASE}?category=${selectedCategory}` : API_BASE
const res = await fetch(url, { headers: getHeaders() })
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const data = await res.json()
setTemplates(Array.isArray(data) ? data : [])
} catch (e: any) {
setError(e.message)
}
}, [selectedCategory])
const loadTypes = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/types`, { headers: getHeaders() })
if (res.ok) {
const data = await res.json()
setTemplateTypes(Array.isArray(data) ? data : [])
}
} catch { /* ignore */ }
}, [])
const loadSettings = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/settings`, { headers: getHeaders() })
if (res.ok) {
const data = await res.json()
setSettings(data)
setSettingsForm(data)
}
} catch { /* ignore */ }
}, [])
const loadLogs = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/logs?limit=50`, { headers: getHeaders() })
if (res.ok) {
const data = await res.json()
setLogs(data.logs || [])
setLogsTotal(data.total || 0)
}
} catch { /* ignore */ }
}, [])
useEffect(() => {
setLoading(true)
Promise.all([loadTemplates(), loadTypes(), loadSettings()])
.finally(() => setLoading(false))
}, [loadTemplates, loadTypes, loadSettings])
useEffect(() => {
if (activeTab === 'logs') loadLogs()
}, [activeTab, loadLogs])
useEffect(() => {
loadTemplates()
}, [selectedCategory, loadTemplates])
// =============================================================================
// EDITOR ACTIONS
// =============================================================================
const openEditor = useCallback(async (template: EmailTemplate) => {
setSelectedTemplate(template)
setActiveTab('editor')
setPreviewHtml(null)
if (template.latest_version) {
setEditorSubject(template.latest_version.subject)
setEditorHtml(template.latest_version.body_html)
setEditorVersion(template.latest_version)
} else {
// Load default content
try {
const res = await fetch(`${API_BASE}/default/${template.template_type}`, { headers: getHeaders() })
if (res.ok) {
const data = await res.json()
setEditorSubject(data.default_subject || '')
setEditorHtml(data.default_body_html || '')
}
} catch { /* ignore */ }
setEditorVersion(null)
}
}, [])
const saveVersion = useCallback(async () => {
if (!selectedTemplate) return
setSaving(true)
try {
const res = await fetch(`${API_BASE}/${selectedTemplate.id}/versions`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({
version: editorVersion ? `${parseFloat(editorVersion.version) + 0.1}` : '1.0',
language: 'de',
subject: editorSubject,
body_html: editorHtml,
}),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const version = await res.json()
setEditorVersion(version)
await loadTemplates()
} catch (e: any) {
setError(e.message)
} finally {
setSaving(false)
}
}, [selectedTemplate, editorSubject, editorHtml, editorVersion, loadTemplates])
const publishVersion = useCallback(async () => {
if (!editorVersion) return
setSaving(true)
try {
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/publish`, {
method: 'POST',
headers: getHeaders(),
})
if (!res.ok) throw new Error(`HTTP ${res.status}`)
const updated = await res.json()
setEditorVersion(updated)
await loadTemplates()
} catch (e: any) {
setError(e.message)
} finally {
setSaving(false)
}
}, [editorVersion, loadTemplates])
const loadPreview = useCallback(async () => {
if (!editorVersion) return
try {
const res = await fetch(`${API_BASE}/versions/${editorVersion.id}/preview`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify({ variables: {} }),
})
if (res.ok) {
const data = await res.json()
setPreviewHtml(data.body_html)
}
} catch { /* ignore */ }
}, [editorVersion])
const saveSettings2 = useCallback(async () => {
if (!settingsForm) return
setSavingSettings(true)
try {
const res = await fetch(`${API_BASE}/settings`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(settingsForm),
})
if (res.ok) {
const data = await res.json()
setSettings(data)
setSettingsForm(data)
}
} catch (e: any) {
setError(e.message)
} finally {
setSavingSettings(false)
}
}, [settingsForm])
const initializeDefaults = useCallback(async () => {
try {
const res = await fetch(`${API_BASE}/initialize`, {
method: 'POST',
headers: getHeaders(),
})
if (res.ok) {
await loadTemplates()
}
} catch (e: any) {
setError(e.message)
}
}, [loadTemplates])
// =============================================================================
// RENDER
// =============================================================================
const tabs: { id: TabId; label: string }[] = [
{ id: 'templates', label: 'Templates' },
{ id: 'editor', label: 'Editor' },
{ id: 'settings', label: 'Einstellungen' },
{ id: 'logs', label: 'Logs' },
]
return (
<div className="space-y-6">
<StepHeader stepId="email-templates" explanation={STEP_EXPLANATIONS['email-templates'] || {
title: 'E-Mail-Templates',
description: 'Verwalten Sie Vorlagen fuer alle DSGVO-relevanten Benachrichtigungen.',
steps: [
'Template-Typen und Variablen pruefen',
'Inhalte im Editor anpassen',
'Vorschau pruefen und publizieren',
'Branding-Einstellungen konfigurieren',
],
}} />
{error && (
<div className="bg-red-50 border border-red-200 rounded-lg p-3 text-sm text-red-700">
{error}
<button onClick={() => setError(null)} className="ml-2 underline">Schliessen</button>
</div>
)}
{/* Tab Navigation */}
<div className="border-b border-gray-200">
<nav className="flex gap-1 -mb-px">
{tabs.map(tab => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-purple-600 text-purple-600'
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
}`}
>
{tab.label}
{tab.id === 'logs' && logsTotal > 0 && (
<span className="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-gray-100 text-gray-600">
{logsTotal}
</span>
)}
</button>
))}
</nav>
</div>
{/* Tab Content */}
{activeTab === 'templates' && (
<TemplatesTab
templates={templates}
loading={loading}
selectedCategory={selectedCategory}
onCategoryChange={setSelectedCategory}
onEdit={openEditor}
onInitialize={initializeDefaults}
/>
)}
{activeTab === 'editor' && (
<EditorTab
template={selectedTemplate}
version={editorVersion}
subject={editorSubject}
html={editorHtml}
previewHtml={previewHtml}
saving={saving}
onSubjectChange={setEditorSubject}
onHtmlChange={setEditorHtml}
onSave={saveVersion}
onPublish={publishVersion}
onPreview={loadPreview}
onBack={() => setActiveTab('templates')}
/>
)}
{activeTab === 'settings' && settingsForm && (
<SettingsTab
settings={settingsForm}
saving={savingSettings}
onChange={setSettingsForm}
onSave={saveSettings2}
/>
)}
{activeTab === 'logs' && (
<LogsTab logs={logs} total={logsTotal} />
)}
</div>
)
}
// =============================================================================
// TAB COMPONENTS
// =============================================================================
function TemplatesTab({
templates, loading, selectedCategory, onCategoryChange, onEdit, onInitialize,
}: {
templates: EmailTemplate[]
loading: boolean
selectedCategory: string | null
onCategoryChange: (cat: string | null) => void
onEdit: (t: EmailTemplate) => void
onInitialize: () => void
}) {
return (
<div className="space-y-4">
{/* Category Pills */}
<div className="flex flex-wrap gap-2">
<button
onClick={() => onCategoryChange(null)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
!selectedCategory ? 'bg-purple-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
Alle
</button>
{Object.entries(CATEGORIES).map(([key, cat]) => (
<button
key={key}
onClick={() => onCategoryChange(key)}
className={`px-3 py-1.5 rounded-full text-sm font-medium transition-colors ${
selectedCategory === key ? 'bg-purple-600 text-white' : `${cat.bgColor} ${cat.color} hover:opacity-80`
}`}
>
{cat.label}
</button>
))}
</div>
{loading ? (
<div className="text-center py-12 text-gray-500">Lade Templates...</div>
) : templates.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-500 mb-4">Keine Templates vorhanden.</p>
<button
onClick={onInitialize}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700"
>
Standard-Templates erstellen
</button>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{templates.map(t => (
<TemplateCard key={t.id} template={t} onEdit={() => onEdit(t)} />
))}
</div>
)}
</div>
)
}
function TemplateCard({ template, onEdit }: { template: EmailTemplate; onEdit: () => void }) {
const cat = CATEGORIES[template.category] || CATEGORIES.general
const version = template.latest_version
const status = version ? (STATUS_BADGE[version.status] || STATUS_BADGE.draft) : STATUS_BADGE.draft
return (
<div
className="bg-white border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow cursor-pointer"
onClick={onEdit}
>
<div className="flex items-start justify-between mb-2">
<div>
<h3 className="font-medium text-gray-900 text-sm">{template.name}</h3>
<span className={`inline-block mt-1 px-2 py-0.5 rounded text-xs ${cat.bgColor} ${cat.color}`}>
{cat.label}
</span>
</div>
<span className={`px-2 py-0.5 rounded text-xs ${status.color}`}>{status.label}</span>
</div>
{template.description && (
<p className="text-xs text-gray-500 mt-2 line-clamp-2">{template.description}</p>
)}
<div className="mt-3 flex flex-wrap gap-1">
{(template.variables || []).slice(0, 4).map(v => (
<span key={v} className="px-1.5 py-0.5 bg-gray-50 text-gray-500 rounded text-xs font-mono">
{`{{${v}}}`}
</span>
))}
{(template.variables || []).length > 4 && (
<span className="text-xs text-gray-400">+{template.variables.length - 4}</span>
)}
</div>
{version && (
<div className="mt-3 text-xs text-gray-400">
v{version.version} &middot; {version.created_at ? new Date(version.created_at).toLocaleDateString('de-DE') : ''}
</div>
)}
</div>
)
}
function EditorTab({
template, version, subject, html, previewHtml, saving,
onSubjectChange, onHtmlChange, onSave, onPublish, onPreview, onBack,
}: {
template: EmailTemplate | null
version: TemplateVersion | null
subject: string
html: string
previewHtml: string | null
saving: boolean
onSubjectChange: (v: string) => void
onHtmlChange: (v: string) => void
onSave: () => void
onPublish: () => void
onPreview: () => void
onBack: () => void
}) {
if (!template) {
return (
<div className="text-center py-12 text-gray-500">
Waehlen Sie ein Template aus der Liste.
<br />
<button onClick={onBack} className="mt-2 text-purple-600 underline">Zurueck zur Liste</button>
</div>
)
}
const cat = CATEGORIES[template.category] || CATEGORIES.general
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<button onClick={onBack} className="text-gray-500 hover:text-gray-700">&larr; Zurueck</button>
<h2 className="text-lg font-semibold">{template.name}</h2>
<span className={`px-2 py-0.5 rounded text-xs ${cat.bgColor} ${cat.color}`}>{cat.label}</span>
{version && (
<span className={`px-2 py-0.5 rounded text-xs ${(STATUS_BADGE[version.status] || STATUS_BADGE.draft).color}`}>
{(STATUS_BADGE[version.status] || STATUS_BADGE.draft).label}
</span>
)}
</div>
<div className="flex gap-2">
<button
onClick={onSave}
disabled={saving}
className="px-3 py-1.5 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Version speichern'}
</button>
{version && version.status !== 'published' && (
<button
onClick={onPublish}
disabled={saving}
className="px-3 py-1.5 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 disabled:opacity-50"
>
Publizieren
</button>
)}
{version && (
<button
onClick={onPreview}
className="px-3 py-1.5 border border-gray-300 text-gray-700 rounded-lg text-sm hover:bg-gray-50"
>
Vorschau
</button>
)}
</div>
</div>
{/* Variables */}
<div className="flex flex-wrap gap-1.5">
<span className="text-xs text-gray-500 mr-1">Variablen:</span>
{(template.variables || []).map(v => (
<button
key={v}
onClick={() => onHtmlChange(html + `{{${v}}}`)}
className="px-2 py-0.5 bg-purple-50 text-purple-700 rounded text-xs font-mono hover:bg-purple-100 transition-colors"
>
{`{{${v}}}`}
</button>
))}
</div>
{/* Split View */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{/* Editor */}
<div className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Betreff</label>
<input
type="text"
value={subject}
onChange={e => onSubjectChange(e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm focus:ring-2 focus:ring-purple-500 focus:border-transparent"
placeholder="E-Mail Betreff..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">HTML-Inhalt</label>
<textarea
value={html}
onChange={e => onHtmlChange(e.target.value)}
rows={20}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono focus:ring-2 focus:ring-purple-500 focus:border-transparent resize-y"
placeholder="<p>Sehr geehrte(r) {{user_name}},</p>"
/>
</div>
</div>
{/* Preview */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vorschau</label>
<div className="border border-gray-200 rounded-lg bg-white p-4 min-h-[400px]">
{previewHtml ? (
<div dangerouslySetInnerHTML={{ __html: previewHtml }} />
) : (
<div dangerouslySetInnerHTML={{ __html: html }} />
)}
</div>
</div>
</div>
</div>
)
}
function SettingsTab({
settings, saving, onChange, onSave,
}: {
settings: Settings
saving: boolean
onChange: (s: Settings) => void
onSave: () => void
}) {
const update = (field: keyof Settings, value: string) => {
onChange({ ...settings, [field]: value })
}
return (
<div className="max-w-2xl space-y-6">
<h2 className="text-lg font-semibold">E-Mail-Einstellungen</h2>
<div className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Absender-Name</label>
<input
type="text"
value={settings.sender_name || ''}
onChange={e => update('sender_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Absender-E-Mail</label>
<input
type="email"
value={settings.sender_email || ''}
onChange={e => update('sender_email', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Antwort-Adresse</label>
<input
type="email"
value={settings.reply_to || ''}
onChange={e => update('reply_to', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="optional"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenname</label>
<input
type="text"
value={settings.company_name || ''}
onChange={e => update('company_name', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Logo URL</label>
<input
type="url"
value={settings.logo_url || ''}
onChange={e => update('logo_url', e.target.value)}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
placeholder="https://..."
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Primaerfarbe</label>
<div className="flex gap-2">
<input
type="color"
value={settings.primary_color || '#4F46E5'}
onChange={e => update('primary_color', e.target.value)}
className="h-10 w-10 rounded cursor-pointer"
/>
<input
type="text"
value={settings.primary_color || ''}
onChange={e => update('primary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Sekundaerfarbe</label>
<div className="flex gap-2">
<input
type="color"
value={settings.secondary_color || '#7C3AED'}
onChange={e => update('secondary_color', e.target.value)}
className="h-10 w-10 rounded cursor-pointer"
/>
<input
type="text"
value={settings.secondary_color || ''}
onChange={e => update('secondary_color', e.target.value)}
className="flex-1 px-3 py-2 border border-gray-300 rounded-lg text-sm font-mono"
/>
</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Firmenadresse</label>
<textarea
value={settings.company_address || ''}
onChange={e => update('company_address', e.target.value)}
rows={2}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Footer-Text</label>
<textarea
value={settings.footer_text || ''}
onChange={e => update('footer_text', e.target.value)}
rows={3}
className="w-full px-3 py-2 border border-gray-300 rounded-lg text-sm"
/>
</div>
</div>
<button
onClick={onSave}
disabled={saving}
className="px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 disabled:opacity-50"
>
{saving ? 'Speichern...' : 'Einstellungen speichern'}
</button>
</div>
)
}
function LogsTab({ logs, total }: { logs: SendLog[]; total: number }) {
return (
<div className="space-y-4">
<h2 className="text-lg font-semibold">E-Mail-Verlauf ({total})</h2>
{logs.length === 0 ? (
<p className="text-gray-500 text-sm">Noch keine E-Mails gesendet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200">
<th className="text-left py-2 px-3 text-gray-500 font-medium">Typ</th>
<th className="text-left py-2 px-3 text-gray-500 font-medium">Empfaenger</th>
<th className="text-left py-2 px-3 text-gray-500 font-medium">Betreff</th>
<th className="text-left py-2 px-3 text-gray-500 font-medium">Status</th>
<th className="text-left py-2 px-3 text-gray-500 font-medium">Datum</th>
</tr>
</thead>
<tbody>
{logs.map(log => (
<tr key={log.id} className="border-b border-gray-100 hover:bg-gray-50">
<td className="py-2 px-3 font-mono text-xs">{log.template_type}</td>
<td className="py-2 px-3">{log.recipient}</td>
<td className="py-2 px-3">{log.subject}</td>
<td className="py-2 px-3">
<span className={`px-2 py-0.5 rounded text-xs ${
log.status === 'sent' || log.status === 'test_sent'
? 'bg-green-100 text-green-700'
: 'bg-red-100 text-red-700'
}`}>
{log.status}
</span>
</td>
<td className="py-2 px-3 text-gray-500">
{log.sent_at ? new Date(log.sent_at).toLocaleString('de-DE') : '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@@ -1,507 +1,121 @@
/**
* DSR API Client
*
* API client for Data Subject Request management
* Connects to the Go Consent Service backend
* API client for Data Subject Request management.
* Connects to the native compliance backend (Python/FastAPI).
*/
import {
DSRRequest,
DSRListResponse,
DSRFilters,
DSRCreateRequest,
DSRUpdateRequest,
DSRVerifyIdentityRequest,
DSRCompleteRequest,
DSRRejectRequest,
DSRExtendDeadlineRequest,
DSRSendCommunicationRequest,
DSRCommunication,
DSRAuditEntry,
DSRStatistics,
DSRDataExport,
DSRErasureChecklist
} from './types'
// =============================================================================
// CONFIGURATION
// =============================================================================
const DSR_API_BASE = process.env.NEXT_PUBLIC_CONSENT_SERVICE_URL || 'http://localhost:8081'
const API_TIMEOUT = 30000 // 30 seconds
// =============================================================================
// HELPER FUNCTIONS
// =============================================================================
function getTenantId(): string {
// In a real app, this would come from auth context or localStorage
if (typeof window !== 'undefined') {
return localStorage.getItem('tenantId') || 'default-tenant'
}
return 'default-tenant'
}
function getAuthHeaders(): HeadersInit {
const headers: HeadersInit = {
'Content-Type': 'application/json',
'X-Tenant-ID': getTenantId()
}
// Add auth token if available
if (typeof window !== 'undefined') {
const token = localStorage.getItem('authToken')
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
}
return headers
}
async function fetchWithTimeout<T>(
url: string,
options: RequestInit = {},
timeout: number = API_TIMEOUT
): Promise<T> {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
headers: {
...getAuthHeaders(),
...options.headers
}
})
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}: ${response.statusText}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
throw new Error(errorMessage)
}
// Handle empty responses
const contentType = response.headers.get('content-type')
if (contentType && contentType.includes('application/json')) {
return response.json()
}
return {} as T
} finally {
clearTimeout(timeoutId)
}
}
// =============================================================================
// DSR LIST & CRUD
// =============================================================================
/**
* Fetch all DSR requests with optional filters
*/
export async function fetchDSRList(filters?: DSRFilters): Promise<DSRListResponse> {
const params = new URLSearchParams()
if (filters) {
if (filters.status) {
const statuses = Array.isArray(filters.status) ? filters.status : [filters.status]
statuses.forEach(s => params.append('status', s))
}
if (filters.type) {
const types = Array.isArray(filters.type) ? filters.type : [filters.type]
types.forEach(t => params.append('type', t))
}
if (filters.priority) params.set('priority', filters.priority)
if (filters.assignedTo) params.set('assignedTo', filters.assignedTo)
if (filters.overdue !== undefined) params.set('overdue', String(filters.overdue))
if (filters.search) params.set('search', filters.search)
if (filters.dateFrom) params.set('dateFrom', filters.dateFrom)
if (filters.dateTo) params.set('dateTo', filters.dateTo)
}
const queryString = params.toString()
const url = `${DSR_API_BASE}/api/v1/admin/dsr${queryString ? `?${queryString}` : ''}`
return fetchWithTimeout<DSRListResponse>(url)
}
/**
* Fetch a single DSR request by ID
*/
export async function fetchDSR(id: string): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(`${DSR_API_BASE}/api/v1/admin/dsr/${id}`)
}
/**
* Create a new DSR request
*/
export async function createDSR(request: DSRCreateRequest): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(`${DSR_API_BASE}/api/v1/admin/dsr`, {
method: 'POST',
body: JSON.stringify(request)
})
}
/**
* Update a DSR request
*/
export async function updateDSR(id: string, update: DSRUpdateRequest): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(`${DSR_API_BASE}/api/v1/admin/dsr/${id}`, {
method: 'PUT',
body: JSON.stringify(update)
})
}
/**
* Delete a DSR request (soft delete - marks as cancelled)
*/
export async function deleteDSR(id: string): Promise<void> {
await fetchWithTimeout<void>(`${DSR_API_BASE}/api/v1/admin/dsr/${id}`, {
method: 'DELETE'
})
}
// =============================================================================
// DSR WORKFLOW ACTIONS
// =============================================================================
/**
* Verify the identity of the requester
*/
export async function verifyIdentity(
dsrId: string,
verification: DSRVerifyIdentityRequest
): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/verify-identity`,
{
method: 'POST',
body: JSON.stringify(verification)
}
)
}
/**
* Complete a DSR request
*/
export async function completeDSR(
dsrId: string,
completion?: DSRCompleteRequest
): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/complete`,
{
method: 'POST',
body: JSON.stringify(completion || {})
}
)
}
/**
* Reject a DSR request
*/
export async function rejectDSR(
dsrId: string,
rejection: DSRRejectRequest
): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/reject`,
{
method: 'POST',
body: JSON.stringify(rejection)
}
)
}
/**
* Extend the deadline for a DSR request
*/
export async function extendDeadline(
dsrId: string,
extension: DSRExtendDeadlineRequest
): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/extend`,
{
method: 'POST',
body: JSON.stringify(extension)
}
)
}
/**
* Assign a DSR request to a user
*/
export async function assignDSR(
dsrId: string,
assignedTo: string
): Promise<DSRRequest> {
return fetchWithTimeout<DSRRequest>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/assign`,
{
method: 'POST',
body: JSON.stringify({ assignedTo })
}
)
}
// =============================================================================
// COMMUNICATION
// =============================================================================
/**
* Get all communications for a DSR request
*/
export async function getCommunications(dsrId: string): Promise<DSRCommunication[]> {
return fetchWithTimeout<DSRCommunication[]>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/communications`
)
}
/**
* Send a communication (email, letter, internal note)
*/
export async function sendCommunication(
dsrId: string,
communication: DSRSendCommunicationRequest
): Promise<DSRCommunication> {
return fetchWithTimeout<DSRCommunication>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/send-communication`,
{
method: 'POST',
body: JSON.stringify(communication)
}
)
}
// =============================================================================
// AUDIT LOG
// =============================================================================
/**
* Get audit log entries for a DSR request
*/
export async function getAuditLog(dsrId: string): Promise<DSRAuditEntry[]> {
return fetchWithTimeout<DSRAuditEntry[]>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/audit`
)
}
// =============================================================================
// STATISTICS
// =============================================================================
/**
* Get DSR statistics
*/
export async function getDSRStatistics(): Promise<DSRStatistics> {
return fetchWithTimeout<DSRStatistics>(
`${DSR_API_BASE}/api/v1/admin/dsr/statistics`
)
}
// =============================================================================
// DATA EXPORT (Art. 15, 20)
// =============================================================================
/**
* Generate data export for Art. 15 (access) or Art. 20 (portability)
*/
export async function generateDataExport(
dsrId: string,
format: 'json' | 'csv' | 'xml' | 'pdf' = 'json'
): Promise<DSRDataExport> {
return fetchWithTimeout<DSRDataExport>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/export`,
{
method: 'POST',
body: JSON.stringify({ format })
}
)
}
/**
* Download generated data export
*/
export async function downloadDataExport(dsrId: string): Promise<Blob> {
const response = await fetch(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/export/download`,
{
headers: getAuthHeaders()
}
)
if (!response.ok) {
throw new Error(`Download failed: ${response.statusText}`)
}
return response.blob()
}
// =============================================================================
// ERASURE CHECKLIST (Art. 17)
// =============================================================================
/**
* Get the erasure checklist for an Art. 17 request
*/
export async function getErasureChecklist(dsrId: string): Promise<DSRErasureChecklist> {
return fetchWithTimeout<DSRErasureChecklist>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/erasure-checklist`
)
}
/**
* Update the erasure checklist
*/
export async function updateErasureChecklist(
dsrId: string,
checklist: DSRErasureChecklist
): Promise<DSRErasureChecklist> {
return fetchWithTimeout<DSRErasureChecklist>(
`${DSR_API_BASE}/api/v1/admin/dsr/${dsrId}/erasure-checklist`,
{
method: 'PUT',
body: JSON.stringify(checklist)
}
)
}
// =============================================================================
// EMAIL TEMPLATES
// =============================================================================
/**
* Get available email templates
*/
export async function getEmailTemplates(): Promise<{ id: string; name: string; stage: string }[]> {
return fetchWithTimeout<{ id: string; name: string; stage: string }[]>(
`${DSR_API_BASE}/api/v1/admin/dsr/email-templates`
)
}
/**
* Preview an email with variables filled in
*/
export async function previewEmail(
templateId: string,
dsrId: string
): Promise<{ subject: string; body: string }> {
return fetchWithTimeout<{ subject: string; body: string }>(
`${DSR_API_BASE}/api/v1/admin/dsr/email-templates/${templateId}/preview`,
{
method: 'POST',
body: JSON.stringify({ dsrId })
}
)
}
// =============================================================================
// SDK API FUNCTIONS (via Next.js proxy to ai-compliance-sdk)
// SDK API FUNCTIONS (via Next.js proxy to compliance backend)
// =============================================================================
interface BackendDSR {
id: string
tenant_id: string
namespace_id?: string
request_number: string
request_type: string
status: string
subject_name: string
subject_email: string
subject_identifier?: string
request_description: string
request_channel: string
priority: string
requester_name: string
requester_email: string
requester_phone?: string
requester_address?: string
requester_customer_id?: string
source: string
source_details?: string
request_text?: string
notes?: string
internal_notes?: string
received_at: string
verified_at?: string
verification_method?: string
deadline_at: string
extended_deadline_at?: string
extension_reason?: string
completed_at?: string
response_sent: boolean
response_sent_at?: string
response_method?: string
rejection_reason?: string
notes?: string
affected_systems?: string[]
extension_approved_by?: string
extension_approved_at?: string
identity_verified: boolean
verification_method?: string
verified_at?: string
verified_by?: string
verification_notes?: string
verification_document_ref?: string
assigned_to?: string
assigned_at?: string
assigned_by?: string
completed_at?: string
completion_notes?: string
rejection_reason?: string
rejection_legal_basis?: string
erasure_checklist?: any[]
data_export?: any
rectification_details?: any
objection_details?: any
affected_systems?: string[]
created_at: string
updated_at: string
}
function mapBackendStatus(status: string): import('./types').DSRStatus {
const mapping: Record<string, import('./types').DSRStatus> = {
'received': 'intake',
'verified': 'identity_verification',
'in_progress': 'processing',
'completed': 'completed',
'rejected': 'rejected',
'extended': 'processing',
}
return mapping[status] || 'intake'
}
function mapBackendChannel(channel: string): import('./types').DSRSource {
const mapping: Record<string, import('./types').DSRSource> = {
'email': 'email',
'form': 'web_form',
'phone': 'phone',
'letter': 'letter',
}
return mapping[channel] || 'other'
created_by?: string
updated_by?: string
}
/**
* Transform flat backend DSR to nested SDK DSRRequest format
* Transform flat backend DSR to nested SDK DSRRequest format.
* New compliance backend already uses the same status names as frontend types.
*/
export function transformBackendDSR(b: BackendDSR): DSRRequest {
const deadlineAt = b.extended_deadline_at || b.deadline_at
const receivedDate = new Date(b.received_at)
const defaultDeadlineDays = 30
const originalDeadline = b.deadline_at || new Date(receivedDate.getTime() + defaultDeadlineDays * 24 * 60 * 60 * 1000).toISOString()
return {
id: b.id,
referenceNumber: `DSR-${new Date(b.created_at).getFullYear()}-${b.id.slice(0, 6).toUpperCase()}`,
referenceNumber: b.request_number,
type: b.request_type as DSRRequest['type'],
status: mapBackendStatus(b.status),
priority: 'normal',
status: (b.status as DSRRequest['status']) || 'intake',
priority: (b.priority as DSRRequest['priority']) || 'normal',
requester: {
name: b.subject_name,
email: b.subject_email,
customerId: b.subject_identifier,
name: b.requester_name,
email: b.requester_email,
phone: b.requester_phone,
address: b.requester_address,
customerId: b.requester_customer_id,
},
source: mapBackendChannel(b.request_channel),
requestText: b.request_description,
source: (b.source as DSRRequest['source']) || 'email',
sourceDetails: b.source_details,
requestText: b.request_text,
receivedAt: b.received_at,
deadline: {
originalDeadline,
currentDeadline: deadlineAt,
originalDeadline: b.deadline_at,
currentDeadline: b.extended_deadline_at || b.deadline_at,
extended: !!b.extended_deadline_at,
extensionReason: b.extension_reason,
extensionApprovedBy: b.extension_approved_by,
extensionApprovedAt: b.extension_approved_at,
},
completedAt: b.completed_at,
identityVerification: {
verified: !!b.verified_at,
verifiedAt: b.verified_at,
verified: b.identity_verified,
method: b.verification_method as any,
verifiedAt: b.verified_at,
verifiedBy: b.verified_by,
notes: b.verification_notes,
documentRef: b.verification_document_ref,
},
assignment: {
assignedTo: b.assigned_to || null,
assignedAt: b.assigned_at,
assignedBy: b.assigned_by,
},
notes: b.notes,
internalNotes: b.internal_notes,
erasureChecklist: b.erasure_checklist ? { items: b.erasure_checklist, canProceedWithErasure: true } : undefined,
dataExport: b.data_export && Object.keys(b.data_export).length > 0 ? b.data_export : undefined,
rectificationDetails: b.rectification_details && Object.keys(b.rectification_details).length > 0 ? b.rectification_details : undefined,
objectionDetails: b.objection_details && Object.keys(b.objection_details).length > 0 ? b.objection_details : undefined,
createdAt: b.created_at,
createdBy: 'system',
createdBy: b.created_by || 'system',
updatedAt: b.updated_at,
updatedBy: b.updated_by,
tenantId: b.tenant_id,
}
}
@@ -516,74 +130,83 @@ function getSdkHeaders(): HeadersInit {
}
/**
* Fetch DSR list from SDK backend via proxy
* Fetch DSR list from compliance backend via proxy
*/
export async function fetchSDKDSRList(): Promise<{ requests: DSRRequest[]; statistics: DSRStatistics }> {
const res = await fetch('/api/sdk/v1/dsgvo/dsr', {
headers: getSdkHeaders(),
})
if (!res.ok) {
throw new Error(`HTTP ${res.status}`)
// Fetch list and stats in parallel
const [listRes, statsRes] = await Promise.all([
fetch('/api/sdk/v1/compliance/dsr?limit=100', { headers: getSdkHeaders() }),
fetch('/api/sdk/v1/compliance/dsr/stats', { headers: getSdkHeaders() }),
])
if (!listRes.ok) {
throw new Error(`HTTP ${listRes.status}`)
}
const data = await res.json()
const backendDSRs: BackendDSR[] = data.dsrs || []
const listData = await listRes.json()
const backendDSRs: BackendDSR[] = listData.requests || []
const requests = backendDSRs.map(transformBackendDSR)
// Calculate statistics locally
const now = new Date()
const statistics: DSRStatistics = {
total: requests.length,
byStatus: {
intake: requests.filter(r => r.status === 'intake').length,
identity_verification: requests.filter(r => r.status === 'identity_verification').length,
processing: requests.filter(r => r.status === 'processing').length,
completed: requests.filter(r => r.status === 'completed').length,
rejected: requests.filter(r => r.status === 'rejected').length,
cancelled: requests.filter(r => r.status === 'cancelled').length,
},
byType: {
access: requests.filter(r => r.type === 'access').length,
rectification: requests.filter(r => r.type === 'rectification').length,
erasure: requests.filter(r => r.type === 'erasure').length,
restriction: requests.filter(r => r.type === 'restriction').length,
portability: requests.filter(r => r.type === 'portability').length,
objection: requests.filter(r => r.type === 'objection').length,
},
overdue: requests.filter(r => {
if (r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled') return false
return new Date(r.deadline.currentDeadline) < now
}).length,
dueThisWeek: requests.filter(r => {
if (r.status === 'completed' || r.status === 'rejected' || r.status === 'cancelled') return false
const deadline = new Date(r.deadline.currentDeadline)
const weekFromNow = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000)
return deadline >= now && deadline <= weekFromNow
}).length,
averageProcessingDays: 0,
completedThisMonth: requests.filter(r => {
if (r.status !== 'completed' || !r.completedAt) return false
const completed = new Date(r.completedAt)
return completed.getMonth() === now.getMonth() && completed.getFullYear() === now.getFullYear()
}).length,
let statistics: DSRStatistics
if (statsRes.ok) {
const statsData = await statsRes.json()
statistics = {
total: statsData.total || 0,
byStatus: statsData.by_status || { intake: 0, identity_verification: 0, processing: 0, completed: 0, rejected: 0, cancelled: 0 },
byType: statsData.by_type || { access: 0, rectification: 0, erasure: 0, restriction: 0, portability: 0, objection: 0 },
overdue: statsData.overdue || 0,
dueThisWeek: statsData.due_this_week || 0,
averageProcessingDays: statsData.average_processing_days || 0,
completedThisMonth: statsData.completed_this_month || 0,
}
} else {
// Fallback: calculate locally
const now = new Date()
statistics = {
total: requests.length,
byStatus: {
intake: requests.filter(r => r.status === 'intake').length,
identity_verification: requests.filter(r => r.status === 'identity_verification').length,
processing: requests.filter(r => r.status === 'processing').length,
completed: requests.filter(r => r.status === 'completed').length,
rejected: requests.filter(r => r.status === 'rejected').length,
cancelled: requests.filter(r => r.status === 'cancelled').length,
},
byType: {
access: requests.filter(r => r.type === 'access').length,
rectification: requests.filter(r => r.type === 'rectification').length,
erasure: requests.filter(r => r.type === 'erasure').length,
restriction: requests.filter(r => r.type === 'restriction').length,
portability: requests.filter(r => r.type === 'portability').length,
objection: requests.filter(r => r.type === 'objection').length,
},
overdue: 0,
dueThisWeek: 0,
averageProcessingDays: 0,
completedThisMonth: 0,
}
}
return { requests, statistics }
}
/**
* Create a new DSR via SDK backend
* Create a new DSR via compliance backend
*/
export async function createSDKDSR(request: DSRCreateRequest): Promise<void> {
const body = {
request_type: request.type,
subject_name: request.requester.name,
subject_email: request.requester.email,
subject_identifier: request.requester.customerId || '',
request_description: request.requestText || '',
request_channel: request.source === 'web_form' ? 'form' : request.source,
notes: '',
requester_name: request.requester.name,
requester_email: request.requester.email,
requester_phone: request.requester.phone || null,
requester_address: request.requester.address || null,
requester_customer_id: request.requester.customerId || null,
source: request.source,
source_details: request.sourceDetails || null,
request_text: request.requestText || '',
priority: request.priority || 'normal',
}
const res = await fetch('/api/sdk/v1/dsgvo/dsr', {
const res = await fetch('/api/sdk/v1/compliance/dsr', {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify(body),
@@ -594,10 +217,10 @@ export async function createSDKDSR(request: DSRCreateRequest): Promise<void> {
}
/**
* Fetch a single DSR by ID from SDK backend
* Fetch a single DSR by ID from compliance backend
*/
export async function fetchSDKDSR(id: string): Promise<DSRRequest | null> {
const res = await fetch(`/api/sdk/v1/dsgvo/dsr/${id}`, {
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}`, {
headers: getSdkHeaders(),
})
if (!res.ok) {
@@ -609,11 +232,11 @@ export async function fetchSDKDSR(id: string): Promise<DSRRequest | null> {
}
/**
* Update DSR status via SDK backend
* Update DSR status via compliance backend
*/
export async function updateSDKDSRStatus(id: string, status: string): Promise<void> {
const res = await fetch(`/api/sdk/v1/dsgvo/dsr/${id}`, {
method: 'PUT',
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/status`, {
method: 'POST',
headers: getSdkHeaders(),
body: JSON.stringify({ status }),
})

View File

@@ -797,18 +797,32 @@ export const SDK_STEPS: SDKStep[] = [
prerequisiteSteps: ['vendor-compliance'],
isOptional: false,
},
{
id: 'email-templates',
seq: 4350,
phase: 2,
package: 'betrieb',
order: 5,
name: 'E-Mail-Templates',
nameShort: 'E-Mails',
description: 'Benachrichtigungs-Vorlagen verwalten',
url: '/sdk/email-templates',
checkpointId: 'CP-EMAIL',
prerequisiteSteps: ['consent-management'],
isOptional: false,
},
{
id: 'notfallplan',
seq: 4400,
phase: 2,
package: 'betrieb',
order: 5,
order: 6,
name: 'Notfallplan & Breach Response',
nameShort: 'Notfallplan',
description: 'Datenpannen-Management nach Art. 33/34 DSGVO',
url: '/sdk/notfallplan',
checkpointId: 'CP-NOTF',
prerequisiteSteps: ['consent-management'],
prerequisiteSteps: ['email-templates'],
isOptional: false,
},
{
@@ -816,7 +830,7 @@ export const SDK_STEPS: SDKStep[] = [
seq: 4500,
phase: 2,
package: 'betrieb',
order: 6,
order: 7,
name: 'Incident Management',
nameShort: 'Incidents',
description: 'Datenpannen erfassen, bewerten und melden (Art. 33/34 DSGVO)',
@@ -830,7 +844,7 @@ export const SDK_STEPS: SDKStep[] = [
seq: 4600,
phase: 2,
package: 'betrieb',
order: 7,
order: 8,
name: 'Hinweisgebersystem',
nameShort: 'Whistleblower',
description: 'Anonymes Meldesystem gemaess HinSchG',
@@ -844,7 +858,7 @@ export const SDK_STEPS: SDKStep[] = [
seq: 4700,
phase: 2,
package: 'betrieb',
order: 8,
order: 9,
name: 'Compliance Academy',
nameShort: 'Academy',
description: 'Mitarbeiter-Schulungen & Zertifikate',
@@ -858,7 +872,7 @@ export const SDK_STEPS: SDKStep[] = [
seq: 4800,
phase: 2,
package: 'betrieb',
order: 9,
order: 10,
name: 'Training Engine',
nameShort: 'Training',
description: 'KI-generierte Schulungsinhalte, Quiz & Medien',