feat(schema-workflow): Phase 1b — SchemaDraftDrawer with status-aware actions
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user