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:
*
* - Race-free: interceptor и TokenSync видят один и тот же token immediately
* после {@code setAccessToken}, без ожидания useEffect cycle.
* - Logout: {@code setAccessToken(null)} → следующий request immediately
* без Authorization, без zombie-header в {@code apiClient.defaults}.
* - Security: access token не сохраняется в {@code localStorage} (XSS
* persistent reach). См. также {@code oidcConfig.userStore} → sessionStorage.
*
*/
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
}