import { useMutation, useQueryClient } from '@tanstack/react-query' import { apiClient, type BulkCloseRequest, type BulkCloseResponse, type CascadeCloseResult, type CreateDictionaryRequest, type CreateRecordRequest, type CreateWebhookSubscriptionRequest, type DictionaryDetail, type DraftResponse, type RecordResponse, type SubmitDraftRequest, type WebhookDelivery, type WebhookSubscription, type WebhookTestPingResult, } from './client' const idempotencyKey = () => typeof crypto !== 'undefined' && 'randomUUID' in crypto ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}` export const useCreateDictionary = () => { const qc = useQueryClient() return useMutation({ mutationFn: async (req: CreateDictionaryRequest): Promise => { const { data } = await apiClient.post('/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 => { const { data } = await apiClient.put( `/dictionaries/${params.name}`, params.payload, ) return data }, onSuccess: (_, params) => { qc.invalidateQueries({ queryKey: ['dictionaries'] }) qc.invalidateQueries({ queryKey: ['dictionary', params.name] }) }, }) } export const useCreateRecord = (dictionaryName: string) => { const qc = useQueryClient() return useMutation({ mutationFn: async (req: CreateRecordRequest): Promise => { const { data } = await apiClient.post( `/dictionaries/${dictionaryName}/records`, req, { headers: { 'Idempotency-Key': idempotencyKey() } }, ) return data }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['records', dictionaryName] }) }, }) } export const useUpdateRecord = (dictionaryName: string) => { const qc = useQueryClient() return useMutation({ mutationFn: async (params: { businessKey: string payload: CreateRecordRequest }): Promise => { const { data } = await apiClient.put( `/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}`, params.payload, { headers: { 'Idempotency-Key': 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 => { 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`. * *

Backend errors: *

    *
  • 409 x_references_blocked_by_dependents — есть BLOCK deps. UI отображает * список и просит resolve.
  • *
  • 409 x_references_cascade_required — confirmed=false при наличии cascade.
  • *
  • 503 x_references_cascade_timeout — tx timeout (>30s). User retry'ит.
  • *
*/ export const useCascadeCloseRecord = (dictionaryName: string) => { const qc = useQueryClient() return useMutation({ mutationFn: async (params: { businessKey: string reason?: string confirmed: boolean }): Promise => { const queryParams: Record = { confirmed: params.confirmed, } if (params.reason) queryParams.reason = params.reason const { data } = await apiClient.post( `/dictionaries/${dictionaryName}/records/${encodeURIComponent(params.businessKey)}/cascade-close`, null, { params: queryParams }, ) 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 => { const { data } = await apiClient.post( `/dictionaries/${dictionaryName}/records/bulk-close`, req, ) return data }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['records', dictionaryName] }) }, }) } /** * Bulk export selected: выгрузить N выбранных записей в CSV. Backend * возвращает text/csv blob с auth header, фронтенд триггерит download * через temporary `` link. * * POST (не GET): >100 ключей не помещается в URL params. */ export const useBulkExportRecords = (dictionaryName: string) => { return useMutation({ mutationFn: async (businessKeys: string[]): Promise => { const response = await apiClient.post( `/dictionaries/${dictionaryName}/records/export-selected.csv`, { businessKeys }, { responseType: 'blob' }, ) // Извлекаем filename из Content-Disposition если есть const cd = response.headers['content-disposition'] as string | undefined const match = cd?.match(/filename="?([^";]+)"?/) const filename = match?.[1] ?? `${dictionaryName}-selected-${new Date().toISOString().slice(0, 10)}.csv` // Trigger browser download через blob URL + temporary 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 => { const { data } = await apiClient.post( '/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 => { const { data } = await apiClient.put( `/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 => { 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 => { const { data } = await apiClient.post( `/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 => { const { data } = await apiClient.post( `/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 => { const { data } = await apiClient.post( `/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 => { const { data } = await apiClient.post( `/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'] }) }, }) } /** * 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 => { const { data } = await apiClient.post( `/drafts/${encodeURIComponent(params.id)}/reject`, null, { params: { comment: params.comment } }, ) return data }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['review-queue'] }) qc.invalidateQueries({ queryKey: ['draft'] }) }, }) } /** Maker отзывает свой pending draft. 403 not_draft_maker если не maker. */ export const useWithdrawDraft = () => { const qc = useQueryClient() return useMutation({ mutationFn: async (id: string): Promise => { const { data } = await apiClient.delete( `/drafts/${encodeURIComponent(id)}`, ) return data }, onSuccess: () => { qc.invalidateQueries({ queryKey: ['review-queue'] }) qc.invalidateQueries({ queryKey: ['draft'] }) }, }) } export const useRetryWebhookDelivery = () => { const qc = useQueryClient() return useMutation({ mutationFn: async (deliveryId: number): Promise => { const { data } = await apiClient.post( `/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'] }) }, }) }