feat(dsr): Go DSR deprecated, Python Export-Endpoint, Frontend an Backend-APIs anbinden
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 34s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 17s
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 34s
CI / test-python-backend-compliance (push) Successful in 30s
CI / test-python-document-crawler (push) Successful in 22s
CI / test-python-dsms-gateway (push) Successful in 17s
- Go: DEPRECATED-Kommentare an allen DSR-Handlern und Routes - Python: GET /dsr/export?format=csv|json (Semikolon-CSV, 12 Spalten) - API-Client: 12 neue Funktionen (verify, assign, extend, complete, reject, communications, exception-checks, history) - Detail-Seite: Alle Actions verdrahtet (keine Coming-soon-Alerts mehr), Communications + Art.17(3)-Checks + Audit-Log live - Haupt-Seite: CSV-Export-Button im Header - Tests: 54/54 bestanden (4 neue Export-Tests) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -13,7 +13,21 @@ import {
|
|||||||
DSRCommunication,
|
DSRCommunication,
|
||||||
DSRVerifyIdentityRequest
|
DSRVerifyIdentityRequest
|
||||||
} from '@/lib/sdk/dsr/types'
|
} from '@/lib/sdk/dsr/types'
|
||||||
import { fetchSDKDSR, updateSDKDSRStatus } from '@/lib/sdk/dsr/api'
|
import {
|
||||||
|
fetchSDKDSR,
|
||||||
|
updateSDKDSRStatus,
|
||||||
|
verifyDSRIdentity,
|
||||||
|
assignDSR,
|
||||||
|
extendDSRDeadline,
|
||||||
|
completeDSR,
|
||||||
|
rejectDSR,
|
||||||
|
fetchDSRCommunications,
|
||||||
|
sendDSRCommunication,
|
||||||
|
fetchDSRExceptionChecks,
|
||||||
|
initDSRExceptionChecks,
|
||||||
|
updateDSRExceptionCheck,
|
||||||
|
fetchDSRHistory,
|
||||||
|
} from '@/lib/sdk/dsr/api'
|
||||||
import {
|
import {
|
||||||
DSRWorkflowStepper,
|
DSRWorkflowStepper,
|
||||||
DSRIdentityModal,
|
DSRIdentityModal,
|
||||||
@@ -22,12 +36,6 @@ import {
|
|||||||
DSRDataExportComponent
|
DSRDataExportComponent
|
||||||
} from '@/components/sdk/dsr'
|
} from '@/components/sdk/dsr'
|
||||||
|
|
||||||
// =============================================================================
|
|
||||||
// MOCK COMMUNICATIONS
|
|
||||||
// =============================================================================
|
|
||||||
|
|
||||||
// TODO: Backend fehlt — Communications API noch nicht implementiert
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// COMPONENTS
|
// COMPONENTS
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -155,56 +163,38 @@ function ActionButtons({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function AuditLog({ request }: { request: DSRRequest }) {
|
function AuditLog({ history }: { history: any[] }) {
|
||||||
type AuditEvent = { action: string; timestamp: string; user: string }
|
|
||||||
|
|
||||||
const events: AuditEvent[] = [
|
|
||||||
{ action: 'Erstellt', timestamp: request.createdAt, user: request.createdBy }
|
|
||||||
]
|
|
||||||
|
|
||||||
if (request.assignment.assignedAt) {
|
|
||||||
events.push({
|
|
||||||
action: `Zugewiesen an ${request.assignment.assignedTo}`,
|
|
||||||
timestamp: request.assignment.assignedAt,
|
|
||||||
user: request.assignment.assignedBy || 'System'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.identityVerification.verifiedAt) {
|
|
||||||
events.push({
|
|
||||||
action: 'Identitaet verifiziert',
|
|
||||||
timestamp: request.identityVerification.verifiedAt,
|
|
||||||
user: request.identityVerification.verifiedBy || 'System'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if (request.completedAt) {
|
|
||||||
events.push({
|
|
||||||
action: request.status === 'rejected' ? 'Abgelehnt' : 'Abgeschlossen',
|
|
||||||
timestamp: request.completedAt,
|
|
||||||
user: request.updatedBy || 'System'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<h4 className="text-sm font-medium text-gray-700">Aktivitaeten</h4>
|
<h4 className="text-sm font-medium text-gray-700">Aktivitaeten</h4>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{events.map((event, idx) => (
|
{history.length === 0 && (
|
||||||
<div key={idx} className="flex items-start gap-2 text-xs">
|
<div className="text-xs text-gray-400">Keine Eintraege</div>
|
||||||
|
)}
|
||||||
|
{history.map((entry, idx) => (
|
||||||
|
<div key={entry.id || idx} className="flex items-start gap-2 text-xs">
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 mt-1.5 flex-shrink-0" />
|
<div className="w-1.5 h-1.5 rounded-full bg-gray-300 mt-1.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
<div className="text-gray-900">{event.action}</div>
|
<div className="text-gray-900">
|
||||||
|
{entry.previous_status
|
||||||
|
? `${entry.previous_status} → ${entry.new_status}`
|
||||||
|
: entry.new_status
|
||||||
|
}
|
||||||
|
{entry.comment && `: ${entry.comment}`}
|
||||||
|
</div>
|
||||||
<div className="text-gray-500">
|
<div className="text-gray-500">
|
||||||
{new Date(event.timestamp).toLocaleDateString('de-DE', {
|
{entry.created_at
|
||||||
day: '2-digit',
|
? new Date(entry.created_at).toLocaleDateString('de-DE', {
|
||||||
month: '2-digit',
|
day: '2-digit',
|
||||||
year: 'numeric',
|
month: '2-digit',
|
||||||
hour: '2-digit',
|
year: 'numeric',
|
||||||
minute: '2-digit'
|
hour: '2-digit',
|
||||||
})}
|
minute: '2-digit'
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}
|
||||||
{' - '}
|
{' - '}
|
||||||
{event.user}
|
{entry.changed_by || 'System'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,10 +215,17 @@ export default function DSRDetailPage() {
|
|||||||
|
|
||||||
const [request, setRequest] = useState<DSRRequest | null>(null)
|
const [request, setRequest] = useState<DSRRequest | null>(null)
|
||||||
const [communications, setCommunications] = useState<DSRCommunication[]>([])
|
const [communications, setCommunications] = useState<DSRCommunication[]>([])
|
||||||
|
const [history, setHistory] = useState<any[]>([])
|
||||||
|
const [exceptionChecks, setExceptionChecks] = useState<any[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [showIdentityModal, setShowIdentityModal] = useState(false)
|
const [showIdentityModal, setShowIdentityModal] = useState(false)
|
||||||
const [activeContentTab, setActiveContentTab] = useState<'details' | 'communication' | 'type-specific'>('details')
|
const [activeContentTab, setActiveContentTab] = useState<'details' | 'communication' | 'type-specific'>('details')
|
||||||
|
|
||||||
|
const reloadRequest = async () => {
|
||||||
|
const found = await fetchSDKDSR(requestId)
|
||||||
|
if (found) setRequest(found)
|
||||||
|
}
|
||||||
|
|
||||||
// Load data from SDK backend
|
// Load data from SDK backend
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
@@ -237,8 +234,40 @@ export default function DSRDetailPage() {
|
|||||||
const found = await fetchSDKDSR(requestId)
|
const found = await fetchSDKDSR(requestId)
|
||||||
if (found) {
|
if (found) {
|
||||||
setRequest(found)
|
setRequest(found)
|
||||||
// TODO: Backend fehlt — Communications API noch nicht implementiert
|
// Load communications, history, and exception checks in parallel
|
||||||
setCommunications([])
|
const [comms, hist] = await Promise.all([
|
||||||
|
fetchDSRCommunications(requestId).catch(() => []),
|
||||||
|
fetchDSRHistory(requestId).catch(() => []),
|
||||||
|
])
|
||||||
|
setCommunications(comms.map((c: any) => ({
|
||||||
|
id: c.id,
|
||||||
|
dsrId: c.dsr_id,
|
||||||
|
type: c.communication_type,
|
||||||
|
channel: c.channel,
|
||||||
|
subject: c.subject,
|
||||||
|
content: c.content,
|
||||||
|
createdAt: c.created_at,
|
||||||
|
createdBy: c.created_by,
|
||||||
|
sentAt: c.sent_at,
|
||||||
|
sentBy: c.sent_by,
|
||||||
|
})))
|
||||||
|
setHistory(hist)
|
||||||
|
|
||||||
|
// Load exception checks for erasure requests
|
||||||
|
if (found.type === 'erasure') {
|
||||||
|
try {
|
||||||
|
const checks = await fetchDSRExceptionChecks(requestId)
|
||||||
|
if (checks.length === 0) {
|
||||||
|
// Auto-initialize if none exist
|
||||||
|
const initialized = await initDSRExceptionChecks(requestId)
|
||||||
|
setExceptionChecks(initialized)
|
||||||
|
} else {
|
||||||
|
setExceptionChecks(checks)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setExceptionChecks([])
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load DSR:', error)
|
console.error('Failed to load DSR:', error)
|
||||||
@@ -252,46 +281,109 @@ export default function DSRDetailPage() {
|
|||||||
const handleVerifyIdentity = async (verification: DSRVerifyIdentityRequest) => {
|
const handleVerifyIdentity = async (verification: DSRVerifyIdentityRequest) => {
|
||||||
if (!request) return
|
if (!request) return
|
||||||
try {
|
try {
|
||||||
await updateSDKDSRStatus(request.id, 'verified')
|
const updated = await verifyDSRIdentity(request.id, {
|
||||||
setRequest({
|
method: verification.method,
|
||||||
...request,
|
notes: verification.notes,
|
||||||
identityVerification: {
|
document_ref: verification.documentRef,
|
||||||
verified: true,
|
|
||||||
method: verification.method,
|
|
||||||
verifiedAt: new Date().toISOString(),
|
|
||||||
verifiedBy: 'Current User',
|
|
||||||
notes: verification.notes
|
|
||||||
},
|
|
||||||
status: request.status === 'identity_verification' ? 'processing' : request.status
|
|
||||||
})
|
})
|
||||||
|
setRequest(updated)
|
||||||
|
// Reload history
|
||||||
|
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to verify identity:', err)
|
console.error('Failed to verify identity:', err)
|
||||||
// Still update locally as fallback
|
}
|
||||||
setRequest({
|
}
|
||||||
...request,
|
|
||||||
identityVerification: {
|
const handleAssign = async () => {
|
||||||
verified: true,
|
if (!request) return
|
||||||
method: verification.method,
|
const assignee = prompt('Zuweisen an (Name/ID):')
|
||||||
verifiedAt: new Date().toISOString(),
|
if (!assignee) return
|
||||||
verifiedBy: 'Current User',
|
try {
|
||||||
notes: verification.notes
|
const updated = await assignDSR(request.id, assignee)
|
||||||
},
|
setRequest(updated)
|
||||||
status: request.status === 'identity_verification' ? 'processing' : request.status
|
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||||
})
|
} catch (err) {
|
||||||
|
console.error('Failed to assign:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExtendDeadline = async () => {
|
||||||
|
if (!request) return
|
||||||
|
const reason = prompt('Grund fuer die Fristverlaengerung:')
|
||||||
|
if (!reason) return
|
||||||
|
const daysStr = prompt('Um wie viele Tage verlaengern? (Standard: 60)', '60')
|
||||||
|
const days = parseInt(daysStr || '60', 10) || 60
|
||||||
|
try {
|
||||||
|
const updated = await extendDSRDeadline(request.id, reason, days)
|
||||||
|
setRequest(updated)
|
||||||
|
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to extend deadline:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleComplete = async () => {
|
||||||
|
if (!request) return
|
||||||
|
const summary = prompt('Zusammenfassung der Bearbeitung:')
|
||||||
|
if (summary === null) return
|
||||||
|
try {
|
||||||
|
const updated = await completeDSR(request.id, summary || undefined)
|
||||||
|
setRequest(updated)
|
||||||
|
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to complete:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReject = async () => {
|
||||||
|
if (!request) return
|
||||||
|
const reason = prompt('Ablehnungsgrund:')
|
||||||
|
if (!reason) return
|
||||||
|
const legalBasis = prompt('Rechtsgrundlage (optional):')
|
||||||
|
try {
|
||||||
|
const updated = await rejectDSR(request.id, reason, legalBasis || undefined)
|
||||||
|
setRequest(updated)
|
||||||
|
fetchDSRHistory(requestId).then(setHistory).catch(() => {})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to reject:', err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSendCommunication = async (message: any) => {
|
const handleSendCommunication = async (message: any) => {
|
||||||
const newComm: DSRCommunication = {
|
if (!request) return
|
||||||
id: `comm-${Date.now()}`,
|
try {
|
||||||
dsrId: requestId,
|
const result = await sendDSRCommunication(requestId, {
|
||||||
...message,
|
communication_type: message.type || 'outgoing',
|
||||||
createdAt: new Date().toISOString(),
|
channel: message.channel || 'email',
|
||||||
createdBy: 'Current User',
|
subject: message.subject,
|
||||||
sentAt: message.type === 'outgoing' ? new Date().toISOString() : undefined,
|
content: message.content,
|
||||||
sentBy: message.type === 'outgoing' ? 'Current User' : undefined
|
})
|
||||||
|
// Map backend response to frontend format
|
||||||
|
const newComm: DSRCommunication = {
|
||||||
|
id: result.id,
|
||||||
|
dsrId: result.dsr_id,
|
||||||
|
type: result.communication_type,
|
||||||
|
channel: result.channel,
|
||||||
|
subject: result.subject,
|
||||||
|
content: result.content,
|
||||||
|
createdAt: result.created_at,
|
||||||
|
createdBy: result.created_by || 'Current User',
|
||||||
|
sentAt: result.sent_at,
|
||||||
|
sentBy: result.sent_by,
|
||||||
|
}
|
||||||
|
setCommunications(prev => [newComm, ...prev])
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send communication:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleExceptionCheckChange = async (checkId: string, applies: boolean, notes?: string) => {
|
||||||
|
try {
|
||||||
|
const updated = await updateDSRExceptionCheck(requestId, checkId, { applies, notes })
|
||||||
|
setExceptionChecks(prev => prev.map(c => c.id === checkId ? updated : c))
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to update exception check:', err)
|
||||||
}
|
}
|
||||||
setCommunications(prev => [newComm, ...prev])
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
@@ -528,10 +620,48 @@ export default function DSRDetailPage() {
|
|||||||
<div>
|
<div>
|
||||||
{/* Art. 17 - Erasure */}
|
{/* Art. 17 - Erasure */}
|
||||||
{request.type === 'erasure' && (
|
{request.type === 'erasure' && (
|
||||||
<DSRErasureChecklistComponent
|
<div className="space-y-4">
|
||||||
checklist={request.erasureChecklist}
|
<DSRErasureChecklistComponent
|
||||||
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
|
checklist={request.erasureChecklist}
|
||||||
/>
|
onChange={(checklist) => setRequest({ ...request, erasureChecklist: checklist })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Art. 17(3) Exception Checks from Backend */}
|
||||||
|
{exceptionChecks.length > 0 && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-lg font-semibold text-gray-900 mb-3">Art. 17(3) Ausnahmepruefung</h3>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">
|
||||||
|
Pruefen Sie, ob eine der gesetzlichen Ausnahmen zur Loeschpflicht greift.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{exceptionChecks.map((check) => (
|
||||||
|
<div key={check.id} className="bg-gray-50 rounded-xl p-4 border border-gray-200">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={check.applies || false}
|
||||||
|
onChange={(e) => handleExceptionCheckChange(check.id, e.target.checked, check.notes)}
|
||||||
|
className="mt-1 h-4 w-4 rounded border-gray-300 text-purple-600 focus:ring-purple-500"
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium text-gray-900 text-sm">
|
||||||
|
{check.article}: {check.label}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{check.description}</div>
|
||||||
|
{check.checked_by && (
|
||||||
|
<div className="text-xs text-gray-400 mt-1">
|
||||||
|
Geprueft von {check.checked_by} am{' '}
|
||||||
|
{check.checked_at ? new Date(check.checked_at).toLocaleDateString('de-DE') : '-'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Art. 15/20 - Data Export */}
|
{/* Art. 15/20 - Data Export */}
|
||||||
@@ -697,16 +827,16 @@ export default function DSRDetailPage() {
|
|||||||
<ActionButtons
|
<ActionButtons
|
||||||
request={request}
|
request={request}
|
||||||
onVerifyIdentity={() => setShowIdentityModal(true)}
|
onVerifyIdentity={() => setShowIdentityModal(true)}
|
||||||
onExtendDeadline={() => alert('Fristverlaengerung - Coming soon')}
|
onExtendDeadline={handleExtendDeadline}
|
||||||
onComplete={() => alert('Abschliessen - Coming soon')}
|
onComplete={handleComplete}
|
||||||
onReject={() => alert('Ablehnen - Coming soon')}
|
onReject={handleReject}
|
||||||
onAssign={() => alert('Zuweisen - Coming soon')}
|
onAssign={handleAssign}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit Log Card */}
|
{/* Audit Log Card */}
|
||||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||||
<AuditLog request={request} />
|
<AuditLog history={history} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -808,15 +808,31 @@ export default function DSRPage() {
|
|||||||
explanation={stepInfo.explanation}
|
explanation={stepInfo.explanation}
|
||||||
tips={stepInfo.tips}
|
tips={stepInfo.tips}
|
||||||
>
|
>
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
onClick={() => setShowCreateModal(true)}
|
<button
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
onClick={() => {
|
||||||
>
|
const link = document.createElement('a')
|
||||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
link.href = '/api/sdk/v1/compliance/dsr/export?format=csv'
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
link.download = 'dsr_export.csv'
|
||||||
</svg>
|
link.click()
|
||||||
Anfrage erfassen
|
}}
|
||||||
</button>
|
className="flex items-center gap-2 px-4 py-2 text-gray-600 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
CSV Export
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(true)}
|
||||||
|
className="flex items-center gap-2 px-4 py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors"
|
||||||
|
>
|
||||||
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 6v6m0 0v6m0-6h6m-6 0H6" />
|
||||||
|
</svg>
|
||||||
|
Anfrage erfassen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</StepHeader>
|
</StepHeader>
|
||||||
|
|
||||||
{/* Tab Navigation */}
|
{/* Tab Navigation */}
|
||||||
|
|||||||
@@ -245,6 +245,171 @@ export async function updateSDKDSRStatus(id: string, status: string): Promise<vo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// WORKFLOW ACTIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify identity of DSR requester
|
||||||
|
*/
|
||||||
|
export async function verifyDSRIdentity(id: string, data: { method: string; notes?: string; document_ref?: string }): Promise<DSRRequest> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/verify-identity`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return transformBackendDSR(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assign DSR to a user
|
||||||
|
*/
|
||||||
|
export async function assignDSR(id: string, assigneeId: string): Promise<DSRRequest> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/assign`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify({ assignee_id: assigneeId }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return transformBackendDSR(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extend DSR deadline (Art. 12 Abs. 3 DSGVO)
|
||||||
|
*/
|
||||||
|
export async function extendDSRDeadline(id: string, reason: string, days: number = 60): Promise<DSRRequest> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/extend`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify({ reason, days }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return transformBackendDSR(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete a DSR
|
||||||
|
*/
|
||||||
|
export async function completeDSR(id: string, summary?: string): Promise<DSRRequest> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/complete`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify({ summary }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return transformBackendDSR(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject a DSR with legal basis
|
||||||
|
*/
|
||||||
|
export async function rejectDSR(id: string, reason: string, legalBasis?: string): Promise<DSRRequest> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/reject`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify({ reason, legal_basis: legalBasis }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return transformBackendDSR(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// COMMUNICATIONS
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch communications for a DSR
|
||||||
|
*/
|
||||||
|
export async function fetchDSRCommunications(id: string): Promise<any[]> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/communications`, {
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a communication for a DSR
|
||||||
|
*/
|
||||||
|
export async function sendDSRCommunication(id: string, data: { communication_type?: string; channel?: string; subject?: string; content: string }): Promise<any> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/communicate`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify({ communication_type: 'outgoing', channel: 'email', ...data }),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// EXCEPTION CHECKS (Art. 17)
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch exception checks for an erasure DSR
|
||||||
|
*/
|
||||||
|
export async function fetchDSRExceptionChecks(id: string): Promise<any[]> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/exception-checks`, {
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize Art. 17(3) exception checks for an erasure DSR
|
||||||
|
*/
|
||||||
|
export async function initDSRExceptionChecks(id: string): Promise<any[]> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/exception-checks/init`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single exception check
|
||||||
|
*/
|
||||||
|
export async function updateDSRExceptionCheck(dsrId: string, checkId: string, data: { applies: boolean; notes?: string }): Promise<any> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${dsrId}/exception-checks/${checkId}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
// =============================================================================
|
||||||
|
// HISTORY
|
||||||
|
// =============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch status change history for a DSR
|
||||||
|
*/
|
||||||
|
export async function fetchDSRHistory(id: string): Promise<any[]> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}/history`, {
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update DSR fields (priority, notes, etc.)
|
||||||
|
*/
|
||||||
|
export async function updateDSR(id: string, data: Record<string, any>): Promise<DSRRequest> {
|
||||||
|
const res = await fetch(`/api/sdk/v1/compliance/dsr/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: getSdkHeaders(),
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||||
|
return transformBackendDSR(await res.json())
|
||||||
|
}
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// MOCK DATA FUNCTIONS (kept as fallback)
|
// MOCK DATA FUNCTIONS (kept as fallback)
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|||||||
@@ -273,6 +273,8 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// DSR - Data Subject Requests / Betroffenenrechte (Art. 15-22)
|
// DSR - Data Subject Requests / Betroffenenrechte (Art. 15-22)
|
||||||
|
// DEPRECATED: DSR is now managed by backend-compliance (Python).
|
||||||
|
// Use: GET/POST/PUT /api/compliance/dsr/* on backend-compliance:8002
|
||||||
dsr := dsgvoRoutes.Group("/dsr")
|
dsr := dsgvoRoutes.Group("/dsr")
|
||||||
{
|
{
|
||||||
dsr.GET("", dsgvoHandlers.ListDSRs)
|
dsr.GET("", dsgvoHandlers.ListDSRs)
|
||||||
@@ -304,7 +306,7 @@ func main() {
|
|||||||
{
|
{
|
||||||
exports.GET("/vvt", dsgvoHandlers.ExportVVT) // DEPRECATED: use backend-compliance /vvt/export?format=csv
|
exports.GET("/vvt", dsgvoHandlers.ExportVVT) // DEPRECATED: use backend-compliance /vvt/export?format=csv
|
||||||
exports.GET("/tom", dsgvoHandlers.ExportTOM) // DEPRECATED: use backend-compliance /tom/export?format=csv
|
exports.GET("/tom", dsgvoHandlers.ExportTOM) // DEPRECATED: use backend-compliance /tom/export?format=csv
|
||||||
exports.GET("/dsr", dsgvoHandlers.ExportDSR)
|
exports.GET("/dsr", dsgvoHandlers.ExportDSR) // DEPRECATED: use backend-compliance /dsr/export?format=csv
|
||||||
exports.GET("/retention", dsgvoHandlers.ExportRetentionPolicies)
|
exports.GET("/retention", dsgvoHandlers.ExportRetentionPolicies)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,9 +220,12 @@ func (h *DSGVOHandlers) CreateTOM(c *gin.Context) {
|
|||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DSR - Data Subject Requests
|
// DSR - Data Subject Requests
|
||||||
|
// DEPRECATED: DSR is now managed by backend-compliance (Python/FastAPI).
|
||||||
|
// Use: /api/compliance/dsr/* endpoints on backend-compliance:8002
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
// ListDSRs returns all DSRs for a tenant
|
// ListDSRs returns all DSRs for a tenant
|
||||||
|
// DEPRECATED: Use backend-compliance GET /api/compliance/dsr
|
||||||
func (h *DSGVOHandlers) ListDSRs(c *gin.Context) {
|
func (h *DSGVOHandlers) ListDSRs(c *gin.Context) {
|
||||||
tenantID := rbac.GetTenantID(c)
|
tenantID := rbac.GetTenantID(c)
|
||||||
if tenantID == uuid.Nil {
|
if tenantID == uuid.Nil {
|
||||||
@@ -243,6 +246,7 @@ func (h *DSGVOHandlers) ListDSRs(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetDSR returns a DSR by ID
|
// GetDSR returns a DSR by ID
|
||||||
|
// DEPRECATED: Use backend-compliance GET /api/compliance/dsr/{id}
|
||||||
func (h *DSGVOHandlers) GetDSR(c *gin.Context) {
|
func (h *DSGVOHandlers) GetDSR(c *gin.Context) {
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -264,6 +268,7 @@ func (h *DSGVOHandlers) GetDSR(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// CreateDSR creates a new DSR
|
// CreateDSR creates a new DSR
|
||||||
|
// DEPRECATED: Use backend-compliance POST /api/compliance/dsr
|
||||||
func (h *DSGVOHandlers) CreateDSR(c *gin.Context) {
|
func (h *DSGVOHandlers) CreateDSR(c *gin.Context) {
|
||||||
tenantID := rbac.GetTenantID(c)
|
tenantID := rbac.GetTenantID(c)
|
||||||
userID := rbac.GetUserID(c)
|
userID := rbac.GetUserID(c)
|
||||||
@@ -293,6 +298,7 @@ func (h *DSGVOHandlers) CreateDSR(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// UpdateDSR updates a DSR
|
// UpdateDSR updates a DSR
|
||||||
|
// DEPRECATED: Use backend-compliance PUT /api/compliance/dsr/{id}
|
||||||
func (h *DSGVOHandlers) UpdateDSR(c *gin.Context) {
|
func (h *DSGVOHandlers) UpdateDSR(c *gin.Context) {
|
||||||
id, err := uuid.Parse(c.Param("id"))
|
id, err := uuid.Parse(c.Param("id"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -612,6 +618,7 @@ func (h *DSGVOHandlers) ExportTOM(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ExportDSR exports DSR overview as CSV/JSON
|
// ExportDSR exports DSR overview as CSV/JSON
|
||||||
|
// DEPRECATED: Use backend-compliance GET /api/compliance/dsr/export?format=csv|json
|
||||||
func (h *DSGVOHandlers) ExportDSR(c *gin.Context) {
|
func (h *DSGVOHandlers) ExportDSR(c *gin.Context) {
|
||||||
tenantID := rbac.GetTenantID(c)
|
tenantID := rbac.GetTenantID(c)
|
||||||
if tenantID == uuid.Nil {
|
if tenantID == uuid.Nil {
|
||||||
|
|||||||
@@ -4,11 +4,14 @@ DSR (Data Subject Request) Routes — Betroffenenanfragen nach DSGVO Art. 15-21.
|
|||||||
Native Python/FastAPI Implementierung, ersetzt Go consent-service Proxy.
|
Native Python/FastAPI Implementierung, ersetzt Go consent-service Proxy.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import csv
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
from fastapi import APIRouter, Depends, HTTPException, Query, Header
|
||||||
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
from sqlalchemy import text, func, and_, or_, cast, String
|
from sqlalchemy import text, func, and_, or_, cast, String
|
||||||
@@ -438,6 +441,61 @@ async def get_dsr_stats(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# Export
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
@router.get("/export")
|
||||||
|
async def export_dsrs(
|
||||||
|
format: str = Query("csv", pattern="^(csv|json)$"),
|
||||||
|
tenant_id: str = Depends(_get_tenant),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Exportiert alle DSRs als CSV oder JSON."""
|
||||||
|
tid = uuid.UUID(tenant_id)
|
||||||
|
dsrs = db.query(DSRRequestDB).filter(
|
||||||
|
DSRRequestDB.tenant_id == tid,
|
||||||
|
).order_by(DSRRequestDB.created_at.desc()).all()
|
||||||
|
|
||||||
|
if format == "json":
|
||||||
|
return {
|
||||||
|
"exported_at": datetime.utcnow().isoformat(),
|
||||||
|
"total": len(dsrs),
|
||||||
|
"requests": [_dsr_to_dict(d) for d in dsrs],
|
||||||
|
}
|
||||||
|
|
||||||
|
# CSV export (semicolon-separated, matching Go format + extended fields)
|
||||||
|
output = io.StringIO()
|
||||||
|
writer = csv.writer(output, delimiter=';', quoting=csv.QUOTE_MINIMAL)
|
||||||
|
writer.writerow([
|
||||||
|
"ID", "Referenznummer", "Typ", "Name", "E-Mail", "Status",
|
||||||
|
"Prioritaet", "Eingegangen", "Frist", "Abgeschlossen", "Quelle", "Zugewiesen",
|
||||||
|
])
|
||||||
|
|
||||||
|
for dsr in dsrs:
|
||||||
|
writer.writerow([
|
||||||
|
str(dsr.id),
|
||||||
|
dsr.request_number or "",
|
||||||
|
dsr.request_type or "",
|
||||||
|
dsr.requester_name or "",
|
||||||
|
dsr.requester_email or "",
|
||||||
|
dsr.status or "",
|
||||||
|
dsr.priority or "",
|
||||||
|
dsr.received_at.strftime("%Y-%m-%d") if dsr.received_at else "",
|
||||||
|
dsr.deadline_at.strftime("%Y-%m-%d") if dsr.deadline_at else "",
|
||||||
|
dsr.completed_at.strftime("%Y-%m-%d") if dsr.completed_at else "",
|
||||||
|
dsr.source or "",
|
||||||
|
dsr.assigned_to or "",
|
||||||
|
])
|
||||||
|
|
||||||
|
output.seek(0)
|
||||||
|
return StreamingResponse(
|
||||||
|
output,
|
||||||
|
media_type="text/csv; charset=utf-8",
|
||||||
|
headers={"Content-Disposition": "attachment; filename=dsr_export.csv"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Deadline Processing (MUST be before /{dsr_id} to avoid path conflicts)
|
# Deadline Processing (MUST be before /{dsr_id} to avoid path conflicts)
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -697,3 +697,49 @@ class TestDSRTemplates:
|
|||||||
fake_id = str(uuid.uuid4())
|
fake_id = str(uuid.uuid4())
|
||||||
resp = client.get(f"/api/compliance/dsr/templates/{fake_id}/versions", headers=HEADERS)
|
resp = client.get(f"/api/compliance/dsr/templates/{fake_id}/versions", headers=HEADERS)
|
||||||
assert resp.status_code == 404
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestDSRExport:
|
||||||
|
"""Tests for DSR export endpoint."""
|
||||||
|
|
||||||
|
def test_export_csv_empty(self):
|
||||||
|
resp = client.get("/api/compliance/dsr/export?format=csv", headers=HEADERS)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert "text/csv" in resp.headers.get("content-type", "")
|
||||||
|
lines = resp.text.strip().split("\n")
|
||||||
|
assert len(lines) == 1 # Header only
|
||||||
|
assert "Referenznummer" in lines[0]
|
||||||
|
assert "Zugewiesen" in lines[0]
|
||||||
|
|
||||||
|
def test_export_csv_with_data(self):
|
||||||
|
# Create a DSR first
|
||||||
|
body = {
|
||||||
|
"request_type": "access",
|
||||||
|
"requester_name": "Export Test",
|
||||||
|
"requester_email": "export@example.de",
|
||||||
|
"source": "email",
|
||||||
|
}
|
||||||
|
create_resp = client.post("/api/compliance/dsr", json=body, headers=HEADERS)
|
||||||
|
assert create_resp.status_code == 200
|
||||||
|
|
||||||
|
resp = client.get("/api/compliance/dsr/export?format=csv", headers=HEADERS)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
lines = resp.text.strip().split("\n")
|
||||||
|
assert len(lines) >= 2 # Header + at least 1 data row
|
||||||
|
# Check data row contains our test data
|
||||||
|
assert "Export Test" in lines[1]
|
||||||
|
assert "export@example.de" in lines[1]
|
||||||
|
assert "access" in lines[1]
|
||||||
|
|
||||||
|
def test_export_json(self):
|
||||||
|
resp = client.get("/api/compliance/dsr/export?format=json", headers=HEADERS)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "exported_at" in data
|
||||||
|
assert "total" in data
|
||||||
|
assert "requests" in data
|
||||||
|
assert isinstance(data["requests"], list)
|
||||||
|
|
||||||
|
def test_export_invalid_format(self):
|
||||||
|
resp = client.get("/api/compliance/dsr/export?format=xml", headers=HEADERS)
|
||||||
|
assert resp.status_code == 422 # Validation error
|
||||||
|
|||||||
Reference in New Issue
Block a user