import axios from 'axios' /** * Source of truth для access token. {@link TokenSync} обновляет это значение * синхронно при изменении OIDC user state. Interceptor читает из in-memory * holder'а — никаких {@code defaults.headers}, никаких {@code localStorage} poll'ов. * *

Зачем in-memory holder, не localStorage: *

*/ let accessToken: string | null = null export const setAccessToken = (token: string | null): void => { accessToken = token } export const getAccessToken = (): string | null => accessToken /** * Handler для 401 → silent renew или redirect. Регистрируется {@link TokenSync} * один раз. Все 401 проходят через единственный interceptor ниже — * двойной handler (как раньше: один в client.ts, второй в TokenSync) приводил * к race signinSilent vs signinRedirect и redirect loop. */ export type Unauthorized401Handler = (error: unknown) => void let unauthorizedHandler: Unauthorized401Handler | null = null export const setUnauthorizedHandler = (h: Unauthorized401Handler | null): void => { unauthorizedHandler = h } export const apiClient = axios.create({ baseURL: import.meta.env.VITE_API_BASE ?? '/api/v1', timeout: 10_000, }) apiClient.interceptors.request.use((config) => { const lang = (typeof navigator !== 'undefined' && navigator.language) || 'ru-RU' config.headers['Accept-Language'] = `${lang},ru-RU;q=0.9,en-US;q=0.8` if (accessToken) { config.headers['Authorization'] = `Bearer ${accessToken}` } else { delete config.headers['Authorization'] } return config }) // Единственный 401 interceptor. Логика silent renew / redirect / loop guard // делегируется handler'у который TokenSync устанавливает через // setUnauthorizedHandler(). apiClient.interceptors.response.use( (resp) => resp, (error) => { if (error?.response?.status === 401 && unauthorizedHandler) { try { unauthorizedHandler(error) } catch (e) { console.error('[api] unauthorized handler failed', e) } } return Promise.reject(error) }, ) export type DataScope = 'PUBLIC' | 'INTERNAL' | 'RESTRICTED' export type DictionaryDefinition = { id: string name: string displayName?: string description?: string scope: DataScope schemaVersion: string bundle: string supportedLocales: string[] defaultLocale: string /** CEO plan E2: per-dict Redis projection materialization opt-in. */ redisProjectionEnabled: boolean /** Approval Workflow v2: per-dict opt-in. */ approvalRequired: boolean approvalMinRole?: string | null recordCount?: number createdAt: string updatedAt: string } export type JsonSchema = { $schema?: string type?: string title?: string description?: string properties?: Record required?: string[] enum?: unknown[] format?: string pattern?: string minLength?: number maxLength?: number minimum?: number maximum?: number items?: JsonSchema additionalProperties?: boolean | JsonSchema ['x-localized']?: boolean ['x-references']?: string ['x-id-source']?: string ['x-unique']?: boolean default?: unknown } export type DictionaryDetail = DictionaryDefinition & { schemaJson: JsonSchema } export type FlattenedRecord = { id: string businessKey: string data: Record dataScope: DataScope validFrom: string validTo: string _meta?: { locale?: string } } export type RecordResponse = { id: string dictionaryId: string businessKey: string data: Record geometryWkt?: string dataScope: DataScope validFrom: string validTo: string createdAt: string updatedAt: string createdBy?: string updatedBy?: string version: number } export type CreateDictionaryRequest = { name: string displayName?: string description?: string scope: DataScope schemaJson: JsonSchema schemaVersion?: string bundle?: string supportedLocales?: string[] defaultLocale?: string /** CEO plan E2: per-dict Redis projection opt-in. Default false. */ redisProjectionEnabled?: boolean /** Approval Workflow v2: per-dict opt-in. Default false. */ approvalRequired?: boolean /** Optional Keycloak role для reviewer'ов на этот dict. */ approvalMinRole?: string } export type CreateRecordRequest = { businessKey: string data: Record geometryWkt?: string validFrom?: string validTo?: string } export type BulkCloseRequest = { businessKeys: string[] at?: string reason?: string } export type BulkCloseResponse = { closed: string[] skipped: { businessKey: string; reason: string }[] errors: { businessKey: string; reason: string }[] } export type AuditAction = 'CREATE' | 'UPDATE' | 'CLOSE' export type AuditEntry = { id: number eventTime: string eventType: string action: AuditAction | string userId?: string | null userScope?: DataScope | null dictionaryName?: string | null dictionaryId?: string | null recordId?: string | null businessKey?: string | null payloadBefore?: Record | null payloadAfter?: Record | null traceId?: string | null requestId?: string | null ipAddress?: string | null userAgent?: string | null } export type AuditPage = { content: AuditEntry[] totalElements: number totalPages: number number: number size: number first: boolean last: boolean } export type AuditFilters = { dictionaryName?: string userId?: string action?: AuditAction | '' businessKey?: string from?: string to?: string page?: number size?: number } export type OutboxStats = { pending: number; dlq: number } export type DlqEntry = { id: number eventType: string dictionaryName?: string | null aggregateId: string kafkaTopic: string attemptCount: number lastError?: string | null createdAt: string dlqAt: string } export type DlqPage = { content: DlqEntry[] totalElements: number totalPages: number number: number size: number } // === Webhooks (v2) === export type WebhookSubscription = { id: string name: string url: string scopeFilter?: DataScope[] | null dictionaryFilter?: string[] | null eventTypeFilter?: string[] | null /** Masked `sk_****` или plaintext только в response создания/rotate. */ hmacSecret: string active: boolean createdAt: string createdBy: string updatedAt: string description?: string | null } export type CreateWebhookSubscriptionRequest = { name: string url: string scopeFilter?: DataScope[] dictionaryFilter?: string[] eventTypeFilter?: string[] active?: boolean description?: string } export type WebhookDeliveryStatus = 'pending' | 'retrying' | 'delivered' | 'dlq' export type WebhookDelivery = { id: number subscriptionId: string outboxEventId: number status: WebhookDeliveryStatus attemptCount: number createdAt: string nextAttemptAt: string lastAttemptAt?: string | null deliveredAt?: string | null lastError?: string | null lastStatusCode?: number | null dlqAt?: string | null } export type WebhookDeliveryPage = { content: WebhookDelivery[] totalElements: number totalPages: number number: number size: number } // === Approval Workflow v2 === export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN' export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE' export type DraftResponse = { id: string dictionaryId: string businessKey: string parentRecordId?: string | null operation: DraftOperation data?: Record | null geometryWkt?: string | null proposedValidFrom?: string | null proposedValidTo?: string | null status: DraftStatus makerId: string makerComment?: string | null submittedAt: string reviewerId?: string | null reviewedAt?: string | null reviewComment?: string | null } export type SubmitDraftRequest = { businessKey: string operation: DraftOperation data?: Record | null geometryWkt?: string | null validFrom?: string | null validTo?: string | null comment?: string | null } export type ReviewQueuePage = { items: DraftResponse[] page: number size: number totalElements: number totalPages: number } // === Smart search (CEO plan v1) === export type SearchItem = { businessKey: string dataScope: string createdAt: string } export type SearchDictGroup = { dictName: string dictDisplayName?: string | null count: number items: SearchItem[] } export type SearchResponse = { query: string total: number groups: SearchDictGroup[] } // === Dependents (Phase 1 dict-relationships-v2) === export type OnCloseAction = 'BLOCK' | 'WARN' | 'CASCADE' export type SchemaDependent = { sourceDict: string sourceDisplayName?: string | null sourceField: string targetField: string onClose: OnCloseAction /** Total active records in source dict (для UI badge "X записей всего"). */ activeRecordsInSourceDict: number } export type RecordDependent = { sourceDict: string sourceDisplayName?: string | null sourceField: string onClose: OnCloseAction id: string businessKey: string validFrom: string validTo: string createdAt: string } export type PerSourceSummary = { sourceDict: string sourceDisplayName?: string | null sourceField: string onClose: OnCloseAction count: number } export type LineageMeta = { /** True если backend использует materialized view; false — JSONB direct (fresh). */ mvEnabled: boolean /** ISO timestamp последнего успешного MV refresh; null если MV выключена / never. */ lastRefreshedAt: string | null /** "ok" | "error" | "never" — null если mvEnabled=false. */ lastStatus: string | null lastDurationMs: number | null } export type CascadeEntry = { sourceDict: string sourceDisplayName?: string | null businessKey: string sourceField: string onClose: OnCloseAction recordId: string } export type CascadePlan = { targetDict: string targetKey: string blockers: CascadeEntry[] warnings: CascadeEntry[] cascade: CascadeEntry[] totalDependents: number } export type CascadeCloseResult = { targetDict: string targetKey: string cascadedRecords: CascadeEntry[] warnings: CascadeEntry[] totalClosed: number } export type RecordDependentsPage = { targetDict: string targetBusinessKey: string items: RecordDependent[] perSource: PerSourceSummary[] total: number page: number size: number /** Phase 2 dict-relationships-v2: backing data source metadata + staleness. */ lineageMeta?: LineageMeta | null } export type WebhookTestPingResult = { /** success | failure | rejected */ status: 'success' | 'failure' | 'rejected' httpStatus?: number | null latencyMs?: number | null message?: string | null }