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
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:
@@ -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') || '',
|
||||
|
||||
@@ -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 (
|
||||
|
||||
825
admin-compliance/app/sdk/email-templates/page.tsx
Normal file
825
admin-compliance/app/sdk/email-templates/page.tsx
Normal 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} · {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">← 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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user