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.
This commit is contained in:
@@ -8,7 +8,9 @@ import {
|
||||
type CreateRecordRequest,
|
||||
type CreateWebhookSubscriptionRequest,
|
||||
type DictionaryDetail,
|
||||
type DraftResponse,
|
||||
type RecordResponse,
|
||||
type SubmitDraftRequest,
|
||||
type WebhookDelivery,
|
||||
type WebhookSubscription,
|
||||
type WebhookTestPingResult,
|
||||
@@ -293,6 +295,98 @@ export const useTestWebhook = () => {
|
||||
* 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({
|
||||
|
||||
Reference in New Issue
Block a user