651 lines
17 KiB
TypeScript
651 lines
17 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
|
||
}
|
||
|
||
/**
|
||
* 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
|
||
}
|