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 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 dataScope: DataScope validFrom: string validTo: string _meta?: { locale?: string } } export type RecordResponse = { id: string dictionaryId: string businessKey: string data: Record 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 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 | null payloadAfter?: Record | 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_****` или 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 | 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 | 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 = 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. * *

Backend сериализует Spring {@code Page} как: * {@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). * *

{@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. * *

Matrix shape: 4 known event types × 3 channels = 12 toggles. User может * opt-out 'Submitted via email' (queue noise) keep 'Approved via email' * (action confirmation). * *

Defaults (server-side, для missing rows): * email=true, express=false, telegram=false. * *

Resolution для каждой ячейки: specific row → wildcard '*' (Phase B * compat) → default policy. * *

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 /** * 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 } /** * Empty-state hint payload (read-api scheduled-summary). Подсчёт записей с * {@code validFrom > now AND validTo > now} в текущем scope view. * *

    *
  • {@code count=0} → frontend hint не показывает.
  • *
  • {@code nearestValidFrom} — ISO datetime ближайшей запланированной; * используется в CTA «Перейти к дате» (time-travel).
  • *
*/ 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 для O(1) lookup и рендерит «Scheduled» badge per row. * Capped 500 (если больше — banner с count'ом всё равно info'нит). */ scheduledBusinessKeys: string[] }