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 => { // 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('/dictionaries', { params: { as_scope: 'PUBLIC,INTERNAL,RESTRICTED' }, }) return data }, }) export const dictionaryDetailQuery = (name: string) => queryOptions({ queryKey: ['dictionary', name] as const, queryFn: async (): Promise => { // Same as listing — pass all scopes; server-side filter применит // user permissions. Без этого INTERNAL dict вернёт 404 даже для // авторизованного юзера с INTERNAL access. const { data } = await apiClient.get(`/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 => { const params: Record = { 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( `/${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 => { try { const { data } = await apiClient.get( `/${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 => { const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get( `/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'а. * *

refetchInterval=20s — banner должен быстро отражать «отправили * на ревью» / «approved» transition от другого пользователя. */ export const dictActiveSchemaDraftQuery = (dictName: string) => queryOptions({ queryKey: ['schema-draft-active', dictName] as const, queryFn: async (): Promise => { try { const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get( '/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 => { const { data } = await apiClient.get( '/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 => { const { data } = await apiClient.get( `/dictionaries/${dictionaryName}/records/${encodeURIComponent(businessKey)}`, ) return data }, }) const auditParams = (f: AuditFilters): Record => { const out: Record = { 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 => { const { data } = await apiClient.get('/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 => { const { data } = await apiClient.get('/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 => { const { data } = await apiClient.get('/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 => { const { data } = await apiClient.get( '/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 => { const { data } = await apiClient.get( `/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 => { const params: Record = { page, size } if (statusFilter) params.status = statusFilter const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get( '/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 => { const params: Record = { bucketBy } if (fromIso) params.from = fromIso if (toIso) params.to = toIso const { data } = await apiClient.get( `/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 => { try { const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get( `/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. * *

Не cache'ится агрессивно (staleTime=0), потому что dependents * быстро меняются — preview всегда должен быть fresh. */ export const cascadePreviewQuery = (dict: string, businessKey: string) => queryOptions({ queryKey: ['cascade-preview', dict, businessKey] as const, queryFn: async (): Promise => { const { data } = await apiClient.get( `/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 => { if (q.length < SEARCH_MIN_LEN) { throw new Error(`search query too short (min ${SEARCH_MIN_LEN} chars)`) } const { data } = await apiClient.get('/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 => { const { data } = await apiClient.get('/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). * *

Combines record-reviews + schema-reviews queues. Возвращает сумму pending * drafts обоих видов. Поллится каждые 30s в синк с queue queries — reviewer * увидит свежий counter при возврате на app даже если /reviews не открыт. * *

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 => { const { data } = await apiClient.get( `/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 => { const { data } = await apiClient.get('/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 => { const { data } = await apiClient.get( `/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 => { try { const { data } = await apiClient.get( `/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) }) /** * Soft-deleted справочники (Корзина admin page). Admin-only — non-admin * получит 403, fallback на пустой массив. 7-day retention window: dict * физически purgе'ится через DictionaryPurgeJob cron после deleted_at + * 7 дней. Restore возможен ТОЛЬКО до purge. */ export const useDictionariesDeleted = (enabled = true) => useQuery({ queryKey: ['dictionaries', 'deleted'], queryFn: async (): Promise => { try { const { data } = await apiClient.get('/dictionaries/deleted') return data } catch (err) { if ((err as { response?: { status?: number } })?.response?.status === 403) { return [] } throw err } }, enabled, staleTime: 30_000, retry: false, }) /** * Список всех закэшированных юзеров — для пикеров «Пользователь» в * фильтрах 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 } /** * Юзеры, реально появлявшиеся в audit_log — для UserPicker'а в /audit. * Backend resolve'ит distinct user_id из audit_log JOIN с user_display_cache * → отдаёт только тех, кто реально что-то делал в ordinis (не всех юзеров * altum realm). Sub'ы которых нет в cache возвращаются с null display полями * — UserPicker покажет UUID-prefix. */ export const auditedUsersQuery = queryOptions({ queryKey: ['users', 'audited'] as const, staleTime: 5 * 60 * 1000, retry: false, queryFn: async (): Promise => { try { const { data } = await apiClient.get('/admin/users/audited', { 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 useAuditedUsers = () => useQuery(auditedUsersQuery) 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 — т.е. не сразу закрытые). Показывается * пользователю когда список пуст, но в будущем что-то запланировано. * *

Fixes UX bug: user создал запись с validFrom=18.05, открыл catalog 15.05 — * пустая таблица, никаких хинтов. Теперь UI показывает «Запланировано: 1 * запись на 18.05 [перейти к дате]». * *

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 => { const { data } = await apiClient.get( `/${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 => { const { data } = await apiClient.get( '/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 => { const { data } = await apiClient.get( `/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. * *

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 => { 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 => { const { data } = await apiClient.get('/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. * *

Poll каждые 30s — same cadence как /reviews queue. {@code staleTime} 5s * чтобы query cache не моргал когда user открывает/закрывает drawer. * {@code refetchIntervalInBackground} = true чтобы tab без focus всё равно * decremented badge когда mark-as-read fires. * *

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 => { const { data } = await apiClient.get( '/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 => { const { data } = await apiClient.get( '/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 => { const { data } = await apiClient.get( '/me/notifications/settings', ) return data }, staleTime: 60_000, }) export const useMyNotificationSettings = () => useQuery(myNotificationSettingsQuery())