832 lines
24 KiB
TypeScript
832 lines
24 KiB
TypeScript
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[]
|
||
}
|