Files
breakpilot-compliance/breakpilot-compliance-sdk/packages/react/src/provider.tsx
Sharang Parnerkar 5cb91e88d2 refactor(compliance-sdk): split client/provider/embed/state under 500 LOC
Phase 4 continuation. All touched files now under the file-size cap, and
drive-by fixes unblock the types/core/react/vanilla builds which were broken
at baseline.

Splits
- packages/types/src/state 505 -> 31 LOC barrel + state-flow/-assessment/-core
- packages/core/src/client 521 -> 395 LOC + client-http 187 LOC (HTTP transport)
- packages/react/src/provider 539 -> 460 LOC + provider-context 101 LOC
- packages/vanilla/src/embed 611 -> 290 LOC + embed-banner 321 + embed-translations 78

Drive-by fixes (pre-existing typecheck/build failures)
- types/rag.ts: rename colliding LegalDocument export to RagLegalDocument
  (the `export *` chain in index.ts was ambiguous; two consumers updated
  - core/modules/rag.ts drops unused import, vue/composables/useRAG.ts
  switches to the renamed symbol).
- core/modules/rag.ts: wrap client searchRAG response to add the missing
  `query` field so the declared SearchResponse return type is satisfied.
- react/provider.tsx: re-export useCompliance so ComplianceDashboard /
  ConsentBanner / DSRPortal legacy `from '../provider'` imports resolve.
- vanilla/embed.ts + web-components/base.ts: default tenantId to ''
  so ComplianceClient construction typechecks.
- vanilla/web-components/consent-banner.ts: tighten categories literal to
  `as const` so t.categories indexing narrows correctly.

Verification: packages/types + core + react + vanilla all `pnpm build`
clean with DTS emission. consent-sdk unaffected (still green).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 22:39:47 +02:00

463 lines
12 KiB
TypeScript

'use client'
import React, {
useReducer,
useEffect,
useCallback,
useMemo,
useRef,
useState,
} from 'react'
import {
ComplianceClient,
sdkReducer,
initialState,
StateSyncManager,
createStateSyncManager,
createDSGVOModule,
createComplianceModule,
createRAGModule,
createSecurityModule,
} from '@breakpilot/compliance-sdk-core'
import type {
CheckpointStatus,
SyncState,
UseCaseAssessment,
Risk,
Control,
} from '@breakpilot/compliance-sdk-types'
import {
getStepById,
getNextStep,
getPreviousStep,
getCompletionPercentage,
getPhaseCompletionPercentage,
} from '@breakpilot/compliance-sdk-types'
import {
ComplianceContext,
SDK_STORAGE_KEY,
type ComplianceContextValue,
type ComplianceProviderProps,
} from './provider-context'
export {
ComplianceContext,
type ComplianceContextValue,
type ComplianceProviderProps,
} from './provider-context'
// Re-export useCompliance so legacy component imports (`from '../provider'`)
// keep resolving. Pre-existing cross-file import that was broken in baseline.
export { useCompliance } from './hooks'
// =============================================================================
// PROVIDER
// =============================================================================
export function ComplianceProvider({
children,
apiEndpoint,
apiKey,
tenantId,
userId = 'default',
enableBackendSync = true,
onNavigate,
onError,
}: ComplianceProviderProps) {
const [state, dispatch] = useReducer(sdkReducer, {
...initialState,
tenantId,
userId,
})
const [isCommandBarOpen, setCommandBarOpen] = useState(false)
const [isInitialized, setIsInitialized] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
const [syncState, setSyncState] = useState<SyncState>({
status: 'idle',
lastSyncedAt: null,
localVersion: 0,
serverVersion: 0,
pendingChanges: 0,
error: null,
})
const [isOnline, setIsOnline] = useState(true)
// Refs
const clientRef = useRef<ComplianceClient | null>(null)
const syncManagerRef = useRef<StateSyncManager | null>(null)
// Initialize client
if (!clientRef.current) {
clientRef.current = new ComplianceClient({
apiEndpoint,
apiKey,
tenantId,
onError: err => {
setError(err)
onError?.(err)
},
})
}
const client = clientRef.current
// Modules
const dsgvo = useMemo(
() => createDSGVOModule(client, () => state),
[client, state]
)
const compliance = useMemo(
() => createComplianceModule(client, () => state),
[client, state]
)
const rag = useMemo(() => createRAGModule(client), [client])
const security = useMemo(
() => createSecurityModule(client, () => state),
[client, state]
)
// Initialize sync manager
useEffect(() => {
if (enableBackendSync && typeof window !== 'undefined') {
syncManagerRef.current = createStateSyncManager(
client,
tenantId,
{ debounceMs: 2000, maxRetries: 3 },
{
onSyncStart: () => {
setSyncState(prev => ({ ...prev, status: 'syncing' }))
},
onSyncComplete: syncedState => {
setSyncState(prev => ({
...prev,
status: 'idle',
lastSyncedAt: new Date(),
pendingChanges: 0,
}))
if (new Date(syncedState.lastModified) > new Date(state.lastModified)) {
dispatch({ type: 'SET_STATE', payload: syncedState })
}
},
onSyncError: err => {
setSyncState(prev => ({
...prev,
status: 'error',
error: err.message,
}))
setError(err)
},
onConflict: () => {
setSyncState(prev => ({ ...prev, status: 'conflict' }))
},
onOffline: () => {
setIsOnline(false)
setSyncState(prev => ({ ...prev, status: 'offline' }))
},
onOnline: () => {
setIsOnline(true)
setSyncState(prev => ({ ...prev, status: 'idle' }))
},
}
)
}
return () => {
syncManagerRef.current?.destroy()
}
}, [enableBackendSync, tenantId, client])
// Load initial state
useEffect(() => {
const loadInitialState = async () => {
setIsLoading(true)
try {
// Load from localStorage first
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
if (stored) {
const parsed = JSON.parse(stored)
if (parsed.lastModified) {
parsed.lastModified = new Date(parsed.lastModified)
}
dispatch({ type: 'SET_STATE', payload: parsed })
}
}
// Then load from server if enabled
if (enableBackendSync && syncManagerRef.current) {
const serverState = await syncManagerRef.current.loadFromServer()
if (serverState) {
dispatch({ type: 'SET_STATE', payload: serverState })
}
}
} catch (err) {
setError(err as Error)
onError?.(err as Error)
} finally {
setIsLoading(false)
setIsInitialized(true)
}
}
loadInitialState()
}, [tenantId, enableBackendSync])
// Auto-save
useEffect(() => {
if (!isInitialized || !state.preferences.autoSave) return
const saveTimeout = setTimeout(() => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
}
if (enableBackendSync && syncManagerRef.current) {
syncManagerRef.current.queueSync(state)
}
} catch (err) {
console.error('Failed to save state:', err)
}
}, 1000)
return () => clearTimeout(saveTimeout)
}, [state, tenantId, isInitialized, enableBackendSync])
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'k') {
e.preventDefault()
setCommandBarOpen(prev => !prev)
}
if (e.key === 'Escape' && isCommandBarOpen) {
setCommandBarOpen(false)
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [isCommandBarOpen])
// Navigation
const currentStep = useMemo(() => getStepById(state.currentStep), [state.currentStep])
const goToStep = useCallback(
(stepId: string) => {
const step = getStepById(stepId)
if (step) {
dispatch({ type: 'SET_CURRENT_STEP', payload: stepId })
onNavigate?.(step.url)
}
},
[onNavigate]
)
const goToNextStep = useCallback(() => {
const nextStep = getNextStep(state.currentStep)
if (nextStep) {
goToStep(nextStep.id)
}
}, [state.currentStep, goToStep])
const goToPreviousStep = useCallback(() => {
const prevStep = getPreviousStep(state.currentStep)
if (prevStep) {
goToStep(prevStep.id)
}
}, [state.currentStep, goToStep])
const canGoNext = useMemo(() => getNextStep(state.currentStep) !== undefined, [state.currentStep])
const canGoPrevious = useMemo(
() => getPreviousStep(state.currentStep) !== undefined,
[state.currentStep]
)
// Progress
const completionPercentage = useMemo(() => getCompletionPercentage(state), [state])
const phase1Completion = useMemo(() => getPhaseCompletionPercentage(state, 1), [state])
const phase2Completion = useMemo(() => getPhaseCompletionPercentage(state, 2), [state])
// Checkpoints
const validateCheckpoint = useCallback(
async (checkpointId: string): Promise<CheckpointStatus> => {
if (enableBackendSync) {
try {
const result = await client.validateCheckpoint(checkpointId, state)
const status: CheckpointStatus = {
checkpointId: result.checkpointId,
passed: result.passed,
validatedAt: new Date(result.validatedAt),
validatedBy: result.validatedBy,
errors: result.errors,
warnings: result.warnings,
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
return status
} catch {
// Fall through to local validation
}
}
// Local validation
const status: CheckpointStatus = {
checkpointId,
passed: true,
validatedAt: new Date(),
validatedBy: 'SYSTEM',
errors: [],
warnings: [],
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status } })
return status
},
[state, enableBackendSync, client]
)
const overrideCheckpoint = useCallback(
async (checkpointId: string, reason: string): Promise<void> => {
const existingStatus = state.checkpoints[checkpointId]
const overriddenStatus: CheckpointStatus = {
...existingStatus,
checkpointId,
passed: true,
overrideReason: reason,
overriddenBy: state.userId,
overriddenAt: new Date(),
errors: [],
warnings: existingStatus?.warnings || [],
}
dispatch({ type: 'SET_CHECKPOINT_STATUS', payload: { id: checkpointId, status: overriddenStatus } })
},
[state.checkpoints, state.userId]
)
const getCheckpointStatus = useCallback(
(checkpointId: string): CheckpointStatus | undefined => {
return state.checkpoints[checkpointId]
},
[state.checkpoints]
)
// State Updates
const updateUseCase = useCallback((id: string, data: Partial<UseCaseAssessment>) => {
dispatch({ type: 'UPDATE_USE_CASE', payload: { id, data } })
}, [])
const addRisk = useCallback((risk: Risk) => {
dispatch({ type: 'ADD_RISK', payload: risk })
}, [])
const updateControl = useCallback((id: string, data: Partial<Control>) => {
dispatch({ type: 'UPDATE_CONTROL', payload: { id, data } })
}, [])
// Persistence
const saveState = useCallback(async (): Promise<void> => {
try {
if (typeof window !== 'undefined') {
localStorage.setItem(`${SDK_STORAGE_KEY}-${tenantId}`, JSON.stringify(state))
}
if (enableBackendSync && syncManagerRef.current) {
await syncManagerRef.current.forceSync(state)
}
} catch (err) {
setError(err as Error)
throw err
}
}, [state, tenantId, enableBackendSync])
const loadState = useCallback(async (): Promise<void> => {
setIsLoading(true)
try {
if (enableBackendSync && syncManagerRef.current) {
const serverState = await syncManagerRef.current.loadFromServer()
if (serverState) {
dispatch({ type: 'SET_STATE', payload: serverState })
return
}
}
if (typeof window !== 'undefined') {
const stored = localStorage.getItem(`${SDK_STORAGE_KEY}-${tenantId}`)
if (stored) {
dispatch({ type: 'SET_STATE', payload: JSON.parse(stored) })
}
}
} catch (err) {
setError(err as Error)
throw err
} finally {
setIsLoading(false)
}
}, [tenantId, enableBackendSync])
const resetState = useCallback(() => {
dispatch({ type: 'RESET_STATE' })
if (typeof window !== 'undefined') {
localStorage.removeItem(`${SDK_STORAGE_KEY}-${tenantId}`)
}
}, [tenantId])
// Sync
const forceSyncToServer = useCallback(async (): Promise<void> => {
if (enableBackendSync && syncManagerRef.current) {
await syncManagerRef.current.forceSync(state)
}
}, [state, enableBackendSync])
// Export
const exportState = useCallback(
async (format: 'json' | 'pdf' | 'zip'): Promise<Blob> => {
if (format === 'json') {
return new Blob([JSON.stringify(state, null, 2)], { type: 'application/json' })
}
return client.exportState(format)
},
[state, client]
)
const value: ComplianceContextValue = {
state,
dispatch,
client,
dsgvo,
compliance,
rag,
security,
currentStep,
goToStep,
goToNextStep,
goToPreviousStep,
canGoNext,
canGoPrevious,
completionPercentage,
phase1Completion,
phase2Completion,
validateCheckpoint,
overrideCheckpoint,
getCheckpointStatus,
updateUseCase,
addRisk,
updateControl,
saveState,
loadState,
resetState,
syncState,
forceSyncToServer,
isOnline,
exportState,
isCommandBarOpen,
setCommandBarOpen,
isInitialized,
isLoading,
error,
}
return <ComplianceContext.Provider value={value}>{children}</ComplianceContext.Provider>
}