diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index b18ba44..cefc1d8 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts b/admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts new file mode 100644 index 0000000..48dccea --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/projects/[projectId]/route.ts @@ -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 } + ) + } +} diff --git a/admin-compliance/app/api/sdk/v1/projects/route.ts b/admin-compliance/app/api/sdk/v1/projects/route.ts new file mode 100644 index 0000000..5f67083 --- /dev/null +++ b/admin-compliance/app/api/sdk/v1/projects/route.ts @@ -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 } + ) + } +} diff --git a/admin-compliance/app/api/sdk/v1/state/route.ts b/admin-compliance/app/api/sdk/v1/state/route.ts index 6623c51..93b1b19 100644 --- a/admin-compliance/app/api/sdk/v1/state/route.ts +++ b/admin-compliance/app/api/sdk/v1/state/route.ts @@ -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 - save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number): Promise - delete(tenantId: string): Promise + get(tenantId: string, projectId?: string): Promise + save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise + delete(tenantId: string, projectId?: string): Promise } class InMemoryStateStore implements StateStore { private store: Map = new Map() - async get(tenantId: string): Promise { - 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 { + return this.store.get(this.key(tenantId, projectId)) || null } async save( tenantId: string, state: unknown, userId?: string, - expectedVersion?: number + expectedVersion?: number, + projectId?: string ): Promise { - 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 { - return this.store.delete(tenantId) + async delete(tenantId: string, projectId?: string): Promise { + return this.store.delete(this.key(tenantId, projectId)) } } @@ -93,11 +100,26 @@ class PostgreSQLStateStore implements StateStore { }) } - async get(tenantId: string): Promise { - 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 { + 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 { + async save(tenantId: string, state: unknown, userId?: string, expectedVersion?: number, projectId?: string): Promise { 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 { - const result = await this.pool.query( - 'DELETE FROM sdk_states WHERE tenant_id = $1', - [tenantId] - ) + async delete(tenantId: string, projectId?: string): Promise { + 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( diff --git a/admin-compliance/app/sdk/layout.tsx b/admin-compliance/app/sdk/layout.tsx index 3baca35..3cd06dc 100644 --- a/admin-compliance/app/sdk/layout.tsx +++ b/admin-compliance/app/sdk/layout.tsx @@ -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 }) {
diff --git a/admin-compliance/lib/sdk/api-client.ts b/admin-compliance/lib/sdk/api-client.ts index 86f0119..18480b7 100644 --- a/admin-compliance/lib/sdk/api-client.ts +++ b/admin-compliance/lib/sdk/api-client.ts @@ -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 = 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 { try { + const params = new URLSearchParams({ tenantId: this.tenantId }) + if (this.projectId) params.set('projectId', this.projectId) const response = await this.fetchWithRetry>( - `${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 { + const params = new URLSearchParams({ tenantId: this.tenantId }) + if (this.projectId) params.set('projectId', this.projectId) await this.fetchWithRetry>( - `${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 { + const response = await this.fetchWithRetry( + `${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 { + const response = await this.fetchWithRetry( + `${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 { + 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! } diff --git a/admin-compliance/lib/sdk/context.tsx b/admin-compliance/lib/sdk/context.tsx index e3ced31..7c27d46 100644 --- a/admin-compliance/lib/sdk/context.tsx +++ b/admin-compliance/lib/sdk/context.tsx @@ -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 + listProjects: () => Promise + switchProject: (projectId: string) => void + archiveProject: (projectId: string) => Promise } const SDKContext = createContext(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 => { @@ -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 => { 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 => { 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 => { @@ -1076,6 +1098,49 @@ export function SDKProvider({ } }, [state, enableBackendSync]) + // Project Management + const createProject = useCallback( + async (name: string, customerType: CustomerType, copyFromProjectId?: string): Promise => { + 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 => { + 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 => { + 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 => { @@ -1136,6 +1201,11 @@ export function SDKProvider({ exportState, isCommandBarOpen, setCommandBarOpen, + projectId, + createProject, + listProjects: listProjectsFn, + switchProject, + archiveProject: archiveProjectFn, } return {children} diff --git a/admin-compliance/lib/sdk/sync.ts b/admin-compliance/lib/sdk/sync.ts index ef59162..bb1d0a4 100644 --- a/admin-compliance/lib/sdk/sync.ts +++ b/admin-compliance/lib/sdk/sync.ts @@ -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 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) } diff --git a/admin-compliance/lib/sdk/types.ts b/admin-compliance/lib/sdk/types.ts index 24aefc1..dda9da4 100644 --- a/admin-compliance/lib/sdk/types.ts +++ b/admin-compliance/lib/sdk/types.ts @@ -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 diff --git a/backend-compliance/compliance/README.md b/backend-compliance/compliance/README.md index 3360061..f4bbac7 100644 --- a/backend-compliance/compliance/README.md +++ b/backend-compliance/compliance/README.md @@ -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) diff --git a/backend-compliance/compliance/api/__init__.py b/backend-compliance/compliance/api/__init__.py index da85716..28e80f8 100644 --- a/backend-compliance/compliance/api/__init__.py +++ b/backend-compliance/compliance/api/__init__.py @@ -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", ] diff --git a/backend-compliance/compliance/api/project_routes.py b/backend-compliance/compliance/api/project_routes.py new file mode 100644 index 0000000..d08fec3 --- /dev/null +++ b/backend-compliance/compliance/api/project_routes.py @@ -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() diff --git a/backend-compliance/migrations/039_compliance_projects.sql b/backend-compliance/migrations/039_compliance_projects.sql new file mode 100644 index 0000000..27abdfc --- /dev/null +++ b/backend-compliance/migrations/039_compliance_projects.sql @@ -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 $$; diff --git a/backend-compliance/tests/test_project_routes.py b/backend-compliance/tests/test_project_routes.py new file mode 100644 index 0000000..8b8c11c --- /dev/null +++ b/backend-compliance/tests/test_project_routes.py @@ -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 diff --git a/docs-src/services/sdk-modules/multi-project.md b/docs-src/services/sdk-modules/multi-project.md new file mode 100644 index 0000000..9d54ccb --- /dev/null +++ b/docs-src/services/sdk-modules/multi-project.md @@ -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 + + {children} + +``` + +### Context-Methoden + +```typescript +const { + createProject, // (name, customerType) => Promise + listProjects, // () => Promise + switchProject, // (projectId) => void (navigiert zu ?project=) + archiveProject, // (projectId) => Promise + 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 | diff --git a/mkdocs.yml b/mkdocs.yml index 83f38b1..819ecd3 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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