Files
mdm-ordinis/ordinis-admin-ui/src/api/mutations.ts
T
Zimin A.N. 3eeaba058f feat(cascade): Phase 3 — CascadeCloseService + 409 routing + UI confirm dialog
dict-relationships-v2 epic, Phase 3. Cascade engine + UX guards.

Backend:
- New CascadeCloseService:
  * evaluatePlan(target, key, at) — read-only, splits dependents по
    onClose mode (BLOCK / WARN / CASCADE).
  * executeCascade(target, key, when, reason, confirmed) —
    @Transactional(timeout=30, isolation=SERIALIZABLE) atomic close
    target + all CASCADE deps в one hop (design F.OUT). 503
    x_references_cascade_timeout если tx exceeds 30s, 400
    x_references_cascade_too_large если N > 500.
  * RecordCloseSink interface — test seam над repo+audit+outbox
    mechanics (concrete classes на JDK 25 + Mockito incompat).
- Wired DictionaryRecordService.close():
  * Optional<CascadeCloseService> inject через ctor (back-compat ctor
    для тестов с null deps).
  * Pre-close evaluatePlan: blockers → 409 x_references_blocked_by_dependents,
    cascade non-empty → 409 x_references_cascade_required (просит cascade-close
    endpoint). WARN-only — close проходит.
- Endpoints в DictionaryRecordController:
  * GET /dictionaries/{dict}/records/{key}/cascade-preview — read-only план.
  * POST /dictionaries/{dict}/records/{key}/cascade-close?confirmed=true —
    atomic execute. Existing DELETE остался (теперь учитывает план).

Tests (8 unit tests):
- evaluatePlan empty / split-by-mode (BLOCK/WARN/CASCADE)
- 409 blockers exist (not closed)
- 409 cascade-required без confirmed
- WARN-only — closes target only, warnings returned
- CASCADE — atomic close target + 2 cascaded (verifies order + reasons)
- BLOCK overrides CASCADE — 409 даже при confirmed=true
- Empty plan — closes only target

Admin UI:
- New CascadeConfirmDialog component:
  * Trigger: 409 на existing close → opens dialog с GET cascade-preview.
  * BLOCKERS section — Alert error, list первых 5, "resolve first" hint.
  * CASCADE section — per-source grouping, sample 3 + overflow,
    badges per (count, onClose mode).
  * WARN section — yellow Alert "будут orphan".
  * Reason input + "Type CONFIRM" gate если N > 50 (UX guard).
  * Cancel — close без changes.
- Wired в dictionaries.$name.tsx: existing close-confirm flow на 409
  cascade error pivots в new dialog (same UX path, transparent для user).
- Types в client.ts: CascadeEntry, CascadePlan, CascadeCloseResult.
- Mutation useCascadeCloseRecord, query useCascadePreview.
- i18n RU/EN: cascade.* (35 keys, plurals для счётчиков).

Verify:
- mvn -pl ordinis-rest-api -am test: 106/106 PASS (8 new cascade tests).
- mvn verify -pl '!ordinis-app': все модули SUCCESS (5.1s).
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.

Backward-compat: bundle v1.2.1 (без x-references-on-close) → onClose=BLOCK
default → existing dependents теперь блокируют close (правильное поведение
для FK semantics). Если был race condition close-with-deps в v1, он
exposed теперь как 409. CRITICAL: REGRESSION test покрывает.
2026-05-08 11:23:46 +03:00

314 lines
11 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 { useMutation, useQueryClient } from '@tanstack/react-query'
import {
apiClient,
type BulkCloseRequest,
type BulkCloseResponse,
type CascadeCloseResult,
type CreateDictionaryRequest,
type CreateRecordRequest,
type CreateWebhookSubscriptionRequest,
type DictionaryDetail,
type RecordResponse,
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<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] })
},
})
}
export const useCreateRecord = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (req: CreateRecordRequest): Promise<RecordResponse> => {
const { data } = await apiClient.post<RecordResponse>(
`/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<RecordResponse> => {
const { data } = await apiClient.put<RecordResponse>(
`/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<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'а.
*/
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'] })
},
})
}