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:
@@ -37,6 +37,9 @@ export type DictionaryDefinition = {
|
||||
defaultLocale: string
|
||||
/** CEO plan E2: per-dict Redis projection materialization opt-in. */
|
||||
redisProjectionEnabled: boolean
|
||||
/** Approval Workflow v2: per-dict opt-in. */
|
||||
approvalRequired: boolean
|
||||
approvalMinRole?: string | null
|
||||
recordCount?: number
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
@@ -107,6 +110,10 @@ export type CreateDictionaryRequest = {
|
||||
defaultLocale?: string
|
||||
/** CEO plan E2: per-dict Redis projection opt-in. Default false. */
|
||||
redisProjectionEnabled?: boolean
|
||||
/** Approval Workflow v2: per-dict opt-in. Default false. */
|
||||
approvalRequired?: boolean
|
||||
/** Optional Keycloak role для reviewer'ов на этот dict. */
|
||||
approvalMinRole?: string
|
||||
}
|
||||
|
||||
export type CreateRecordRequest = {
|
||||
@@ -246,6 +253,48 @@ export type WebhookDeliveryPage = {
|
||||
size: number
|
||||
}
|
||||
|
||||
// === Approval Workflow v2 ===
|
||||
|
||||
export type DraftStatus = 'PENDING' | 'APPROVED' | 'REJECTED' | 'WITHDRAWN'
|
||||
export type DraftOperation = 'CREATE' | 'UPDATE' | 'CLOSE'
|
||||
|
||||
export type DraftResponse = {
|
||||
id: string
|
||||
dictionaryId: string
|
||||
businessKey: string
|
||||
parentRecordId?: string | null
|
||||
operation: DraftOperation
|
||||
data?: Record<string, unknown> | null
|
||||
geometryWkt?: string | null
|
||||
proposedValidFrom?: string | null
|
||||
proposedValidTo?: string | null
|
||||
status: DraftStatus
|
||||
makerId: string
|
||||
makerComment?: string | null
|
||||
submittedAt: string
|
||||
reviewerId?: string | null
|
||||
reviewedAt?: string | null
|
||||
reviewComment?: string | null
|
||||
}
|
||||
|
||||
export type SubmitDraftRequest = {
|
||||
businessKey: string
|
||||
operation: DraftOperation
|
||||
data?: Record<string, unknown> | null
|
||||
geometryWkt?: string | null
|
||||
validFrom?: string | null
|
||||
validTo?: string | null
|
||||
comment?: string | null
|
||||
}
|
||||
|
||||
export type ReviewQueuePage = {
|
||||
items: DraftResponse[]
|
||||
page: number
|
||||
size: number
|
||||
totalElements: number
|
||||
totalPages: number
|
||||
}
|
||||
|
||||
// === Smart search (CEO plan v1) ===
|
||||
|
||||
export type SearchItem = {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
type DictionaryDefinition,
|
||||
type DictionaryDetail,
|
||||
type DlqPage,
|
||||
type DraftResponse,
|
||||
type FlattenedRecord,
|
||||
type OutboxStats,
|
||||
type RecordDependentsPage,
|
||||
type RecordResponse,
|
||||
type ReviewQueuePage,
|
||||
type SchemaDependent,
|
||||
type SearchResponse,
|
||||
type WebhookDeliveryPage,
|
||||
@@ -388,6 +390,66 @@ export const useSearch = (q: string | undefined, size = 100, perDict = 10) =>
|
||||
enabled: Boolean(q && q.length >= 3),
|
||||
})
|
||||
|
||||
// === Approval Workflow v2 ===
|
||||
|
||||
/** Global reviewer queue — pool model (D3=A). All pending drafts FIFO. */
|
||||
export const reviewQueueQuery = (page = 0, size = 50) =>
|
||||
queryOptions({
|
||||
queryKey: ['review-queue', page, size] as const,
|
||||
queryFn: async (): Promise<ReviewQueuePage> => {
|
||||
const { data } = await apiClient.get<ReviewQueuePage>('/admin/reviews/pending', {
|
||||
params: { page, size },
|
||||
})
|
||||
return data
|
||||
},
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
export const useReviewQueue = (page = 0, size = 50) =>
|
||||
useQuery(reviewQueueQuery(page, size))
|
||||
|
||||
/** Single draft by id — for diff viewer drawer. */
|
||||
export const draftQuery = (id: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['draft', id] as const,
|
||||
queryFn: async (): Promise<DraftResponse> => {
|
||||
const { data } = await apiClient.get<DraftResponse>(
|
||||
`/drafts/${encodeURIComponent(id)}`,
|
||||
)
|
||||
return data
|
||||
},
|
||||
})
|
||||
|
||||
export const useDraft = (id: string | undefined) =>
|
||||
useQuery({
|
||||
...draftQuery(id ?? ''),
|
||||
enabled: Boolean(id),
|
||||
})
|
||||
|
||||
/** Live record by businessKey — for diff comparison "current vs proposed". */
|
||||
export const liveRecordQuery = (dict: string, businessKey: string) =>
|
||||
queryOptions({
|
||||
queryKey: ['live-record', dict, businessKey] as const,
|
||||
queryFn: async (): Promise<RecordResponse | null> => {
|
||||
try {
|
||||
const { data } = await apiClient.get<RecordResponse>(
|
||||
`/dictionaries/${encodeURIComponent(dict)}/records/${encodeURIComponent(businessKey)}`,
|
||||
)
|
||||
return data
|
||||
} catch (e: unknown) {
|
||||
const status = (e as { response?: { status?: number } })?.response?.status
|
||||
if (status === 404) return null
|
||||
throw e
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const useLiveRecord = (dict: string | undefined, businessKey: string | undefined) =>
|
||||
useQuery({
|
||||
...liveRecordQuery(dict ?? '', businessKey ?? ''),
|
||||
enabled: Boolean(dict && businessKey),
|
||||
})
|
||||
|
||||
export const useDictionaries = () => useQuery(dictionariesQuery)
|
||||
export const useDictionaryDetail = (name: string) => useQuery(dictionaryDetailQuery(name))
|
||||
export const useRecords = (
|
||||
|
||||
Reference in New Issue
Block a user