Files
mdm-ordinis/ordinis-admin-ui/src/api/mutations.ts
T
2026-05-12 11:08:31 +00:00

637 lines
23 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 CreateSchemaDraftRequest,
type CreateWebhookSubscriptionRequest,
type DecisionRequest,
type DictionaryDetail,
type DraftResponse,
type PublishSchemaDraftRequest,
type PublishSchemaDraftResult,
type RecordResponse,
type SchemaDraft,
type SubmitDraftRequest,
type SubmitForReviewRequest,
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] })
},
})
}
/**
* 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'] })
},
})
}
/**
* 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
},
})
}
// === 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'] })
},
})
}
/** 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'] })
},
})
}