Files
mdm-ordinis/ordinis-admin-ui/src/api/mutations.ts
T
Zimin A.N. 84a4de4ceb feat(approval): Approval Workflow v2 Phase 2 — admin UI reviewer queue + toggle
Phase 2 admin UI на скелете Phase 0/1 (e438c4c, 2020af9). Делает workflow
end-to-end usable: reviewer'ы могут approve/reject через UI; admins
включают approval_required per-dict через checkbox.

API surface (admin-ui):
- types: DraftStatus, DraftOperation, DraftResponse, SubmitDraftRequest,
  ReviewQueuePage. + DictionaryDefinition: approvalRequired, approvalMinRole.
- queries (react-query):
  * useReviewQueue (page, size) — global queue, refetch 30s.
  * useDraft(id) — single draft for diff drawer.
  * useLiveRecord(dict, bk) — current live record for side-by-side comparison.
- mutations:
  * useSubmitDraft(dict) — POST /dictionaries/{n}/records/drafts
  * useApproveDraft() — POST /drafts/{id}/approve
  * useRejectDraft() — POST /drafts/{id}/reject (reason required)
  * useWithdrawDraft() — DELETE /drafts/{id}

New /reviews route:
- Table queue: dict, business_key, operation badge, maker, submitted_at.
- Click "Просмотр" → side-by-side drawer:
  * left: current live (если есть; для CREATE — placeholder).
  * right: proposed (или CLOSE notice).
  * maker comment chip если есть.
  * Reviewer comment input + Approve / Reject buttons.
  * Reject disabled пока comment пустой (mirror's backend 400).
- Auto-invalidates на success — queue refresh, list updates.
- Nav link "Согласования" в header.

DictionaryEditorDialog Metadata tab:
- New checkbox "Требовать approval (maker-checker)" — orbit/8 styled
  (отделимо от Redis projection card visually).
- При checked → shown approval_min_role TextInput (e.g. "ordinis:internal").
- payload sends approvalRequired + approvalMinRole.

i18n RU/EN:
- nav.reviews, reviews.{title,description,empty,queueTotal_*,col.*,
  action.*, drawer.*}
- schema.approvalRequired.{label,hint}, schema.approvalMinRole.{label,hint}
- Plurals для RU queueTotal (one/few/many/other).

Verify:
- pnpm tsc --noEmit: clean.
- pnpm test (vitest): 89/89 PASS.
- pnpm build: clean.
- routeTree.gen.ts auto-regenerated с /reviews.

Phase 3 (next session):
- e2e tests: full flow maker submit → checker approve → record live + outbox event.
- e2e: reject keeps draft, doesn't publish.
- e2e: self-approve blocked (no_self_approve constraint).
- e2e: two pending drafts на one business_key blocked (one_pending_per_key).
- Per-dict opt-in soak: 1-2 dicts (например ground_station) с
  approval_required=true в staging.
2026-05-08 13:28:56 +03:00

408 lines
14 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 DraftResponse,
type RecordResponse,
type SubmitDraftRequest,
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'а.
*/
// === 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'] })
},
})
}
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'] })
},
})
}