fix: Vorbereitung-Module auf 100% — Feld-Fixes, Backend-Persistenz, Endpoints

- ScopeExportTab: 11 Feldnamen-Mismatches gegen ScopeDecision Interface korrigiert
  (level→determinedLevel, riskScore→risk_score, hardTriggers→triggeredHardTriggers,
  depthDescription→depth, effortEstimate→estimatedEffort, isMandatory→required,
  triggeredByHardTrigger→triggeredBy, effortDays→estimatedEffort)
- Company Profile: GET vom Backend beim Mount, snake_case→camelCase, SDK State Fallback
- Modules: Aktivierung/Deaktivierung ans Backend schreiben (activate/deactivate Endpoints)
- Obligations: Explizites Fehler-Banner statt stiller Fallback bei Backend-Fehler
- Source Policy: BlockedContentDB Model + GET /api/v1/admin/blocked-content Endpoint
- Import: Offline-Modus Label fuer Backend-Fallback

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-02 12:02:40 +01:00
parent 80a988dc58
commit f7a0b11e41
10 changed files with 278 additions and 39 deletions

View File

@@ -1130,16 +1130,65 @@ export default function CompanyProfilePage() {
const totalSteps = wizardSteps.length
const lastStep = wizardSteps[wizardSteps.length - 1].id
// Load existing profile
// Load existing profile: first try backend, then SDK state as fallback
useEffect(() => {
if (state.companyProfile) {
setFormData(state.companyProfile)
// If profile is complete, show last step
if (state.companyProfile.isComplete) {
setCurrentStep(5)
let cancelled = false
async function loadFromBackend() {
try {
const response = await fetch('/api/sdk/v1/company-profile?tenant_id=default')
if (response.ok) {
const data = await response.json()
if (data && !cancelled) {
const backendProfile: Partial<CompanyProfile> = {
companyName: data.company_name || '',
legalForm: data.legal_form || undefined,
industry: data.industry || '',
foundedYear: data.founded_year || undefined,
businessModel: data.business_model || undefined,
offerings: data.offerings || [],
companySize: data.company_size || undefined,
employeeCount: data.employee_count || '',
annualRevenue: data.annual_revenue || '',
headquartersCountry: data.headquarters_country || 'DE',
headquartersCity: data.headquarters_city || '',
hasInternationalLocations: data.has_international_locations || false,
internationalCountries: data.international_countries || [],
targetMarkets: data.target_markets || [],
primaryJurisdiction: data.primary_jurisdiction || 'DE',
isDataController: data.is_data_controller ?? true,
isDataProcessor: data.is_data_processor ?? false,
usesAI: data.uses_ai ?? false,
aiUseCases: data.ai_use_cases || [],
dpoName: data.dpo_name || '',
dpoEmail: data.dpo_email || '',
isComplete: data.is_complete || false,
}
setFormData(backendProfile)
if (backendProfile.isComplete) {
setCurrentStep(5)
}
return
}
}
} catch {
// Backend not available, fall through to SDK state
}
// Fallback: use SDK state
if (!cancelled && state.companyProfile) {
setFormData(state.companyProfile)
if (state.companyProfile.isComplete) {
setCurrentStep(5)
}
}
}
}, [state.companyProfile])
loadFromBackend()
return () => { cancelled = true }
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const updateFormData = (updates: Partial<CompanyProfile>) => {
setFormData(prev => ({ ...prev, ...updates }))

View File

@@ -425,7 +425,7 @@ export default function ImportPage() {
setFiles(prev => prev.map(f => (f.id === file.id ? { ...f, status: 'error' as const, error: 'Analyse fehlgeschlagen' } : f)))
}
} catch {
// Fallback: create basic document without backend analysis
// Offline-Modus: create basic document without backend analysis
const doc: ImportedDocument = {
id: file.id,
name: file.file.name,
@@ -438,7 +438,7 @@ export default function ImportPage() {
confidence: 0.5,
extractedEntities: [],
gaps: [],
recommendations: ['Backend nicht erreichbar manuelle Pruefung empfohlen'],
recommendations: ['Offline-Modus — Backend nicht erreichbar, manuelle Pruefung empfohlen'],
},
}
addImportedDocument(doc)

View File

@@ -301,7 +301,7 @@ export default function ModulesPage() {
.filter(m => state.modules.some(sm => sm.id === m.id))
.reduce((sum, m) => sum + m.controlsCount, 0)
const handleActivateModule = (module: DisplayModule) => {
const handleActivateModule = async (module: DisplayModule) => {
const serviceModule: ServiceModule = {
id: module.id,
name: module.name,
@@ -312,11 +312,27 @@ export default function ModulesPage() {
hasAIComponents: module.hasAIComponents,
}
dispatch({ type: 'ADD_MODULE', payload: serviceModule })
try {
await fetch(`/api/sdk/v1/modules/${encodeURIComponent(module.id)}/activate`, {
method: 'POST',
})
} catch {
console.warn('Could not persist module activation to backend')
}
}
const handleDeactivateModule = (moduleId: string) => {
const handleDeactivateModule = async (moduleId: string) => {
const updatedModules = state.modules.filter(m => m.id !== moduleId)
dispatch({ type: 'SET_STATE', payload: { modules: updatedModules } })
try {
await fetch(`/api/sdk/v1/modules/${encodeURIComponent(moduleId)}/deactivate`, {
method: 'POST',
})
} catch {
console.warn('Could not persist module deactivation to backend')
}
}
const stepInfo = STEP_EXPLANATIONS['modules']

View File

@@ -198,6 +198,7 @@ export default function ObligationsPage() {
const [filter, setFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [backendAvailable, setBackendAvailable] = useState(false)
const [backendError, setBackendError] = useState<string | null>(null)
useEffect(() => {
async function loadObligations() {
@@ -215,7 +216,7 @@ export default function ObligationsPage() {
}
}
} catch {
// Backend unavailable, use SDK state obligations
setBackendError('Backend nicht erreichbar — Pflichten aus lokalem State geladen (Offline-Modus)')
}
// Fallback: use obligations from SDK state
@@ -275,6 +276,14 @@ export default function ObligationsPage() {
Pflichten aus UCCA-Assessments geladen (Live-Daten)
</div>
)}
{backendError && !backendAvailable && (
<div className="bg-amber-50 border border-amber-200 rounded-lg p-3 text-sm text-amber-700 flex items-center gap-2">
<svg className="w-4 h-4 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
{backendError}
</div>
)}
{/* Loading */}
{loading && (

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ moduleId: string }> }
) {
try {
const { moduleId } = await params
const response = await fetch(
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/activate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to activate module:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,36 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8002'
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ moduleId: string }> }
) {
try {
const { moduleId } = await params
const response = await fetch(
`${BACKEND_URL}/api/modules/${encodeURIComponent(moduleId)}/deactivate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
const data = await response.json()
return NextResponse.json(data)
} catch (error) {
console.error('Failed to deactivate module:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -34,10 +34,10 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
const headers = ['Typ', 'Tiefe', 'Aufwand (Tage)', 'Pflicht', 'Hard-Trigger']
const rows = decision.requiredDocuments.map((doc) => [
DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType,
doc.depthDescription,
doc.effortEstimate?.days?.toString() || '0',
doc.isMandatory ? 'Ja' : 'Nein',
doc.triggeredByHardTrigger ? 'Ja' : 'Nein',
doc.depth,
doc.estimatedEffort || '0',
doc.required ? 'Ja' : 'Nein',
doc.triggeredBy.length > 0 ? 'Ja' : 'Nein',
])
const csvContent = [headers, ...rows].map((row) => row.map((cell) => `"${cell}"`).join(',')).join('\n')
@@ -57,32 +57,29 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
let markdown = `# Compliance Scope Entscheidung\n\n`
markdown += `**Datum:** ${new Date().toLocaleDateString('de-DE')}\n\n`
markdown += `## Einstufung\n\n`
markdown += `**Level:** ${decision.level} - ${DEPTH_LEVEL_LABELS[decision.level]}\n\n`
markdown += `**Level:** ${decision.determinedLevel} - ${DEPTH_LEVEL_LABELS[decision.determinedLevel]}\n\n`
if (decision.reasoning) {
markdown += `**Begründung:** ${decision.reasoning}\n\n`
}
if (decision.scores) {
markdown += `## Scores\n\n`
markdown += `- **Risiko-Score:** ${decision.scores.riskScore}/100\n`
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexityScore}/100\n`
markdown += `- **Assurance-Score:** ${decision.scores.assuranceScore}/100\n`
markdown += `- **Gesamt-Score:** ${decision.scores.compositeScore}/100\n\n`
markdown += `- **Risiko-Score:** ${decision.scores.risk_score}/100\n`
markdown += `- **Komplexitäts-Score:** ${decision.scores.complexity_score}/100\n`
markdown += `- **Assurance-Score:** ${decision.scores.assurance_need}/100\n`
markdown += `- **Gesamt-Score:** ${decision.scores.composite_score}/100\n\n`
}
if (decision.hardTriggers && decision.hardTriggers.length > 0) {
const matchedTriggers = decision.hardTriggers.filter((ht) => ht.matched)
if (matchedTriggers.length > 0) {
markdown += `## Aktive Hard-Trigger\n\n`
matchedTriggers.forEach((trigger) => {
markdown += `- **${trigger.label}**\n`
markdown += ` - ${trigger.description}\n`
if (trigger.legalReference) {
markdown += ` - Rechtsgrundlage: ${trigger.legalReference}\n`
}
})
markdown += `\n`
}
if (decision.triggeredHardTriggers && decision.triggeredHardTriggers.length > 0) {
markdown += `## Aktive Hard-Trigger\n\n`
decision.triggeredHardTriggers.forEach((trigger) => {
markdown += `- **${trigger.rule.label}**\n`
markdown += ` - ${trigger.rule.description}\n`
if (trigger.rule.legalReference) {
markdown += ` - Rechtsgrundlage: ${trigger.rule.legalReference}\n`
}
})
markdown += `\n`
}
if (decision.requiredDocuments && decision.requiredDocuments.length > 0) {
@@ -90,9 +87,9 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
markdown += `| Typ | Tiefe | Aufwand | Pflicht | Hard-Trigger |\n`
markdown += `|-----|-------|---------|---------|-------------|\n`
decision.requiredDocuments.forEach((doc) => {
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depthDescription} | ${
doc.effortEstimate?.days || 0
} Tage | ${doc.isMandatory ? 'Ja' : 'Nein'} | ${doc.triggeredByHardTrigger ? 'Ja' : 'Nein'} |\n`
markdown += `| ${DOCUMENT_TYPE_LABELS[doc.documentType] || doc.documentType} | ${doc.depth} | ${
doc.estimatedEffort || '0'
} | ${doc.required ? 'Ja' : 'Nein'} | ${doc.triggeredBy.length > 0 ? 'Ja' : 'Nein'} |\n`
})
markdown += `\n`
}
@@ -111,8 +108,8 @@ export function ScopeExportTab({ decision: decisionProp, answers: answersProp, s
decision.nextActions.forEach((action) => {
markdown += `${action.priority}. **${action.title}**\n`
markdown += ` ${action.description}\n`
if (action.effortDays) {
markdown += ` Aufwand: ${action.effortDays} Tage\n`
if (action.estimatedEffort) {
markdown += ` Aufwand: ${action.estimatedEffort}\n`
}
markdown += `\n`
})

View File

@@ -198,6 +198,42 @@ async def seed_modules(
raise HTTPException(status_code=500, detail=str(e))
@router.post("/modules/{module_id}/activate")
async def activate_module(module_id: str, db: Session = Depends(get_db)):
"""Activate a service module."""
from ..db.repository import ServiceModuleRepository
repo = ServiceModuleRepository(db)
module = repo.get_by_id(module_id)
if not module:
module = repo.get_by_name(module_id)
if not module:
raise HTTPException(status_code=404, detail=f"Module {module_id} not found")
module.is_active = True
db.commit()
return {"status": "activated", "id": module.id, "name": module.name}
@router.post("/modules/{module_id}/deactivate")
async def deactivate_module(module_id: str, db: Session = Depends(get_db)):
"""Deactivate a service module."""
from ..db.repository import ServiceModuleRepository
repo = ServiceModuleRepository(db)
module = repo.get_by_id(module_id)
if not module:
module = repo.get_by_name(module_id)
if not module:
raise HTTPException(status_code=404, detail=f"Module {module_id} not found")
module.is_active = False
db.commit()
return {"status": "deactivated", "id": module.id, "name": module.name}
@router.post("/modules/{module_id}/regulations", response_model=ModuleRegulationMappingResponse)
async def add_module_regulation(
module_id: str,

View File

@@ -32,6 +32,7 @@ from sqlalchemy.orm import Session
from database import get_db
from compliance.db.source_policy_models import (
AllowedSourceDB,
BlockedContentDB,
SourceOperationDB,
PIIRuleDB,
SourcePolicyAuditDB,
@@ -398,6 +399,43 @@ async def delete_pii_rule(rule_id: str, db: Session = Depends(get_db)):
return {"status": "deleted", "id": rule_id}
# =============================================================================
# Blocked Content
# =============================================================================
@router.get("/blocked-content")
async def list_blocked_content(
limit: int = Query(50, ge=1, le=500),
offset: int = Query(0, ge=0),
domain: Optional[str] = None,
db: Session = Depends(get_db),
):
"""List blocked content entries."""
query = db.query(BlockedContentDB)
if domain:
query = query.filter(BlockedContentDB.domain == domain)
total = query.count()
entries = query.order_by(BlockedContentDB.created_at.desc()).offset(offset).limit(limit).all()
return {
"blocked": [
{
"id": str(e.id),
"url": e.url,
"domain": e.domain,
"block_reason": e.block_reason,
"rule_id": str(e.rule_id) if e.rule_id else None,
"details": e.details,
"created_at": e.created_at.isoformat() if e.created_at else None,
}
for e in entries
],
"total": total,
}
# =============================================================================
# Audit Trail
# =============================================================================

View File

@@ -85,6 +85,28 @@ class PIIRuleDB(Base):
)
class BlockedContentDB(Base):
"""Blocked content entries tracked by source policy enforcement."""
__tablename__ = 'compliance_blocked_content'
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
url = Column(Text, nullable=True)
domain = Column(String(255), nullable=False)
block_reason = Column(String(100), nullable=False) # unlicensed, pii, blacklisted, etc.
rule_id = Column(UUID(as_uuid=True), nullable=True) # PII rule or source that triggered block
details = Column(JSON, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
__table_args__ = (
Index('idx_blocked_content_domain', 'domain'),
Index('idx_blocked_content_created', 'created_at'),
)
def __repr__(self):
return f"<BlockedContent {self.domain}: {self.block_reason}>"
class SourcePolicyAuditDB(Base):
"""Audit trail for source policy changes."""