Files
mdm-ordinis/ordinis-admin-ui/src/api/client.ts
T

454 lines
11 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
}
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<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
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
}