feat(schema-workflow): PATCH /drafts/{id} — edit без recreate (A)
This commit is contained in:
@@ -496,6 +496,7 @@ export type ChangelogKind =
|
||||
| 'definition_create'
|
||||
| 'definition_update'
|
||||
| 'draft_create'
|
||||
| 'draft_update'
|
||||
| 'draft_review'
|
||||
| 'draft_approve'
|
||||
| 'draft_reject'
|
||||
@@ -602,6 +603,18 @@ export type CreateSchemaDraftRequest = {
|
||||
proposedSchema: unknown
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial update existing draft без recreate. Status-gate enforced
|
||||
* backend'ом: только DRAFT и CHANGES_REQUESTED. CHANGES_REQUESTED →
|
||||
* DRAFT auto-transition после update (maker должен ре-submit).
|
||||
*/
|
||||
export type UpdateSchemaDraftRequest = {
|
||||
/** Replacement JSON Schema (full document). undefined = не меняем. */
|
||||
proposedSchema?: unknown
|
||||
/** Новая maker rationale. undefined = не меняем. */
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export type SubmitForReviewRequest = {
|
||||
/** Optional explicit reviewer IDs. NULL/[] = pool. */
|
||||
reviewers?: string[] | null
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type SchemaDraft,
|
||||
type SubmitDraftRequest,
|
||||
type SubmitForReviewRequest,
|
||||
type UpdateSchemaDraftRequest,
|
||||
type WebhookDelivery,
|
||||
type WebhookSubscription,
|
||||
type WebhookTestPingResult,
|
||||
@@ -514,6 +515,35 @@ export const useCreateSchemaDraft = (dictionaryName: string) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Update content (proposedSchema / reason) в существующем draft'е без
|
||||
* recreate. Status-gate: только DRAFT и CHANGES_REQUESTED. Backend
|
||||
* автоматически переводит CHANGES_REQUESTED → DRAFT после update
|
||||
* (maker должен ре-submit на ревью осознанно).
|
||||
*/
|
||||
export const useUpdateSchemaDraft = (dictionaryName: string) => {
|
||||
const qc = useQueryClient()
|
||||
return useMutation({
|
||||
mutationFn: async (params: {
|
||||
draftId: string
|
||||
body: UpdateSchemaDraftRequest
|
||||
}): Promise<SchemaDraft> => {
|
||||
const { data } = await apiClient.patch<SchemaDraft>(
|
||||
`/dictionaries/${dictionaryName}/drafts/${params.draftId}`,
|
||||
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: ['changelog', dictionaryName] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** Maker submits draft → review_pending. 202 Accepted. */
|
||||
export const useSubmitSchemaDraftForReview = (dictionaryName: string) => {
|
||||
const qc = useQueryClient()
|
||||
|
||||
@@ -32,6 +32,8 @@ export function kindIcon(kind: ChangelogKind): Icon {
|
||||
return PencilSimpleIcon
|
||||
case 'draft_create':
|
||||
return ChatCircleTextIcon
|
||||
case 'draft_update':
|
||||
return PencilSimpleIcon
|
||||
case 'draft_review':
|
||||
return EyeIcon
|
||||
case 'draft_approve':
|
||||
@@ -61,6 +63,7 @@ export function kindBadgeVariant(kind: ChangelogKind): BadgeProps['variant'] {
|
||||
case 'draft_changes_requested':
|
||||
return 'warning'
|
||||
case 'draft_create':
|
||||
case 'draft_update':
|
||||
case 'draft_review':
|
||||
case 'draft_withdraw':
|
||||
return 'neutral'
|
||||
@@ -72,6 +75,7 @@ export function kindLabel(kind: ChangelogKind, t: TFn): string {
|
||||
definition_create: 'Создан словарь',
|
||||
definition_update: 'Изменена схема',
|
||||
draft_create: 'Черновик создан',
|
||||
draft_update: 'Черновик обновлён',
|
||||
draft_review: 'Отправлен на ревью',
|
||||
draft_approve: 'Одобрен',
|
||||
draft_reject: 'Отклонён',
|
||||
|
||||
@@ -179,6 +179,7 @@ function SchemaDraftBanner({
|
||||
onClose={() => setDrawerOpen(false)}
|
||||
dictionaryName={detail.name}
|
||||
currentHeadVersion={detail.schemaVersion}
|
||||
detail={detail}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -9,15 +9,21 @@ import {
|
||||
toast,
|
||||
} from '@/ui'
|
||||
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
|
||||
import type { DictionaryDetail } from '@/api/client'
|
||||
import { useCreateSchemaDraft } from '@/api/mutations'
|
||||
import type { DictionaryDetail, SchemaDraft } from '@/api/client'
|
||||
import { useCreateSchemaDraft, useUpdateSchemaDraft } from '@/api/mutations'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
detail: DictionaryDetail
|
||||
/** Optional callback после успешного create — parent может закрыть modal +
|
||||
* открыть SchemaDraftDrawer чтоб maker сразу submit'нул. */
|
||||
/**
|
||||
* Edit mode: если передан существующий draft, modal работает как
|
||||
* редактор (PATCH /drafts/{id}) вместо create. Initial JSON и reason
|
||||
* seeded из draft'а. Используется когда maker правит draft после
|
||||
* CHANGES_REQUESTED — backend auto-transition CHANGES_REQUESTED → DRAFT.
|
||||
*/
|
||||
editDraft?: SchemaDraft
|
||||
/** Callback после create — parent может закрыть modal + открыть drawer. */
|
||||
onCreated?: (draftId: string) => void
|
||||
}
|
||||
|
||||
@@ -39,26 +45,36 @@ type Props = {
|
||||
* вернёт detail в message.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) {
|
||||
export function CreateSchemaDraftModal({ open, onClose, detail, editDraft, onCreated }: Props) {
|
||||
const { t } = useTranslation()
|
||||
const isEdit = Boolean(editDraft)
|
||||
// В edit mode seed'им из draft'овой proposedSchema, иначе из live HEAD.
|
||||
const initialJson = useMemo(
|
||||
() => JSON.stringify(detail.schemaJson, null, 2),
|
||||
[detail.schemaJson],
|
||||
() =>
|
||||
JSON.stringify(
|
||||
editDraft ? editDraft.proposedSchema : detail.schemaJson,
|
||||
null,
|
||||
2,
|
||||
),
|
||||
[editDraft, detail.schemaJson],
|
||||
)
|
||||
const initialReason = editDraft?.reason ?? ''
|
||||
const [json, setJson] = useState(initialJson)
|
||||
const [reason, setReason] = useState('')
|
||||
const [reason, setReason] = useState(initialReason)
|
||||
const [parseError, setParseError] = useState<string | null>(null)
|
||||
|
||||
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setJson(initialJson)
|
||||
setReason('')
|
||||
setReason(initialReason)
|
||||
setParseError(null)
|
||||
}
|
||||
}, [open, initialJson])
|
||||
}, [open, initialJson, initialReason])
|
||||
|
||||
const createMut = useCreateSchemaDraft(detail.name)
|
||||
const updateMut = useUpdateSchemaDraft(detail.name)
|
||||
const pending = createMut.isPending || updateMut.isPending
|
||||
|
||||
const handleFormat = () => {
|
||||
try {
|
||||
@@ -100,6 +116,39 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
setParseError((e as Error).message)
|
||||
return
|
||||
}
|
||||
const handleErrorToast = (err: unknown, fallbackKey: string, fallbackText: string) => {
|
||||
let msg = t(fallbackKey, { defaultValue: fallbackText })
|
||||
if (axios.isAxiosError(err)) {
|
||||
const body = err.response?.data as { code?: string; message?: string } | undefined
|
||||
msg = body?.message ?? body?.code ?? msg
|
||||
}
|
||||
toast.error(msg)
|
||||
}
|
||||
|
||||
if (isEdit && editDraft) {
|
||||
updateMut.mutate(
|
||||
{
|
||||
draftId: editDraft.draftId,
|
||||
body: { proposedSchema: parsed, reason: reason || undefined },
|
||||
},
|
||||
{
|
||||
onSuccess: () => {
|
||||
toast.success(
|
||||
t('createDraft.toast.updated', { defaultValue: 'Черновик обновлён' }),
|
||||
)
|
||||
onClose()
|
||||
},
|
||||
onError: (err) =>
|
||||
handleErrorToast(
|
||||
err,
|
||||
'createDraft.toast.updateFailed',
|
||||
'Не удалось обновить черновик',
|
||||
),
|
||||
},
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
createMut.mutate(
|
||||
{
|
||||
fromVersion: detail.schemaVersion,
|
||||
@@ -114,18 +163,12 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
onCreated?.(draft.draftId)
|
||||
onClose()
|
||||
},
|
||||
onError: (err) => {
|
||||
let msg = t('createDraft.toast.failed', {
|
||||
defaultValue: 'Не удалось создать черновик',
|
||||
})
|
||||
if (axios.isAxiosError(err)) {
|
||||
const body = err.response?.data as
|
||||
| { code?: string; message?: string }
|
||||
| undefined
|
||||
msg = body?.message ?? body?.code ?? msg
|
||||
}
|
||||
toast.error(msg)
|
||||
},
|
||||
onError: (err) =>
|
||||
handleErrorToast(
|
||||
err,
|
||||
'createDraft.toast.failed',
|
||||
'Не удалось создать черновик',
|
||||
),
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -135,15 +178,28 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
isOpen={open}
|
||||
onClose={onClose}
|
||||
size="xl"
|
||||
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })}
|
||||
description={`${detail.name} · v${detail.schemaVersion}`}
|
||||
title={
|
||||
isEdit
|
||||
? t('createDraft.editTitle', { defaultValue: 'Редактировать черновик' })
|
||||
: t('createDraft.title', { defaultValue: 'Создать черновик схемы' })
|
||||
}
|
||||
description={
|
||||
isEdit && editDraft
|
||||
? `${detail.name} · черновик от v${editDraft.branchedFromVersion}`
|
||||
: `${detail.name} · v${detail.schemaVersion}`
|
||||
}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<Alert variant="info">
|
||||
{t('createDraft.intro', {
|
||||
defaultValue:
|
||||
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
||||
})}
|
||||
{isEdit
|
||||
? t('createDraft.editIntro', {
|
||||
defaultValue:
|
||||
'После сохранения статус черновика вернётся в draft — отправьте его на ревью заново.',
|
||||
})
|
||||
: t('createDraft.intro', {
|
||||
defaultValue:
|
||||
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
|
||||
})}
|
||||
</Alert>
|
||||
|
||||
{/* Reason */}
|
||||
@@ -158,7 +214,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
|
||||
})}
|
||||
rows={2}
|
||||
disabled={createMut.isPending}
|
||||
disabled={pending}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +230,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleFormat}
|
||||
disabled={createMut.isPending}
|
||||
disabled={pending}
|
||||
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('createDraft.format', { defaultValue: 'Format' })}
|
||||
@@ -184,7 +240,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleValidate}
|
||||
disabled={createMut.isPending}
|
||||
disabled={pending}
|
||||
leftIcon={<CheckSquareIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('createDraft.validate', { defaultValue: 'Validate' })}
|
||||
@@ -201,7 +257,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
if (parseError) setParseError(null)
|
||||
}}
|
||||
spellCheck={false}
|
||||
disabled={createMut.isPending}
|
||||
disabled={pending}
|
||||
className="w-full min-h-[400px] max-h-[60vh] text-mono text-cell rounded-md border border-line bg-[#0c0a23] text-[#f0eaff] p-3 focus:outline-none focus:ring-1 focus:ring-accent"
|
||||
/>
|
||||
{parseError && (
|
||||
@@ -217,7 +273,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={onClose}
|
||||
disabled={createMut.isPending}
|
||||
disabled={pending}
|
||||
>
|
||||
{t('common.cancel', { defaultValue: 'Отмена' })}
|
||||
</Button>
|
||||
@@ -225,11 +281,15 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={handleSubmit}
|
||||
disabled={createMut.isPending || Boolean(parseError)}
|
||||
disabled={pending || Boolean(parseError)}
|
||||
>
|
||||
{createMut.isPending
|
||||
? t('createDraft.creating', { defaultValue: 'Создание…' })
|
||||
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
|
||||
{pending
|
||||
? isEdit
|
||||
? t('createDraft.updating', { defaultValue: 'Сохранение…' })
|
||||
: t('createDraft.creating', { defaultValue: 'Создание…' })
|
||||
: isEdit
|
||||
? t('createDraft.submitEdit', { defaultValue: 'Сохранить' })
|
||||
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Alert, Badge, Button, Drawer, LoadingBlock, TextArea, toast } from '@/u
|
||||
import {
|
||||
CheckIcon,
|
||||
PaperPlaneTiltIcon,
|
||||
PencilSimpleIcon,
|
||||
XCircleIcon,
|
||||
ArrowsCounterClockwiseIcon,
|
||||
RocketIcon,
|
||||
@@ -12,10 +13,12 @@ import {
|
||||
} from '@phosphor-icons/react'
|
||||
import {
|
||||
isActiveSchemaDraft,
|
||||
type DictionaryDetail,
|
||||
type SchemaDraft,
|
||||
type SchemaDraftStatus,
|
||||
type SchemaDraftVerdict,
|
||||
} from '@/api/client'
|
||||
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
|
||||
import { useDictActiveSchemaDraft } from '@/api/queries'
|
||||
import {
|
||||
useDecideSchemaDraft,
|
||||
@@ -31,6 +34,9 @@ type Props = {
|
||||
dictionaryName: string
|
||||
/** Current live schemaVersion — passed для publish expectedHeadVersion check. */
|
||||
currentHeadVersion: string | undefined
|
||||
/** Optional full dict detail — нужен для edit modal seeding. Если не
|
||||
* передан, edit button скрывается. */
|
||||
detail?: DictionaryDetail
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,9 +64,11 @@ export function SchemaDraftDrawer({
|
||||
onClose,
|
||||
dictionaryName,
|
||||
currentHeadVersion,
|
||||
detail,
|
||||
}: Props) {
|
||||
const { t } = useTranslation()
|
||||
const [comment, setComment] = useState('')
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
|
||||
open ? dictionaryName : undefined,
|
||||
)
|
||||
@@ -241,9 +249,18 @@ export function SchemaDraftDrawer({
|
||||
onDecide={onDecide}
|
||||
onPublish={onPublish}
|
||||
onWithdraw={onWithdraw}
|
||||
onEdit={detail ? () => setEditOpen(true) : undefined}
|
||||
disabled={anyPending}
|
||||
hasCurrentVersion={Boolean(currentHeadVersion)}
|
||||
/>
|
||||
{detail && (
|
||||
<CreateSchemaDraftModal
|
||||
open={editOpen}
|
||||
onClose={() => setEditOpen(false)}
|
||||
detail={detail}
|
||||
editDraft={draft}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -380,6 +397,7 @@ function DraftActions({
|
||||
onDecide,
|
||||
onPublish,
|
||||
onWithdraw,
|
||||
onEdit,
|
||||
disabled,
|
||||
hasCurrentVersion,
|
||||
}: {
|
||||
@@ -388,6 +406,7 @@ function DraftActions({
|
||||
onDecide: (v: SchemaDraftVerdict) => void
|
||||
onPublish: () => void
|
||||
onWithdraw: () => void
|
||||
onEdit?: () => void
|
||||
disabled: boolean
|
||||
hasCurrentVersion: boolean
|
||||
}) {
|
||||
@@ -411,21 +430,36 @@ function DraftActions({
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-line">
|
||||
{(status === 'draft' || status === 'changes_requested') && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSubmitReview}
|
||||
disabled={disabled}
|
||||
leftIcon={<PaperPlaneTiltIcon weight="regular" size={14} />}
|
||||
>
|
||||
{status === 'draft'
|
||||
? t('workflow.schemaDraft.actions.submit', {
|
||||
defaultValue: 'Отправить на ревью',
|
||||
})
|
||||
: t('workflow.schemaDraft.actions.resubmit', {
|
||||
defaultValue: 'Доработать и отправить',
|
||||
<>
|
||||
{onEdit && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onEdit}
|
||||
disabled={disabled}
|
||||
leftIcon={<PencilSimpleIcon weight="regular" size={14} />}
|
||||
>
|
||||
{t('workflow.schemaDraft.actions.edit', {
|
||||
defaultValue: 'Редактировать',
|
||||
})}
|
||||
</Button>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSubmitReview}
|
||||
disabled={disabled}
|
||||
leftIcon={<PaperPlaneTiltIcon weight="regular" size={14} />}
|
||||
>
|
||||
{status === 'draft'
|
||||
? t('workflow.schemaDraft.actions.submit', {
|
||||
defaultValue: 'Отправить на ревью',
|
||||
})
|
||||
: t('workflow.schemaDraft.actions.resubmit', {
|
||||
defaultValue: 'Доработать и отправить',
|
||||
})}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{status === 'review_pending' && canReview && (
|
||||
<>
|
||||
|
||||
@@ -417,6 +417,7 @@ i18n
|
||||
'changelog.kind.definition_create': 'Создан словарь',
|
||||
'changelog.kind.definition_update': 'Изменена схема',
|
||||
'changelog.kind.draft_create': 'Черновик создан',
|
||||
'changelog.kind.draft_update': 'Черновик обновлён',
|
||||
'changelog.kind.draft_review': 'Отправлен на ревью',
|
||||
'changelog.kind.draft_approve': 'Одобрен',
|
||||
'changelog.kind.draft_reject': 'Отклонён',
|
||||
@@ -491,6 +492,13 @@ i18n
|
||||
'createDraft.creating': 'Создание…',
|
||||
'createDraft.toast.created': 'Черновик создан',
|
||||
'createDraft.toast.failed': 'Не удалось создать черновик',
|
||||
'createDraft.editTitle': 'Редактировать черновик',
|
||||
'createDraft.editIntro': 'После сохранения статус черновика вернётся в draft — отправьте его на ревью заново.',
|
||||
'createDraft.submitEdit': 'Сохранить',
|
||||
'createDraft.updating': 'Сохранение…',
|
||||
'createDraft.toast.updated': 'Черновик обновлён',
|
||||
'createDraft.toast.updateFailed': 'Не удалось обновить черновик',
|
||||
'workflow.schemaDraft.actions.edit': 'Редактировать',
|
||||
// === Schema review queue panel ===
|
||||
'reviews.schema.title': 'Изменения схем',
|
||||
'reviews.schema.label': 'Schema workflow',
|
||||
@@ -1036,6 +1044,7 @@ i18n
|
||||
'changelog.kind.definition_create': 'Dictionary created',
|
||||
'changelog.kind.definition_update': 'Schema updated',
|
||||
'changelog.kind.draft_create': 'Draft created',
|
||||
'changelog.kind.draft_update': 'Draft updated',
|
||||
'changelog.kind.draft_review': 'Sent for review',
|
||||
'changelog.kind.draft_approve': 'Approved',
|
||||
'changelog.kind.draft_reject': 'Rejected',
|
||||
@@ -1110,6 +1119,13 @@ i18n
|
||||
'createDraft.creating': 'Creating…',
|
||||
'createDraft.toast.created': 'Draft created',
|
||||
'createDraft.toast.failed': 'Failed to create draft',
|
||||
'createDraft.editTitle': 'Edit draft',
|
||||
'createDraft.editIntro': 'After save, the draft status reverts to draft — re-submit for review explicitly.',
|
||||
'createDraft.submitEdit': 'Save',
|
||||
'createDraft.updating': 'Saving…',
|
||||
'createDraft.toast.updated': 'Draft updated',
|
||||
'createDraft.toast.updateFailed': 'Failed to update draft',
|
||||
'workflow.schemaDraft.actions.edit': 'Edit',
|
||||
// === Schema review queue panel ===
|
||||
'reviews.schema.title': 'Schema changes',
|
||||
'reviews.schema.label': 'Schema workflow',
|
||||
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
|
||||
/**
|
||||
* Partial update черновика без recreate. Полезно maker'у после
|
||||
* {@code CHANGES_REQUESTED} — вместо withdraw + create нового draft'а
|
||||
* (с потерей id, history, reviewer assignment'ов) — обновляем content
|
||||
* существующего.
|
||||
*
|
||||
* <p>Endpoint: {@code PATCH /api/v1/dictionaries/{name}/drafts/{draftId}}.
|
||||
*
|
||||
* <p>Status-gate (enforced backend'ом):
|
||||
* <ul>
|
||||
* <li>{@code DRAFT} — OK.</li>
|
||||
* <li>{@code CHANGES_REQUESTED} — OK; status переводится обратно в
|
||||
* {@code DRAFT} автоматически (maker должен ре-submit).</li>
|
||||
* <li>{@code REVIEW_PENDING} / {@code APPROVED} — 409 invalid_transition.
|
||||
* Pending review не должен ловить moving target; approved content
|
||||
* freezing до publish.</li>
|
||||
* <li>Terminal — 409.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Все поля optional. Если null — соответствующее поле draft'а не
|
||||
* меняется. Это позволяет, например, обновить только reason без замены
|
||||
* proposedSchema.
|
||||
*/
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public record UpdateSchemaDraftRequest(
|
||||
/** Replacement JSON Schema (full document). null = не меняем. */
|
||||
JsonNode proposedSchema,
|
||||
/** Новая maker rationale. null = не меняем. */
|
||||
String reason) {}
|
||||
+2
@@ -55,6 +55,7 @@ public class ChangelogService {
|
||||
"DEFINITION_CREATED",
|
||||
"DEFINITION_UPDATED",
|
||||
"SCHEMA_DRAFT_CREATED",
|
||||
"SCHEMA_DRAFT_UPDATED",
|
||||
"SCHEMA_DRAFT_REVIEW_REQUESTED",
|
||||
"SCHEMA_DRAFT_DECIDED",
|
||||
"SCHEMA_DRAFT_PUBLISHED",
|
||||
@@ -201,6 +202,7 @@ public class ChangelogService {
|
||||
case "DEFINITION_CREATED" -> "definition_create";
|
||||
case "DEFINITION_UPDATED" -> "definition_update";
|
||||
case "SCHEMA_DRAFT_CREATED" -> "draft_create";
|
||||
case "SCHEMA_DRAFT_UPDATED" -> "draft_update";
|
||||
case "SCHEMA_DRAFT_REVIEW_REQUESTED" -> "draft_review";
|
||||
case "SCHEMA_DRAFT_DECIDED" -> switch (action == null ? "" : action) {
|
||||
case "DRAFT_APPROVE" -> "draft_approve";
|
||||
|
||||
+60
@@ -11,6 +11,7 @@ import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDr
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.UpdateSchemaDraftRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
@@ -348,6 +349,65 @@ public class SchemaDraftService {
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maker правит proposedSchema или reason в существующем draft'е без
|
||||
* recreate. Полезно после CHANGES_REQUESTED: вместо withdraw + create
|
||||
* (с потерей draftId, history события, reviewer assignment) — просто
|
||||
* обновляем content.
|
||||
*
|
||||
* <p>Status-gate: только {@code DRAFT} и {@code CHANGES_REQUESTED}.
|
||||
* Из {@code REVIEW_PENDING} update запрещён — pending review должен
|
||||
* сначала разрешиться decision'ом (reviewer не должен ловить moving
|
||||
* target). Из {@code APPROVED} тоже запрещён — после approve content
|
||||
* заморожен до publish (иначе одобрили одну схему, опубликовали другую).
|
||||
*
|
||||
* <p>Side effect: если status был {@code CHANGES_REQUESTED}, переводим
|
||||
* обратно в {@code DRAFT} чтобы maker должен был осознанно submit'нуть
|
||||
* на ревью заново. Это match'ит логичную ментальную модель «правки →
|
||||
* новый draft state → re-submit».
|
||||
*
|
||||
* <p>Audit + outbox event {@code SchemaDraftUpdated} даёт changelog
|
||||
* timeline видимость "maker правил черновик после review feedback".
|
||||
*/
|
||||
@Transactional
|
||||
public DictionarySchemaDraft updateContent(UUID draftId, UpdateSchemaDraftRequest req) {
|
||||
DictionarySchemaDraft draft = findById(draftId);
|
||||
assertMakerActor(draft, "update_content");
|
||||
|
||||
SchemaDraftStatus current = draft.getStatus();
|
||||
if (current != SchemaDraftStatus.DRAFT && current != SchemaDraftStatus.CHANGES_REQUESTED) {
|
||||
throw OrdinisException.conflict(
|
||||
"invalid_transition",
|
||||
"Draft " + draftId + " status=" + current.dbValue()
|
||||
+ " — update возможен только из draft или changes_requested.");
|
||||
}
|
||||
|
||||
JsonNode beforeSchema = draft.getProposedSchema();
|
||||
if (req.proposedSchema() != null) {
|
||||
draft.setProposedSchema(req.proposedSchema());
|
||||
}
|
||||
if (req.reason() != null) {
|
||||
draft.setReason(req.reason());
|
||||
}
|
||||
// CHANGES_REQUESTED → DRAFT: maker должен заново submit'нуть после правок.
|
||||
if (current == SchemaDraftStatus.CHANGES_REQUESTED) {
|
||||
draft.setStatus(SchemaDraftStatus.DRAFT);
|
||||
}
|
||||
|
||||
DictionarySchemaDraft saved = repository.save(draft);
|
||||
DictionaryDefinition def = definitionService.findById(saved.getDictionaryId());
|
||||
emitEvent("SchemaDraftUpdated", saved, def, Map.of(
|
||||
"previousStatus", current.dbValue()));
|
||||
if (auditLogger != null) {
|
||||
auditLogger.schemaDraftEvent(
|
||||
"SCHEMA_DRAFT_UPDATED", "DRAFT_UPDATE", def,
|
||||
beforeSchema, saved.getProposedSchema());
|
||||
}
|
||||
log.info("schema_draft_updated id={} dict={} maker={} prevStatus={}",
|
||||
saved.getId(), def.getName(), saved.getMakerId(), current.dbValue());
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** Maker отзывает свой draft (только non-terminal статусы). */
|
||||
@Transactional
|
||||
public DictionarySchemaDraft withdraw(UUID draftId) {
|
||||
|
||||
+18
@@ -7,6 +7,7 @@ import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionReques
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SchemaDraftResponse;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.UpdateSchemaDraftRequest;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
|
||||
import cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow.SchemaDraftService;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -130,6 +131,23 @@ public class SchemaDraftController {
|
||||
"publishedAt", draft.getPublishedAt());
|
||||
}
|
||||
|
||||
/**
|
||||
* Partial update content draft'а без recreate. Status-gate:
|
||||
* только DRAFT и CHANGES_REQUESTED. CHANGES_REQUESTED → DRAFT
|
||||
* автоматически после update (maker должен ре-submit).
|
||||
*
|
||||
* <p>409 {@code invalid_transition} — review_pending / approved /
|
||||
* terminal. 403 {@code maker_only} — actor != maker.
|
||||
*/
|
||||
@org.springframework.web.bind.annotation.PatchMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
|
||||
public SchemaDraftResponse update(
|
||||
@PathVariable String name,
|
||||
@PathVariable UUID draftId,
|
||||
@Valid @RequestBody UpdateSchemaDraftRequest req) {
|
||||
DictionarySchemaDraft draft = draftService.updateContent(draftId, req);
|
||||
return SchemaDraftResponse.from(draft, name);
|
||||
}
|
||||
|
||||
/** Withdraw — maker отменяет non-terminal draft. */
|
||||
@DeleteMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
|
||||
public SchemaDraftResponse withdraw(
|
||||
|
||||
Reference in New Issue
Block a user