858 lines
31 KiB
TypeScript
858 lines
31 KiB
TypeScript
import { useCallback, useRef } from 'react'
|
||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||
import {
|
||
apiClient,
|
||
type AiFieldSuggestion,
|
||
type BulkCloseRequest,
|
||
type BulkCloseResponse,
|
||
type CascadeCloseResult,
|
||
type CreateDictionaryRequest,
|
||
type CreateRecordRequest,
|
||
type CreateSchemaDraftRequest,
|
||
type CreateWebhookSubscriptionRequest,
|
||
type DecisionRequest,
|
||
type DictionaryDetail,
|
||
type DraftResponse,
|
||
type NotificationPreferences,
|
||
type PublishSchemaDraftRequest,
|
||
type PublishSchemaDraftResult,
|
||
type RecordResponse,
|
||
type SchemaDraft,
|
||
type SubmitDraftRequest,
|
||
type SubmitForReviewRequest,
|
||
type UpdateSchemaDraftRequest,
|
||
type WebhookDelivery,
|
||
type WebhookSubscription,
|
||
type WebhookTestPingResult,
|
||
} from './client'
|
||
|
||
/**
|
||
* Parse `Content-Disposition` filename (RFC 5987 + legacy):
|
||
* - `filename*=UTF-8''Cyrillic%20Name.csv` (RFC 5987 — для не-ASCII names)
|
||
* - `filename="legacy.csv"` (RFC 2183 — ASCII-only)
|
||
*
|
||
* Возвращает первое match'нувшее имя или null. RFC 5987 priority — стандарт
|
||
* требует UA prefer the encoded form when both present.
|
||
*/
|
||
export const parseContentDispositionFilename = (
|
||
cd: string | undefined,
|
||
): string | null => {
|
||
if (!cd) return null
|
||
// RFC 5987: filename*=charset''url-encoded-value
|
||
const rfc5987 = cd.match(/filename\*=(?:([^']*)'[^']*')?([^;]+)/i)
|
||
if (rfc5987) {
|
||
const charset = rfc5987[1] || 'UTF-8'
|
||
const raw = rfc5987[2].trim().replace(/^"(.*)"$/, '$1')
|
||
try {
|
||
// decodeURIComponent works для UTF-8 (и compatible сюда же ISO-8859-1).
|
||
// Если backend present extended-charset — fallback на raw value.
|
||
if (charset.toUpperCase() === 'UTF-8' || charset.toUpperCase() === 'ISO-8859-1') {
|
||
return decodeURIComponent(raw)
|
||
}
|
||
return raw
|
||
} catch {
|
||
return raw
|
||
}
|
||
}
|
||
// RFC 2183: filename="..." or filename=raw
|
||
const legacy = cd.match(/filename=("([^"]+)"|([^;]+))/i)
|
||
if (legacy) return (legacy[2] ?? legacy[3])?.trim() ?? null
|
||
return null
|
||
}
|
||
|
||
/**
|
||
* Generate UUID v4 (или fallback). Раньше вызывался **per mutate** в каждой
|
||
* useCreateRecord/useUpdateRecord — двойной submit формы (Enter + click) давал
|
||
* два разных ключа → server создавал два состояния вместо идемпотентного
|
||
* deduping. Теперь caller генерирует ключ один раз на форму через
|
||
* {@link useFormIdempotencyKey} и передаёт его mutation'у.
|
||
*/
|
||
export const generateIdempotencyKey = (): string =>
|
||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||
? crypto.randomUUID()
|
||
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||
|
||
/**
|
||
* Stable idempotency key для жизненного цикла одной формы.
|
||
*
|
||
* <p>Возвращает {@code [key, reset]}. {@code key} стабилен между ререндерами;
|
||
* {@code reset()} генерирует новый — вызывается на onSuccess чтобы следующая
|
||
* форма (например, edit того же record после save) получила свежий ключ.
|
||
*
|
||
* <p>Защита от двойного submit (Enter + click за одну сессию формы): оба
|
||
* запроса несут один и тот же {@code Idempotency-Key} → backend deduplicate'ит
|
||
* на втором.
|
||
*
|
||
* <p><b>Usage:</b>
|
||
* <pre>{@code
|
||
* const [idempotencyKey, resetIdempotencyKey] = useFormIdempotencyKey()
|
||
* const createMut = useCreateRecord(name)
|
||
* // submit:
|
||
* createMut.mutate(
|
||
* { payload: req, idempotencyKey },
|
||
* { onSuccess: () => { resetIdempotencyKey(); closeModal() } },
|
||
* )
|
||
* }</pre>
|
||
*/
|
||
export const useFormIdempotencyKey = (): [string, () => void] => {
|
||
const ref = useRef<string | null>(null)
|
||
if (ref.current === null) ref.current = generateIdempotencyKey()
|
||
const reset = useCallback(() => {
|
||
ref.current = generateIdempotencyKey()
|
||
}, [])
|
||
return [ref.current, reset]
|
||
}
|
||
|
||
export const useCreateDictionary = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (req: CreateDictionaryRequest): Promise<DictionaryDetail> => {
|
||
const { data } = await apiClient.post<DictionaryDetail>('/dictionaries', req)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['dictionaries'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
export const useUpdateDictionary = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
name: string
|
||
payload: CreateDictionaryRequest
|
||
}): Promise<DictionaryDetail> => {
|
||
const { data } = await apiClient.put<DictionaryDetail>(
|
||
`/dictionaries/${params.name}`,
|
||
params.payload,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: (_, params) => {
|
||
qc.invalidateQueries({ queryKey: ['dictionaries'] })
|
||
qc.invalidateQueries({ queryKey: ['dictionary', params.name] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Удаление справочника. Cascade на records + drafts + schema_drafts через
|
||
* FK ON DELETE CASCADE (migration 0030). Только admin role.
|
||
*
|
||
* <p>Backend errors:
|
||
* <ul>
|
||
* <li>403 {@code forbidden_role} — не admin.</li>
|
||
* <li>404 {@code dictionary_not_found} — нет либо вне scope.</li>
|
||
* <li>409 {@code has_dependents} — другой dict ссылается через
|
||
* x-references. message содержит список dependents. UI показывает
|
||
* их юзеру и предлагает либо убрать FK у зависимых, либо force.</li>
|
||
* </ul>
|
||
*/
|
||
export const useDeleteDictionary = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: { name: string; force?: boolean }): Promise<void> => {
|
||
const qs = params.force ? '?force=true' : ''
|
||
await apiClient.delete(`/dictionaries/${params.name}${qs}`)
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['dictionaries'] })
|
||
qc.invalidateQueries({ queryKey: ['dictionaries', 'deleted'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Восстановление soft-deleted справочника. Admin-only. Сбрасывает
|
||
* deleted_at/deleted_by в NULL → dict снова visible в списке.
|
||
*
|
||
* <p>7-day retention window: admin может вызвать restore в течение 7 дней
|
||
* после soft-delete. После — physical purge через DictionaryPurgeJob
|
||
* (backend cron). Восстановление невозможно после purge — только из DB
|
||
* backup.
|
||
*
|
||
* <p>Backend errors:
|
||
* <ul>
|
||
* <li>403 forbidden_role — не admin</li>
|
||
* <li>404 dictionary_not_found — name never existed</li>
|
||
* <li>409 not_deleted — dict не помечен как удалённый</li>
|
||
* </ul>
|
||
*/
|
||
export const useRestoreDictionary = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (name: string): Promise<DictionaryDetail> => {
|
||
const { data } = await apiClient.post<DictionaryDetail>(
|
||
`/dictionaries/${name}/restore`,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: (_, name) => {
|
||
qc.invalidateQueries({ queryKey: ['dictionaries'] })
|
||
qc.invalidateQueries({ queryKey: ['dictionary', name] })
|
||
qc.invalidateQueries({ queryKey: ['dictionaries', 'deleted'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Create-record mutation. {@code idempotencyKey} — required, передаётся caller'ом
|
||
* (один на жизнь формы; см. {@link useFormIdempotencyKey}). Это защищает от
|
||
* двойного submit (Enter + click): backend deduplicate'ит по тому же ключу.
|
||
*/
|
||
export const useCreateRecord = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
payload: CreateRecordRequest
|
||
idempotencyKey: string
|
||
}): Promise<RecordResponse> => {
|
||
const { data } = await apiClient.post<RecordResponse>(
|
||
`/dictionaries/${dictionaryName}/records`,
|
||
params.payload,
|
||
{ headers: { 'Idempotency-Key': params.idempotencyKey } },
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Update-record mutation. См. {@link useCreateRecord} для idempotency rationale.
|
||
*/
|
||
export const useUpdateRecord = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
businessKey: string
|
||
payload: CreateRecordRequest
|
||
idempotencyKey: string
|
||
}): Promise<RecordResponse> => {
|
||
const { data } = await apiClient.put<RecordResponse>(
|
||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`,
|
||
params.payload,
|
||
{ headers: { 'Idempotency-Key': params.idempotencyKey } },
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
export const useCloseRecord = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: { businessKey: string; reason?: string }): Promise<void> => {
|
||
await apiClient.delete(
|
||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`,
|
||
{ params: params.reason ? { reason: params.reason } : undefined },
|
||
)
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Cascade close — Phase 3 dict-relationships-v2. Атомарно закрывает target +
|
||
* все CASCADE dependents в одной transaction. UI должен сначала вызвать
|
||
* `cascade-preview` query, показать confirmation, и только потом запускать
|
||
* этот мутатор с `confirmed=true`.
|
||
*
|
||
* <p>Backend errors:
|
||
* <ul>
|
||
* <li>409 x_references_blocked_by_dependents — есть BLOCK deps. UI отображает
|
||
* список и просит resolve.</li>
|
||
* <li>409 x_references_cascade_required — confirmed=false при наличии cascade.</li>
|
||
* <li>503 x_references_cascade_timeout — tx timeout (>30s). User retry'ит.</li>
|
||
* </ul>
|
||
*/
|
||
export const useCascadeCloseRecord = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
businessKey: string
|
||
reason?: string
|
||
confirmed: boolean
|
||
}): Promise<CascadeCloseResult> => {
|
||
const queryParams: Record<string, string | boolean> = {
|
||
confirmed: params.confirmed,
|
||
}
|
||
if (params.reason) queryParams.reason = params.reason
|
||
const { data } = await apiClient.post<CascadeCloseResult>(
|
||
`/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}/cascade-close`,
|
||
null,
|
||
// Cascade tx до 30s на backend (см. 503 x_references_cascade_timeout).
|
||
// Дефолт apiClient timeout=10s слишком агрессивен — обрезает запрос
|
||
// ДО того как backend сам timeout'ает с понятной ошибкой. Per-request
|
||
// override на 60s гарантирует что user видит backend response.
|
||
{ params: queryParams, timeout: 60_000 },
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
// Invalidate всё что может быть affected: records list + dependents
|
||
// queries (target и cascaded sources меняют состояние).
|
||
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['record-dependents'] })
|
||
qc.invalidateQueries({ queryKey: ['records'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Bulk close: закрыть N records одним запросом. Backend per-record транзакция,
|
||
* partial success возможен (already_closed / not_found попадает в skipped).
|
||
*/
|
||
export const useBulkCloseRecords = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (req: BulkCloseRequest): Promise<BulkCloseResponse> => {
|
||
// Bulk close — per-record транзакция; на 1000+ keys запрос идёт >10s.
|
||
// Дефолт apiClient timeout слишком жёсткий → 60s safety.
|
||
const { data } = await apiClient.post<BulkCloseResponse>(
|
||
`/dictionaries/${dictionaryName}/records/bulk-close`,
|
||
req,
|
||
{ timeout: 60_000 },
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Bulk export selected: выгрузить N выбранных записей в CSV. Backend
|
||
* возвращает text/csv blob с auth header, фронтенд триггерит download
|
||
* через temporary `<a>` link.
|
||
*
|
||
* POST (не GET): >100 ключей не помещается в URL params.
|
||
*/
|
||
export const useBulkExportRecords = (dictionaryName: string) => {
|
||
return useMutation({
|
||
mutationFn: async (businessKeys: string[]): Promise<void> => {
|
||
const response = await apiClient.post(
|
||
`/dictionaries/${dictionaryName}/records/export-selected.csv`,
|
||
{ businessKeys },
|
||
// 60s timeout: 10k+ записей export может идти >10s.
|
||
{ responseType: 'blob', timeout: 60_000 },
|
||
)
|
||
const cd = response.headers['content-disposition'] as string | undefined
|
||
const filename =
|
||
parseContentDispositionFilename(cd) ??
|
||
`${dictionaryName}-selected-${new Date().toISOString().slice(0, 10)}.csv`
|
||
|
||
// Trigger browser download через blob URL + temporary <a download>
|
||
const url = window.URL.createObjectURL(response.data as Blob)
|
||
const link = document.createElement('a')
|
||
link.href = url
|
||
link.download = filename
|
||
document.body.appendChild(link)
|
||
link.click()
|
||
document.body.removeChild(link)
|
||
// Через rAF чтобы download успел запуститься до revoke
|
||
requestAnimationFrame(() => window.URL.revokeObjectURL(url))
|
||
},
|
||
})
|
||
}
|
||
|
||
// === Webhooks (v2) ===
|
||
|
||
export const useCreateWebhook = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (req: CreateWebhookSubscriptionRequest): Promise<WebhookSubscription> => {
|
||
const { data } = await apiClient.post<WebhookSubscription>(
|
||
'/admin/webhooks/subscriptions',
|
||
req,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
export const useUpdateWebhook = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
id: string
|
||
payload: CreateWebhookSubscriptionRequest
|
||
}): Promise<WebhookSubscription> => {
|
||
const { data } = await apiClient.put<WebhookSubscription>(
|
||
`/admin/webhooks/subscriptions/${encodeURIComponent(params.id)}`,
|
||
params.payload,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: (_data, vars) => {
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', vars.id] })
|
||
},
|
||
})
|
||
}
|
||
|
||
export const useDeleteWebhook = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (id: string): Promise<void> => {
|
||
await apiClient.delete(`/admin/webhooks/subscriptions/${encodeURIComponent(id)}`)
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
export const useRotateWebhookSecret = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (id: string): Promise<WebhookSubscription> => {
|
||
const { data } = await apiClient.post<WebhookSubscription>(
|
||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/rotate-secret`,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: (_data, id) => {
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions'] })
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'subscriptions', id] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Test ping — receiver получает synthetic event с тем же HMAC и headers
|
||
* что у production delivery. Возвращает result sync для inline feedback.
|
||
*
|
||
* Use case: после создания subscription / rotate secret — verify что
|
||
* receiver достижим, валидирует HMAC, не возвращает 5xx.
|
||
*/
|
||
export const useTestWebhook = () => {
|
||
return useMutation({
|
||
mutationFn: async (id: string): Promise<WebhookTestPingResult> => {
|
||
const { data } = await apiClient.post<WebhookTestPingResult>(
|
||
`/admin/webhooks/subscriptions/${encodeURIComponent(id)}/test`,
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Replay delivery из DLQ или retrying — backend сбрасывает attemptCount=0,
|
||
* dlqAt=null, status=retrying. Dispatcher подхватит на следующем sendTick.
|
||
*
|
||
* Use case: оператор fixed receiver URL/cert, хочет replay'нуть failed
|
||
* delivery без ожидания TTL bypass'а.
|
||
*/
|
||
// === Approval Workflow v2 mutations ===
|
||
|
||
/**
|
||
* Maker submits a proposal. Backend creates draft с status=PENDING.
|
||
* 409 draft_already_pending если уже есть pending draft на этот business_key.
|
||
*/
|
||
export const useSubmitDraft = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (req: SubmitDraftRequest): Promise<DraftResponse> => {
|
||
const { data } = await apiClient.post<DraftResponse>(
|
||
`/dictionaries/${encodeURIComponent(dictionaryName)}/records/drafts`,
|
||
req,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||
qc.invalidateQueries({ queryKey: ['records', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Reviewer approves pending draft. SERIALIZABLE tx → COPY draft → live +
|
||
* outbox event. 409 draft_not_pending / self_approve_forbidden если что-то
|
||
* не так.
|
||
*/
|
||
export const useApproveDraft = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
id: string
|
||
comment?: string
|
||
}): Promise<DraftResponse> => {
|
||
const { data } = await apiClient.post<DraftResponse>(
|
||
`/drafts/${encodeURIComponent(params.id)}/approve`,
|
||
null,
|
||
{ params: params.comment ? { comment: params.comment } : undefined },
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||
qc.invalidateQueries({ queryKey: ['records'] })
|
||
qc.invalidateQueries({ queryKey: ['draft'] })
|
||
// ['drafts'] = my-drafts list + by-dict list. Без этого "Мои" tab
|
||
// badge продолжал показывать счётчик после approve (status
|
||
// pending → decided, но cached list не обновлялся).
|
||
qc.invalidateQueries({ queryKey: ['drafts'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Reviewer rejects pending draft. Reason required (compliance, D4=A
|
||
* soft-archive). 400 reject_reason_required если reason пустой.
|
||
*/
|
||
export const useRejectDraft = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
id: string
|
||
comment: string
|
||
}): Promise<DraftResponse> => {
|
||
const { data } = await apiClient.post<DraftResponse>(
|
||
`/drafts/${encodeURIComponent(params.id)}/reject`,
|
||
null,
|
||
{ params: { comment: params.comment } },
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||
qc.invalidateQueries({ queryKey: ['draft'] })
|
||
// Та же причина что в useApproveDraft — "Мои" badge не обновлялся.
|
||
qc.invalidateQueries({ queryKey: ['drafts'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/** Maker отзывает свой pending draft. 403 not_draft_maker если не maker. */
|
||
export const useWithdrawDraft = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (id: string): Promise<DraftResponse> => {
|
||
const { data } = await apiClient.delete<DraftResponse>(
|
||
`/drafts/${encodeURIComponent(id)}`,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['review-queue'] })
|
||
qc.invalidateQueries({ queryKey: ['draft'] })
|
||
qc.invalidateQueries({ queryKey: ['drafts'] }) // /me + by-dict
|
||
},
|
||
})
|
||
}
|
||
|
||
// === Schema workflow mutations (Phase 1a, v2.2.0 backend) =============
|
||
|
||
/**
|
||
* Создаёт schema draft от current HEAD. Backend проверяет fromVersion
|
||
* против live schemaVersion (409 version_mismatch если расходится).
|
||
* Также 409 active_draft_exists если на dict'е уже есть non-terminal draft.
|
||
*
|
||
* <p>onSuccess invalidates: active-draft query (banner picks up) +
|
||
* drafts list + reviewer pool queue.
|
||
*/
|
||
export const useCreateSchemaDraft = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (payload: CreateSchemaDraftRequest): Promise<SchemaDraft> => {
|
||
const { data } = await apiClient.post<SchemaDraft>(
|
||
`/dictionaries/${dictionaryName}/drafts`,
|
||
payload,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Update content (proposedSchema / reason) в существующем draft'е без
|
||
* recreate. Status-gate: только DRAFT и CHANGES_REQUESTED. Backend
|
||
* автоматически переводит CHANGES_REQUESTED → DRAFT после update
|
||
* (maker должен ре-submit на ревью осознанно).
|
||
*/
|
||
export const useUpdateSchemaDraft = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
draftId: string
|
||
body: UpdateSchemaDraftRequest
|
||
}): Promise<SchemaDraft> => {
|
||
const { data } = await apiClient.patch<SchemaDraft>(
|
||
`/dictionaries/${dictionaryName}/drafts/${params.draftId}`,
|
||
params.body,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
|
||
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/** Maker submits draft → review_pending. 202 Accepted. */
|
||
export const useSubmitSchemaDraftForReview = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
draftId: string
|
||
body?: SubmitForReviewRequest
|
||
}): Promise<SchemaDraft> => {
|
||
const { data } = await apiClient.post<SchemaDraft>(
|
||
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/review`,
|
||
params.body ?? {},
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Reviewer decide: approve / request_changes / reject. Backend
|
||
* проверяет self_approve_forbidden (maker_id != actor) → 403.
|
||
*/
|
||
export const useDecideSchemaDraft = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
draftId: string
|
||
body: DecisionRequest
|
||
}): Promise<SchemaDraft> => {
|
||
const { data } = await apiClient.post<SchemaDraft>(
|
||
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/decision`,
|
||
params.body,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
|
||
// Changelog gets new entry (draft_approve / draft_reject / draft_changes_requested)
|
||
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Publish approved draft → bump live HEAD. Backend computes new version
|
||
* (minor default, major на breaking). Returns wrapped {draft, newVersion, publishedAt}.
|
||
*
|
||
* <p>onSuccess invalidates dict detail (schema/version changed) + changelog +
|
||
* draft queries.
|
||
*/
|
||
export const usePublishSchemaDraft = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (params: {
|
||
draftId: string
|
||
body: PublishSchemaDraftRequest
|
||
}): Promise<PublishSchemaDraftResult> => {
|
||
const { data } = await apiClient.post<PublishSchemaDraftResult>(
|
||
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/publish`,
|
||
params.body,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
|
||
qc.invalidateQueries({ queryKey: ['dictionary', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/** Maker withdraws non-terminal draft (terminal state). */
|
||
export const useWithdrawSchemaDraft = (dictionaryName: string) => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (draftId: string): Promise<SchemaDraft> => {
|
||
const { data } = await apiClient.delete<SchemaDraft>(
|
||
`/dictionaries/${dictionaryName}/drafts/${draftId}`,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
|
||
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
|
||
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
|
||
},
|
||
})
|
||
}
|
||
|
||
export const useRetryWebhookDelivery = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (deliveryId: number): Promise<WebhookDelivery> => {
|
||
const { data } = await apiClient.post<WebhookDelivery>(
|
||
`/admin/webhooks/deliveries/${deliveryId}/retry`,
|
||
)
|
||
return data
|
||
},
|
||
onSuccess: () => {
|
||
// Invalidate всё что показывает delivery rows: detail page deliveries,
|
||
// global DLQ list. Cheaper чем targeted invalidation т.к. delivery
|
||
// counts маленькие.
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'deliveries'] })
|
||
qc.invalidateQueries({ queryKey: ['webhooks', 'dlq'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
// === Notifications mutations (TODO 7) ===
|
||
|
||
/**
|
||
* Mark одно notification как read. Optimistic update — UI обновляется
|
||
* мгновенно, query invalidates после server confirm. Если backend reject'нет,
|
||
* onError refetch'нет original state.
|
||
*/
|
||
export const useMarkNotificationRead = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (notificationId: string): Promise<void> => {
|
||
await apiClient.post(
|
||
`/me/notifications/${encodeURIComponent(notificationId)}/read`,
|
||
)
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Mark все unread notifications as read. Drawer footer button "Mark all read".
|
||
*/
|
||
export const useMarkAllNotificationsRead = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (): Promise<void> => {
|
||
await apiClient.post('/me/notifications/read-all')
|
||
},
|
||
onSuccess: () => {
|
||
qc.invalidateQueries({ queryKey: ['notifications', 'me'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Phase B — update per-channel notification preferences. PUT upserts all 3
|
||
* каналов атомарно (backend @Transactional). Optimistic update — UI flips
|
||
* toggle instantly, onError refetch restores original state.
|
||
*
|
||
* <p>Cache key 'notifications/me/preferences' — separate from feed query
|
||
* (которая использует 'notifications/me'). Invalidate prefs alone не
|
||
* triggers feed refetch.
|
||
*/
|
||
export const useUpdateNotificationPreferences = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (prefs: NotificationPreferences): Promise<NotificationPreferences> => {
|
||
const { data } = await apiClient.put<NotificationPreferences>(
|
||
'/me/notifications/preferences',
|
||
prefs,
|
||
)
|
||
return data
|
||
},
|
||
onMutate: async (next) => {
|
||
await qc.cancelQueries({ queryKey: ['notifications', 'me', 'preferences'] })
|
||
const prev = qc.getQueryData<NotificationPreferences>([
|
||
'notifications',
|
||
'me',
|
||
'preferences',
|
||
])
|
||
qc.setQueryData(['notifications', 'me', 'preferences'], next)
|
||
return { prev }
|
||
},
|
||
onError: (_err, _next, ctx) => {
|
||
if (ctx?.prev) {
|
||
qc.setQueryData(['notifications', 'me', 'preferences'], ctx.prev)
|
||
}
|
||
},
|
||
onSettled: () => {
|
||
qc.invalidateQueries({ queryKey: ['notifications', 'me', 'preferences'] })
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* AI Schema Assist — per-field suggest. Returns parsed {fieldName, schema}.
|
||
* Каллер показывает diff preview, user accept/edit/reject.
|
||
*
|
||
* 404 → AI disabled на бэкенде — frontend hides button (handled в caller).
|
||
* 503/circuit_open → temporary unavailable, show banner.
|
||
* 422 → bad output from LLM — show error message, suggest retry.
|
||
* 429 → rate limit, show "try again in a minute".
|
||
*/
|
||
export const useAiSuggestField = () => {
|
||
return useMutation({
|
||
mutationFn: async (req: {
|
||
existingSchema: unknown
|
||
prompt: string
|
||
}): Promise<AiFieldSuggestion> => {
|
||
const { data } = await apiClient.post<AiFieldSuggestion>(
|
||
'/ai/suggest-field',
|
||
req,
|
||
{ timeout: 30_000 }, // override default 10s — LLM может быть slow
|
||
)
|
||
return data
|
||
},
|
||
})
|
||
}
|
||
|
||
/**
|
||
* Phase C Don't Disturb — update quiet hours settings.
|
||
* Optimistic update + invalidation на settled.
|
||
*/
|
||
export const useUpdateNotificationSettings = () => {
|
||
const qc = useQueryClient()
|
||
return useMutation({
|
||
mutationFn: async (
|
||
next: import('./client').NotificationSettings,
|
||
): Promise<import('./client').NotificationSettings> => {
|
||
const { data } = await apiClient.put<import('./client').NotificationSettings>(
|
||
'/me/notifications/settings',
|
||
next,
|
||
)
|
||
return data
|
||
},
|
||
onSettled: () => {
|
||
qc.invalidateQueries({ queryKey: ['notifications', 'me', 'settings'] })
|
||
},
|
||
})
|
||
}
|