fix(admin-v2): Restore complete admin-v2 application
The admin-v2 application was incomplete in the repository. This commit restores all missing components: - Admin pages (76 pages): dashboard, ai, compliance, dsgvo, education, infrastructure, communication, development, onboarding, rbac - SDK pages (45 pages): tom, dsfa, vvt, loeschfristen, einwilligungen, vendor-compliance, tom-generator, dsr, and more - Developer portal (25 pages): API docs, SDK guides, frameworks - All components, lib files, hooks, and types - Updated package.json with all dependencies The issue was caused by incomplete initial repository state - the full admin-v2 codebase existed in backend/admin-v2 and docs-src/admin-v2 but was never fully synced to the main admin-v2 directory. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
482
admin-v2/lib/sdk/sync.ts
Normal file
482
admin-v2/lib/sdk/sync.ts
Normal file
@@ -0,0 +1,482 @@
|
||||
/**
|
||||
* SDK State Synchronization
|
||||
*
|
||||
* Handles offline/online sync, multi-tab coordination,
|
||||
* and conflict resolution for SDK state.
|
||||
*/
|
||||
|
||||
import { SDKState } from './types'
|
||||
import { SDKApiClient, StateResponse } from './api-client'
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type SyncStatus = 'idle' | 'syncing' | 'error' | 'offline' | 'conflict'
|
||||
|
||||
export interface SyncState {
|
||||
status: SyncStatus
|
||||
lastSyncedAt: Date | null
|
||||
localVersion: number
|
||||
serverVersion: number
|
||||
pendingChanges: number
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface ConflictResolution {
|
||||
strategy: 'local' | 'server' | 'merge'
|
||||
mergedState?: SDKState
|
||||
}
|
||||
|
||||
export interface SyncOptions {
|
||||
debounceMs?: number
|
||||
maxRetries?: number
|
||||
conflictHandler?: (local: SDKState, server: SDKState) => Promise<ConflictResolution>
|
||||
}
|
||||
|
||||
export interface SyncCallbacks {
|
||||
onSyncStart?: () => void
|
||||
onSyncComplete?: (state: SDKState) => void
|
||||
onSyncError?: (error: Error) => void
|
||||
onConflict?: (local: SDKState, server: SDKState) => void
|
||||
onOffline?: () => void
|
||||
onOnline?: () => void
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// CONSTANTS
|
||||
// =============================================================================
|
||||
|
||||
const STORAGE_KEY_PREFIX = 'ai-compliance-sdk-state'
|
||||
const SYNC_CHANNEL = 'sdk-state-sync'
|
||||
const DEFAULT_DEBOUNCE_MS = 2000
|
||||
const DEFAULT_MAX_RETRIES = 3
|
||||
|
||||
// =============================================================================
|
||||
// STATE SYNC MANAGER
|
||||
// =============================================================================
|
||||
|
||||
export class StateSyncManager {
|
||||
private apiClient: SDKApiClient
|
||||
private tenantId: string
|
||||
private options: Required<SyncOptions>
|
||||
private callbacks: SyncCallbacks
|
||||
private syncState: SyncState
|
||||
private broadcastChannel: BroadcastChannel | null = null
|
||||
private debounceTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
private pendingState: SDKState | null = null
|
||||
private isOnline: boolean = true
|
||||
|
||||
constructor(
|
||||
apiClient: SDKApiClient,
|
||||
tenantId: string,
|
||||
options: SyncOptions = {},
|
||||
callbacks: SyncCallbacks = {}
|
||||
) {
|
||||
this.apiClient = apiClient
|
||||
this.tenantId = tenantId
|
||||
this.callbacks = callbacks
|
||||
this.options = {
|
||||
debounceMs: options.debounceMs ?? DEFAULT_DEBOUNCE_MS,
|
||||
maxRetries: options.maxRetries ?? DEFAULT_MAX_RETRIES,
|
||||
conflictHandler: options.conflictHandler ?? this.defaultConflictHandler,
|
||||
}
|
||||
|
||||
this.syncState = {
|
||||
status: 'idle',
|
||||
lastSyncedAt: null,
|
||||
localVersion: 0,
|
||||
serverVersion: 0,
|
||||
pendingChanges: 0,
|
||||
error: null,
|
||||
}
|
||||
|
||||
this.setupBroadcastChannel()
|
||||
this.setupOnlineListener()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Setup Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private setupBroadcastChannel(): void {
|
||||
if (typeof window === 'undefined' || !('BroadcastChannel' in window)) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
this.broadcastChannel = new BroadcastChannel(`${SYNC_CHANNEL}-${this.tenantId}`)
|
||||
this.broadcastChannel.onmessage = this.handleBroadcastMessage.bind(this)
|
||||
} catch (error) {
|
||||
console.warn('BroadcastChannel not available:', error)
|
||||
}
|
||||
}
|
||||
|
||||
private setupOnlineListener(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true
|
||||
this.syncState.status = 'idle'
|
||||
this.callbacks.onOnline?.()
|
||||
// Attempt to sync any pending changes
|
||||
if (this.pendingState) {
|
||||
this.syncToServer(this.pendingState)
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false
|
||||
this.syncState.status = 'offline'
|
||||
this.callbacks.onOffline?.()
|
||||
})
|
||||
|
||||
// Check initial online status
|
||||
this.isOnline = navigator.onLine
|
||||
if (!this.isOnline) {
|
||||
this.syncState.status = 'offline'
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Broadcast Channel Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private handleBroadcastMessage(event: MessageEvent): void {
|
||||
const { type, state, version, tabId } = event.data
|
||||
|
||||
switch (type) {
|
||||
case 'STATE_UPDATED':
|
||||
// Another tab updated the state
|
||||
if (version > this.syncState.localVersion) {
|
||||
this.syncState.localVersion = version
|
||||
this.saveToLocalStorage(state)
|
||||
this.callbacks.onSyncComplete?.(state)
|
||||
}
|
||||
break
|
||||
|
||||
case 'SYNC_COMPLETE':
|
||||
// Another tab completed a sync
|
||||
this.syncState.serverVersion = version
|
||||
break
|
||||
|
||||
case 'REQUEST_STATE':
|
||||
// Another tab is requesting the current state
|
||||
this.broadcastState()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastState(): void {
|
||||
if (!this.broadcastChannel) return
|
||||
|
||||
const state = this.loadFromLocalStorage()
|
||||
if (state) {
|
||||
this.broadcastChannel.postMessage({
|
||||
type: 'STATE_UPDATED',
|
||||
state,
|
||||
version: this.syncState.localVersion,
|
||||
tabId: this.getTabId(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private broadcastSyncComplete(version: number): void {
|
||||
if (!this.broadcastChannel) return
|
||||
|
||||
this.broadcastChannel.postMessage({
|
||||
type: 'SYNC_COMPLETE',
|
||||
version,
|
||||
tabId: this.getTabId(),
|
||||
})
|
||||
}
|
||||
|
||||
private getTabId(): string {
|
||||
if (typeof window === 'undefined') return 'server'
|
||||
|
||||
let tabId = sessionStorage.getItem('sdk-tab-id')
|
||||
if (!tabId) {
|
||||
tabId = `tab-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`
|
||||
sessionStorage.setItem('sdk-tab-id', tabId)
|
||||
}
|
||||
return tabId
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Local Storage Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private getStorageKey(): string {
|
||||
return `${STORAGE_KEY_PREFIX}-${this.tenantId}`
|
||||
}
|
||||
|
||||
saveToLocalStorage(state: SDKState): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
const data = {
|
||||
state,
|
||||
version: this.syncState.localVersion,
|
||||
savedAt: new Date().toISOString(),
|
||||
}
|
||||
localStorage.setItem(this.getStorageKey(), JSON.stringify(data))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
loadFromLocalStorage(): SDKState | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
|
||||
try {
|
||||
const stored = localStorage.getItem(this.getStorageKey())
|
||||
if (stored) {
|
||||
const data = JSON.parse(stored)
|
||||
this.syncState.localVersion = data.version || 0
|
||||
return data.state
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load from localStorage:', error)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
clearLocalStorage(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
try {
|
||||
localStorage.removeItem(this.getStorageKey())
|
||||
} catch (error) {
|
||||
console.error('Failed to clear localStorage:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sync Methods
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Queue a state change for syncing (debounced)
|
||||
*/
|
||||
queueSync(state: SDKState): void {
|
||||
this.pendingState = state
|
||||
this.syncState.pendingChanges++
|
||||
|
||||
// Save to localStorage immediately
|
||||
this.syncState.localVersion++
|
||||
this.saveToLocalStorage(state)
|
||||
|
||||
// Broadcast to other tabs
|
||||
this.broadcastState()
|
||||
|
||||
// Debounce server sync
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout)
|
||||
}
|
||||
|
||||
this.debounceTimeout = setTimeout(() => {
|
||||
this.syncToServer(state)
|
||||
}, this.options.debounceMs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Force immediate sync to server
|
||||
*/
|
||||
async forcSync(state: SDKState): Promise<void> {
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout)
|
||||
this.debounceTimeout = null
|
||||
}
|
||||
|
||||
await this.syncToServer(state)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync state to server
|
||||
*/
|
||||
private async syncToServer(state: SDKState): Promise<void> {
|
||||
if (!this.isOnline) {
|
||||
this.syncState.status = 'offline'
|
||||
return
|
||||
}
|
||||
|
||||
this.syncState.status = 'syncing'
|
||||
this.callbacks.onSyncStart?.()
|
||||
|
||||
try {
|
||||
const response = await this.apiClient.saveState(state, this.syncState.serverVersion)
|
||||
|
||||
this.syncState = {
|
||||
...this.syncState,
|
||||
status: 'idle',
|
||||
lastSyncedAt: new Date(),
|
||||
serverVersion: response.version,
|
||||
pendingChanges: 0,
|
||||
error: null,
|
||||
}
|
||||
|
||||
this.pendingState = null
|
||||
this.broadcastSyncComplete(response.version)
|
||||
this.callbacks.onSyncComplete?.(state)
|
||||
} catch (error) {
|
||||
// Handle version conflict (409)
|
||||
if ((error as { status?: number }).status === 409) {
|
||||
await this.handleConflict(state)
|
||||
} else {
|
||||
this.syncState.status = 'error'
|
||||
this.syncState.error = (error as Error).message
|
||||
this.callbacks.onSyncError?.(error as Error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load state from server
|
||||
*/
|
||||
async loadFromServer(): Promise<SDKState | null> {
|
||||
if (!this.isOnline) {
|
||||
return this.loadFromLocalStorage()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await this.apiClient.getState()
|
||||
|
||||
if (response) {
|
||||
this.syncState.serverVersion = response.version
|
||||
this.syncState.localVersion = response.version
|
||||
this.saveToLocalStorage(response.state)
|
||||
return response.state
|
||||
}
|
||||
|
||||
// No server state, return local if available
|
||||
return this.loadFromLocalStorage()
|
||||
} catch (error) {
|
||||
console.error('Failed to load from server:', error)
|
||||
// Fallback to local storage
|
||||
return this.loadFromLocalStorage()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Conflict Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
private async handleConflict(localState: SDKState): Promise<void> {
|
||||
this.syncState.status = 'conflict'
|
||||
|
||||
try {
|
||||
// Fetch server state
|
||||
const serverResponse = await this.apiClient.getState()
|
||||
|
||||
if (!serverResponse) {
|
||||
// Server has no state, use local
|
||||
await this.apiClient.saveState(localState)
|
||||
return
|
||||
}
|
||||
|
||||
const serverState = serverResponse.state
|
||||
this.callbacks.onConflict?.(localState, serverState)
|
||||
|
||||
// Resolve conflict
|
||||
const resolution = await this.options.conflictHandler(localState, serverState)
|
||||
|
||||
let resolvedState: SDKState
|
||||
switch (resolution.strategy) {
|
||||
case 'local':
|
||||
resolvedState = localState
|
||||
break
|
||||
case 'server':
|
||||
resolvedState = serverState
|
||||
break
|
||||
case 'merge':
|
||||
resolvedState = resolution.mergedState || localState
|
||||
break
|
||||
}
|
||||
|
||||
// Save resolved state
|
||||
const response = await this.apiClient.saveState(resolvedState)
|
||||
this.syncState.serverVersion = response.version
|
||||
this.syncState.localVersion = response.version
|
||||
this.saveToLocalStorage(resolvedState)
|
||||
this.syncState.status = 'idle'
|
||||
this.callbacks.onSyncComplete?.(resolvedState)
|
||||
} catch (error) {
|
||||
this.syncState.status = 'error'
|
||||
this.syncState.error = (error as Error).message
|
||||
this.callbacks.onSyncError?.(error as Error)
|
||||
}
|
||||
}
|
||||
|
||||
private async defaultConflictHandler(
|
||||
local: SDKState,
|
||||
server: SDKState
|
||||
): Promise<ConflictResolution> {
|
||||
// Default: Server wins, but we preserve certain local-only data
|
||||
const localTime = new Date(local.lastModified).getTime()
|
||||
const serverTime = new Date(server.lastModified).getTime()
|
||||
|
||||
if (localTime > serverTime) {
|
||||
// Local is newer, use local
|
||||
return { strategy: 'local' }
|
||||
}
|
||||
|
||||
// Merge: Use server state but preserve local UI preferences
|
||||
const mergedState: SDKState = {
|
||||
...server,
|
||||
preferences: local.preferences,
|
||||
commandBarHistory: [
|
||||
...local.commandBarHistory,
|
||||
...server.commandBarHistory.filter(
|
||||
h => !local.commandBarHistory.some(lh => lh.id === h.id)
|
||||
),
|
||||
].slice(0, 50),
|
||||
recentSearches: [...new Set([...local.recentSearches, ...server.recentSearches])].slice(0, 20),
|
||||
}
|
||||
|
||||
return { strategy: 'merge', mergedState }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Getters & Cleanup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
getSyncState(): SyncState {
|
||||
return { ...this.syncState }
|
||||
}
|
||||
|
||||
isOnlineStatus(): boolean {
|
||||
return this.isOnline
|
||||
}
|
||||
|
||||
hasPendingChanges(): boolean {
|
||||
return this.syncState.pendingChanges > 0 || this.pendingState !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup resources
|
||||
*/
|
||||
destroy(): void {
|
||||
if (this.debounceTimeout) {
|
||||
clearTimeout(this.debounceTimeout)
|
||||
}
|
||||
|
||||
if (this.broadcastChannel) {
|
||||
this.broadcastChannel.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// FACTORY FUNCTION
|
||||
// =============================================================================
|
||||
|
||||
export function createStateSyncManager(
|
||||
apiClient: SDKApiClient,
|
||||
tenantId: string,
|
||||
options?: SyncOptions,
|
||||
callbacks?: SyncCallbacks
|
||||
): StateSyncManager {
|
||||
return new StateSyncManager(apiClient, tenantId, options, callbacks)
|
||||
}
|
||||
Reference in New Issue
Block a user