3eeaba058f
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 покрывает.
314 lines
11 KiB
TypeScript
314 lines
11 KiB
TypeScript
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'] })
|
||
},
|
||
})
|
||
}
|