Files
mdm-ordinis/ordinis-admin-ui/src/api/client.ts
T
2026-06-22 09:18:50 +00:00

832 lines
24 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'
import { ensureFreshToken, getToken, isAuthenticated } from '@/lib/keycloak'
/**
* 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,
})
/**
* Request interceptor — altum-style. Перед каждым запросом:
* 1. Если юзер authenticated → ensureFreshToken(30): обновит если осталось
* < 30с до exp; на refresh failure ВНУТРЬ keycloak-js делает kc.login()
* (full redirect), interceptor не зацикливается.
* 2. Подкидывает `Authorization: Bearer ${getToken()}` если токен есть.
*
* Зачем явный refresh-перед-запросом vs фоновый `automaticSilentRenew` от
* oidc-client-ts: последний триггерил бесконечный POST /token loop когда
* refresh expired (см. MR !269 revert). Явный путь — один backchannel call
* перед запросом, никаких таймеров, никаких race'ов.
*/
apiClient.interceptors.request.use(async (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 (isAuthenticated()) {
await ensureFreshToken(30)
}
const token = getToken()
if (token) {
config.headers['Authorization'] = `Bearer ${token}`
} else {
delete config.headers['Authorization']
}
return config
})
// 401 interceptor — простая модель: на 401 от backend'а пробуем silent
// refresh + retry. На неудачу — full redirect через keycloak-js
// (ensureFreshToken внутри сделает kc.login() при refresh fail).
// Никаких 403→reauth (см. MR !269 revert): нельзя различить «нет JWT»
// от «scope insufficient», старая попытка вешала цикл с Keycloak.
apiClient.interceptors.response.use(
(resp) => resp,
async (error) => {
if (error?.response?.status === 401 && !error.config?._retried) {
try {
error.config._retried = true
await ensureFreshToken(0)
const token = getToken()
if (token) {
error.config.headers['Authorization'] = `Bearer ${token}`
return apiClient(error.config)
}
} catch (e) {
console.error('[api] 401 retry 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 }[]
}
/**
* Все backend audit action types — должны matchить AuditLogger.write(...) +
* SchemaDraftService.applyDecision(...) + DraftService.emitDraftEvent(...).
* QA report BUG-004: фильтр в audit предлагал только 3 (CREATE/UPDATE/CLOSE),
* остальные 8 не были фильтруемы.
*/
export type AuditAction =
| 'CREATE'
| 'UPDATE'
| 'CLOSE'
| 'DEFINITION_CREATE'
| 'DEFINITION_UPDATE'
| 'DRAFT_CREATE'
| 'DRAFT_SUBMIT'
| 'DRAFT_REVIEW'
| 'DRAFT_APPROVE'
| 'DRAFT_REJECT'
| 'DRAFT_CHANGES_REQUESTED'
| 'DRAFT_WITHDRAW'
| 'DRAFT_PUBLISH'
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
}
/** Один bucket histogram time-series stats. ISO ts + counts по status. */
export type WebhookStatsBucket = {
ts: string
pending: number
retrying: number
delivered: number
dlq: number
total: number
}
/** Response от GET /admin/webhooks/subscriptions/{id}/stats. */
export type WebhookDeliveryStats = {
bucketBy: 'hour' | 'day'
from: string
to: string
buckets: WebhookStatsBucket[]
}
// === Approval Workflow v2 ===
export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE' | 'REOPEN'
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_update'
| '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
}
/**
* Partial update existing draft без recreate. Status-gate enforced
* backend'ом: только DRAFT и CHANGES_REQUESTED. CHANGES_REQUESTED →
* DRAFT auto-transition после update (maker должен ре-submit).
*/
export type UpdateSchemaDraftRequest = {
/** Replacement JSON Schema (full document). undefined = не меняем. */
proposedSchema?: unknown
/** Новая maker rationale. undefined = не меняем. */
reason?: string
}
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.
*
* <p>Backend сериализует Spring {@code Page<SchemaDraft>} как:
* {@code {items, page, size, totalElements, totalPages}}. Раньше type
* объявлял {@code total} что было mismatched — consumers (`reviews.tsx`,
* `useReviewsBadgeCount`) читали {@code data.total} который === undefined,
* fallback'или на {@code items.length} (или 0 для badge — отсюда баг
* "бэйджа нет"). Type совпадает с фактическим контрактом backend'а. */
export type SchemaReviewQueuePage = {
items: SchemaDraft[]
page: number
size: number
totalElements: number
totalPages: 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
}
// === Notifications (TODO 7: draft decision toast + email) ===
/**
* Single notification row для current user. Backend serializes
* {@code notification_log} entry joined с {@code notification_read_state}
* (per-user unread tracking).
*
* <p>{@code payload} — JSON payload эвента (e.g. RecordDraftApproved).
* {@code payloadPreview} — server-pre-rendered summary string чтобы UI
* не парсил event payload (event_type → user-facing text mapping живёт
* backend'е).
*/
export type NotificationItem = {
id: string
eventType: string
channel: 'EMAIL' | 'EXPRESS' | 'TELEGRAM' | 'IN_APP'
sentAt: string
read: boolean
payloadPreview: string
/** Optional deep-link (e.g. /reviews/{draftId} для draft decision). */
href?: string | null
}
export type NotificationsResponse = {
items: NotificationItem[]
page: number
size: number
totalElements: number
/** Unread count for badge — pre-filtered server-side. */
totalUnread: number
}
/**
* Phase B-2 — per-(eventType, channel) notification opt-in/out для current
* user. Backend endpoint: GET/PUT /api/v1/me/notifications/preferences.
*
* <p>Matrix shape: 4 known event types × 3 channels = 12 toggles. User может
* opt-out 'Submitted via email' (queue noise) keep 'Approved via email'
* (action confirmation).
*
* <p>Defaults (server-side, для missing rows):
* email=true, express=false, telegram=false.
*
* <p>Resolution для каждой ячейки: specific row → wildcard '*' (Phase B
* compat) → default policy.
*
* <p>Express/Telegram channels не wired backend-side — toggles в UI disabled.
*/
export type ChannelToggles = {
email: boolean
express: boolean
telegram: boolean
}
/** Map keyed по event type. Backend сейчас возвращает 4 known events. */
export type NotificationPreferences = Record<string, ChannelToggles>
/**
* Phase C Don't Disturb — per-user quiet hours. Когда window активен,
* external channels (email + express) DROPPED dispatcher'ом. Bell badge
* остаётся.
*/
export type NotificationSettings = {
quietHoursEnabled: boolean
quietHoursStart: string | null // "HH:mm" or "HH:mm:ss"
quietHoursEnd: string | null
quietHoursTz: string // IANA, default "Europe/Moscow"
}
/**
* Canonical event types — должны совпадать с backend
* MeNotificationsPreferencesController.KNOWN_EVENT_TYPES.
*/
export const NOTIFICATION_EVENT_TYPES = [
'RecordDraftSubmitted',
'RecordDraftApproved',
'RecordDraftRejected',
'RecordDraftWithdrawn',
] as const
export type NotificationEventType = (typeof NOTIFICATION_EVENT_TYPES)[number]
/**
* Schema template summary (AI Schema Assist Phase 1, Approach A core).
* Curated skeleton schemas для quick start при создании нового справочника.
* Поле {@code icon} опционально — phosphor-icons name string.
*/
export type SchemaTemplateSummary = {
id: string
name: string
description: string
icon: string | null
tags: string[]
}
/** Full schema template c JSON Schema body (lazy fetch when user picks).
* schemaJson — curated server-side, прошёл валидацию при загрузке
* template'а; здесь типизируем как JsonSchema чтобы parseSchemaJson
* и downstream-консьюмеры не падали на TS2345 от unknown. */
export type SchemaTemplateDetail = SchemaTemplateSummary & {
schemaJson: JsonSchema
}
/**
* AI Schema Assist suggest-field response. Shape должен matchить
* AiSchemaController output (parsed LLM JSON: {fieldName, schema}).
*/
export type AiFieldSuggestion = {
fieldName: string
schema: Record<string, unknown>
}
/**
* Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с
* {@code validFrom > now AND validTo > now} в текущем scope view.
*
* <ul>
* <li>{@code count=0} → frontend hint не показывает.</li>
* <li>{@code nearestValidFrom} — ISO datetime ближайшей запланированной;
* используется в CTA «Перейти к дате» (time-travel).</li>
* </ul>
*/
export type ScheduledRecordsSummary = {
count: number
nearestValidFrom: string | null
/** Top-50 sorted asc upcoming validFrom ISO timestamps — для TimeTravel marks. */
upcomingValidFroms: string[]
/**
* Distinct businessKeys имеющие future scheduled version. Frontend строит
* Set<string> для O(1) lookup и рендерит «Scheduled» badge per row.
* Capped 500 (если больше — banner с count'ом всё равно info'нит).
*/
scheduledBusinessKeys: string[]
}