feat: Package 4 Nachbesserungen — History-Tracking, Pagination, Frontend-Fixes
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
Backend:
- Migration 009: compliance_einwilligungen_consent_history Tabelle
- EinwilligungenConsentHistoryDB Modell (consent_id, action, version, ip, ua, source)
- _record_history() Helper: automatisch bei POST /consents (granted) + PUT /revoke (revoked)
- GET /consents/{id}/history Endpoint (vor revoke platziert für korrektes Routing)
- GET /consents: history-Array pro Eintrag (inline Sub-Query)
- 5 neue Tests (TestConsentHistoryTracking) — 32/32 bestanden
Frontend:
- consent/route.ts: limit+offset aus Frontend-Request weitergeleitet, total-Feld ergänzt
- Neuer Proxy consent/[id]/history/route.ts für GET /consents/{id}/history
- page.tsx: globalStats state + loadStats() (Backend /consents/stats für globale Zahlen)
- page.tsx: Stats-Kacheln auf globalStats umgestellt (nicht mehr page-relativ)
- page.tsx: history-Mapper: created_at→timestamp, consent_version→version
- page.tsx: loadStats() bei Mount + nach Revoke
Dokumentation:
- Developer Portal: neue API-Docs-Seite /api/einwilligungen (Consent + Legal Docs + Cookie Banner)
- developer-portal/app/api/page.tsx: Consent Management Abschnitt
- MkDocs: History-Endpoint, Pagination-Abschnitt, History-Tracking Abschnitt
- Deploy-Skript: scripts/apply_consent_history_migration.sh
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -733,57 +733,101 @@ export default function EinwilligungenPage() {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [selectedRecord, setSelectedRecord] = useState<ConsentRecord | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const PAGE_SIZE = 50
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [totalRecords, setTotalRecords] = useState(0)
|
||||
const [globalStats, setGlobalStats] = useState({ total: 0, active: 0, revoked: 0 })
|
||||
|
||||
const loadConsents = React.useCallback(async (page: number) => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
const offset = (page - 1) * PAGE_SIZE
|
||||
const listResponse = await fetch(
|
||||
`/api/sdk/v1/einwilligungen/consent?limit=${PAGE_SIZE}&offset=${offset}`
|
||||
)
|
||||
if (listResponse.ok) {
|
||||
const listData = await listResponse.json()
|
||||
setTotalRecords(listData.total ?? 0)
|
||||
if (listData.consents?.length > 0) {
|
||||
const mapped: ConsentRecord[] = listData.consents.map((c: {
|
||||
id: string
|
||||
user_id: string
|
||||
data_point_id: string
|
||||
granted: boolean
|
||||
granted_at: string
|
||||
revoked_at?: string
|
||||
consent_version?: string
|
||||
source?: string
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
history?: Array<{
|
||||
id: string
|
||||
action: string
|
||||
created_at: string
|
||||
consent_version?: string
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
source?: string
|
||||
}>
|
||||
}) => ({
|
||||
id: c.id,
|
||||
identifier: c.user_id,
|
||||
email: c.user_id,
|
||||
consentType: (c.data_point_id as ConsentType) || 'privacy',
|
||||
status: (c.revoked_at ? 'withdrawn' : 'granted') as ConsentStatus,
|
||||
currentVersion: c.consent_version || '1.0',
|
||||
grantedAt: c.granted_at ? new Date(c.granted_at) : null,
|
||||
withdrawnAt: c.revoked_at ? new Date(c.revoked_at) : null,
|
||||
source: c.source ?? null,
|
||||
ipAddress: c.ip_address ?? '',
|
||||
userAgent: c.user_agent ?? '',
|
||||
history: (c.history ?? []).map(h => ({
|
||||
id: h.id,
|
||||
action: h.action as HistoryAction,
|
||||
timestamp: new Date(h.created_at),
|
||||
version: h.consent_version || '1.0',
|
||||
ipAddress: h.ip_address ?? '',
|
||||
userAgent: h.user_agent ?? '',
|
||||
source: h.source ?? '',
|
||||
})),
|
||||
}))
|
||||
setRecords(mapped)
|
||||
} else {
|
||||
setRecords([])
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Backend nicht erreichbar, leere Liste anzeigen
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const loadStats = React.useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/sdk/v1/einwilligungen/consent?stats=true')
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
const s = data.statistics
|
||||
if (s) {
|
||||
setGlobalStats({
|
||||
total: s.total_consents ?? 0,
|
||||
active: s.active_consents ?? 0,
|
||||
revoked: s.revoked_consents ?? 0,
|
||||
})
|
||||
setTotalRecords(s.total_consents ?? 0)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Statistiken nicht erreichbar — lokale Werte behalten
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
const loadConsents = async () => {
|
||||
try {
|
||||
const response = await fetch('/api/sdk/v1/einwilligungen/consent?stats=true')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// Backend returns stats; actual record list requires separate call
|
||||
const listResponse = await fetch('/api/sdk/v1/einwilligungen/consent')
|
||||
if (listResponse.ok) {
|
||||
const listData = await listResponse.json()
|
||||
// Map backend records to frontend ConsentRecord shape if any returned
|
||||
if (listData.consents && listData.consents.length > 0) {
|
||||
const mapped: ConsentRecord[] = listData.consents.map((c: {
|
||||
id: string
|
||||
user_id: string
|
||||
data_point_id: string
|
||||
granted: boolean
|
||||
granted_at: string
|
||||
revoked_at?: string
|
||||
consent_version?: string
|
||||
source?: string
|
||||
ip_address?: string
|
||||
user_agent?: string
|
||||
history?: ConsentHistoryEntry[]
|
||||
}) => ({
|
||||
id: c.id,
|
||||
identifier: c.user_id,
|
||||
email: c.user_id,
|
||||
consentType: (c.data_point_id as ConsentType) || 'privacy',
|
||||
status: (c.revoked_at ? 'withdrawn' : 'granted') as ConsentStatus,
|
||||
currentVersion: c.consent_version || '1.0',
|
||||
grantedAt: c.granted_at ? new Date(c.granted_at) : null,
|
||||
withdrawnAt: c.revoked_at ? new Date(c.revoked_at) : null,
|
||||
source: c.source ?? null,
|
||||
ipAddress: c.ip_address ?? '',
|
||||
userAgent: c.user_agent ?? '',
|
||||
history: c.history ?? [],
|
||||
}))
|
||||
setRecords(mapped)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Backend not reachable, start with empty list
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
loadConsents()
|
||||
}, [])
|
||||
loadStats()
|
||||
}, [loadStats])
|
||||
|
||||
React.useEffect(() => { loadConsents(currentPage) }, [currentPage, loadConsents])
|
||||
|
||||
const filteredRecords = records.filter(record => {
|
||||
const matchesFilter = filter === 'all' || record.consentType === filter || record.status === filter
|
||||
@@ -793,8 +837,6 @@ export default function EinwilligungenPage() {
|
||||
return matchesFilter && matchesSearch
|
||||
})
|
||||
|
||||
const grantedCount = records.filter(r => r.status === 'granted').length
|
||||
const withdrawnCount = records.filter(r => r.status === 'withdrawn').length
|
||||
const versionUpdates = records.reduce((acc, r) => acc + r.history.filter(h => h.action === 'version_update').length, 0)
|
||||
|
||||
const handleRevoke = async (recordId: string) => {
|
||||
@@ -829,6 +871,7 @@ export default function EinwilligungenPage() {
|
||||
}
|
||||
return r
|
||||
}))
|
||||
loadStats()
|
||||
}
|
||||
} catch {
|
||||
// Fallback: update local state even if API call fails
|
||||
@@ -869,15 +912,15 @@ export default function EinwilligungenPage() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<div className="bg-white rounded-xl border border-gray-200 p-6">
|
||||
<div className="text-sm text-gray-500">Gesamt</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{records.length}</div>
|
||||
<div className="text-3xl font-bold text-gray-900">{globalStats.total}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-green-200 p-6">
|
||||
<div className="text-sm text-green-600">Aktive Einwilligungen</div>
|
||||
<div className="text-3xl font-bold text-green-600">{grantedCount}</div>
|
||||
<div className="text-3xl font-bold text-green-600">{globalStats.active}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-red-200 p-6">
|
||||
<div className="text-sm text-red-600">Widerrufen</div>
|
||||
<div className="text-3xl font-bold text-red-600">{withdrawnCount}</div>
|
||||
<div className="text-3xl font-bold text-red-600">{globalStats.revoked}</div>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl border border-blue-200 p-6">
|
||||
<div className="text-sm text-blue-600">Versions-Updates</div>
|
||||
@@ -970,23 +1013,51 @@ export default function EinwilligungenPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pagination placeholder */}
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Zeige {filteredRecords.length} von {records.length} Einträgen
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">
|
||||
Zurück
|
||||
</button>
|
||||
<button className="px-3 py-1 text-sm text-white bg-purple-600 rounded-lg">1</button>
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">2</button>
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">3</button>
|
||||
<button className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200">
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Pagination */}
|
||||
{(() => {
|
||||
const totalPages = Math.ceil(totalRecords / PAGE_SIZE)
|
||||
return (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-gray-500">
|
||||
Zeige {totalRecords === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1}–
|
||||
{Math.min(currentPage * PAGE_SIZE, totalRecords)} von {totalRecords} Einträgen
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
|
||||
const page = Math.max(1, Math.min(currentPage - 2, totalPages - 4)) + i
|
||||
if (page < 1 || page > totalPages) return null
|
||||
return (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-1 text-sm rounded-lg ${
|
||||
page === currentPage
|
||||
? 'text-white bg-purple-600'
|
||||
: 'text-gray-600 bg-gray-100 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages || 1, p + 1))}
|
||||
disabled={currentPage >= (totalPages || 1)}
|
||||
className="px-3 py-1 text-sm text-gray-600 bg-gray-100 rounded-lg hover:bg-gray-200 disabled:opacity-50"
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Detail Modal */}
|
||||
{selectedRecord && (
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* API Route: Consent History
|
||||
*
|
||||
* GET /api/sdk/v1/einwilligungen/consent/{id}/history
|
||||
* Proxies to backend-compliance: GET /api/compliance/einwilligungen/consents/{id}/history
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
|
||||
|
||||
function getTenantId(request: NextRequest): string {
|
||||
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
const clientTenantId = request.headers.get('x-tenant-id') || request.headers.get('X-Tenant-ID')
|
||||
return (clientTenantId && uuidRegex.test(clientTenantId))
|
||||
? clientTenantId
|
||||
: (process.env.DEFAULT_TENANT_ID || '9282a473-5c95-4b3a-bf78-0ecc0ec71d3e')
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/sdk/v1/einwilligungen/consent/{id}/history
|
||||
* Gibt die Änderungshistorie einer Einwilligung zurück.
|
||||
*/
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const tenantId = getTenantId(request)
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/einwilligungen/consents/${params.id}/history`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-Tenant-ID': tenantId,
|
||||
},
|
||||
signal: AbortSignal.timeout(30000),
|
||||
}
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
return NextResponse.json({ error: errorText }, { status: response.status })
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json(data)
|
||||
} catch (error) {
|
||||
console.error('Error fetching consent history:', error)
|
||||
return NextResponse.json({ error: 'Failed to fetch consent history' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -106,10 +106,13 @@ export async function GET(request: NextRequest) {
|
||||
return NextResponse.json({ statistics: stats })
|
||||
}
|
||||
|
||||
// Fetch consents with optional user filter
|
||||
// Fetch consents — forward pagination params from frontend
|
||||
const limit = searchParams.get('limit') || '50'
|
||||
const offset = searchParams.get('offset') || '0'
|
||||
const queryParams = new URLSearchParams()
|
||||
if (userId) queryParams.set('user_id', userId)
|
||||
queryParams.set('limit', '50')
|
||||
queryParams.set('limit', limit)
|
||||
queryParams.set('offset', offset)
|
||||
|
||||
const response = await fetch(
|
||||
`${BACKEND_URL}/api/compliance/einwilligungen/consents?${queryParams.toString()}`,
|
||||
@@ -123,9 +126,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const data = await response.json()
|
||||
return NextResponse.json({
|
||||
total: data.total || 0,
|
||||
totalConsents: data.total || 0,
|
||||
activeConsents: (data.consents || []).filter((c: { granted: boolean; revoked_at: string | null }) => c.granted && !c.revoked_at).length,
|
||||
revokedConsents: (data.consents || []).filter((c: { revoked_at: string | null }) => c.revoked_at).length,
|
||||
offset: data.offset || 0,
|
||||
limit: data.limit || parseInt(limit),
|
||||
consents: data.consents || [],
|
||||
})
|
||||
} catch (error) {
|
||||
|
||||
Reference in New Issue
Block a user