import { useCallback, useRef } from 'react' 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' /** * 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 для жизненного цикла одной формы. * *

Возвращает {@code [key, reset]}. {@code key} стабилен между ререндерами; * {@code reset()} генерирует новый — вызывается на onSuccess чтобы следующая * форма (например, edit того же record после save) получила свежий ключ. * *

Защита от двойного submit (Enter + click за одну сессию формы): оба * запроса несут один и тот же {@code Idempotency-Key} → backend deduplicate'ит * на втором. * *

Usage: *

{@code
 * const [idempotencyKey, resetIdempotencyKey] = useFormIdempotencyKey()
 * const createMut = useCreateRecord(name)
 * // submit:
 * createMut.mutate(
 *   { payload: req, idempotencyKey },
 *   { onSuccess: () => { resetIdempotencyKey(); closeModal() } },
 * )
 * }
*/ export const useFormIdempotencyKey = (): [string, () => void] => { const ref = useRef(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 => { 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] }) }, }) } /** * 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 => { const { data } = await apiClient.post( `/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 => { const { data } = await apiClient.put( `/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 => { 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'] }) qc.invalidateQueries({ queryKey: ['drafts'] }) // /me + by-dict }, }) } 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'] }) }, }) }