feat(schema-workflow): Phase 1b — SchemaDraftDrawer with status-aware actions

This commit is contained in:
Александр Зимин
2026-05-12 11:08:31 +00:00
parent 2a3f2099e5
commit dd3a2d97e5
6 changed files with 1065 additions and 3 deletions
+94
View File
@@ -542,6 +542,100 @@ export type ChangelogResponse = {
* definition_create. Для draft_* (не publish) — after=proposed_schema,
* before=live schema на момент event'а (то что предлагалось vs реальность).
*/
// === Schema workflow (maker-checker drafts, v2.2.0 backend) ===
/**
* Server возвращает status в lowercase ({@code "draft"}, {@code "review_pending"},
* ...) — match'им backend dbValue() напрямую, без перевода в frontend.
*
* Terminal: REJECTED, PUBLISHED, WITHDRAWN.
* Active: DRAFT, REVIEW_PENDING, APPROVED, CHANGES_REQUESTED.
*/
export type SchemaDraftStatus =
| 'draft'
| 'review_pending'
| 'approved'
| 'changes_requested'
| 'rejected'
| 'published'
| 'withdrawn'
export const TERMINAL_SCHEMA_DRAFT_STATUSES: ReadonlySet<SchemaDraftStatus> = new Set([
'rejected',
'published',
'withdrawn',
])
export const isActiveSchemaDraft = (s: SchemaDraftStatus): boolean =>
!TERMINAL_SCHEMA_DRAFT_STATUSES.has(s)
export type SchemaDraft = {
draftId: string
dictionaryId: string
dictionaryName: string
status: SchemaDraftStatus
branchedFromVersion: string
/** Full proposed JSON Schema. */
proposedSchema: unknown
reason?: string | null
makerId: string
createdAt: string
submittedAt?: string | null
/** Optional reviewer list (NULL = open pool). */
reviewers?: unknown | null
reviewNote?: string | null
decisionUserId?: string | null
decisionAt?: string | null
decisionComment?: string | null
publishedAt?: string | null
publishedVersion?: string | null
publishNote?: string | null
/** JPA optimistic lock counter. */
version: number
}
export type CreateSchemaDraftRequest = {
/** Current HEAD schema version this draft branches from. */
fromVersion: string
reason?: string
/** Full proposed JSON Schema (replace, not patch). */
proposedSchema: unknown
}
export type SubmitForReviewRequest = {
/** Optional explicit reviewer IDs. NULL/[] = pool. */
reviewers?: string[] | null
note?: string | null
}
export type SchemaDraftVerdict = 'APPROVE' | 'REQUEST_CHANGES' | 'REJECT'
export type DecisionRequest = {
decision: SchemaDraftVerdict
comment?: string
}
export type PublishSchemaDraftRequest = {
/** Optimistic lock — current schema version от UI. */
expectedHeadVersion: string
publishNote?: string
}
/** Response для POST /publish — wraps draft + computed bump. */
export type PublishSchemaDraftResult = {
draft: SchemaDraft
newVersion: string
publishedAt: string
}
/** Pool queue для /admin/schema-reviews/pending. */
export type SchemaReviewQueuePage = {
items: SchemaDraft[]
page: number
size: number
total: number
}
export type ChangelogDiff = {
id: number
dictionaryName: string
+136
View File
@@ -7,11 +7,17 @@ import {
type CascadeCloseResult,
type CreateDictionaryRequest,
type CreateRecordRequest,
type CreateSchemaDraftRequest,
type CreateWebhookSubscriptionRequest,
type DecisionRequest,
type DictionaryDetail,
type DraftResponse,
type PublishSchemaDraftRequest,
type PublishSchemaDraftResult,
type RecordResponse,
type SchemaDraft,
type SubmitDraftRequest,
type SubmitForReviewRequest,
type WebhookDelivery,
type WebhookSubscription,
type WebhookTestPingResult,
@@ -480,6 +486,136 @@ export const useWithdrawDraft = () => {
})
}
// === Schema workflow mutations (Phase 1a, v2.2.0 backend) =============
/**
* Создаёт schema draft от current HEAD. Backend проверяет fromVersion
* против live schemaVersion (409 version_mismatch если расходится).
* Также 409 active_draft_exists если на dict'е уже есть non-terminal draft.
*
* <p>onSuccess invalidates: active-draft query (banner picks up) +
* drafts list + reviewer pool queue.
*/
export const useCreateSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (payload: CreateSchemaDraftRequest): Promise<SchemaDraft> => {
const { data } = await apiClient.post<SchemaDraft>(
`/dictionaries/${dictionaryName}/drafts`,
payload,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
},
})
}
/** Maker submits draft → review_pending. 202 Accepted. */
export const useSubmitSchemaDraftForReview = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
draftId: string
body?: SubmitForReviewRequest
}): Promise<SchemaDraft> => {
const { data } = await apiClient.post<SchemaDraft>(
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/review`,
params.body ?? {},
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
},
})
}
/**
* Reviewer decide: approve / request_changes / reject. Backend
* проверяет self_approve_forbidden (maker_id != actor) → 403.
*/
export const useDecideSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
draftId: string
body: DecisionRequest
}): Promise<SchemaDraft> => {
const { data } = await apiClient.post<SchemaDraft>(
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/decision`,
params.body,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
// Changelog gets new entry (draft_approve / draft_reject / draft_changes_requested)
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
},
})
}
/**
* Publish approved draft → bump live HEAD. Backend computes new version
* (minor default, major на breaking). Returns wrapped {draft, newVersion, publishedAt}.
*
* <p>onSuccess invalidates dict detail (schema/version changed) + changelog +
* draft queries.
*/
export const usePublishSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (params: {
draftId: string
body: PublishSchemaDraftRequest
}): Promise<PublishSchemaDraftResult> => {
const { data } = await apiClient.post<PublishSchemaDraftResult>(
`/dictionaries/${dictionaryName}/drafts/${params.draftId}/publish`,
params.body,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
qc.invalidateQueries({ queryKey: ['dictionary', dictionaryName] })
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
},
})
}
/** Maker withdraws non-terminal draft (terminal state). */
export const useWithdrawSchemaDraft = (dictionaryName: string) => {
const qc = useQueryClient()
return useMutation({
mutationFn: async (draftId: string): Promise<SchemaDraft> => {
const { data } = await apiClient.delete<SchemaDraft>(
`/dictionaries/${dictionaryName}/drafts/${draftId}`,
)
return data
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['schema-draft-active', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-draft', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-drafts', dictionaryName] })
qc.invalidateQueries({ queryKey: ['schema-review-queue'] })
qc.invalidateQueries({ queryKey: ['changelog', dictionaryName] })
},
})
}
export const useRetryWebhookDelivery = () => {
const qc = useQueryClient()
return useMutation({
+97
View File
@@ -1,4 +1,5 @@
import { useQuery, queryOptions } from '@tanstack/react-query'
import axios from 'axios'
import {
apiClient,
type AuditFilters,
@@ -6,6 +7,8 @@ import {
type CascadePlan,
type ChangelogDiff,
type ChangelogResponse,
type SchemaDraft,
type SchemaReviewQueuePage,
type DictionaryDefinition,
type DictionaryDetail,
type DlqPage,
@@ -192,6 +195,100 @@ export const useChangelogDiff = (
enabled: Boolean(dictionaryName) && Boolean(entryId),
})
// === Schema workflow drafts (Phase 1a) ================================
/**
* Active schema draft на словаре, если есть. Backend возвращает 404
* {@code no_active_draft} когда нет non-terminal draft'а — мы маппим
* это в null чтобы UI рендерил «no draft» state без error toast'а.
*
* <p>refetchInterval=20s — banner должен быстро отражать «отправили
* на ревью» / «approved» transition от другого пользователя.
*/
export const dictActiveSchemaDraftQuery = (dictName: string) =>
queryOptions({
queryKey: ['schema-draft-active', dictName] as const,
queryFn: async (): Promise<SchemaDraft | null> => {
try {
const { data } = await apiClient.get<SchemaDraft>(
`/dictionaries/${dictName}/drafts/active`,
)
return data
} catch (err) {
// 404 no_active_draft — это not-an-error, отдаём null.
if (axios.isAxiosError(err) && err.response?.status === 404) {
return null
}
throw err
}
},
refetchInterval: 20_000,
})
export const useDictActiveSchemaDraft = (dictName: string | undefined) =>
useQuery({
...dictActiveSchemaDraftQuery(dictName ?? ''),
enabled: Boolean(dictName),
})
/** History всех schema drafts на словаре (для admin audit / settings tab). */
export const dictSchemaDraftsQuery = (dictName: string) =>
queryOptions({
queryKey: ['schema-drafts', dictName] as const,
queryFn: async (): Promise<SchemaDraft[]> => {
const { data } = await apiClient.get<SchemaDraft[]>(
`/dictionaries/${dictName}/drafts`,
)
return data
},
})
export const useDictSchemaDrafts = (dictName: string | undefined) =>
useQuery({
...dictSchemaDraftsQuery(dictName ?? ''),
enabled: Boolean(dictName),
})
/** Single draft drill-in (decision modal, deep-link). */
export const schemaDraftQuery = (dictName: string, draftId: string) =>
queryOptions({
queryKey: ['schema-draft', dictName, draftId] as const,
queryFn: async (): Promise<SchemaDraft> => {
const { data } = await apiClient.get<SchemaDraft>(
`/dictionaries/${dictName}/drafts/${draftId}`,
)
return data
},
})
export const useSchemaDraft = (
dictName: string | undefined,
draftId: string | undefined,
) =>
useQuery({
...schemaDraftQuery(dictName ?? '', draftId ?? ''),
enabled: Boolean(dictName) && Boolean(draftId),
})
/**
* Global pool queue для approvers — все review_pending drafts.
* Backend response: {items, page, size, total}.
*/
export const schemaReviewQueueQuery = (page = 0, size = 50) =>
queryOptions({
queryKey: ['schema-review-queue', page, size] as const,
queryFn: async (): Promise<SchemaReviewQueuePage> => {
const { data } = await apiClient.get<SchemaReviewQueuePage>(
'/admin/schema-reviews/pending',
{ params: { page, size } },
)
return data
},
})
export const useSchemaReviewQueue = (page = 0, size = 50) =>
useQuery(schemaReviewQueueQuery(page, size))
export const recordRawQuery = (dictionaryName: string, businessKey: string) =>
queryOptions({
queryKey: ['record-raw', dictionaryName, businessKey] as const,