3f05741ed1
Backend: GET /api/v1/admin/users (INTERNAL+ scope) — list cached юзеров из user_display_cache. UserDisplayService.listAll(limit) сортирует по preferred_username, hard-cap 1000. Контроллер: UserDisplayController.list(). Frontend: - queries.ts → useAllUsers() / allUsersQuery (5min stale, 403/404 → []). - components/audit/UserPicker.tsx (новый, ~140 строк) — cmdk-powered combobox: trigger показывает username (или UUID prefix fallback), popover c поиском по preferredUsername/email/UUID. X сбрасывает selection. - routes/audit.tsx — TextInput → UserPicker. UX: раньше юзер вручную копипастил UUID из audit-row'а; теперь поиск по имени/email типа `solov` → находит `solovyev.da`.
1022 lines
35 KiB
TypeScript
1022 lines
35 KiB
TypeScript
import { useQuery, queryOptions } from '@tanstack/react-query'
|
||
import axios from 'axios'
|
||
import {
|
||
apiClient,
|
||
type AuditFilters,
|
||
type AuditPage,
|
||
type CascadePlan,
|
||
type ChangelogDiff,
|
||
type ChangelogResponse,
|
||
type NotificationPreferences,
|
||
type NotificationsResponse,
|
||
type SchemaDraft,
|
||
type SchemaReviewQueuePage,
|
||
type DictionaryDefinition,
|
||
type DictionaryDetail,
|
||
type DlqPage,
|
||
type DraftResponse,
|
||
type FlattenedRecord,
|
||
type OutboxStats,
|
||
type RecordDependentsPage,
|
||
type RecordResponse,
|
||
type ReviewQueuePage,
|
||
type ScheduledRecordsSummary,
|
||
type SchemaDependent,
|
||
type SchemaTemplateDetail,
|
||
type SchemaTemplateSummary,
|
||
type SearchResponse,
|
||
type WebhookDeliveryPage,
|
||
type WebhookDeliveryStats,
|
||
type WebhookSubscription,
|
||
} from './client'
|
||
|
||
export const dictionariesQuery = queryOptions({
|
||
queryKey: ['dictionaries'] as const,
|
||
queryFn: async (): Promise<DictionaryDefinition[]> => {
|
||
// Backend по default возвращает только PUBLIC scope (~user feedback:
|
||
// «куда опять internal словари делись»). Передаём весь triplet —
|
||
// server-side всё равно отфильтрует по реальным scope permissions
|
||
// юзера, и invisible scopes просто не вернутся. Это match'ит то что
|
||
// useRecords / catalog row делает per-record (см. строку ~65).
|
||
const { data } = await apiClient.get<DictionaryDefinition[]>('/dictionaries', {
|
||
params: { as_scope: 'PUBLIC,INTERNAL,RESTRICTED' },
|
||
})
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const dictionaryDetailQuery = (name: string) =>
|
||
queryOptions({
|
||
queryKey: ['dictionary', name] as const,
|
||
queryFn: async (): Promise<DictionaryDetail> => {
|
||
// Same as listing — pass all scopes; server-side filter применит
|
||
// user permissions. Без этого INTERNAL dict вернёт 404 даже для
|
||
// авторизованного юзера с INTERNAL access.
|
||
const { data } = await apiClient.get<DictionaryDetail>(`/dictionaries/${name}`, {
|
||
params: { as_scope: 'PUBLIC,INTERNAL,RESTRICTED' },
|
||
})
|
||
return data
|
||
},
|
||
})
|
||
|
||
export type RecordsFilter = {
|
||
/** CSV bbox: west,south,east,north */
|
||
bbox?: string
|
||
/** GeoJSON Polygon as raw object — serialized as JSON в query string. */
|
||
polygon?: GeoJSON.Polygon
|
||
/** ISO datetime для time-travel запроса (CEO plan time-travel UI).
|
||
* Backend (read-api) findActiveAt(dict, key, at) возвращает active version
|
||
* на этот момент. Без этого param'а — now(). */
|
||
at?: string
|
||
}
|
||
|
||
export const recordsQuery = (
|
||
dictionaryName: string,
|
||
scopeCsv: string,
|
||
filter?: RecordsFilter,
|
||
) =>
|
||
queryOptions({
|
||
queryKey: [
|
||
'records',
|
||
dictionaryName,
|
||
scopeCsv,
|
||
filter?.bbox ?? null,
|
||
filter?.polygon ? JSON.stringify(filter.polygon) : null,
|
||
filter?.at ?? null,
|
||
] as const,
|
||
queryFn: async (): Promise<FlattenedRecord[]> => {
|
||
const params: Record<string, string> = { as_scope: scopeCsv }
|
||
if (filter?.bbox) params.bbox = filter.bbox
|
||
if (filter?.polygon) params.polygon = JSON.stringify(filter.polygon)
|
||
if (filter?.at) params.at = filter.at
|
||
const { data } = await apiClient.get<FlattenedRecord[]>(
|
||
`/${dictionaryName}/records`,
|
||
{ params },
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
/**
|
||
* Fetch records from a target dictionary for use as FK reference options.
|
||
* Used by SchemaDrivenForm when a property has `x-references: dict.field`.
|
||
*
|
||
* Returns 404 → empty array (scope-hide: target dict not accessible).
|
||
* 5min staleTime — FK targets are typically slow-moving reference data.
|
||
*/
|
||
export const referencedRecordsQuery = (dictionaryName: string) =>
|
||
queryOptions({
|
||
queryKey: ['fk-options', dictionaryName] as const,
|
||
queryFn: async (): Promise<FlattenedRecord[]> => {
|
||
try {
|
||
const { data } = await apiClient.get<FlattenedRecord[]>(
|
||
`/${dictionaryName}/records`,
|
||
{ params: { as_scope: 'PUBLIC,INTERNAL,RESTRICTED' } },
|
||
)
|
||
return data
|
||
} catch (e: unknown) {
|
||
const status = (e as { response?: { status?: number } })?.response?.status
|
||
if (status === 404 || status === 403) return []
|
||
throw e
|
||
}
|
||
},
|
||
staleTime: 5 * 60_000,
|
||
})
|
||
|
||
export const useReferencedRecords = (dictionaryName: string | undefined) =>
|
||
useQuery({
|
||
...referencedRecordsQuery(dictionaryName ?? ''),
|
||
enabled: Boolean(dictionaryName),
|
||
})
|
||
|
||
export const recordHistoryQuery = (dictionaryName: string, businessKey: string) =>
|
||
queryOptions({
|
||
queryKey: ['record-history', dictionaryName, businessKey] as const,
|
||
queryFn: async (): Promise<RecordResponse[]> => {
|
||
const { data } = await apiClient.get<RecordResponse[]>(
|
||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(businessKey)}/history`,
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useRecordHistory = (
|
||
dictionaryName: string,
|
||
businessKey: string | undefined,
|
||
) =>
|
||
useQuery({
|
||
...recordHistoryQuery(dictionaryName, businessKey ?? ''),
|
||
enabled: Boolean(businessKey),
|
||
})
|
||
|
||
// === Changelog (schema versions timeline) ============================
|
||
|
||
/**
|
||
* Per-dict changelog. Backend default limit=50; покрывает типичные ~5-30
|
||
* schema versions без pagination. Cursor можно добавить позже без
|
||
* breaking change.
|
||
*/
|
||
export const changelogQuery = (dictionaryName: string, limit = 50) =>
|
||
queryOptions({
|
||
queryKey: ['changelog', dictionaryName, limit] as const,
|
||
queryFn: async (): Promise<ChangelogResponse> => {
|
||
const { data } = await apiClient.get<ChangelogResponse>(
|
||
`/dictionaries/${dictionaryName}/changelog`,
|
||
{ params: { limit } },
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useChangelog = (dictionaryName: string | undefined, limit = 50) =>
|
||
useQuery({
|
||
...changelogQuery(dictionaryName ?? '', limit),
|
||
enabled: Boolean(dictionaryName),
|
||
})
|
||
|
||
/**
|
||
* Full before/after diff payload для одного changelog entry'я. Lazy —
|
||
* fetch только когда юзер раскрывает Compare modal. Кешируем по
|
||
* (dict, entryId) — diff immutable, можно держать stale infinitely
|
||
* (audit_log row не меняется).
|
||
*/
|
||
export const changelogDiffQuery = (dictionaryName: string, entryId: number) =>
|
||
queryOptions({
|
||
queryKey: ['changelog-diff', dictionaryName, entryId] as const,
|
||
queryFn: async (): Promise<ChangelogDiff> => {
|
||
const { data } = await apiClient.get<ChangelogDiff>(
|
||
`/dictionaries/${dictionaryName}/changelog/${entryId}/diff`,
|
||
)
|
||
return data
|
||
},
|
||
staleTime: Infinity,
|
||
})
|
||
|
||
export const useChangelogDiff = (
|
||
dictionaryName: string | undefined,
|
||
entryId: number | undefined,
|
||
) =>
|
||
useQuery({
|
||
...changelogDiffQuery(dictionaryName ?? '', entryId ?? 0),
|
||
enabled: Boolean(dictionaryName) && Boolean(entryId),
|
||
})
|
||
|
||
// === Schema workflow drafts (Phase 1a) ================================
|
||
|
||
/**
|
||
* Active schema draft на словаре, если есть. Backend возвращает 404
|
||
* {@code no_active_draft} когда нет non-terminal draft'а — мы маппим
|
||
* это в null чтобы UI рендерил «no draft» state без error toast'а.
|
||
*
|
||
* <p>refetchInterval=20s — banner должен быстро отражать «отправили
|
||
* на ревью» / «approved» transition от другого пользователя.
|
||
*/
|
||
export const dictActiveSchemaDraftQuery = (dictName: string) =>
|
||
queryOptions({
|
||
queryKey: ['schema-draft-active', dictName] as const,
|
||
queryFn: async (): Promise<SchemaDraft | null> => {
|
||
try {
|
||
const { data } = await apiClient.get<SchemaDraft>(
|
||
`/dictionaries/${dictName}/drafts/active`,
|
||
)
|
||
return data
|
||
} catch (err) {
|
||
// 404 no_active_draft — это not-an-error, отдаём null.
|
||
if (axios.isAxiosError(err) && err.response?.status === 404) {
|
||
return null
|
||
}
|
||
throw err
|
||
}
|
||
},
|
||
refetchInterval: 20_000,
|
||
})
|
||
|
||
export const useDictActiveSchemaDraft = (dictName: string | undefined) =>
|
||
useQuery({
|
||
...dictActiveSchemaDraftQuery(dictName ?? ''),
|
||
enabled: Boolean(dictName),
|
||
})
|
||
|
||
/** History всех schema drafts на словаре (для admin audit / settings tab). */
|
||
export const dictSchemaDraftsQuery = (dictName: string) =>
|
||
queryOptions({
|
||
queryKey: ['schema-drafts', dictName] as const,
|
||
queryFn: async (): Promise<SchemaDraft[]> => {
|
||
const { data } = await apiClient.get<SchemaDraft[]>(
|
||
`/dictionaries/${dictName}/drafts`,
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useDictSchemaDrafts = (dictName: string | undefined) =>
|
||
useQuery({
|
||
...dictSchemaDraftsQuery(dictName ?? ''),
|
||
enabled: Boolean(dictName),
|
||
})
|
||
|
||
/** Single draft drill-in (decision modal, deep-link). */
|
||
export const schemaDraftQuery = (dictName: string, draftId: string) =>
|
||
queryOptions({
|
||
queryKey: ['schema-draft', dictName, draftId] as const,
|
||
queryFn: async (): Promise<SchemaDraft> => {
|
||
const { data } = await apiClient.get<SchemaDraft>(
|
||
`/dictionaries/${dictName}/drafts/${draftId}`,
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useSchemaDraft = (
|
||
dictName: string | undefined,
|
||
draftId: string | undefined,
|
||
) =>
|
||
useQuery({
|
||
...schemaDraftQuery(dictName ?? '', draftId ?? ''),
|
||
enabled: Boolean(dictName) && Boolean(draftId),
|
||
})
|
||
|
||
/**
|
||
* Global pool queue для approvers — все review_pending drafts.
|
||
* Backend response: {items, page, size, total}.
|
||
*/
|
||
export const schemaReviewQueueQuery = (page = 0, size = 50) =>
|
||
queryOptions({
|
||
queryKey: ['schema-review-queue', page, size] as const,
|
||
queryFn: async (): Promise<SchemaReviewQueuePage> => {
|
||
const { data } = await apiClient.get<SchemaReviewQueuePage>(
|
||
'/admin/schema-reviews/pending',
|
||
{ params: { page, size } },
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useSchemaReviewQueue = (page = 0, size = 50) =>
|
||
useQuery(schemaReviewQueueQuery(page, size))
|
||
|
||
/**
|
||
* Maker self-tracking — все мои schema drafts (любой статус). Зеркало
|
||
* `myDraftsQuery` для record drafts. Frontend объединяет обе очереди
|
||
* на /my-drafts, чтобы пользователь не запоминал какой тип где.
|
||
*/
|
||
export const mySchemaDraftsQuery = (page = 0, size = 50) =>
|
||
queryOptions({
|
||
queryKey: ['schema-drafts', 'me', page, size] as const,
|
||
queryFn: async (): Promise<SchemaReviewQueuePage> => {
|
||
const { data } = await apiClient.get<SchemaReviewQueuePage>(
|
||
'/admin/schema-drafts/me',
|
||
{ params: { page, size } },
|
||
)
|
||
return data
|
||
},
|
||
refetchInterval: 30_000,
|
||
})
|
||
|
||
export const useMySchemaDrafts = (page = 0, size = 50) =>
|
||
useQuery(mySchemaDraftsQuery(page, size))
|
||
|
||
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
|
||
queryOptions({
|
||
queryKey: ['record-raw', dictionaryName, businessKey] as const,
|
||
queryFn: async (): Promise<RecordResponse> => {
|
||
const { data } = await apiClient.get<RecordResponse>(
|
||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(businessKey)}`,
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
const auditParams = (f: AuditFilters): Record<string, string | number> => {
|
||
const out: Record<string, string | number> = {
|
||
page: f.page ?? 0,
|
||
size: f.size ?? 50,
|
||
}
|
||
if (f.dictionaryName) out.dictionaryName = f.dictionaryName
|
||
if (f.userId) out.userId = f.userId
|
||
if (f.action) out.action = f.action
|
||
if (f.businessKey) out.businessKey = f.businessKey
|
||
if (f.from) out.from = f.from
|
||
if (f.to) out.to = f.to
|
||
return out
|
||
}
|
||
|
||
export const auditQuery = (f: AuditFilters) =>
|
||
queryOptions({
|
||
queryKey: ['audit', f] as const,
|
||
queryFn: async (): Promise<AuditPage> => {
|
||
const { data } = await apiClient.get<AuditPage>('/admin/audit', {
|
||
params: auditParams(f),
|
||
})
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useAudit = (f: AuditFilters) => useQuery(auditQuery(f))
|
||
|
||
export const buildAuditExportUrl = (f: AuditFilters): string => {
|
||
const base = (apiClient.defaults.baseURL ?? '/api/v1').replace(/\/$/, '')
|
||
const sp = new URLSearchParams()
|
||
if (f.dictionaryName) sp.set('dictionaryName', f.dictionaryName)
|
||
if (f.userId) sp.set('userId', f.userId)
|
||
if (f.action) sp.set('action', f.action)
|
||
if (f.businessKey) sp.set('businessKey', f.businessKey)
|
||
if (f.from) sp.set('from', f.from)
|
||
if (f.to) sp.set('to', f.to)
|
||
const qs = sp.toString()
|
||
return `${base}/admin/audit/export.csv${qs ? `?${qs}` : ''}`
|
||
}
|
||
|
||
export const outboxStatsQuery = queryOptions({
|
||
queryKey: ['outbox', 'stats'] as const,
|
||
queryFn: async (): Promise<OutboxStats> => {
|
||
const { data } = await apiClient.get<OutboxStats>('/admin/outbox/stats')
|
||
return data
|
||
},
|
||
refetchInterval: 30_000,
|
||
})
|
||
|
||
/**
|
||
* Outbox stats poll. Backend gate INTERNAL scope — пользователь без
|
||
* нужной роли получает 403. Передавай {@code enabled=false} для
|
||
* unauthenticated/non-INTERNAL users чтобы не спамить console 403'ами.
|
||
*/
|
||
export const useOutboxStats = (enabled = true) =>
|
||
useQuery({ ...outboxStatsQuery, enabled })
|
||
|
||
export const dlqQuery = (page: number, size: number) =>
|
||
queryOptions({
|
||
queryKey: ['outbox', 'dlq', page, size] as const,
|
||
queryFn: async (): Promise<DlqPage> => {
|
||
const { data } = await apiClient.get<DlqPage>('/admin/outbox/dlq', {
|
||
params: { page, size },
|
||
})
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useDlq = (page: number, size: number) => useQuery(dlqQuery(page, size))
|
||
|
||
// === Webhooks (v2) ===
|
||
|
||
export const webhookSubscriptionsQuery = queryOptions({
|
||
queryKey: ['webhooks', 'subscriptions'] as const,
|
||
queryFn: async (): Promise<WebhookSubscription[]> => {
|
||
const { data } = await apiClient.get<WebhookSubscription[]>(
|
||
'/admin/webhooks/subscriptions',
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
/**
|
||
* Webhook subscriptions list. INTERNAL-scoped (admin/webhooks/...). Same
|
||
* pattern as {@link useOutboxStats} — pass {@code enabled=false} для
|
||
* pollers (Sidebar badge) когда user не имеет INTERNAL scope, чтобы
|
||
* избежать 403 spam.
|
||
*/
|
||
export const useWebhookSubscriptions = (enabled = true) =>
|
||
useQuery({ ...webhookSubscriptionsQuery, enabled })
|
||
|
||
export const webhookSubscriptionQuery = (id: string) =>
|
||
queryOptions({
|
||
queryKey: ['webhooks', 'subscriptions', id] as const,
|
||
queryFn: async (): Promise<WebhookSubscription> => {
|
||
const { data } = await apiClient.get<WebhookSubscription>(
|
||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}`,
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useWebhookSubscription = (id: string) =>
|
||
useQuery({
|
||
...webhookSubscriptionQuery(id),
|
||
enabled: Boolean(id),
|
||
})
|
||
|
||
export const webhookDeliveriesQuery = (
|
||
id: string,
|
||
page: number,
|
||
size: number,
|
||
statusFilter?: string,
|
||
) =>
|
||
queryOptions({
|
||
queryKey: ['webhooks', 'deliveries', id, page, size, statusFilter ?? null] as const,
|
||
queryFn: async (): Promise<WebhookDeliveryPage> => {
|
||
const params: Record<string, string | number> = { page, size }
|
||
if (statusFilter) params.status = statusFilter
|
||
const { data } = await apiClient.get<WebhookDeliveryPage>(
|
||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/deliveries`,
|
||
{ params },
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useWebhookDeliveries = (
|
||
id: string,
|
||
page: number,
|
||
size: number,
|
||
statusFilter?: string,
|
||
) =>
|
||
useQuery({
|
||
...webhookDeliveriesQuery(id, page, size, statusFilter),
|
||
enabled: Boolean(id),
|
||
})
|
||
|
||
export const webhookDlqQuery = (page: number, size: number) =>
|
||
queryOptions({
|
||
queryKey: ['webhooks', 'dlq', page, size] as const,
|
||
queryFn: async (): Promise<WebhookDeliveryPage> => {
|
||
const { data } = await apiClient.get<WebhookDeliveryPage>(
|
||
'/admin/webhooks/deliveries/dlq',
|
||
{ params: { page, size } },
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useWebhookDlq = (page: number, size: number) =>
|
||
useQuery(webhookDlqQuery(page, size))
|
||
|
||
export const webhookStatsQuery = (
|
||
id: string,
|
||
bucketBy: 'hour' | 'day',
|
||
fromIso?: string,
|
||
toIso?: string,
|
||
) =>
|
||
queryOptions({
|
||
queryKey: [
|
||
'webhooks',
|
||
'stats',
|
||
id,
|
||
bucketBy,
|
||
fromIso ?? null,
|
||
toIso ?? null,
|
||
] as const,
|
||
queryFn: async (): Promise<WebhookDeliveryStats> => {
|
||
const params: Record<string, string> = { bucketBy }
|
||
if (fromIso) params.from = fromIso
|
||
if (toIso) params.to = toIso
|
||
const { data } = await apiClient.get<WebhookDeliveryStats>(
|
||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/stats`,
|
||
{ params },
|
||
)
|
||
return data
|
||
},
|
||
// Stats для admin dashboard — fresh data важна, но автополлинг мы
|
||
// отключаем (юзер сам refresh'ит / меняет range).
|
||
staleTime: 30 * 1000,
|
||
})
|
||
|
||
export const useWebhookStats = (
|
||
id: string,
|
||
bucketBy: 'hour' | 'day',
|
||
fromIso?: string,
|
||
toIso?: string,
|
||
) =>
|
||
useQuery({
|
||
...webhookStatsQuery(id, bucketBy, fromIso, toIso),
|
||
enabled: Boolean(id),
|
||
})
|
||
|
||
// === Dependents (Phase 1 dict-relationships-v2) ===
|
||
|
||
/**
|
||
* Schema-level reverse FK list для target dict — какие dict.field ссылаются.
|
||
* 5 min staleTime: schema-level changes идут через bundle import (редко).
|
||
*
|
||
* 404 / 403 → пустой list (consistent с serverside scope-hide).
|
||
*/
|
||
export const dictionaryDependentsQuery = (name: string) =>
|
||
queryOptions({
|
||
queryKey: ['dictionary-dependents', name] as const,
|
||
queryFn: async (): Promise<SchemaDependent[]> => {
|
||
try {
|
||
const { data } = await apiClient.get<SchemaDependent[]>(
|
||
`/dictionaries/${encodeURIComponent(name)}/dependents`,
|
||
)
|
||
return data
|
||
} catch (e: unknown) {
|
||
const status = (e as { response?: { status?: number } })?.response?.status
|
||
if (status === 404 || status === 403) return []
|
||
throw e
|
||
}
|
||
},
|
||
staleTime: 5 * 60_000,
|
||
})
|
||
|
||
export const useDictionaryDependents = (name: string | undefined) =>
|
||
useQuery({
|
||
...dictionaryDependentsQuery(name ?? ''),
|
||
enabled: Boolean(name),
|
||
})
|
||
|
||
/**
|
||
* Record-level dependents — paginated. Disabled пока businessKey пустой
|
||
* (closed drawer / no selection).
|
||
*/
|
||
export const recordDependentsQuery = (
|
||
dict: string,
|
||
businessKey: string,
|
||
page: number,
|
||
size: number,
|
||
) =>
|
||
queryOptions({
|
||
queryKey: ['record-dependents', dict, businessKey, page, size] as const,
|
||
queryFn: async (): Promise<RecordDependentsPage> => {
|
||
const { data } = await apiClient.get<RecordDependentsPage>(
|
||
`/records/${encodeURIComponent(dict)}/${encodeURIComponent(businessKey)}/dependents`,
|
||
{ params: { page, size } },
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useRecordDependents = (
|
||
dict: string,
|
||
businessKey: string | undefined,
|
||
page = 0,
|
||
size = 50,
|
||
) =>
|
||
useQuery({
|
||
...recordDependentsQuery(dict, businessKey ?? '', page, size),
|
||
enabled: Boolean(businessKey),
|
||
})
|
||
|
||
/**
|
||
* Cascade preview — read-only план: blockers / warnings / cascade entries.
|
||
* Используется UI'ем перед открытием cascade confirmation dialog.
|
||
*
|
||
* <p>Не cache'ится агрессивно (staleTime=0), потому что dependents
|
||
* быстро меняются — preview всегда должен быть fresh.
|
||
*/
|
||
export const cascadePreviewQuery = (dict: string, businessKey: string) =>
|
||
queryOptions({
|
||
queryKey: ['cascade-preview', dict, businessKey] as const,
|
||
queryFn: async (): Promise<CascadePlan> => {
|
||
const { data } = await apiClient.get<CascadePlan>(
|
||
`/dictionaries/${encodeURIComponent(dict)}/records/${encodeURIComponent(businessKey)}/cascade-preview`,
|
||
)
|
||
return data
|
||
},
|
||
staleTime: 0,
|
||
})
|
||
|
||
export const useCascadePreview = (dict: string, businessKey: string | undefined) =>
|
||
useQuery({
|
||
...cascadePreviewQuery(dict, businessKey ?? ''),
|
||
enabled: Boolean(businessKey),
|
||
})
|
||
|
||
// === Smart search (CEO plan v1) ===
|
||
|
||
/**
|
||
* Free-form ILIKE search across all dictionaries (active records only).
|
||
* Min query length = 3 (backend требует для использования trigram index).
|
||
*
|
||
* Min-length guard: throw в queryFn — защищает от prefetch (router loader)
|
||
* вызовов с q="ab" → backend 400. queryFn enforces invariant; useQuery
|
||
* `enabled` gating опциональный для UI shortcut, но не source of truth.
|
||
*/
|
||
export const SEARCH_MIN_LEN = 3
|
||
|
||
export const searchQuery = (q: string, size = 100, perDict = 10) =>
|
||
queryOptions({
|
||
queryKey: ['search', q, size, perDict] as const,
|
||
queryFn: async (): Promise<SearchResponse> => {
|
||
if (q.length < SEARCH_MIN_LEN) {
|
||
throw new Error(`search query too short (min ${SEARCH_MIN_LEN} chars)`)
|
||
}
|
||
const { data } = await apiClient.get<SearchResponse>('/search', {
|
||
params: { q, size, perDict },
|
||
})
|
||
return data
|
||
},
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
|
||
useQuery({
|
||
...searchQuery(q ?? '', size, perDict),
|
||
enabled: Boolean(q && q.length >= SEARCH_MIN_LEN),
|
||
})
|
||
|
||
// === Approval Workflow v2 ===
|
||
|
||
/** Global reviewer queue — pool model (D3=A). All pending drafts FIFO. */
|
||
export const reviewQueueQuery = (page = 0, size = 50) =>
|
||
queryOptions({
|
||
queryKey: ['review-queue', page, size] as const,
|
||
queryFn: async (): Promise<ReviewQueuePage> => {
|
||
const { data } = await apiClient.get<ReviewQueuePage>('/admin/reviews/pending', {
|
||
params: { page, size },
|
||
})
|
||
return data
|
||
},
|
||
refetchInterval: 30_000,
|
||
})
|
||
|
||
export const useReviewQueue = (page = 0, size = 50) =>
|
||
useQuery(reviewQueueQuery(page, size))
|
||
|
||
/**
|
||
* Lightweight count для sidebar badge — fetch only metadata (size=1).
|
||
*
|
||
* <p>Combines record-reviews + schema-reviews queues. Возвращает сумму pending
|
||
* drafts обоих видов. Поллится каждые 30s в синк с queue queries — reviewer
|
||
* увидит свежий counter при возврате на app даже если /reviews не открыт.
|
||
*
|
||
* <p>Gated на authenticated — anonymous не должен дёргать admin endpoint'ы
|
||
* (вернут 401, шум в логах). Когда Phase 1d Keycloak roles подключатся,
|
||
* этот gate можно сузить до approver-role (useCanReviewSchema).
|
||
*/
|
||
export const useReviewsBadgeCount = (enabled = true) => {
|
||
const recordQ = useQuery({
|
||
...reviewQueueQuery(0, 1),
|
||
enabled,
|
||
// Override interval: badge ОК с 60s — это не deep view, lower frequency
|
||
// снижает background load когда tab idle.
|
||
refetchInterval: 60_000,
|
||
})
|
||
const schemaQ = useQuery({
|
||
...schemaReviewQueueQuery(0, 1),
|
||
enabled,
|
||
refetchInterval: 60_000,
|
||
})
|
||
const recordCount = recordQ.data?.totalElements ?? 0
|
||
const schemaCount = schemaQ.data?.totalElements ?? 0
|
||
return {
|
||
total: recordCount + schemaCount,
|
||
recordCount,
|
||
schemaCount,
|
||
isLoading: recordQ.isLoading || schemaQ.isLoading,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Pending drafts на конкретный dict — для бейджа "На review" в records list.
|
||
* Лёгкий poll каждые 30s. Backend возвращает только PENDING (фильтрация в SQL).
|
||
*/
|
||
export const dictPendingDraftsQuery = (dictName: string) =>
|
||
queryOptions({
|
||
queryKey: ['drafts', 'by-dict', dictName] as const,
|
||
queryFn: async (): Promise<DraftResponse[]> => {
|
||
const { data } = await apiClient.get<DraftResponse[]>(
|
||
`/dictionaries/${encodeURIComponent(dictName)}/records/drafts`,
|
||
)
|
||
return data
|
||
},
|
||
refetchInterval: 30_000,
|
||
})
|
||
|
||
export const useDictPendingDrafts = (dictName: string | undefined) =>
|
||
useQuery({
|
||
...dictPendingDraftsQuery(dictName ?? ''),
|
||
enabled: Boolean(dictName),
|
||
})
|
||
|
||
/**
|
||
* Maker self-tracking — "что я отправил". Все статусы (PENDING/APPROVED/
|
||
* REJECTED/WITHDRAWN), sorted submitted_at DESC. Refetch на 30s — maker
|
||
* увидит когда reviewer approve/reject.
|
||
*/
|
||
export const myDraftsQuery = (page = 0, size = 50) =>
|
||
queryOptions({
|
||
queryKey: ['drafts', 'me', page, size] as const,
|
||
queryFn: async (): Promise<ReviewQueuePage> => {
|
||
const { data } = await apiClient.get<ReviewQueuePage>('/drafts/me', {
|
||
params: { page, size },
|
||
})
|
||
return data
|
||
},
|
||
refetchInterval: 30_000,
|
||
})
|
||
|
||
export const useMyDrafts = (page = 0, size = 50) =>
|
||
useQuery(myDraftsQuery(page, size))
|
||
|
||
/** Single draft by id — for diff viewer drawer. */
|
||
export const draftQuery = (id: string) =>
|
||
queryOptions({
|
||
queryKey: ['draft', id] as const,
|
||
queryFn: async (): Promise<DraftResponse> => {
|
||
const { data } = await apiClient.get<DraftResponse>(
|
||
`/drafts/${encodeURIComponent(id)}`,
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
|
||
export const useDraft = (id: string | undefined) =>
|
||
useQuery({
|
||
...draftQuery(id ?? ''),
|
||
enabled: Boolean(id),
|
||
})
|
||
|
||
/** Live record by businessKey — for diff comparison "current vs proposed". */
|
||
export const liveRecordQuery = (dict: string, businessKey: string) =>
|
||
queryOptions({
|
||
queryKey: ['live-record', dict, businessKey] as const,
|
||
queryFn: async (): Promise<RecordResponse | null> => {
|
||
try {
|
||
const { data } = await apiClient.get<RecordResponse>(
|
||
`/dictionaries/${encodeURIComponent(dict)}/records/${encodeURIComponent(businessKey)}`,
|
||
)
|
||
return data
|
||
} catch (e: unknown) {
|
||
const status = (e as { response?: { status?: number } })?.response?.status
|
||
if (status === 404) return null
|
||
throw e
|
||
}
|
||
},
|
||
})
|
||
|
||
export const useLiveRecord = (dict: string | undefined, businessKey: string | undefined) =>
|
||
useQuery({
|
||
...liveRecordQuery(dict ?? '', businessKey ?? ''),
|
||
enabled: Boolean(dict && businessKey),
|
||
})
|
||
|
||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||
export const useDictionaryDetail = (name: string | undefined) =>
|
||
useQuery({ ...dictionaryDetailQuery(name ?? ''), enabled: Boolean(name) })
|
||
|
||
/**
|
||
* Список всех закэшированных юзеров — для пикеров «Пользователь» в
|
||
* фильтрах audit/reviews. INTERNAL+ scope; non-INTERNAL получит 403,
|
||
* fallback на пустой массив (retry: false).
|
||
*/
|
||
export type UserListItem = {
|
||
sub: string
|
||
preferredUsername?: string | null
|
||
name?: string | null
|
||
email?: string | null
|
||
updatedAt: string
|
||
}
|
||
|
||
export const allUsersQuery = queryOptions({
|
||
queryKey: ['users', 'all'] as const,
|
||
staleTime: 5 * 60 * 1000,
|
||
retry: false,
|
||
queryFn: async (): Promise<UserListItem[]> => {
|
||
try {
|
||
const { data } = await apiClient.get<UserListItem[]>('/admin/users', {
|
||
params: { limit: 500 },
|
||
})
|
||
return data
|
||
} catch (e: unknown) {
|
||
const status = (e as { response?: { status?: number } })?.response?.status
|
||
if (status === 403 || status === 404) return []
|
||
throw e
|
||
}
|
||
},
|
||
})
|
||
|
||
export const useAllUsers = () => useQuery(allUsersQuery)
|
||
export const useRecords = (
|
||
dictionaryName: string,
|
||
scopeCsv: string,
|
||
filter?: RecordsFilter,
|
||
) => useQuery(recordsQuery(dictionaryName, scopeCsv, filter))
|
||
|
||
/**
|
||
* Scheduled records hint для empty-state. Fetch'ит count записей с
|
||
* validFrom > now (но validTo > now — т.е. не сразу закрытые). Показывается
|
||
* пользователю когда список пуст, но в будущем что-то запланировано.
|
||
*
|
||
* <p>Fixes UX bug: user создал запись с validFrom=18.05, открыл catalog 15.05 —
|
||
* пустая таблица, никаких хинтов. Теперь UI показывает «Запланировано: 1
|
||
* запись на 18.05 [перейти к дате]».
|
||
*
|
||
* <p>Stale 30s — scheduled state changes медленно. Refetch on focus = true
|
||
* (default) — пользователь возвращается во вкладку → fresh count.
|
||
*/
|
||
export const scheduledSummaryQuery = (dictionaryName: string, scopeCsv: string) =>
|
||
queryOptions({
|
||
queryKey: ['scheduled-summary', dictionaryName, scopeCsv] as const,
|
||
queryFn: async (): Promise<ScheduledRecordsSummary> => {
|
||
const { data } = await apiClient.get<ScheduledRecordsSummary>(
|
||
`/${dictionaryName}/records/scheduled-summary`,
|
||
{ params: { as_scope: scopeCsv } },
|
||
)
|
||
return data
|
||
},
|
||
staleTime: 30_000,
|
||
})
|
||
|
||
export const useScheduledSummary = (dictionaryName: string, scopeCsv: string) =>
|
||
useQuery({
|
||
...scheduledSummaryQuery(dictionaryName, scopeCsv),
|
||
enabled: Boolean(dictionaryName),
|
||
})
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// Schema templates (AI Schema Assist Phase 1, Approach A core)
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Catalog of curated schema templates — lightweight summary без schemaJson.
|
||
* Stale time 5min — templates change только при deploy.
|
||
*/
|
||
export const schemaTemplatesQuery = queryOptions({
|
||
queryKey: ['schema-templates'] as const,
|
||
queryFn: async (): Promise<SchemaTemplateSummary[]> => {
|
||
const { data } = await apiClient.get<SchemaTemplateSummary[]>(
|
||
'/dictionaries/templates',
|
||
)
|
||
return data
|
||
},
|
||
staleTime: 5 * 60_000,
|
||
})
|
||
|
||
export const useSchemaTemplates = () => useQuery(schemaTemplatesQuery)
|
||
|
||
/** Detail fetch — lazy when user picks template. */
|
||
export const schemaTemplateDetailQuery = (id: string) =>
|
||
queryOptions({
|
||
queryKey: ['schema-templates', id] as const,
|
||
queryFn: async (): Promise<SchemaTemplateDetail> => {
|
||
const { data } = await apiClient.get<SchemaTemplateDetail>(
|
||
`/dictionaries/templates/${id}`,
|
||
)
|
||
return data
|
||
},
|
||
staleTime: 5 * 60_000,
|
||
})
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// AI feature detection — probe endpoint
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Probe AI Schema Assist availability. Backend регистрирует /ai/suggest-field
|
||
* conditionally на ordinis.ai.enabled — если выключено, OPTIONS возвращает 404.
|
||
*
|
||
* <p>Used UI чтобы decide показывать ли «AI suggest» button. Cache 5 min —
|
||
* feature flag меняется только при helm upgrade.
|
||
*/
|
||
export const aiFeatureAvailableQuery = queryOptions({
|
||
queryKey: ['ai-feature-available'] as const,
|
||
queryFn: async (): Promise<boolean> => {
|
||
try {
|
||
const { data } = await apiClient.get<{ enabled: boolean }>('/ai/info')
|
||
return Boolean(data?.enabled)
|
||
} catch {
|
||
// 404 (AI disabled) или network error → hide button (graceful default).
|
||
return false
|
||
}
|
||
},
|
||
staleTime: 5 * 60_000,
|
||
retry: false,
|
||
})
|
||
|
||
export const useAiFeatureAvailable = () => useQuery(aiFeatureAvailableQuery)
|
||
export const useRecordRaw = (
|
||
dictionaryName: string,
|
||
businessKey: string | undefined,
|
||
) =>
|
||
useQuery({
|
||
...recordRawQuery(dictionaryName, businessKey ?? ''),
|
||
enabled: Boolean(businessKey),
|
||
})
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// App version + update detection
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
export type ServerVersion = {
|
||
version: string
|
||
commit: string
|
||
branch: string
|
||
tag: string
|
||
builtAt: string
|
||
}
|
||
|
||
export const serverVersionQuery = queryOptions({
|
||
queryKey: ['version'] as const,
|
||
queryFn: async (): Promise<ServerVersion> => {
|
||
const { data } = await apiClient.get<ServerVersion>('/version')
|
||
return data
|
||
},
|
||
// Poll каждые 5 минут — обновление detection без excessive load.
|
||
// Stale-while-revalidate: даже если нет focus, перезапрашиваем по таймеру.
|
||
refetchInterval: 5 * 60 * 1000,
|
||
refetchIntervalInBackground: true,
|
||
staleTime: 60 * 1000,
|
||
})
|
||
|
||
export const useServerVersion = () => useQuery(serverVersionQuery)
|
||
|
||
// === Notifications (TODO 7: draft decision toast + email) ===
|
||
|
||
/**
|
||
* Current user's notifications feed. Backend filters by recipient = sub claim;
|
||
* returns paginated rows + `totalUnread` для bell badge counter.
|
||
*
|
||
* <p>Poll каждые 30s — same cadence как /reviews queue. {@code staleTime} 5s
|
||
* чтобы query cache не моргал когда user открывает/закрывает drawer.
|
||
* {@code refetchIntervalInBackground} = true чтобы tab без focus всё равно
|
||
* decremented badge когда mark-as-read fires.
|
||
*
|
||
* <p>Caveat: `unreadOnly=true` gives a tight badge query (1 unread → badge "1"),
|
||
* full feed query (default) used drawer открыт. Splitting queries keeps badge
|
||
* fetch cheap when drawer закрыт.
|
||
*/
|
||
export const myNotificationsQuery = (unreadOnly = false, limit = 20) =>
|
||
queryOptions({
|
||
queryKey: ['notifications', 'me', { unreadOnly, limit }] as const,
|
||
queryFn: async (): Promise<NotificationsResponse> => {
|
||
const { data } = await apiClient.get<NotificationsResponse>(
|
||
'/me/notifications',
|
||
{ params: { unreadOnly, limit } },
|
||
)
|
||
return data
|
||
},
|
||
refetchInterval: 30_000,
|
||
refetchIntervalInBackground: true,
|
||
staleTime: 5_000,
|
||
})
|
||
|
||
export const useMyNotifications = (unreadOnly = false, limit = 20) =>
|
||
useQuery(myNotificationsQuery(unreadOnly, limit))
|
||
|
||
/**
|
||
* Phase B — per-channel notification preferences для current user. GET — single
|
||
* row (no pagination), 401 если anonymous. Default semantics filled in
|
||
* backend-side когда DB row missing (email=true / express=false / telegram=false).
|
||
*/
|
||
export const myNotificationPreferencesQuery = () =>
|
||
queryOptions({
|
||
queryKey: ['notifications', 'me', 'preferences'] as const,
|
||
queryFn: async (): Promise<NotificationPreferences> => {
|
||
const { data } = await apiClient.get<NotificationPreferences>(
|
||
'/me/notifications/preferences',
|
||
)
|
||
return data
|
||
},
|
||
staleTime: 60_000, // prefs не меняются часто, кэш дольше
|
||
})
|
||
|
||
export const useMyNotificationPreferences = () =>
|
||
useQuery(myNotificationPreferencesQuery())
|
||
|
||
/**
|
||
* Phase C Don't Disturb — per-user quiet hours settings query.
|
||
* GET возвращает default shape если settings row missing на бэке.
|
||
*/
|
||
export const myNotificationSettingsQuery = () =>
|
||
queryOptions({
|
||
queryKey: ['notifications', 'me', 'settings'] as const,
|
||
queryFn: async (): Promise<import('./client').NotificationSettings> => {
|
||
const { data } = await apiClient.get<import('./client').NotificationSettings>(
|
||
'/me/notifications/settings',
|
||
)
|
||
return data
|
||
},
|
||
staleTime: 60_000,
|
||
})
|
||
|
||
export const useMyNotificationSettings = () => useQuery(myNotificationSettingsQuery())
|