This repository has been archived on 2026-02-15. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
breakpilot-pwa/admin-v2/lib/sdk/api-client.ts
BreakPilot Dev 19855efacc
Some checks failed
Tests / Go Tests (push) Has been cancelled
Tests / Python Tests (push) Has been cancelled
Tests / Integration Tests (push) Has been cancelled
Tests / Go Lint (push) Has been cancelled
Tests / Python Lint (push) Has been cancelled
Tests / Security Scan (push) Has been cancelled
Tests / All Checks Passed (push) Has been cancelled
Security Scanning / Secret Scanning (push) Has been cancelled
Security Scanning / Dependency Vulnerability Scan (push) Has been cancelled
Security Scanning / Go Security Scan (push) Has been cancelled
Security Scanning / Python Security Scan (push) Has been cancelled
Security Scanning / Node.js Security Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
CI/CD Pipeline / Go Tests (push) Has been cancelled
CI/CD Pipeline / Python Tests (push) Has been cancelled
CI/CD Pipeline / Website Tests (push) Has been cancelled
CI/CD Pipeline / Linting (push) Has been cancelled
CI/CD Pipeline / Security Scan (push) Has been cancelled
CI/CD Pipeline / Docker Build & Push (push) Has been cancelled
CI/CD Pipeline / Integration Tests (push) Has been cancelled
CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
CI/CD Pipeline / Deploy to Production (push) Has been cancelled
CI/CD Pipeline / CI Summary (push) Has been cancelled
ci/woodpecker/manual/build-ci-image Pipeline was successful
ci/woodpecker/manual/main Pipeline failed
feat: BreakPilot PWA - Full codebase (clean push without large binaries)
All services: admin-v2, studio-v2, website, ai-compliance-sdk,
consent-service, klausur-service, voice-service, and infrastructure.
Large PDFs and compiled binaries excluded via .gitignore.
2026-02-11 13:25:58 +01:00

472 lines
12 KiB
TypeScript

/**
* SDK API Client
*
* Centralized API client for SDK state management with error handling,
* retry logic, and optimistic locking support.
*/
import { SDKState, CheckpointStatus } from './types'
// =============================================================================
// TYPES
// =============================================================================
export interface APIResponse<T> {
success: boolean
data?: T
error?: string
version?: number
lastModified?: string
}
export interface StateResponse {
tenantId: string
state: SDKState
version: number
lastModified: string
}
export interface SaveStateRequest {
tenantId: string
state: SDKState
version?: number // For optimistic locking
}
export interface CheckpointValidationResult {
checkpointId: string
passed: boolean
errors: Array<{
ruleId: string
field: string
message: string
severity: 'ERROR' | 'WARNING' | 'INFO'
}>
warnings: Array<{
ruleId: string
field: string
message: string
severity: 'ERROR' | 'WARNING' | 'INFO'
}>
validatedAt: string
validatedBy: string
}
export interface APIError extends Error {
status?: number
code?: string
retryable: boolean
}
// =============================================================================
// CONFIGURATION
// =============================================================================
const DEFAULT_BASE_URL = '/api/sdk/v1'
const DEFAULT_TIMEOUT = 30000 // 30 seconds
const MAX_RETRIES = 3
const RETRY_DELAYS = [1000, 2000, 4000] // Exponential backoff
// =============================================================================
// API CLIENT
// =============================================================================
export class SDKApiClient {
private baseUrl: string
private tenantId: string
private timeout: number
private abortControllers: Map<string, AbortController> = new Map()
constructor(options: {
baseUrl?: string
tenantId: string
timeout?: number
}) {
this.baseUrl = options.baseUrl || DEFAULT_BASE_URL
this.tenantId = options.tenantId
this.timeout = options.timeout || DEFAULT_TIMEOUT
}
// ---------------------------------------------------------------------------
// Private Methods
// ---------------------------------------------------------------------------
private createError(message: string, status?: number, retryable = false): APIError {
const error = new Error(message) as APIError
error.status = status
error.retryable = retryable
return error
}
private async fetchWithTimeout(
url: string,
options: RequestInit,
requestId: string
): Promise<Response> {
const controller = new AbortController()
this.abortControllers.set(requestId, controller)
const timeoutId = setTimeout(() => controller.abort(), this.timeout)
try {
const response = await fetch(url, {
...options,
signal: controller.signal,
})
return response
} finally {
clearTimeout(timeoutId)
this.abortControllers.delete(requestId)
}
}
private async fetchWithRetry<T>(
url: string,
options: RequestInit,
retries = MAX_RETRIES
): Promise<T> {
const requestId = `${Date.now()}-${Math.random()}`
let lastError: Error | null = null
for (let attempt = 0; attempt <= retries; attempt++) {
try {
const response = await this.fetchWithTimeout(url, options, requestId)
if (!response.ok) {
const errorBody = await response.text()
let errorMessage = `HTTP ${response.status}`
try {
const errorJson = JSON.parse(errorBody)
errorMessage = errorJson.error || errorJson.message || errorMessage
} catch {
// Keep the HTTP status message
}
// Don't retry client errors (4xx) except for 429 (rate limit)
const retryable = response.status >= 500 || response.status === 429
if (!retryable || attempt === retries) {
throw this.createError(errorMessage, response.status, retryable)
}
} else {
const data = await response.json()
return data as T
}
} catch (error) {
lastError = error as Error
if (error instanceof Error && error.name === 'AbortError') {
throw this.createError('Request timeout', 408, true)
}
// Check if it's a retryable error
const apiError = error as APIError
if (!apiError.retryable || attempt === retries) {
throw error
}
}
// Wait before retrying (exponential backoff)
if (attempt < retries) {
await this.sleep(RETRY_DELAYS[attempt] || RETRY_DELAYS[RETRY_DELAYS.length - 1])
}
}
throw lastError || this.createError('Unknown error', 500, false)
}
private sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
// ---------------------------------------------------------------------------
// Public Methods - State Management
// ---------------------------------------------------------------------------
/**
* Load SDK state for the current tenant
*/
async getState(): Promise<StateResponse | null> {
try {
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
`${this.baseUrl}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
)
if (response.success && response.data) {
return response.data
}
return null
} catch (error) {
const apiError = error as APIError
// 404 means no state exists yet - that's okay
if (apiError.status === 404) {
return null
}
throw error
}
}
/**
* Save SDK state for the current tenant
* Supports optimistic locking via version parameter
*/
async saveState(state: SDKState, version?: number): Promise<StateResponse> {
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
`${this.baseUrl}/state`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(version !== undefined && { 'If-Match': String(version) }),
},
body: JSON.stringify({
tenantId: this.tenantId,
state,
version,
}),
}
)
if (!response.success) {
throw this.createError(response.error || 'Failed to save state', 500, true)
}
return response.data!
}
/**
* Delete SDK state for the current tenant
*/
async deleteState(): Promise<void> {
await this.fetchWithRetry<APIResponse<void>>(
`${this.baseUrl}/state?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
}
)
}
// ---------------------------------------------------------------------------
// Public Methods - Checkpoint Validation
// ---------------------------------------------------------------------------
/**
* Validate a specific checkpoint
*/
async validateCheckpoint(
checkpointId: string,
data?: unknown
): Promise<CheckpointValidationResult> {
const response = await this.fetchWithRetry<APIResponse<CheckpointValidationResult>>(
`${this.baseUrl}/checkpoints/validate`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tenantId: this.tenantId,
checkpointId,
data,
}),
}
)
if (!response.success || !response.data) {
throw this.createError(response.error || 'Checkpoint validation failed', 500, true)
}
return response.data
}
/**
* Get all checkpoint statuses
*/
async getCheckpoints(): Promise<Record<string, CheckpointStatus>> {
const response = await this.fetchWithRetry<APIResponse<Record<string, CheckpointStatus>>>(
`${this.baseUrl}/checkpoints?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
)
return response.data || {}
}
// ---------------------------------------------------------------------------
// Public Methods - Flow Navigation
// ---------------------------------------------------------------------------
/**
* Get current flow state
*/
async getFlowState(): Promise<{
currentStep: string
currentPhase: 1 | 2
completedSteps: string[]
suggestions: Array<{ stepId: string; reason: string }>
}> {
const response = await this.fetchWithRetry<APIResponse<{
currentStep: string
currentPhase: 1 | 2
completedSteps: string[]
suggestions: Array<{ stepId: string; reason: string }>
}>>(
`${this.baseUrl}/flow?tenantId=${encodeURIComponent(this.tenantId)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
}
)
if (!response.data) {
throw this.createError('Failed to get flow state', 500, true)
}
return response.data
}
/**
* Navigate to next/previous step
*/
async navigateFlow(direction: 'next' | 'previous'): Promise<{
stepId: string
phase: 1 | 2
}> {
const response = await this.fetchWithRetry<APIResponse<{
stepId: string
phase: 1 | 2
}>>(
`${this.baseUrl}/flow`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
tenantId: this.tenantId,
direction,
}),
}
)
if (!response.data) {
throw this.createError('Failed to navigate flow', 500, true)
}
return response.data
}
// ---------------------------------------------------------------------------
// Public Methods - Export
// ---------------------------------------------------------------------------
/**
* Export SDK state in various formats
*/
async exportState(format: 'json' | 'pdf' | 'zip'): Promise<Blob> {
const response = await this.fetchWithTimeout(
`${this.baseUrl}/export?tenantId=${encodeURIComponent(this.tenantId)}&format=${format}`,
{
method: 'GET',
headers: {
'Accept': format === 'json' ? 'application/json' : format === 'pdf' ? 'application/pdf' : 'application/zip',
},
},
`export-${Date.now()}`
)
if (!response.ok) {
throw this.createError(`Export failed: ${response.statusText}`, response.status, true)
}
return response.blob()
}
// ---------------------------------------------------------------------------
// Public Methods - Utility
// ---------------------------------------------------------------------------
/**
* Cancel all pending requests
*/
cancelAllRequests(): void {
this.abortControllers.forEach(controller => controller.abort())
this.abortControllers.clear()
}
/**
* Update tenant ID (useful when switching contexts)
*/
setTenantId(tenantId: string): void {
this.tenantId = tenantId
}
/**
* Get current tenant ID
*/
getTenantId(): string {
return this.tenantId
}
/**
* Health check
*/
async healthCheck(): Promise<boolean> {
try {
const response = await this.fetchWithTimeout(
`${this.baseUrl}/health`,
{ method: 'GET' },
`health-${Date.now()}`
)
return response.ok
} catch {
return false
}
}
}
// =============================================================================
// SINGLETON FACTORY
// =============================================================================
let clientInstance: SDKApiClient | null = null
export function getSDKApiClient(tenantId?: string): SDKApiClient {
if (!clientInstance && !tenantId) {
throw new Error('SDKApiClient not initialized. Provide tenantId on first call.')
}
if (!clientInstance && tenantId) {
clientInstance = new SDKApiClient({ tenantId })
}
if (tenantId && clientInstance && clientInstance.getTenantId() !== tenantId) {
clientInstance.setTenantId(tenantId)
}
return clientInstance!
}
export function resetSDKApiClient(): void {
if (clientInstance) {
clientInstance.cancelAllRequests()
}
clientInstance = null
}