feat(sdk): Multi-Projekt-Architektur — mehrere Projekte pro Tenant
Some checks failed
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) Failing after 33s
CI / test-python-backend-compliance (push) Successful in 34s
CI / test-python-document-crawler (push) Successful in 23s
CI / test-python-dsms-gateway (push) Successful in 19s

Jeder Tenant kann jetzt mehrere Compliance-Projekte anlegen (z.B. verschiedene
Produkte, Tochterunternehmen). CompanyProfile ist pro Projekt kopierbar und
danach unabhaengig editierbar. Multi-Tab-Support via separater BroadcastChannel
und localStorage Keys pro Projekt.

- Migration 039: compliance_projects Tabelle, sdk_states.project_id
- Backend: FastAPI CRUD-Routes fuer Projekte mit Tenant-Isolation
- Frontend: ProjectSelector UI, SDKProvider mit projectId, URL ?project=
- State API: UPSERT auf (tenant_id, project_id) mit Abwaertskompatibilitaet
- Tests: pytest fuer Model-Validierung, Row-Konvertierung, Tenant-Isolation
- Docs: MKDocs Seite, CLAUDE.md, Backend README

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-03-09 14:53:50 +01:00
parent d3fc4cdaaa
commit 0affa4eb66
19 changed files with 1833 additions and 102 deletions

View File

@@ -260,6 +260,40 @@ ssh macmini "git -C /Users/benjaminadmin/Projekte/breakpilot-compliance pull --n
- `lib/sdk/catalog-manager/` — catalog-registry.ts, types.ts
- 17 DSGVO/AI-Act Kataloge (dsfa, vvt-baseline, vendor-compliance, etc.)
### Multi-Projekt-Architektur (seit 2026-03-09)
Jeder Tenant kann mehrere Compliance-Projekte anlegen. CompanyProfile ist **pro Projekt** (nicht tenant-weit).
**URL-Schema:** `/sdk?project={uuid}` — alle SDK-Seiten enthalten `?project=` Query-Param.
`/sdk` ohne `?project=` zeigt die Projektliste (ProjectSelector).
**Datenbank:**
- `compliance_projects` — Projekt-Metadaten (Name, Typ, Status, Version)
- `sdk_states` — UNIQUE auf `(tenant_id, project_id)` statt nur `tenant_id`
- Migration: `039_compliance_projects.sql`
**Backend API (FastAPI):**
```
GET /api/v1/projects → Alle Projekte des Tenants
POST /api/v1/projects → Neues Projekt erstellen (mit copy_from_project_id)
GET /api/v1/projects/{project_id} → Einzelnes Projekt laden
PATCH /api/v1/projects/{project_id} → Projekt aktualisieren
DELETE /api/v1/projects/{project_id} → Projekt archivieren (Soft Delete)
```
**Frontend:**
- `components/sdk/ProjectSelector/ProjectSelector.tsx` — Projektliste + Erstellen-Dialog
- `lib/sdk/types.ts``ProjectInfo` Interface, `SDKState.projectId`
- `lib/sdk/context.tsx``projectId` Prop, `createProject()`, `listProjects()`, `switchProject()`
- `lib/sdk/sync.ts` — BroadcastChannel + localStorage pro Projekt
- `lib/sdk/api-client.ts``projectId` in State-API + Projekt-CRUD-Methoden
- `app/sdk/layout.tsx` — liest `?project=` aus searchParams
- `app/api/sdk/v1/projects/` — Next.js Proxy zum Backend
**Multi-Tab:** Tab A (Projekt X) und Tab B (Projekt Y) interferieren nicht — separate BroadcastChannel + localStorage Keys.
**Stammdaten-Kopie:** Neues Projekt mit `copy_from_project_id` → Backend kopiert `companyProfile` aus dem Quell-State. Danach unabhaengig editierbar.
### Backend-Compliance APIs
```
POST/GET /api/v1/compliance/risks

View File

@@ -0,0 +1,120 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
/**
* Proxy: GET /api/sdk/v1/projects/{projectId} → Backend
*/
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params
const tenantId = request.headers.get('X-Tenant-ID') ||
new URL(request.url).searchParams.get('tenant_id') || ''
const response = await fetch(
`${BACKEND_URL}/api/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
{
headers: { 'X-Tenant-ID': tenantId },
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to get project:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}
/**
* Proxy: PATCH /api/sdk/v1/projects/{projectId} → Backend
*/
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params
const body = await request.json()
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
const response = await fetch(
`${BACKEND_URL}/api/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
body: JSON.stringify(body),
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to update project:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}
/**
* Proxy: DELETE /api/sdk/v1/projects/{projectId} → Backend (soft delete)
*/
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ projectId: string }> }
) {
try {
const { projectId } = await params
const tenantId = request.headers.get('X-Tenant-ID') ||
new URL(request.url).searchParams.get('tenant_id') || ''
const response = await fetch(
`${BACKEND_URL}/api/v1/projects/${projectId}?tenant_id=${encodeURIComponent(tenantId)}`,
{
method: 'DELETE',
headers: { 'X-Tenant-ID': tenantId },
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to archive project:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -0,0 +1,75 @@
import { NextRequest, NextResponse } from 'next/server'
const BACKEND_URL = process.env.BACKEND_URL || 'http://backend-compliance:8002'
/**
* Proxy: GET /api/sdk/v1/projects → Backend GET /api/v1/projects
*/
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const tenantId = searchParams.get('tenant_id') || request.headers.get('X-Tenant-ID') || ''
const includeArchived = searchParams.get('include_archived') || 'false'
const response = await fetch(
`${BACKEND_URL}/api/v1/projects?tenant_id=${encodeURIComponent(tenantId)}&include_archived=${includeArchived}`,
{
headers: { 'X-Tenant-ID': tenantId },
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json(await response.json())
} catch (error) {
console.error('Failed to list projects:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}
/**
* Proxy: POST /api/sdk/v1/projects → Backend POST /api/v1/projects
*/
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const tenantId = body.tenant_id || request.headers.get('X-Tenant-ID') || ''
const response = await fetch(
`${BACKEND_URL}/api/v1/projects?tenant_id=${encodeURIComponent(tenantId)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': tenantId,
},
body: JSON.stringify(body),
}
)
if (!response.ok) {
const errorText = await response.text()
return NextResponse.json(
{ error: 'Backend error', details: errorText },
{ status: response.status }
)
}
return NextResponse.json(await response.json(), { status: 201 })
} catch (error) {
console.error('Failed to create project:', error)
return NextResponse.json(
{ error: 'Failed to connect to backend' },
{ status: 503 }
)
}
}

View File

@@ -2,17 +2,18 @@ import { NextRequest, NextResponse } from 'next/server'
import { Pool } from 'pg'
/**
* SDK State Management API
* SDK State Management API (Multi-Project)
*
* GET /api/sdk/v1/state?tenantId=xxx - Load state for a tenant
* POST /api/sdk/v1/state - Save state for a tenant
* DELETE /api/sdk/v1/state?tenantId=xxx - Clear state for a tenant
* GET /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Load state for a tenant+project
* POST /api/sdk/v1/state - Save state for a tenant+project
* DELETE /api/sdk/v1/state?tenantId=xxx&projectId=yyy - Clear state
*
* Features:
* - Versioning for optimistic locking
* - Last-Modified headers
* - ETag support for caching
* - PostgreSQL persistence (with InMemory fallback)
* - projectId support for multi-project architecture
*/
// =============================================================================
@@ -32,25 +33,31 @@ interface StoredState {
// =============================================================================
interface StateStore {
get(tenantId: string): Promise<StoredState | null>
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState>
delete(tenantId: string): Promise<boolean>
get(tenantId: string, projectId?: string): Promise<StoredState | null>
save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise<StoredState>
delete(tenantId: string, projectId?: string): Promise<boolean>
}
class InMemoryStateStore implements StateStore {
private store: Map<string, StoredState> = new Map()
async get(tenantId: string): Promise<StoredState | null> {
return this.store.get(tenantId) || null
private key(tenantId: string, projectId?: string): string {
return projectId ? `${tenantId}:${projectId}` : tenantId
}
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
return this.store.get(this.key(tenantId, projectId)) || null
}
async save(
tenantId: string,
state: unknown,
userId?: string,
expectedVersion?: number
expectedVersion?: number,
projectId?: string
): Promise<StoredState> {
const existing = this.store.get(tenantId)
const k = this.key(tenantId, projectId)
const existing = this.store.get(k)
if (expectedVersion !== undefined && existing && existing.version !== expectedVersion) {
const error = new Error('Version conflict') as Error & { status: number }
@@ -72,12 +79,12 @@ class InMemoryStateStore implements StateStore {
updatedAt: now,
}
this.store.set(tenantId, stored)
this.store.set(k, stored)
return stored
}
async delete(tenantId: string): Promise<boolean> {
return this.store.delete(tenantId)
async delete(tenantId: string, projectId?: string): Promise<boolean> {
return this.store.delete(this.key(tenantId, projectId))
}
}
@@ -93,11 +100,26 @@ class PostgreSQLStateStore implements StateStore {
})
}
async get(tenantId: string): Promise<StoredState | null> {
const result = await this.pool.query(
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1',
[tenantId]
)
async get(tenantId: string, projectId?: string): Promise<StoredState | null> {
let result
if (projectId) {
result = await this.pool.query(
'SELECT state, version, user_id, created_at, updated_at FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
[tenantId, projectId]
)
} else {
// Backwards compatibility: find the single active project for this tenant
result = await this.pool.query(
`SELECT s.state, s.version, s.user_id, s.created_at, s.updated_at
FROM sdk_states s
LEFT JOIN compliance_projects p ON s.project_id = p.id
WHERE s.tenant_id = $1
AND (p.status = 'active' OR p.id IS NULL)
ORDER BY s.updated_at DESC
LIMIT 1`,
[tenantId]
)
}
if (result.rows.length === 0) return null
const row = result.rows[0]
return {
@@ -109,25 +131,71 @@ class PostgreSQLStateStore implements StateStore {
}
}
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise<StoredState> {
async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise<StoredState> {
const now = new Date().toISOString()
const stateWithTimestamp = {
...(state as object),
lastModified: now,
}
// Use UPSERT with version check
const result = await this.pool.query(`
INSERT INTO sdk_states (tenant_id, user_id, state, version, created_at, updated_at)
VALUES ($1, $2, $3::jsonb, 1, NOW(), NOW())
ON CONFLICT (tenant_id) DO UPDATE SET
state = $3::jsonb,
user_id = COALESCE($2, sdk_states.user_id),
version = sdk_states.version + 1,
updated_at = NOW()
WHERE ($4::int IS NULL OR sdk_states.version = $4)
RETURNING version, user_id, created_at, updated_at
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null])
let result
if (projectId) {
// Multi-project: UPSERT on (tenant_id, project_id)
result = await this.pool.query(`
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
state = $3::jsonb,
user_id = COALESCE($2, sdk_states.user_id),
version = sdk_states.version + 1,
updated_at = NOW()
WHERE ($4::int IS NULL OR sdk_states.version = $4)
RETURNING version, user_id, created_at, updated_at
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, projectId])
} else {
// Backwards compatibility: find the single project for this tenant
// First try to find an existing project
const projectResult = await this.pool.query(
`SELECT id FROM compliance_projects WHERE tenant_id = $1 AND status = 'active' ORDER BY created_at ASC LIMIT 1`,
[tenantId]
)
if (projectResult.rows.length > 0) {
const foundProjectId = projectResult.rows[0].id
result = await this.pool.query(`
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
state = $3::jsonb,
user_id = COALESCE($2, sdk_states.user_id),
version = sdk_states.version + 1,
updated_at = NOW()
WHERE ($4::int IS NULL OR sdk_states.version = $4)
RETURNING version, user_id, created_at, updated_at
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, foundProjectId])
} else {
// No project exists — create a default one
const newProject = await this.pool.query(
`INSERT INTO compliance_projects (tenant_id, name, customer_type, status)
VALUES ($1, 'Projekt 1', 'new', 'active')
RETURNING id`,
[tenantId]
)
const newProjectId = newProject.rows[0].id
result = await this.pool.query(`
INSERT INTO sdk_states (tenant_id, project_id, user_id, state, version, created_at, updated_at)
VALUES ($1, $5, $2, $3::jsonb, 1, NOW(), NOW())
ON CONFLICT (tenant_id, project_id) DO UPDATE SET
state = $3::jsonb,
user_id = COALESCE($2, sdk_states.user_id),
version = sdk_states.version + 1,
updated_at = NOW()
WHERE ($4::int IS NULL OR sdk_states.version = $4)
RETURNING version, user_id, created_at, updated_at
`, [tenantId, userId, JSON.stringify(stateWithTimestamp), expectedVersion ?? null, newProjectId])
}
}
if (result.rows.length === 0) {
const error = new Error('Version conflict') as Error & { status: number }
@@ -145,11 +213,19 @@ class PostgreSQLStateStore implements StateStore {
}
}
async delete(tenantId: string): Promise<boolean> {
const result = await this.pool.query(
'DELETE FROM sdk_states WHERE tenant_id = $1',
[tenantId]
)
async delete(tenantId: string, projectId?: string): Promise<boolean> {
let result
if (projectId) {
result = await this.pool.query(
'DELETE FROM sdk_states WHERE tenant_id = $1 AND project_id = $2',
[tenantId, projectId]
)
} else {
result = await this.pool.query(
'DELETE FROM sdk_states WHERE tenant_id = $1',
[tenantId]
)
}
return (result.rowCount ?? 0) > 0
}
}
@@ -186,6 +262,7 @@ export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const tenantId = searchParams.get('tenantId')
const projectId = searchParams.get('projectId') || undefined
if (!tenantId) {
return NextResponse.json(
@@ -194,7 +271,7 @@ export async function GET(request: NextRequest) {
)
}
const stored = await stateStore.get(tenantId)
const stored = await stateStore.get(tenantId, projectId)
if (!stored) {
return NextResponse.json(
@@ -216,6 +293,7 @@ export async function GET(request: NextRequest) {
success: true,
data: {
tenantId,
projectId: projectId || null,
state: stored.state,
version: stored.version,
lastModified: stored.updatedAt,
@@ -241,7 +319,7 @@ export async function GET(request: NextRequest) {
export async function POST(request: NextRequest) {
try {
const body = await request.json()
const { tenantId, state, version } = body
const { tenantId, state, version, projectId } = body
if (!tenantId) {
return NextResponse.json(
@@ -261,7 +339,7 @@ export async function POST(request: NextRequest) {
const ifMatch = request.headers.get('If-Match')
const expectedVersion = ifMatch ? parseInt(ifMatch, 10) : version
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion)
const stored = await stateStore.save(tenantId, state, body.userId, expectedVersion, projectId || undefined)
const etag = generateETag(stored.version, stored.updatedAt)
@@ -270,6 +348,7 @@ export async function POST(request: NextRequest) {
success: true,
data: {
tenantId,
projectId: projectId || null,
state: stored.state,
version: stored.version,
lastModified: stored.updatedAt,
@@ -309,6 +388,7 @@ export async function DELETE(request: NextRequest) {
try {
const { searchParams } = new URL(request.url)
const tenantId = searchParams.get('tenantId')
const projectId = searchParams.get('projectId') || undefined
if (!tenantId) {
return NextResponse.json(
@@ -317,7 +397,7 @@ export async function DELETE(request: NextRequest) {
)
}
const deleted = await stateStore.delete(tenantId)
const deleted = await stateStore.delete(tenantId, projectId)
if (!deleted) {
return NextResponse.json(

View File

@@ -1,7 +1,7 @@
'use client'
import { useEffect, useState } from 'react'
import { usePathname } from 'next/navigation'
import { usePathname, useSearchParams } from 'next/navigation'
import { SDKProvider } from '@/lib/sdk'
import { SDKSidebar } from '@/components/sdk/Sidebar/SDKSidebar'
import { CommandBar } from '@/components/sdk/CommandBar'
@@ -36,7 +36,7 @@ const SYNC_STATUS_CONFIG = {
} as const
function SDKHeader({ sidebarCollapsed }: { sidebarCollapsed: boolean }) {
const { state, currentStep, setCommandBarOpen, completionPercentage, syncState } = useSDK()
const { state, currentStep, setCommandBarOpen, completionPercentage, syncState, projectId } = useSDK()
const syncConfig = SYNC_STATUS_CONFIG[syncState.status] || SYNC_STATUS_CONFIG.idle
@@ -47,6 +47,14 @@ function SDKHeader({ sidebarCollapsed }: { sidebarCollapsed: boolean }) {
<div className="flex items-center gap-3">
<nav className="flex items-center text-sm text-gray-500">
<span>SDK</span>
{state.projectInfo && (
<>
<svg className="w-4 h-4 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-gray-700 font-medium">{state.projectInfo.name}</span>
</>
)}
<svg className="w-4 h-4 mx-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
@@ -102,10 +110,19 @@ function SDKHeader({ sidebarCollapsed }: { sidebarCollapsed: boolean }) {
{/* Session Info Bar */}
<div className="flex items-center gap-4 px-6 py-1.5 bg-gray-50 border-t border-gray-100 text-xs text-gray-500">
{/* Projekt + Version */}
{/* Projekt-Name */}
<span className="text-gray-700 font-medium">
{state.companyProfile?.companyName || 'Kein Projekt'}
{state.projectInfo?.name || state.companyProfile?.companyName || 'Kein Projekt'}
</span>
{/* Firmenname (falls abweichend vom Projektnamen) */}
{state.projectInfo && state.companyProfile?.companyName && state.companyProfile.companyName !== state.projectInfo.name && (
<>
<span className="text-gray-300">|</span>
<span className="text-gray-600">{state.companyProfile.companyName}</span>
</>
)}
<span className="font-mono text-gray-400">
V{String(state.projectVersion || 1).padStart(3, '0')}
</span>
@@ -149,7 +166,7 @@ function SDKHeader({ sidebarCollapsed }: { sidebarCollapsed: boolean }) {
// =============================================================================
function SDKInnerLayout({ children }: { children: React.ReactNode }) {
const { isCommandBarOpen, setCommandBarOpen } = useSDK()
const { isCommandBarOpen, setCommandBarOpen, projectId } = useSDK()
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
const pathname = usePathname()
@@ -172,16 +189,18 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-gray-50">
{/* Sidebar */}
<SDKSidebar
collapsed={sidebarCollapsed}
onCollapsedChange={handleCollapsedChange}
/>
{/* Sidebar — only show when a project is selected */}
{projectId && (
<SDKSidebar
collapsed={sidebarCollapsed}
onCollapsedChange={handleCollapsedChange}
/>
)}
{/* Main Content - dynamic margin based on sidebar state */}
<div className={`${sidebarCollapsed ? 'ml-16' : 'ml-64'} flex flex-col min-h-screen transition-all duration-300`}>
{/* Header */}
<SDKHeader sidebarCollapsed={sidebarCollapsed} />
<div className={`${projectId ? (sidebarCollapsed ? 'ml-16' : 'ml-64') : ''} flex flex-col min-h-screen transition-all duration-300`}>
{/* Header — only show when a project is selected */}
{projectId && <SDKHeader sidebarCollapsed={sidebarCollapsed} />}
{/* Page Content */}
<main className="flex-1 p-6">{children}</main>
@@ -191,10 +210,10 @@ function SDKInnerLayout({ children }: { children: React.ReactNode }) {
{isCommandBarOpen && <CommandBar onClose={() => setCommandBarOpen(false)} />}
{/* Pipeline Sidebar (FAB on mobile/tablet, fixed on desktop xl+) */}
<SDKPipelineSidebar />
{projectId && <SDKPipelineSidebar />}
{/* Compliance Advisor Widget */}
<ComplianceAdvisorWidget currentStep={currentStep} />
{projectId && <ComplianceAdvisorWidget currentStep={currentStep} />}
</div>
)
}
@@ -208,8 +227,11 @@ export default function SDKRootLayout({
}: {
children: React.ReactNode
}) {
const searchParams = useSearchParams()
const projectId = searchParams.get('project') || undefined
return (
<SDKProvider enableBackendSync={true}>
<SDKProvider enableBackendSync={true} projectId={projectId}>
<SDKInnerLayout>{children}</SDKInnerLayout>
</SDKProvider>
)

View File

@@ -4,6 +4,7 @@ import React from 'react'
import Link from 'next/link'
import { useSDK, SDK_PACKAGES, getStepsForPackage } from '@/lib/sdk'
import { CustomerTypeSelector } from '@/components/sdk/CustomerTypeSelector'
import { ProjectSelector } from '@/components/sdk/ProjectSelector/ProjectSelector'
import type { CustomerType, SDKPackageId } from '@/lib/sdk/types'
// =============================================================================
@@ -42,15 +43,18 @@ function PackageCard({
completion,
stepsCount,
isLocked,
projectId,
}: {
pkg: (typeof SDK_PACKAGES)[number]
completion: number
stepsCount: number
isLocked: boolean
projectId?: string
}) {
const steps = getStepsForPackage(pkg.id)
const firstStep = steps[0]
const href = firstStep?.url || '/sdk'
const baseHref = firstStep?.url || '/sdk'
const href = projectId ? `${baseHref}?project=${projectId}` : baseHref
const content = (
<div
@@ -133,16 +137,19 @@ function QuickActionCard({
icon,
href,
color,
projectId,
}: {
title: string
description: string
icon: React.ReactNode
href: string
color: string
projectId?: string
}) {
const finalHref = projectId ? `${href}?project=${projectId}` : href
return (
<Link
href={href}
href={finalHref}
className="flex items-center gap-4 p-4 bg-white rounded-xl border border-gray-200 hover:border-purple-300 hover:shadow-md transition-all"
>
<div className={`p-3 rounded-lg ${color}`}>{icon}</div>
@@ -162,7 +169,12 @@ function QuickActionCard({
// =============================================================================
export default function SDKDashboard() {
const { state, packageCompletion, completionPercentage, setCustomerType } = useSDK()
const { state, packageCompletion, completionPercentage, setCustomerType, projectId } = useSDK()
// No project selected → show project list
if (!projectId) {
return <ProjectSelector />
}
// Calculate total steps
const totalSteps = SDK_PACKAGES.reduce((sum, pkg) => {
@@ -282,7 +294,7 @@ export default function SDKDashboard() {
Laden Sie Ihre vorhandenen Compliance-Dokumente hoch. Unsere KI analysiert sie und zeigt Ihnen, welche Erweiterungen fuer KI-Compliance erforderlich sind.
</p>
<Link
href="/sdk/import"
href={projectId ? `/sdk/import?project=${projectId}` : '/sdk/import'}
className="inline-flex items-center gap-2 mt-4 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -345,6 +357,7 @@ export default function SDKDashboard() {
completion={packageCompletion[pkg.id]}
stepsCount={visibleSteps.length}
isLocked={isPackageLocked(pkg.id)}
projectId={projectId}
/>
)
})}
@@ -365,6 +378,7 @@ export default function SDKDashboard() {
}
href="/sdk/advisory-board"
color="bg-purple-50"
projectId={projectId}
/>
<QuickActionCard
title="Security Screening"
@@ -376,6 +390,7 @@ export default function SDKDashboard() {
}
href="/sdk/screening"
color="bg-red-50"
projectId={projectId}
/>
<QuickActionCard
title="DSFA generieren"
@@ -387,6 +402,7 @@ export default function SDKDashboard() {
}
href="/sdk/dsfa"
color="bg-blue-50"
projectId={projectId}
/>
<QuickActionCard
title="Legal RAG"
@@ -398,6 +414,7 @@ export default function SDKDashboard() {
}
href="/sdk/rag"
color="bg-green-50"
projectId={projectId}
/>
</div>
</div>

View File

@@ -0,0 +1,346 @@
'use client'
import React, { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useSDK } from '@/lib/sdk'
import type { ProjectInfo, CustomerType } from '@/lib/sdk/types'
// =============================================================================
// CREATE PROJECT DIALOG
// =============================================================================
interface CreateProjectDialogProps {
open: boolean
onClose: () => void
onCreated: (project: ProjectInfo) => void
existingProjects: ProjectInfo[]
}
function CreateProjectDialog({ open, onClose, onCreated, existingProjects }: CreateProjectDialogProps) {
const { createProject } = useSDK()
const [name, setName] = useState('')
const [customerType, setCustomerType] = useState<CustomerType>('new')
const [copyFromId, setCopyFromId] = useState<string>('')
const [isSubmitting, setIsSubmitting] = useState(false)
const [error, setError] = useState('')
if (!open) return null
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!name.trim()) {
setError('Projektname ist erforderlich')
return
}
setIsSubmitting(true)
setError('')
try {
const project = await createProject(
name.trim(),
customerType,
copyFromId || undefined
)
onCreated(project)
setName('')
setCopyFromId('')
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : 'Fehler beim Erstellen des Projekts')
} finally {
setIsSubmitting(false)
}
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
<div
className="bg-white rounded-2xl shadow-xl w-full max-w-lg mx-4 p-6"
onClick={e => e.stopPropagation()}
>
<h2 className="text-xl font-bold text-gray-900 mb-6">Neues Projekt erstellen</h2>
<form onSubmit={handleSubmit} className="space-y-5">
{/* Project Name */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Projektname *
</label>
<input
type="text"
value={name}
onChange={e => setName(e.target.value)}
placeholder="z.B. KI-Produkt X, SaaS API, Tochter GmbH..."
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none"
autoFocus
/>
</div>
{/* Customer Type */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
Projekttyp
</label>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
onClick={() => setCustomerType('new')}
className={`p-3 rounded-lg border-2 text-left transition-all ${
customerType === 'new'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">Neukunde</div>
<div className="text-xs text-gray-500 mt-0.5">Compliance von Grund auf</div>
</button>
<button
type="button"
onClick={() => setCustomerType('existing')}
className={`p-3 rounded-lg border-2 text-left transition-all ${
customerType === 'existing'
? 'border-purple-500 bg-purple-50'
: 'border-gray-200 hover:border-gray-300'
}`}
>
<div className="font-medium text-sm text-gray-900">Bestandskunde</div>
<div className="text-xs text-gray-500 mt-0.5">Bestehende Dokumente erweitern</div>
</button>
</div>
</div>
{/* Copy from existing project */}
{existingProjects.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
Stammdaten kopieren von (optional)
</label>
<select
value={copyFromId}
onChange={e => setCopyFromId(e.target.value)}
className="w-full px-4 py-2.5 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-purple-500 outline-none bg-white"
>
<option value=""> Keine Kopie (leeres Projekt) </option>
{existingProjects.map(p => (
<option key={p.id} value={p.id}>
{p.name} (V{String(p.projectVersion).padStart(3, '0')})
</option>
))}
</select>
<p className="mt-1 text-xs text-gray-500">
Firmenprofil wird kopiert und kann dann unabhaengig bearbeitet werden.
</p>
</div>
)}
{/* Error */}
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700">
{error}
</div>
)}
{/* Actions */}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 px-4 py-2.5 text-sm font-medium text-gray-700 bg-gray-100 hover:bg-gray-200 rounded-lg transition-colors"
>
Abbrechen
</button>
<button
type="submit"
disabled={isSubmitting || !name.trim()}
className="flex-1 px-4 py-2.5 text-sm font-medium text-white bg-purple-600 hover:bg-purple-700 disabled:bg-purple-300 rounded-lg transition-colors"
>
{isSubmitting ? 'Erstelle...' : 'Projekt erstellen'}
</button>
</div>
</form>
</div>
</div>
)
}
// =============================================================================
// PROJECT CARD
// =============================================================================
function ProjectCard({ project, onClick }: { project: ProjectInfo; onClick: () => void }) {
const timeAgo = formatTimeAgo(project.updatedAt)
return (
<button
onClick={onClick}
className="block w-full text-left bg-white rounded-xl border-2 border-gray-200 hover:border-purple-300 hover:shadow-lg p-6 transition-all"
>
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-gray-900 text-lg truncate pr-2">{project.name}</h3>
<span className={`px-2 py-0.5 text-xs rounded-full flex-shrink-0 ${
project.status === 'active'
? 'bg-green-100 text-green-700'
: 'bg-gray-100 text-gray-500'
}`}>
{project.status === 'active' ? 'Aktiv' : 'Archiviert'}
</span>
</div>
{project.description && (
<p className="text-sm text-gray-500 mb-3 line-clamp-2">{project.description}</p>
)}
<div className="flex items-center gap-4 text-sm text-gray-500">
<span className="font-mono">V{String(project.projectVersion).padStart(3, '0')}</span>
<span className="text-gray-300">|</span>
<div className="flex items-center gap-2 flex-1">
<div className="flex-1 h-1.5 bg-gray-100 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all ${
project.completionPercentage === 100 ? 'bg-green-500' : 'bg-purple-600'
}`}
style={{ width: `${project.completionPercentage}%` }}
/>
</div>
<span className="font-medium text-gray-600">{project.completionPercentage}%</span>
</div>
<span className="text-gray-300">|</span>
<span>{timeAgo}</span>
</div>
<div className="mt-2 text-xs text-gray-400">
{project.customerType === 'new' ? 'Neukunde' : 'Bestandskunde'}
</div>
</button>
)
}
// =============================================================================
// HELPER
// =============================================================================
function formatTimeAgo(dateStr: string): string {
const date = new Date(dateStr)
const now = Date.now()
const diff = now - date.getTime()
const seconds = Math.floor(diff / 1000)
if (seconds < 60) return 'Gerade eben'
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `vor ${minutes} Min`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `vor ${hours} Std`
const days = Math.floor(hours / 24)
return `vor ${days} Tag${days > 1 ? 'en' : ''}`
}
// =============================================================================
// MAIN COMPONENT
// =============================================================================
export function ProjectSelector() {
const router = useRouter()
const { listProjects } = useSDK()
const [projects, setProjects] = useState<ProjectInfo[]>([])
const [loading, setLoading] = useState(true)
const [showCreateDialog, setShowCreateDialog] = useState(false)
useEffect(() => {
loadProjects()
}, [])
const loadProjects = async () => {
setLoading(true)
try {
const result = await listProjects()
setProjects(result)
} catch (error) {
console.error('Failed to load projects:', error)
} finally {
setLoading(false)
}
}
const handleProjectClick = (project: ProjectInfo) => {
router.push(`/sdk?project=${project.id}`)
}
const handleProjectCreated = (project: ProjectInfo) => {
router.push(`/sdk?project=${project.id}`)
}
return (
<div className="max-w-4xl mx-auto py-12 px-4">
{/* Header */}
<div className="flex items-center justify-between mb-8">
<div>
<h1 className="text-2xl font-bold text-gray-900">Ihre Projekte</h1>
<p className="mt-1 text-gray-500">
Waehlen Sie ein Compliance-Projekt oder erstellen Sie ein neues.
</p>
</div>
<button
onClick={() => setShowCreateDialog(true)}
className="flex items-center gap-2 px-4 py-2.5 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium text-sm"
>
<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>
Neues Projekt
</button>
</div>
{/* Loading */}
{loading && (
<div className="flex items-center justify-center py-16">
<div className="w-8 h-8 border-4 border-purple-200 border-t-purple-600 rounded-full animate-spin" />
</div>
)}
{/* Empty State */}
{!loading && projects.length === 0 && (
<div className="text-center py-16">
<div className="w-16 h-16 mx-auto mb-4 bg-purple-100 rounded-2xl flex items-center justify-center">
<svg className="w-8 h-8 text-purple-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-gray-900">Noch keine Projekte</h2>
<p className="mt-2 text-gray-500 max-w-md mx-auto">
Erstellen Sie Ihr erstes Compliance-Projekt, um mit der DSGVO- und AI-Act-Konformitaet zu beginnen.
</p>
<button
onClick={() => setShowCreateDialog(true)}
className="mt-6 inline-flex items-center gap-2 px-6 py-3 bg-purple-600 text-white rounded-lg hover:bg-purple-700 transition-colors font-medium"
>
<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>
Erstes Projekt erstellen
</button>
</div>
)}
{/* Project Grid */}
{!loading && projects.length > 0 && (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{projects.map(project => (
<ProjectCard
key={project.id}
project={project}
onClick={() => handleProjectClick(project)}
/>
))}
</div>
)}
{/* Create Dialog */}
<CreateProjectDialog
open={showCreateDialog}
onClose={() => setShowCreateDialog(false)}
onCreated={handleProjectCreated}
existingProjects={projects}
/>
</div>
)
}

View File

@@ -13,6 +13,15 @@ import {
type RAGCorpusStatus,
} from '@/lib/sdk'
/**
* Append ?project= to a URL if a projectId is set
*/
function withProject(url: string, projectId?: string): string {
if (!projectId) return url
const separator = url.includes('?') ? '&' : '?'
return `${url}${separator}project=${projectId}`
}
// =============================================================================
// ICONS
// =============================================================================
@@ -179,9 +188,10 @@ interface StepItemProps {
isLocked: boolean
checkpointStatus?: 'passed' | 'failed' | 'warning' | 'pending'
collapsed: boolean
projectId?: string
}
function StepItem({ step, isActive, isCompleted, isLocked, checkpointStatus, collapsed }: StepItemProps) {
function StepItem({ step, isActive, isCompleted, isLocked, checkpointStatus, collapsed, projectId }: StepItemProps) {
const content = (
<div
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
@@ -243,7 +253,7 @@ function StepItem({ step, isActive, isCompleted, isLocked, checkpointStatus, col
}
return (
<Link href={step.url} className="block">
<Link href={withProject(step.url, projectId)} className="block">
{content}
</Link>
)
@@ -259,9 +269,10 @@ interface AdditionalModuleItemProps {
label: string
isActive: boolean
collapsed: boolean
projectId?: string
}
function AdditionalModuleItem({ href, icon, label, isActive, collapsed }: AdditionalModuleItemProps) {
function AdditionalModuleItem({ href, icon, label, isActive, collapsed, projectId }: AdditionalModuleItemProps) {
const isExternal = href.startsWith('http')
const className = `flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
@@ -288,7 +299,7 @@ function AdditionalModuleItem({ href, icon, label, isActive, collapsed }: Additi
}
return (
<Link href={href} className={className} title={collapsed ? label : undefined}>
<Link href={withProject(href, projectId)} className={className} title={collapsed ? label : undefined}>
{icon}
{!collapsed && <span>{label}</span>}
</Link>
@@ -341,7 +352,7 @@ function CorpusStalenessInfo({ ragCorpusStatus }: { ragCorpusStatus: RAGCorpusSt
export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarProps) {
const pathname = usePathname()
const { state, packageCompletion, completionPercentage, getCheckpointStatus, setCustomerType } = useSDK()
const { state, packageCompletion, completionPercentage, getCheckpointStatus, projectId } = useSDK()
const [pendingCRCount, setPendingCRCount] = React.useState(0)
// Poll pending change-request count every 60s
@@ -430,12 +441,10 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
<aside className={`fixed left-0 top-0 h-screen ${collapsed ? 'w-16' : 'w-64'} bg-white border-r border-gray-200 flex flex-col z-40 transition-all duration-300`}>
{/* Header */}
<div className={`p-4 border-b border-gray-200 ${collapsed ? 'flex justify-center' : ''}`}>
<button
onClick={() => {
setCustomerType(null as any)
window.location.href = '/sdk'
}}
<Link
href="/sdk"
className={`flex items-center gap-3 ${collapsed ? 'justify-center' : ''} hover:opacity-80 transition-opacity`}
title="Zurueck zur Projektliste"
>
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-purple-600 to-indigo-600 flex items-center justify-center flex-shrink-0">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -450,10 +459,12 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
{!collapsed && (
<div className="text-left">
<div className="font-bold text-gray-900">AI Compliance</div>
<div className="text-xs text-gray-500">SDK</div>
<div className="text-xs text-gray-500 truncate max-w-[140px]">
{state.projectInfo?.name || 'SDK'}
</div>
</div>
)}
</button>
</Link>
</div>
{/* Overall Progress - hidden when collapsed */}
@@ -504,6 +515,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
isLocked={isStepLocked(step)}
checkpointStatus={getStepCheckpointStatus(step)}
collapsed={collapsed}
projectId={projectId}
/>
))}
</div>
@@ -530,6 +542,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="CE-Compliance (IACE)"
isActive={pathname?.startsWith('/sdk/iace') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
@@ -555,6 +568,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Legal RAG"
isActive={pathname === '/sdk/rag'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/quality"
@@ -571,6 +585,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="AI Quality"
isActive={pathname === '/sdk/quality'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/security-backlog"
@@ -587,6 +602,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Security Backlog"
isActive={pathname === '/sdk/security-backlog'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/compliance-hub"
@@ -599,6 +615,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Compliance Hub"
isActive={pathname === '/sdk/compliance-hub'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/dsms"
@@ -611,6 +628,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="DSMS"
isActive={pathname === '/sdk/dsms'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/sdk-flow"
@@ -623,6 +641,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="SDK Flow"
isActive={pathname === '/sdk/sdk-flow'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/architecture"
@@ -635,6 +654,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Architektur"
isActive={pathname === '/sdk/architecture'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/agents"
@@ -647,6 +667,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Agenten"
isActive={pathname?.startsWith('/sdk/agents') ?? false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/workshop"
@@ -659,6 +680,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Workshop"
isActive={pathname === '/sdk/workshop'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/portfolio"
@@ -671,6 +693,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Portfolio"
isActive={pathname === '/sdk/portfolio'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/roadmap"
@@ -683,6 +706,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Roadmap"
isActive={pathname === '/sdk/roadmap'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/isms"
@@ -695,6 +719,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="ISMS (ISO 27001)"
isActive={pathname === '/sdk/isms'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/audit-llm"
@@ -707,6 +732,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="LLM Audit"
isActive={pathname === '/sdk/audit-llm'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/rbac"
@@ -719,6 +745,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="RBAC Admin"
isActive={pathname === '/sdk/rbac'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/catalog-manager"
@@ -731,6 +758,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Kataloge"
isActive={pathname === '/sdk/catalog-manager'}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="/sdk/api-docs"
@@ -743,9 +771,10 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="API-Referenz"
isActive={pathname === '/sdk/api-docs'}
collapsed={collapsed}
projectId={projectId}
/>
<Link
href="/sdk/change-requests"
href={withProject('/sdk/change-requests', projectId)}
className={`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
collapsed ? 'justify-center' : ''
} ${
@@ -784,6 +813,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="Developer Portal"
isActive={false}
collapsed={collapsed}
projectId={projectId}
/>
<AdditionalModuleItem
href="https://macmini:8011"
@@ -796,6 +826,7 @@ export function SDKSidebar({ collapsed = false, onCollapsedChange }: SDKSidebarP
label="SDK Dokumentation"
isActive={false}
collapsed={collapsed}
projectId={projectId}
/>
</div>
</nav>

View File

@@ -5,7 +5,7 @@
* retry logic, and optimistic locking support.
*/
import { SDKState, CheckpointStatus } from './types'
import { SDKState, CheckpointStatus, ProjectInfo } from './types'
// =============================================================================
// TYPES
@@ -73,16 +73,19 @@ const RETRY_DELAYS = [1000, 2000, 4000] // Exponential backoff
export class SDKApiClient {
private baseUrl: string
private tenantId: string
private projectId: string | undefined
private timeout: number
private abortControllers: Map<string, AbortController> = new Map()
constructor(options: {
baseUrl?: string
tenantId: string
projectId?: string
timeout?: number
}) {
this.baseUrl = options.baseUrl || DEFAULT_BASE_URL
this.tenantId = options.tenantId
this.projectId = options.projectId
this.timeout = options.timeout || DEFAULT_TIMEOUT
}
@@ -188,8 +191,10 @@ export class SDKApiClient {
*/
async getState(): Promise<StateResponse | null> {
try {
const params = new URLSearchParams({ tenantId: this.tenantId })
if (this.projectId) params.set('projectId', this.projectId)
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
`${this.baseUrl}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
`${this.baseUrl}/state?${params.toString()}`,
{
method: 'GET',
headers: {
@@ -228,6 +233,7 @@ export class SDKApiClient {
},
body: JSON.stringify({
tenantId: this.tenantId,
projectId: this.projectId,
state,
version,
}),
@@ -245,8 +251,10 @@ export class SDKApiClient {
* Delete SDK state for the current tenant
*/
async deleteState(): Promise<void> {
const params = new URLSearchParams({ tenantId: this.tenantId })
if (this.projectId) params.set('projectId', this.projectId)
await this.fetchWithRetry<APIResponse<void>>(
`${this.baseUrl}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
`${this.baseUrl}/state?${params.toString()}`,
{
method: 'DELETE',
headers: {
@@ -571,6 +579,107 @@ export class SDKApiClient {
return this.tenantId
}
/**
* Set project ID for multi-project support
*/
setProjectId(projectId: string | undefined): void {
this.projectId = projectId
}
/**
* Get current project ID
*/
getProjectId(): string | undefined {
return this.projectId
}
// ---------------------------------------------------------------------------
// Public Methods - Project Management
// ---------------------------------------------------------------------------
/**
* List all projects for the current tenant
*/
async listProjects(): Promise<{ projects: ProjectInfo[]; total: number }> {
const response = await this.fetchWithRetry<{ projects: ProjectInfo[]; total: number }>(
`${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
}
)
return response
}
/**
* Create a new project
*/
async createProject(data: {
name: string
description?: string
customer_type?: string
copy_from_project_id?: string
}): Promise<ProjectInfo> {
const response = await this.fetchWithRetry<ProjectInfo>(
`${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
body: JSON.stringify({
...data,
tenant_id: this.tenantId,
}),
}
)
return response
}
/**
* Update an existing project
*/
async updateProject(projectId: string, data: {
name?: string
description?: string
}): Promise<ProjectInfo> {
const response = await this.fetchWithRetry<ProjectInfo>(
`${this.baseUrl}/projects/${projectId}?tenant_id=${encodeURIComponent(this.tenantId)}`,
{
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
body: JSON.stringify({
...data,
tenant_id: this.tenantId,
}),
}
)
return response
}
/**
* Archive (soft-delete) a project
*/
async archiveProject(projectId: string): Promise<void> {
await this.fetchWithRetry<{ success: boolean }>(
`${this.baseUrl}/projects/${projectId}?tenant_id=${encodeURIComponent(this.tenantId)}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-Tenant-ID': this.tenantId,
},
}
)
}
/**
* Health check
*/
@@ -594,19 +703,23 @@ export class SDKApiClient {
let clientInstance: SDKApiClient | null = null
export function getSDKApiClient(tenantId?: string): SDKApiClient {
export function getSDKApiClient(tenantId?: string, projectId?: string): SDKApiClient {
if (!clientInstance && !tenantId) {
throw new Error('SDKApiClient not initialized. Provide tenantId on first call.')
}
if (!clientInstance && tenantId) {
clientInstance = new SDKApiClient({ tenantId })
clientInstance = new SDKApiClient({ tenantId, projectId })
}
if (tenantId && clientInstance && clientInstance.getTenantId() !== tenantId) {
clientInstance.setTenantId(tenantId)
}
if (clientInstance) {
clientInstance.setProjectId(projectId)
}
return clientInstance!
}

View File

@@ -16,6 +16,7 @@ import {
ImportedDocument,
GapAnalysis,
SDKPackageId,
ProjectInfo,
SDK_STEPS,
SDK_PACKAGES,
getStepById,
@@ -57,6 +58,10 @@ const initialState: SDKState = {
userId: '',
subscription: 'PROFESSIONAL',
// Project Context
projectId: '',
projectInfo: null,
// Customer Type
customerType: null,
@@ -548,6 +553,13 @@ interface SDKContextValue {
// Command Bar
isCommandBarOpen: boolean
setCommandBarOpen: (open: boolean) => void
// Project Management
projectId: string | undefined
createProject: (name: string, customerType: CustomerType, copyFromProjectId?: string) => Promise<ProjectInfo>
listProjects: () => Promise<ProjectInfo[]>
switchProject: (projectId: string) => void
archiveProject: (projectId: string) => Promise<void>
}
const SDKContext = createContext<SDKContextValue | null>(null)
@@ -562,6 +574,7 @@ interface SDKProviderProps {
children: React.ReactNode
tenantId?: string
userId?: string
projectId?: string
enableBackendSync?: boolean
}
@@ -569,6 +582,7 @@ export function SDKProvider({
children,
tenantId = 'default',
userId = 'default',
projectId,
enableBackendSync = false,
}: SDKProviderProps) {
const router = useRouter()
@@ -577,6 +591,7 @@ export function SDKProvider({
...initialState,
tenantId,
userId,
projectId: projectId || '',
})
const [isCommandBarOpen, setCommandBarOpen] = React.useState(false)
const [isInitialized, setIsInitialized] = React.useState(false)
@@ -597,7 +612,7 @@ export function SDKProvider({
// Initialize API client and sync manager
useEffect(() => {
if (enableBackendSync && typeof window !== 'undefined') {
apiClientRef.current = getSDKApiClient(tenantId)
apiClientRef.current = getSDKApiClient(tenantId, projectId)
syncManagerRef.current = createStateSyncManager(
apiClientRef.current,
@@ -640,7 +655,8 @@ export function SDKProvider({
setIsOnline(true)
setSyncState(prev => ({ ...prev, status: 'idle' }))
},
}
},
projectId
)
}
@@ -654,7 +670,7 @@ export function SDKProvider({
apiClientRef.current = null
}
}
}, [enableBackendSync, tenantId])
}, [enableBackendSync, tenantId, projectId])
// Sync current step with URL
useEffect(() => {
@@ -666,12 +682,17 @@ export function SDKProvider({
}
}, [pathname, state.currentStep])
// Storage key — per tenant+project
const storageKey = projectId
? `${SDK_STORAGE_KEY}-${tenantId}-${projectId}`
: `${SDK_STORAGE_KEY}-${tenantId}`
// Load state on mount (localStorage first, then server)
useEffect(() => {
const loadInitialState = async () => {
try {
// First, try loading from localStorage
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
if (parsed.lastModified) {
@@ -699,7 +720,7 @@ export function SDKProvider({
}
loadInitialState()
}, [tenantId, enableBackendSync])
}, [tenantId, projectId, enableBackendSync, storageKey])
// Auto-save to localStorage and sync to server
useEffect(() => {
@@ -707,8 +728,8 @@ export function SDKProvider({
const saveTimeout = setTimeout(() => {
try {
// Save to localStorage
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
// Save to localStorage (per tenant+project)
localStorage.setItem(storageKey, JSON.stringify(state))
// Sync to server if backend sync is enabled
if (enableBackendSync && syncManagerRef.current) {
@@ -720,7 +741,7 @@ export function SDKProvider({
}, 1000)
return () => clearTimeout(saveTimeout)
}, [state, tenantId, isInitialized, enableBackendSync])
}, [state, tenantId, projectId, isInitialized, enableBackendSync, storageKey])
// Keyboard shortcut for Command Bar
useEffect(() => {
@@ -746,10 +767,11 @@ export function SDKProvider({
const step = getStepById(stepId)
if (step) {
dispatch({ type: 'SET_CURRENT_STEP', payload: stepId })
router.push(step.url)
const url = projectId ? `${step.url}?project=${projectId}` : step.url
router.push(url)
}
},
[router]
[router, projectId]
)
const goToNextStep = useCallback(() => {
@@ -992,7 +1014,7 @@ export function SDKProvider({
}
// Also save to localStorage for immediate availability
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(demoState))
localStorage.setItem(storageKey, JSON.stringify(demoState))
// Update local state
dispatch({ type: 'LOAD_DEMO_DATA', payload: demoState })
@@ -1005,7 +1027,7 @@ export function SDKProvider({
message: error instanceof Error ? error.message : 'Unbekannter Fehler beim Laden der Demo-Daten',
}
}
}, [tenantId, userId, enableBackendSync])
}, [tenantId, userId, enableBackendSync, storageKey])
// Clear demo data
const clearDemoData = useCallback(async (): Promise<boolean> => {
@@ -1016,7 +1038,7 @@ export function SDKProvider({
}
// Clear localStorage
localStorage.removeItem(`${SDK_STORAGE_KEY}-${tenantId}`)
localStorage.removeItem(storageKey)
// Reset local state
dispatch({ type: 'RESET_STATE' })
@@ -1026,7 +1048,7 @@ export function SDKProvider({
console.error('Failed to clear demo data:', error)
return false
}
}, [tenantId, enableBackendSync])
}, [storageKey, enableBackendSync])
// Check if demo data is loaded (has use cases with demo- prefix)
const isDemoDataLoaded = useMemo(() => {
@@ -1036,7 +1058,7 @@ export function SDKProvider({
// Persistence
const saveState = useCallback(async (): Promise<void> => {
try {
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
localStorage.setItem(storageKey, JSON.stringify(state))
if (enableBackendSync && syncManagerRef.current) {
await syncManagerRef.current.forcSync(state)
@@ -1045,7 +1067,7 @@ export function SDKProvider({
console.error('Failed to save SDK state:', error)
throw error
}
}, [state, tenantId, enableBackendSync])
}, [state, storageKey, enableBackendSync])
const loadState = useCallback(async (): Promise<void> => {
try {
@@ -1058,7 +1080,7 @@ export function SDKProvider({
}
// Fall back to localStorage
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
const stored = localStorage.getItem(storageKey)
if (stored) {
const parsed = JSON.parse(stored)
dispatch({ type: 'SET_STATE', payload: parsed })
@@ -1067,7 +1089,7 @@ export function SDKProvider({
console.error('Failed to load SDK state:', error)
throw error
}
}, [tenantId, enableBackendSync])
}, [storageKey, enableBackendSync])
// Force sync to server
const forceSyncToServer = useCallback(async (): Promise<void> => {
@@ -1076,6 +1098,49 @@ export function SDKProvider({
}
}, [state, enableBackendSync])
// Project Management
const createProject = useCallback(
async (name: string, customerType: CustomerType, copyFromProjectId?: string): Promise<ProjectInfo> => {
if (!apiClientRef.current) {
throw new Error('Backend sync not enabled')
}
return apiClientRef.current.createProject({
name,
customer_type: customerType,
copy_from_project_id: copyFromProjectId,
})
},
[]
)
const listProjectsFn = useCallback(async (): Promise<ProjectInfo[]> => {
if (!apiClientRef.current) {
return []
}
const result = await apiClientRef.current.listProjects()
return result.projects
}, [])
const switchProject = useCallback(
(newProjectId: string) => {
// Navigate to the SDK dashboard with the new project
const params = new URLSearchParams(window.location.search)
params.set('project', newProjectId)
router.push(`/sdk?${params.toString()}`)
},
[router]
)
const archiveProjectFn = useCallback(
async (archiveId: string): Promise<void> => {
if (!apiClientRef.current) {
throw new Error('Backend sync not enabled')
}
await apiClientRef.current.archiveProject(archiveId)
},
[]
)
// Export
const exportState = useCallback(
async (format: 'json' | 'pdf' | 'zip'): Promise<Blob> => {
@@ -1136,6 +1201,11 @@ export function SDKProvider({
exportState,
isCommandBarOpen,
setCommandBarOpen,
projectId,
createProject,
listProjects: listProjectsFn,
switchProject,
archiveProject: archiveProjectFn,
}
return <SDKContext.Provider value={value}>{children}</SDKContext.Provider>

View File

@@ -59,6 +59,7 @@ const DEFAULT_MAX_RETRIES = 3
export class StateSyncManager {
private apiClient: SDKApiClient
private tenantId: string
private projectId: string | undefined
private options: Required<SyncOptions>
private callbacks: SyncCallbacks
private syncState: SyncState
@@ -71,10 +72,12 @@ export class StateSyncManager {
apiClient: SDKApiClient,
tenantId: string,
options: SyncOptions = {},
callbacks: SyncCallbacks = {}
callbacks: SyncCallbacks = {},
projectId?: string
) {
this.apiClient = apiClient
this.tenantId = tenantId
this.projectId = projectId
this.callbacks = callbacks
this.options = {
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
@@ -105,7 +108,10 @@ export class StateSyncManager {
}
try {
this.broadcastChannel = new BroadcastChannel(`${SYNC_CHANNEL}-${this.tenantId}`)
const channelName = this.projectId
? `${SYNC_CHANNEL}-${this.tenantId}-${this.projectId}`
: `${SYNC_CHANNEL}-${this.tenantId}`
this.broadcastChannel = new BroadcastChannel(channelName)
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this)
} catch (error) {
console.warn('BroadcastChannel not available:', error)
@@ -209,7 +215,9 @@ export class StateSyncManager {
// ---------------------------------------------------------------------------
private getStorageKey(): string {
return `${STORAGE_KEY_PREFIX}-${this.tenantId}`
return this.projectId
? `${STORAGE_KEY_PREFIX}-${this.tenantId}-${this.projectId}`
: `${STORAGE_KEY_PREFIX}-${this.tenantId}`
}
saveToLocalStorage(state: SDKState): void {
@@ -476,7 +484,8 @@ export function createStateSyncManager(
apiClient: SDKApiClient,
tenantId: string,
options?: SyncOptions,
callbacks?: SyncCallbacks
callbacks?: SyncCallbacks,
projectId?: string
): StateSyncManager {
return new StateSyncManager(apiClient, tenantId, options, callbacks)
return new StateSyncManager(apiClient, tenantId, options, callbacks, projectId)
}

View File

@@ -23,6 +23,22 @@ export type SDKPackageId = 'vorbereitung' | 'analyse' | 'dokumentation' | 'recht
export type CustomerType = 'new' | 'existing'
// =============================================================================
// PROJECT INFO (Multi-Projekt-Architektur)
// =============================================================================
export interface ProjectInfo {
id: string
name: string
description: string
customerType: CustomerType
status: 'active' | 'archived'
projectVersion: number
completionPercentage: number
createdAt: string
updatedAt: string
}
// =============================================================================
// COMPANY PROFILE (Business Context - collected before use cases)
// =============================================================================
@@ -1497,6 +1513,10 @@ export interface SDKState {
userId: string
subscription: SubscriptionTier
// Project Context (Multi-Projekt)
projectId: string
projectInfo: ProjectInfo | null
// Customer Type (new vs existing)
customerType: CustomerType | null

View File

@@ -288,6 +288,22 @@ curl -X POST http://localhost:8000/api/v1/compliance/scraper/fetch \
2. Re-Seed ausfuehren
3. Mappings werden automatisch generiert
## Multi-Projekt-Architektur (Migration 039)
Jeder Tenant kann mehrere Compliance-Projekte anlegen. Neue Tabelle `compliance_projects`, `sdk_states` erweitert um `project_id`.
### Projekt-API Endpoints
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/api/v1/projects` | Alle Projekte des Tenants |
| POST | `/api/v1/projects` | Neues Projekt erstellen |
| GET | `/api/v1/projects/{id}` | Einzelnes Projekt |
| PATCH | `/api/v1/projects/{id}` | Projekt aktualisieren |
| DELETE | `/api/v1/projects/{id}` | Projekt archivieren |
Siehe `compliance/api/project_routes.py` und `migrations/039_compliance_projects.sql`.
## Changelog
### v2.0 (2026-01-17)

View File

@@ -31,6 +31,7 @@ from .vendor_compliance_routes import router as vendor_compliance_router
from .incident_routes import router as incident_router
from .change_request_routes import router as change_request_router
from .generation_routes import router as generation_router
from .project_routes import router as project_router
# Include sub-routers
router.include_router(audit_router)
@@ -63,6 +64,7 @@ router.include_router(vendor_compliance_router)
router.include_router(incident_router)
router.include_router(change_request_router)
router.include_router(generation_router)
router.include_router(project_router)
__all__ = [
"router",
@@ -95,4 +97,5 @@ __all__ = [
"incident_router",
"change_request_router",
"generation_router",
"project_router",
]

View File

@@ -0,0 +1,300 @@
"""
FastAPI routes for Compliance Projects (Multi-Projekt-Architektur).
Endpoints:
- GET /v1/projects → List all projects for a tenant
- POST /v1/projects → Create a new project
- GET /v1/projects/{project_id} → Get a single project
- PATCH /v1/projects/{project_id} → Update project (name, description)
- DELETE /v1/projects/{project_id} → Archive project (soft delete)
"""
import json
import logging
from typing import Optional
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from sqlalchemy import text
from database import SessionLocal
from .tenant_utils import get_tenant_id
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/v1/projects", tags=["projects"])
# =============================================================================
# REQUEST/RESPONSE MODELS
# =============================================================================
class CreateProjectRequest(BaseModel):
name: str
description: str = ""
customer_type: str = "new" # 'new' | 'existing'
copy_from_project_id: Optional[str] = None # Copy company profile from existing project
class UpdateProjectRequest(BaseModel):
name: Optional[str] = None
description: Optional[str] = None
class ProjectResponse(BaseModel):
id: str
tenant_id: str
name: str
description: str
customer_type: str
status: str
project_version: int
completion_percentage: int
created_at: str
updated_at: str
# =============================================================================
# HELPERS
# =============================================================================
def _row_to_response(row) -> dict:
"""Convert a DB row to ProjectResponse dict."""
return {
"id": str(row.id),
"tenant_id": row.tenant_id,
"name": row.name,
"description": row.description or "",
"customer_type": row.customer_type or "new",
"status": row.status or "active",
"project_version": row.project_version or 1,
"completion_percentage": row.completion_percentage or 0,
"created_at": row.created_at.isoformat() if row.created_at else "",
"updated_at": row.updated_at.isoformat() if row.updated_at else "",
}
# =============================================================================
# ENDPOINTS
# =============================================================================
@router.get("")
async def list_projects(
tenant_id: str = Depends(get_tenant_id),
include_archived: bool = False,
):
"""List all projects for the tenant."""
db = SessionLocal()
try:
if include_archived:
query = text("""
SELECT id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
FROM compliance_projects
WHERE tenant_id = :tenant_id AND status != 'deleted'
ORDER BY created_at DESC
""")
else:
query = text("""
SELECT id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
FROM compliance_projects
WHERE tenant_id = :tenant_id AND status = 'active'
ORDER BY created_at DESC
""")
result = db.execute(query, {"tenant_id": tenant_id})
rows = result.fetchall()
return {
"projects": [_row_to_response(row) for row in rows],
"total": len(rows),
}
finally:
db.close()
@router.post("", status_code=201)
async def create_project(
body: CreateProjectRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Create a new compliance project.
Optionally copies the company profile (companyProfile) from an existing
project's sdk_states into the new project's state. This allows a tenant
to start a new project for a subsidiary with the same base data.
"""
db = SessionLocal()
try:
# Create the project row
result = db.execute(
text("""
INSERT INTO compliance_projects
(tenant_id, name, description, customer_type, status)
VALUES
(:tenant_id, :name, :description, :customer_type, 'active')
RETURNING id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
"""),
{
"tenant_id": tenant_id,
"name": body.name,
"description": body.description,
"customer_type": body.customer_type,
},
)
project_row = result.fetchone()
project_id = str(project_row.id)
# Build initial SDK state
initial_state = {
"version": "1.0.0",
"projectVersion": 1,
"tenantId": tenant_id,
"projectId": project_id,
"customerType": body.customer_type,
"companyProfile": None,
}
# If copy_from_project_id is provided, copy company profile
if body.copy_from_project_id:
source = db.execute(
text("""
SELECT state FROM sdk_states
WHERE tenant_id = :tenant_id AND project_id = :project_id
"""),
{
"tenant_id": tenant_id,
"project_id": body.copy_from_project_id,
},
).fetchone()
if source and source.state:
source_state = source.state if isinstance(source.state, dict) else json.loads(source.state)
if "companyProfile" in source_state:
initial_state["companyProfile"] = source_state["companyProfile"]
if "customerType" in source_state:
initial_state["customerType"] = source_state["customerType"]
# Create the sdk_states row for this project
db.execute(
text("""
INSERT INTO sdk_states (tenant_id, project_id, state, version, created_at, updated_at)
VALUES (:tenant_id, :project_id, :state::jsonb, 1, NOW(), NOW())
"""),
{
"tenant_id": tenant_id,
"project_id": project_id,
"state": json.dumps(initial_state),
},
)
db.commit()
logger.info("Created project %s for tenant %s", project_id, tenant_id)
return _row_to_response(project_row)
except Exception:
db.rollback()
raise
finally:
db.close()
@router.get("/{project_id}")
async def get_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Get a single project by ID (tenant-scoped)."""
db = SessionLocal()
try:
result = db.execute(
text("""
SELECT id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
FROM compliance_projects
WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted'
"""),
{"project_id": project_id, "tenant_id": tenant_id},
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found")
return _row_to_response(row)
finally:
db.close()
@router.patch("/{project_id}")
async def update_project(
project_id: str,
body: UpdateProjectRequest,
tenant_id: str = Depends(get_tenant_id),
):
"""Update project name/description."""
db = SessionLocal()
try:
# Build SET clause dynamically
updates = {}
set_parts = ["updated_at = NOW()"]
if body.name is not None:
set_parts.append("name = :name")
updates["name"] = body.name
if body.description is not None:
set_parts.append("description = :description")
updates["description"] = body.description
updates["project_id"] = project_id
updates["tenant_id"] = tenant_id
result = db.execute(
text(f"""
UPDATE compliance_projects
SET {', '.join(set_parts)}
WHERE id = :project_id AND tenant_id = :tenant_id AND status != 'deleted'
RETURNING id, tenant_id, name, description, customer_type, status,
project_version, completion_percentage, created_at, updated_at
"""),
updates,
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found")
db.commit()
return _row_to_response(row)
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()
@router.delete("/{project_id}")
async def archive_project(
project_id: str,
tenant_id: str = Depends(get_tenant_id),
):
"""Soft-delete (archive) a project."""
db = SessionLocal()
try:
result = db.execute(
text("""
UPDATE compliance_projects
SET status = 'archived', archived_at = NOW(), updated_at = NOW()
WHERE id = :project_id AND tenant_id = :tenant_id AND status = 'active'
RETURNING id
"""),
{"project_id": project_id, "tenant_id": tenant_id},
)
row = result.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Project not found or already archived")
db.commit()
return {"success": True, "id": str(row.id), "status": "archived"}
except HTTPException:
raise
except Exception:
db.rollback()
raise
finally:
db.close()

View File

@@ -0,0 +1,95 @@
-- Migration 039: Multi-Projekt-Architektur
-- Enables multiple compliance projects per tenant (Cloud-Ready Multi-Tenancy)
--
-- Changes:
-- 1. New table compliance_projects (project metadata)
-- 2. sdk_states: Drop UNIQUE(tenant_id), add project_id column with FK
-- 3. Migrate existing data: Create default project for each existing sdk_states row
-- =============================================================================
-- 1. New table: compliance_projects
-- =============================================================================
CREATE TABLE IF NOT EXISTS compliance_projects (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id VARCHAR(255) NOT NULL,
name VARCHAR(500) NOT NULL,
description TEXT DEFAULT '',
customer_type VARCHAR(20) DEFAULT 'new', -- 'new' | 'existing'
status VARCHAR(20) DEFAULT 'active', -- 'active' | 'archived' | 'deleted'
project_version INTEGER DEFAULT 1,
completion_percentage INTEGER DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
archived_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_compliance_projects_tenant ON compliance_projects(tenant_id);
CREATE INDEX IF NOT EXISTS idx_compliance_projects_status ON compliance_projects(tenant_id, status);
-- =============================================================================
-- 2. sdk_states: Add project_id, adjust constraints
-- =============================================================================
-- Drop the old UNIQUE constraint on tenant_id (allows multiple states per tenant)
ALTER TABLE sdk_states DROP CONSTRAINT IF EXISTS sdk_states_tenant_id_key;
-- Add project_id column (nullable initially for migration)
ALTER TABLE sdk_states ADD COLUMN IF NOT EXISTS project_id UUID;
-- =============================================================================
-- 3. Data migration: Create default projects for existing states
-- =============================================================================
-- For each existing sdk_states row without a project, create a default project
INSERT INTO compliance_projects (id, tenant_id, name, customer_type, status)
SELECT
gen_random_uuid(),
s.tenant_id,
COALESCE(s.state->'companyProfile'->>'companyName', 'Projekt 1'),
COALESCE(s.state->>'customerType', 'new'),
'active'
FROM sdk_states s
WHERE s.project_id IS NULL
ON CONFLICT DO NOTHING;
-- Link existing states to their newly created projects
UPDATE sdk_states s
SET project_id = p.id
FROM compliance_projects p
WHERE s.tenant_id = p.tenant_id AND s.project_id IS NULL;
-- =============================================================================
-- 4. Add constraints after migration
-- =============================================================================
-- Make project_id NOT NULL now that all rows have a value
-- (Only if there are no NULL values remaining — safe guard)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM sdk_states WHERE project_id IS NULL) THEN
ALTER TABLE sdk_states ALTER COLUMN project_id SET NOT NULL;
END IF;
END $$;
-- Unique constraint: one state per (tenant, project)
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'uq_sdk_states_tenant_project'
) THEN
ALTER TABLE sdk_states ADD CONSTRAINT uq_sdk_states_tenant_project
UNIQUE (tenant_id, project_id);
END IF;
END $$;
-- Foreign key to compliance_projects
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint WHERE conname = 'fk_sdk_states_project'
) THEN
ALTER TABLE sdk_states ADD CONSTRAINT fk_sdk_states_project
FOREIGN KEY (project_id) REFERENCES compliance_projects(id) ON DELETE CASCADE;
END IF;
END $$;

View File

@@ -0,0 +1,189 @@
"""Tests for Compliance Project routes (project_routes.py)."""
import json
import pytest
from unittest.mock import MagicMock, patch, PropertyMock
from datetime import datetime, timezone
from compliance.api.project_routes import (
CreateProjectRequest,
UpdateProjectRequest,
_row_to_response,
)
class TestCreateProjectRequest:
"""Tests for request model validation."""
def test_default_values(self):
req = CreateProjectRequest(name="Test Project")
assert req.name == "Test Project"
assert req.description == ""
assert req.customer_type == "new"
assert req.copy_from_project_id is None
def test_full_values(self):
req = CreateProjectRequest(
name="KI-Produkt X",
description="DSGVO-Compliance fuer Produkt X",
customer_type="existing",
copy_from_project_id="uuid-123",
)
assert req.name == "KI-Produkt X"
assert req.description == "DSGVO-Compliance fuer Produkt X"
assert req.customer_type == "existing"
assert req.copy_from_project_id == "uuid-123"
def test_name_required(self):
with pytest.raises(Exception):
CreateProjectRequest()
def test_serialization(self):
req = CreateProjectRequest(name="Test")
data = req.model_dump()
assert data["name"] == "Test"
assert data["customer_type"] == "new"
class TestUpdateProjectRequest:
"""Tests for update model."""
def test_empty_update(self):
req = UpdateProjectRequest()
assert req.name is None
assert req.description is None
def test_partial_update(self):
req = UpdateProjectRequest(name="New Name")
assert req.name == "New Name"
assert req.description is None
class TestRowToResponse:
"""Tests for DB row to response conversion."""
def _make_row(self, **overrides):
now = datetime.now(timezone.utc)
defaults = {
"id": "uuid-project-1",
"tenant_id": "uuid-tenant-1",
"name": "Test Project",
"description": "A test project",
"customer_type": "new",
"status": "active",
"project_version": 1,
"completion_percentage": 0,
"created_at": now,
"updated_at": now,
}
defaults.update(overrides)
mock = MagicMock()
for key, value in defaults.items():
setattr(mock, key, value)
return mock
def test_basic_conversion(self):
row = self._make_row()
result = _row_to_response(row)
assert result["id"] == "uuid-project-1"
assert result["tenant_id"] == "uuid-tenant-1"
assert result["name"] == "Test Project"
assert result["description"] == "A test project"
assert result["customer_type"] == "new"
assert result["status"] == "active"
assert result["project_version"] == 1
assert result["completion_percentage"] == 0
def test_null_description(self):
row = self._make_row(description=None)
result = _row_to_response(row)
assert result["description"] == ""
def test_null_customer_type(self):
row = self._make_row(customer_type=None)
result = _row_to_response(row)
assert result["customer_type"] == "new"
def test_null_status(self):
row = self._make_row(status=None)
result = _row_to_response(row)
assert result["status"] == "active"
def test_created_at_iso(self):
dt = datetime(2026, 3, 9, 12, 0, 0, tzinfo=timezone.utc)
row = self._make_row(created_at=dt)
result = _row_to_response(row)
assert "2026-03-09" in result["created_at"]
def test_archived_project(self):
row = self._make_row(status="archived", completion_percentage=75)
result = _row_to_response(row)
assert result["status"] == "archived"
assert result["completion_percentage"] == 75
class TestCreateProjectCopiesProfile:
"""Tests that creating a project with copy_from_project_id works."""
def test_copy_request_model(self):
req = CreateProjectRequest(
name="Tochter GmbH",
customer_type="existing",
copy_from_project_id="source-uuid-123",
)
assert req.copy_from_project_id == "source-uuid-123"
def test_no_copy_request_model(self):
req = CreateProjectRequest(name="Brand New")
assert req.copy_from_project_id is None
class TestTenantIsolation:
"""Tests verifying tenant isolation is enforced in query patterns."""
def test_list_query_includes_tenant_filter(self):
"""Verify that our SQL queries always filter by tenant_id."""
import inspect
from compliance.api.project_routes import list_projects
source = inspect.getsource(list_projects)
assert "tenant_id" in source
assert "WHERE" in source
def test_get_query_includes_tenant_filter(self):
import inspect
from compliance.api.project_routes import get_project
source = inspect.getsource(get_project)
assert "tenant_id" in source
assert "project_id" in source
def test_archive_query_includes_tenant_filter(self):
import inspect
from compliance.api.project_routes import archive_project
source = inspect.getsource(archive_project)
assert "tenant_id" in source
def test_update_query_includes_tenant_filter(self):
import inspect
from compliance.api.project_routes import update_project
source = inspect.getsource(update_project)
assert "tenant_id" in source
class TestStateIsolation:
"""Tests verifying state isolation between projects."""
def test_create_project_creates_sdk_state(self):
"""Verify create_project function inserts into sdk_states."""
import inspect
from compliance.api.project_routes import create_project
source = inspect.getsource(create_project)
assert "sdk_states" in source
assert "project_id" in source
def test_create_project_copies_company_profile(self):
"""Verify create_project copies companyProfile when copy_from specified."""
import inspect
from compliance.api.project_routes import create_project
source = inspect.getsource(create_project)
assert "copy_from_project_id" in source
assert "companyProfile" in source

View File

@@ -0,0 +1,190 @@
# Multi-Projekt-Architektur
Jeder Tenant kann mehrere Compliance-Projekte anlegen (z.B. verschiedene Produkte, Tochterunternehmen). CompanyProfile ist **pro Projekt** — nicht tenant-weit.
## Uebersicht
```mermaid
graph TD
T[Tenant] --> P1[Projekt A: KI-Produkt X]
T --> P2[Projekt B: SaaS API]
T --> P3[Projekt C: Tochter GmbH]
P1 --> S1[SDK State A]
P2 --> S2[SDK State B]
P3 --> S3[SDK State C]
S1 --> CP1[CompanyProfile A]
S2 --> CP2[CompanyProfile B]
S3 --> CP3[CompanyProfile C]
```
## Datenmodell
### compliance_projects
| Spalte | Typ | Beschreibung |
|--------|-----|--------------|
| `id` | UUID | Primaerschluessel |
| `tenant_id` | VARCHAR(255) | Tenant-Zuordnung |
| `name` | VARCHAR(500) | Projektname |
| `description` | TEXT | Beschreibung |
| `customer_type` | VARCHAR(20) | `'new'` oder `'existing'` |
| `status` | VARCHAR(20) | `'active'`, `'archived'`, `'deleted'` |
| `project_version` | INTEGER | Versionszaehler |
| `completion_percentage` | INTEGER | Fortschritt (0-100) |
| `created_at` | TIMESTAMPTZ | Erstellungszeitpunkt |
| `updated_at` | TIMESTAMPTZ | Letzte Aenderung |
| `archived_at` | TIMESTAMPTZ | Archivierungszeitpunkt |
### sdk_states (erweitert)
- UNIQUE-Constraint auf `(tenant_id, project_id)` statt nur `tenant_id`
- `project_id UUID NOT NULL` — FK auf `compliance_projects(id) ON DELETE CASCADE`
### Daten-Ownership
| Daten | Scope | Speicherort |
|-------|-------|-------------|
| Firmenname, Rechtsform, DSB, Standorte | Pro Projekt | `sdk_states.state.companyProfile` |
| Projektname, Typ, Status | Projekt | `compliance_projects` |
| SDK State (VVT, DSFA, TOM, etc.) | Projekt | `sdk_states` (JSONB) |
## Backend API
Alle Endpoints sind tenant-isoliert via `X-Tenant-ID` Header.
### Endpoints
| Method | Endpoint | Beschreibung |
|--------|----------|--------------|
| GET | `/api/v1/projects` | Alle aktiven Projekte des Tenants |
| POST | `/api/v1/projects` | Neues Projekt erstellen |
| GET | `/api/v1/projects/{id}` | Einzelnes Projekt laden |
| PATCH | `/api/v1/projects/{id}` | Projekt aktualisieren |
| DELETE | `/api/v1/projects/{id}` | Projekt archivieren (Soft Delete) |
### Projekt erstellen
```json
POST /api/v1/projects
{
"name": "KI-Produkt X",
"description": "DSGVO-Compliance fuer Produkt X",
"customer_type": "existing",
"copy_from_project_id": "uuid-123"
}
```
**Response (201):**
```json
{
"id": "uuid-new",
"tenant_id": "uuid-tenant",
"name": "KI-Produkt X",
"description": "DSGVO-Compliance fuer Produkt X",
"customer_type": "existing",
"status": "active",
"project_version": 1,
"completion_percentage": 0,
"created_at": "2026-03-09T12:00:00Z",
"updated_at": "2026-03-09T12:00:00Z"
}
```
### Stammdaten-Kopie
Wenn `copy_from_project_id` angegeben, kopiert das Backend `companyProfile` aus dem Quell-State in den neuen State. Die kopierten Daten sind danach **unabhaengig editierbar**.
Anwendungsfall: Konzern mit Tochterfirmen — gleiche Rechtsform, aber unterschiedliche Adresse/Mitarbeiterzahl.
### Projekt archivieren
```
DELETE /api/v1/projects/{id}
```
Setzt `status='archived'` und `archived_at=NOW()`. Archivierte Projekte erscheinen nicht in der Standardliste (nur mit `?include_archived=true`).
## Frontend
### URL-Schema
```
/sdk → Projektliste (ProjectSelector)
/sdk?project={uuid} → Dashboard im Projekt-Kontext
/sdk/vvt?project={uuid} → VVT im Projekt-Kontext
/sdk/dsfa?project={uuid} → DSFA im Projekt-Kontext
```
Alle internen Links enthalten automatisch `?project=`.
### Komponenten
| Komponente | Datei | Beschreibung |
|------------|-------|--------------|
| `ProjectSelector` | `components/sdk/ProjectSelector/ProjectSelector.tsx` | Projektliste + Erstellen-Dialog |
| `ProjectCard` | (gleiche Datei) | Einzelne Projektkarte |
| `CreateProjectDialog` | (gleiche Datei) | Modal fuer neues Projekt |
### State-Isolation (Multi-Tab)
- **BroadcastChannel:** `sdk-state-sync-{tenantId}-{projectId}` — pro Projekt
- **localStorage:** `ai-compliance-sdk-state-{tenantId}-{projectId}` — pro Projekt
- Tab A (Projekt X) und Tab B (Projekt Y) interferieren nicht
### SDKProvider
```typescript
<SDKProvider
enableBackendSync={true}
projectId={searchParams.get('project')}
>
{children}
</SDKProvider>
```
### Context-Methoden
```typescript
const {
createProject, // (name, customerType) => Promise<ProjectInfo>
listProjects, // () => Promise<ProjectInfo[]>
switchProject, // (projectId) => void (navigiert zu ?project=)
archiveProject, // (projectId) => Promise<void>
projectId, // aktuelles Projekt-UUID
} = useSDK()
```
## Migration (039)
**Datei:** `backend-compliance/migrations/039_compliance_projects.sql`
1. Erstellt `compliance_projects` Tabelle
2. Entfernt `UNIQUE(tenant_id)` von `sdk_states`
3. Fuegt `project_id UUID` zu `sdk_states` hinzu
4. Migriert bestehende Daten (Default-Projekt pro existierendem State)
5. Setzt `project_id` auf `NOT NULL`
### Migration ausfuehren
```bash
ssh macmini "/usr/local/bin/docker exec bp-compliance-backend \
psql \$COMPLIANCE_DATABASE_URL -f /app/migrations/039_compliance_projects.sql"
```
## Tests
```bash
# Backend-Tests
ssh macmini "/usr/local/bin/docker exec bp-compliance-backend \
pytest tests/test_project_routes.py -v"
```
| Test | Beschreibung |
|------|--------------|
| `TestCreateProjectRequest` | Model-Validierung (Name, Defaults) |
| `TestUpdateProjectRequest` | Partial Update Model |
| `TestRowToResponse` | DB-Row-zu-Response Konvertierung |
| `TestCreateProjectCopiesProfile` | Copy-Request Model |
| `TestTenantIsolation` | SQL-Queries filtern nach tenant_id |
| `TestStateIsolation` | sdk_states + companyProfile pro Projekt |

View File

@@ -95,6 +95,7 @@ nav:
- Training Engine (CP-TRAIN): services/sdk-modules/training.md
- SDK Workflow & Seq-Nummern: services/sdk-modules/sdk-workflow.md
- Multi-Tenancy: services/sdk-modules/multi-tenancy.md
- Multi-Projekt: services/sdk-modules/multi-project.md
- Stammdaten / Company Profile: services/sdk-modules/stammdaten.md
- Dokument-Versionierung: services/sdk-modules/versionierung.md
- Change-Request System (CP-CR): services/sdk-modules/change-requests.md