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 } /** * baseURL построение: * - Если VITE_API_BASE задан и заканчивается на /api/v1 (или содержит api/) — * используем как есть. * - Если VITE_API_BASE задан без api-prefix (e.g. https://api.example.com) — * автоматически добавляем `/api/v1` суффикс. * - Default: `/api/v1` (relative — admin-ui nginx proxies to writer/read-api). * * Раньше ambiguous — если кто-то задал `VITE_API_BASE=https://api.example.com` * без `/v1`, все запросы становились `/dictionaries` относительно корня * (правильно `/api/v1/dictionaries`). Теперь fall-through к expected layout. */ const buildBaseURL = (): string => { const env = import.meta.env.VITE_API_BASE if (!env) return '/api/v1' const trimmed = env.replace(/\/+$/, '') if (/\/api(\/|$)/i.test(trimmed)) return trimmed return `${trimmed}/api/v1` } export const apiClient = axios.create({ baseURL: buildBaseURL(), 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 /** * Section bucket for form rendering. Если задан — поле попадает в эту * секцию (можно использовать predefined ID: identity / references / * description / dates / geometry / physical / extra; или любой custom * string — он отобразится с humanized fallback меткой). * * Без `x-section` SchemaDrivenForm.deriveSection auto-derive'ит секцию * по эвристикам (FK→references, localized→description, format=date→dates, * geometry-like keys→geometry, required→identity, else→extra). */ ['x-section']?: string 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 } // === Changelog (v2.4.0 + full timeline + diff) === /** * Frontend-friendly event kind. Backend mapping см. * ChangelogService.mapEventTypeToKind. Используем для icon/color/label * выбора в History tab. Versioning events * (definition_create / definition_update / draft_publish) показываются * с version label; promotional draft events идут без version. */ export type ChangelogKind = | 'definition_create' | 'definition_update' | 'draft_create' | 'draft_review' | 'draft_approve' | 'draft_reject' | 'draft_changes_requested' | 'draft_publish' | 'draft_withdraw' export type ChangelogPublisher = { /** JWT sub claim (Keycloak user id). */ sub: string /** Display name. Backend пока эхает sub; готовим место под user-resolve. */ name: string } export type ChangelogDiffSummary = { added: string[] removed: string[] changed: string[] } export type ChangelogEntry = { /** audit_log.id — deep-link на diff endpoint. */ id: number /** Semver. null для promotional draft events. */ version: string | null publishedAt: string publishedBy: ChangelogPublisher /** Header summary text для card UI. */ headers: string diff: ChangelogDiffSummary recordsAffected: number isCurrent: boolean kind: ChangelogKind } export type ChangelogResponse = { dictionary: string currentVersion: string entries: ChangelogEntry[] } /** * Full before/after schemas для side-by-side render. before=null для * definition_create. Для draft_* (не publish) — after=proposed_schema, * before=live schema на момент event'а (то что предлагалось vs реальность). */ // === Schema workflow (maker-checker drafts, v2.2.0 backend) === /** * Server возвращает status в lowercase ({@code "draft"}, {@code "review_pending"}, * ...) — match'им backend dbValue() напрямую, без перевода в frontend. * * Terminal: REJECTED, PUBLISHED, WITHDRAWN. * Active: DRAFT, REVIEW_PENDING, APPROVED, CHANGES_REQUESTED. */ export type SchemaDraftStatus = | 'draft' | 'review_pending' | 'approved' | 'changes_requested' | 'rejected' | 'published' | 'withdrawn' export const TERMINAL_SCHEMA_DRAFT_STATUSES: ReadonlySet = new Set([ 'rejected', 'published', 'withdrawn', ]) export const isActiveSchemaDraft = (s: SchemaDraftStatus): boolean => !TERMINAL_SCHEMA_DRAFT_STATUSES.has(s) export type SchemaDraft = { draftId: string dictionaryId: string dictionaryName: string status: SchemaDraftStatus branchedFromVersion: string /** Full proposed JSON Schema. */ proposedSchema: unknown reason?: string | null makerId: string createdAt: string submittedAt?: string | null /** Optional reviewer list (NULL = open pool). */ reviewers?: unknown | null reviewNote?: string | null decisionUserId?: string | null decisionAt?: string | null decisionComment?: string | null publishedAt?: string | null publishedVersion?: string | null publishNote?: string | null /** JPA optimistic lock counter. */ version: number } export type CreateSchemaDraftRequest = { /** Current HEAD schema version this draft branches from. */ fromVersion: string reason?: string /** Full proposed JSON Schema (replace, not patch). */ proposedSchema: unknown } export type SubmitForReviewRequest = { /** Optional explicit reviewer IDs. NULL/[] = pool. */ reviewers?: string[] | null note?: string | null } export type SchemaDraftVerdict = 'APPROVE' | 'REQUEST_CHANGES' | 'REJECT' export type DecisionRequest = { decision: SchemaDraftVerdict comment?: string } export type PublishSchemaDraftRequest = { /** Optimistic lock — current schema version от UI. */ expectedHeadVersion: string publishNote?: string } /** Response для POST /publish — wraps draft + computed bump. */ export type PublishSchemaDraftResult = { draft: SchemaDraft newVersion: string publishedAt: string } /** Pool queue для /admin/schema-reviews/pending. */ export type SchemaReviewQueuePage = { items: SchemaDraft[] page: number size: number total: number } export type ChangelogDiff = { id: number dictionaryName: string /** Raw audit event_type (SCHEMA_UPDATED / SCHEMA_DRAFT_PUBLISHED / ...). */ eventType: string kind: ChangelogKind occurredAt: string publishedBy: ChangelogPublisher before: unknown | null after: unknown | null summary: ChangelogDiffSummary }