Files
mdm-ordinis/ordinis-admin-ui/src/api/mutations.ts
T

460 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 для жизненного цикла одной формы.
*
* <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] })
},
})
}
/**
* 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,
{ 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<BulkCloseResponse> => {
const { data } = await apiClient.post<BulkCloseResponse>(
`/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 `<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 },
{ 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 <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'] })
},
})
}
/**
* 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'] })
},
})
}
/** 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
},
})
}
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'] })
},
})
}