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

@@ -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