Files
mdm-ordinis/ordinis-admin-ui/src/api/client.ts
T
2026-05-12 11:08:31 +00:00

651 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
/**
* Source of truth для access token. {@link TokenSync} обновляет это значение
* синхронно при изменении OIDC user state. Interceptor читает из in-memory
* holder'а — никаких {@code defaults.headers}, никаких {@code localStorage} poll'ов.
*
* <p>Зачем in-memory holder, не localStorage:
* <ul>
* <li>Race-free: interceptor и TokenSync видят один и тот же token immediately
* после {@code setAccessToken}, без ожидания useEffect cycle.</li>
* <li>Logout: {@code setAccessToken(null)} → следующий request immediately
* без Authorization, без zombie-header в {@code apiClient.defaults}.</li>
* <li>Security: access token не сохраняется в {@code localStorage} (XSS
* persistent reach). См. также {@code oidcConfig.userStore} → sessionStorage.</li>
* </ul>
*/
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<string, JsonSchema>
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<string, unknown>
dataScope: DataScope
validFrom: string
validTo: string
_meta?: { locale?: string }
}
export type RecordResponse = {
id: string
dictionaryId: string
businessKey: string
data: Record<string, unknown>
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<string, unknown>
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<string, unknown> | null
payloadAfter?: Record<string, unknown> | 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_****<last4>` или 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<string, unknown> | 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<string, unknown> | 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<SchemaDraftStatus> = 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
}