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