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'] })
},
})
}