Migration 040 with wiki_categories + wiki_articles tables, 10 seed articles across 8 categories (DSGVO, Art. 9, AVV, HinSchG etc.). Read-only FastAPI API, Next.js proxy, and two-column frontend with full-text search. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
886 lines
24 KiB
TypeScript
886 lines
24 KiB
TypeScript
/**
|
|
* SDK API Client
|
|
*
|
|
* Centralized API client for SDK state management with error handling,
|
|
* retry logic, and optimistic locking support.
|
|
*/
|
|
|
|
import { SDKState, CheckpointStatus, ProjectInfo, WikiCategory, WikiArticle, WikiSearchResult } 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 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
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 params = new URLSearchParams({ tenantId: this.tenantId })
|
|
if (this.projectId) params.set('projectId', this.projectId)
|
|
const response = await this.fetchWithRetry<APIResponse<StateResponse>>(
|
|
`${this.baseUrl}/state?${params.toString()}`,
|
|
{
|
|
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,
|
|
projectId: this.projectId,
|
|
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> {
|
|
const params = new URLSearchParams({ tenantId: this.tenantId })
|
|
if (this.projectId) params.set('projectId', this.projectId)
|
|
await this.fetchWithRetry<APIResponse<void>>(
|
|
`${this.baseUrl}/state?${params.toString()}`,
|
|
{
|
|
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 - Modules
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Get available compliance modules from backend
|
|
*/
|
|
async getModules(filters?: {
|
|
serviceType?: string
|
|
criticality?: string
|
|
processesPii?: boolean
|
|
aiComponents?: boolean
|
|
}): Promise<{ modules: unknown[]; total: number }> {
|
|
const params = new URLSearchParams()
|
|
if (filters?.serviceType) params.set('service_type', filters.serviceType)
|
|
if (filters?.criticality) params.set('criticality', filters.criticality)
|
|
if (filters?.processesPii !== undefined) params.set('processes_pii', String(filters.processesPii))
|
|
if (filters?.aiComponents !== undefined) params.set('ai_components', String(filters.aiComponents))
|
|
|
|
const queryString = params.toString()
|
|
const url = `${this.baseUrl}/modules${queryString ? `?${queryString}` : ''}`
|
|
|
|
const response = await this.fetchWithRetry<{ modules: unknown[]; total: number }>(
|
|
url,
|
|
{
|
|
method: 'GET',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
}
|
|
)
|
|
|
|
return response
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public Methods - UCCA (Use Case Compliance Assessment)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Assess a use case
|
|
*/
|
|
async assessUseCase(intake: unknown): Promise<unknown> {
|
|
const response = await this.fetchWithRetry<APIResponse<unknown>>(
|
|
`${this.baseUrl}/ucca/assess`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
body: JSON.stringify(intake),
|
|
}
|
|
)
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Get all assessments
|
|
*/
|
|
async getAssessments(): Promise<unknown[]> {
|
|
const response = await this.fetchWithRetry<APIResponse<unknown[]>>(
|
|
`${this.baseUrl}/ucca/assessments?tenantId=${encodeURIComponent(this.tenantId)}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
}
|
|
)
|
|
return response.data || []
|
|
}
|
|
|
|
/**
|
|
* Get a single assessment
|
|
*/
|
|
async getAssessment(id: string): Promise<unknown> {
|
|
const response = await this.fetchWithRetry<APIResponse<unknown>>(
|
|
`${this.baseUrl}/ucca/assessments/${id}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
}
|
|
)
|
|
return response.data
|
|
}
|
|
|
|
/**
|
|
* Delete an assessment
|
|
*/
|
|
async deleteAssessment(id: string): Promise<void> {
|
|
await this.fetchWithRetry<APIResponse<void>>(
|
|
`${this.baseUrl}/ucca/assessments/${id}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public Methods - Document Import
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Analyze an uploaded document
|
|
*/
|
|
async analyzeDocument(formData: FormData): Promise<unknown> {
|
|
const response = await this.fetchWithRetry<APIResponse<unknown>>(
|
|
`${this.baseUrl}/import/analyze`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
body: formData,
|
|
}
|
|
)
|
|
return response.data
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public Methods - System Screening
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Scan a dependency file (package-lock.json, requirements.txt, etc.)
|
|
*/
|
|
async scanDependencies(formData: FormData): Promise<unknown> {
|
|
const response = await this.fetchWithRetry<APIResponse<unknown>>(
|
|
`${this.baseUrl}/screening/scan`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
body: formData,
|
|
}
|
|
)
|
|
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
|
|
}
|
|
|
|
/**
|
|
* 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(includeArchived = true): Promise<{ projects: ProjectInfo[]; total: number }> {
|
|
const response = await this.fetchWithRetry<{ projects: ProjectInfo[]; total: number }>(
|
|
`${this.baseUrl}/projects?tenant_id=${encodeURIComponent(this.tenantId)}&include_archived=${includeArchived}`,
|
|
{
|
|
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
|
|
}
|
|
|
|
/**
|
|
* Get a single project by ID
|
|
*/
|
|
async getProject(projectId: string): Promise<ProjectInfo> {
|
|
const response = await this.fetchWithRetry<ProjectInfo>(
|
|
`${this.baseUrl}/projects/${projectId}?tenant_id=${encodeURIComponent(this.tenantId)}`,
|
|
{
|
|
method: 'GET',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-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,
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
/**
|
|
* Restore an archived project
|
|
*/
|
|
async restoreProject(projectId: string): Promise<ProjectInfo> {
|
|
const response = await this.fetchWithRetry<ProjectInfo>(
|
|
`${this.baseUrl}/projects/${projectId}/restore?tenant_id=${encodeURIComponent(this.tenantId)}`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
}
|
|
)
|
|
return response
|
|
}
|
|
|
|
/**
|
|
* Permanently delete a project and all data
|
|
*/
|
|
async permanentlyDeleteProject(projectId: string): Promise<void> {
|
|
await this.fetchWithRetry<{ success: boolean }>(
|
|
`${this.baseUrl}/projects/${projectId}/permanent?tenant_id=${encodeURIComponent(this.tenantId)}`,
|
|
{
|
|
method: 'DELETE',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-Tenant-ID': this.tenantId,
|
|
},
|
|
}
|
|
)
|
|
}
|
|
|
|
// ===========================================================================
|
|
// WIKI (read-only knowledge base)
|
|
// ===========================================================================
|
|
|
|
/**
|
|
* List all wiki categories with article counts
|
|
*/
|
|
async listWikiCategories(): Promise<WikiCategory[]> {
|
|
const data = await this.fetchWithRetry<{ categories: Array<{
|
|
id: string; name: string; description: string; icon: string;
|
|
sort_order: number; article_count: number
|
|
}> }>(
|
|
`${this.baseUrl}/wiki?endpoint=categories`,
|
|
{ method: 'GET' }
|
|
)
|
|
return (data.categories || []).map(c => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
description: c.description,
|
|
icon: c.icon,
|
|
sortOrder: c.sort_order,
|
|
articleCount: c.article_count,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* List wiki articles, optionally filtered by category
|
|
*/
|
|
async listWikiArticles(categoryId?: string): Promise<WikiArticle[]> {
|
|
const params = new URLSearchParams({ endpoint: 'articles' })
|
|
if (categoryId) params.set('category_id', categoryId)
|
|
const data = await this.fetchWithRetry<{ articles: Array<{
|
|
id: string; category_id: string; category_name: string; title: string;
|
|
summary: string; content: string; legal_refs: string[]; tags: string[];
|
|
relevance: string; source_urls: string[]; version: number; updated_at: string
|
|
}> }>(
|
|
`${this.baseUrl}/wiki?${params.toString()}`,
|
|
{ method: 'GET' }
|
|
)
|
|
return (data.articles || []).map(a => ({
|
|
id: a.id,
|
|
categoryId: a.category_id,
|
|
categoryName: a.category_name,
|
|
title: a.title,
|
|
summary: a.summary,
|
|
content: a.content,
|
|
legalRefs: a.legal_refs || [],
|
|
tags: a.tags || [],
|
|
relevance: a.relevance as WikiArticle['relevance'],
|
|
sourceUrls: a.source_urls || [],
|
|
version: a.version,
|
|
updatedAt: a.updated_at,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* Get a single wiki article by ID
|
|
*/
|
|
async getWikiArticle(id: string): Promise<WikiArticle> {
|
|
const data = await this.fetchWithRetry<{
|
|
id: string; category_id: string; category_name: string; title: string;
|
|
summary: string; content: string; legal_refs: string[]; tags: string[];
|
|
relevance: string; source_urls: string[]; version: number; updated_at: string
|
|
}>(
|
|
`${this.baseUrl}/wiki?endpoint=article&id=${encodeURIComponent(id)}`,
|
|
{ method: 'GET' }
|
|
)
|
|
return {
|
|
id: data.id,
|
|
categoryId: data.category_id,
|
|
categoryName: data.category_name,
|
|
title: data.title,
|
|
summary: data.summary,
|
|
content: data.content,
|
|
legalRefs: data.legal_refs || [],
|
|
tags: data.tags || [],
|
|
relevance: data.relevance as WikiArticle['relevance'],
|
|
sourceUrls: data.source_urls || [],
|
|
version: data.version,
|
|
updatedAt: data.updated_at,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Full-text search across wiki articles
|
|
*/
|
|
async searchWiki(query: string): Promise<WikiSearchResult[]> {
|
|
const data = await this.fetchWithRetry<{ results: Array<{
|
|
id: string; title: string; summary: string; category_name: string;
|
|
relevance: string; highlight: string
|
|
}> }>(
|
|
`${this.baseUrl}/wiki?endpoint=search&q=${encodeURIComponent(query)}`,
|
|
{ method: 'GET' }
|
|
)
|
|
return (data.results || []).map(r => ({
|
|
id: r.id,
|
|
title: r.title,
|
|
summary: r.summary,
|
|
categoryName: r.category_name,
|
|
relevance: r.relevance,
|
|
highlight: r.highlight,
|
|
}))
|
|
}
|
|
|
|
/**
|
|
* 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, projectId?: string): SDKApiClient {
|
|
if (!clientInstance && !tenantId) {
|
|
throw new Error('SDKApiClient not initialized. Provide tenantId on first call.')
|
|
}
|
|
|
|
if (!clientInstance && tenantId) {
|
|
clientInstance = new SDKApiClient({ tenantId, projectId })
|
|
}
|
|
|
|
if (tenantId && clientInstance && clientInstance.getTenantId() !== tenantId) {
|
|
clientInstance.setTenantId(tenantId)
|
|
}
|
|
|
|
if (clientInstance) {
|
|
clientInstance.setProjectId(projectId)
|
|
}
|
|
|
|
return clientInstance!
|
|
}
|
|
|
|
export function resetSDKApiClient(): void {
|
|
if (clientInstance) {
|
|
clientInstance.cancelAllRequests()
|
|
}
|
|
clientInstance = null
|
|
}
|