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:
@@ -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 }))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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']
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user