feat(schema-workflow): PATCH /drafts/{id} — edit без recreate (A)

This commit is contained in:
Александр Зимин
2026-05-12 11:33:01 +00:00
parent ab6ec6ebec
commit b3a9104d54
11 changed files with 323 additions and 51 deletions
+13
View File
@@ -496,6 +496,7 @@ export type ChangelogKind =
| 'definition_create' | 'definition_create'
| 'definition_update' | 'definition_update'
| 'draft_create' | 'draft_create'
| 'draft_update'
| 'draft_review' | 'draft_review'
| 'draft_approve' | 'draft_approve'
| 'draft_reject' | 'draft_reject'
@@ -602,6 +603,18 @@ export type CreateSchemaDraftRequest = {
proposedSchema: unknown 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 = { export type SubmitForReviewRequest = {
/** Optional explicit reviewer IDs. NULL/[] = pool. */ /** Optional explicit reviewer IDs. NULL/[] = pool. */
reviewers?: string[] | null reviewers?: string[] | null
+30
View File
@@ -18,6 +18,7 @@ import {
type SchemaDraft, type SchemaDraft,
type SubmitDraftRequest, type SubmitDraftRequest,
type SubmitForReviewRequest, type SubmitForReviewRequest,
type UpdateSchemaDraftRequest,
type WebhookDelivery, type WebhookDelivery,
type WebhookSubscription, type WebhookSubscription,
type WebhookTestPingResult, 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. */ /** Maker submits draft → review_pending. 202 Accepted. */
export const useSubmitSchemaDraftForReview = (dictionaryName: string) => { export const useSubmitSchemaDraftForReview = (dictionaryName: string) => {
const qc = useQueryClient() const qc = useQueryClient()
@@ -32,6 +32,8 @@ export function kindIcon(kind: ChangelogKind): Icon {
return PencilSimpleIcon return PencilSimpleIcon
case 'draft_create': case 'draft_create':
return ChatCircleTextIcon return ChatCircleTextIcon
case 'draft_update':
return PencilSimpleIcon
case 'draft_review': case 'draft_review':
return EyeIcon return EyeIcon
case 'draft_approve': case 'draft_approve':
@@ -61,6 +63,7 @@ export function kindBadgeVariant(kind: ChangelogKind): BadgeProps['variant'] {
case 'draft_changes_requested': case 'draft_changes_requested':
return 'warning' return 'warning'
case 'draft_create': case 'draft_create':
case 'draft_update':
case 'draft_review': case 'draft_review':
case 'draft_withdraw': case 'draft_withdraw':
return 'neutral' return 'neutral'
@@ -72,6 +75,7 @@ export function kindLabel(kind: ChangelogKind, t: TFn): string {
definition_create: 'Создан словарь', definition_create: 'Создан словарь',
definition_update: 'Изменена схема', definition_update: 'Изменена схема',
draft_create: 'Черновик создан', draft_create: 'Черновик создан',
draft_update: 'Черновик обновлён',
draft_review: 'Отправлен на ревью', draft_review: 'Отправлен на ревью',
draft_approve: 'Одобрен', draft_approve: 'Одобрен',
draft_reject: 'Отклонён', draft_reject: 'Отклонён',
@@ -179,6 +179,7 @@ function SchemaDraftBanner({
onClose={() => setDrawerOpen(false)} onClose={() => setDrawerOpen(false)}
dictionaryName={detail.name} dictionaryName={detail.name}
currentHeadVersion={detail.schemaVersion} currentHeadVersion={detail.schemaVersion}
detail={detail}
/> />
</> </>
) )
@@ -9,15 +9,21 @@ import {
toast, toast,
} from '@/ui' } from '@/ui'
import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react' import { ArrowsClockwiseIcon, CheckSquareIcon } from '@phosphor-icons/react'
import type { DictionaryDetail } from '@/api/client' import type { DictionaryDetail, SchemaDraft } from '@/api/client'
import { useCreateSchemaDraft } from '@/api/mutations' import { useCreateSchemaDraft, useUpdateSchemaDraft } from '@/api/mutations'
type Props = { type Props = {
open: boolean open: boolean
onClose: () => void onClose: () => void
detail: DictionaryDetail 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 onCreated?: (draftId: string) => void
} }
@@ -39,26 +45,36 @@ type Props = {
* вернёт detail в message.</li> * вернёт detail в message.</li>
* </ul> * </ul>
*/ */
export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Props) { export function CreateSchemaDraftModal({ open, onClose, detail, editDraft, onCreated }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const isEdit = Boolean(editDraft)
// В edit mode seed'им из draft'овой proposedSchema, иначе из live HEAD.
const initialJson = useMemo( 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 [json, setJson] = useState(initialJson)
const [reason, setReason] = useState('') const [reason, setReason] = useState(initialReason)
const [parseError, setParseError] = useState<string | null>(null) const [parseError, setParseError] = useState<string | null>(null)
// Reset state при re-open чтобы не показывать stale данные с прошлого dict'а. // Reset state при re-open чтобы не показывать stale данные с прошлого dict'а.
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setJson(initialJson) setJson(initialJson)
setReason('') setReason(initialReason)
setParseError(null) setParseError(null)
} }
}, [open, initialJson]) }, [open, initialJson, initialReason])
const createMut = useCreateSchemaDraft(detail.name) const createMut = useCreateSchemaDraft(detail.name)
const updateMut = useUpdateSchemaDraft(detail.name)
const pending = createMut.isPending || updateMut.isPending
const handleFormat = () => { const handleFormat = () => {
try { try {
@@ -100,6 +116,39 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
setParseError((e as Error).message) setParseError((e as Error).message)
return 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( createMut.mutate(
{ {
fromVersion: detail.schemaVersion, fromVersion: detail.schemaVersion,
@@ -114,18 +163,12 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
onCreated?.(draft.draftId) onCreated?.(draft.draftId)
onClose() onClose()
}, },
onError: (err) => { onError: (err) =>
let msg = t('createDraft.toast.failed', { handleErrorToast(
defaultValue: 'Не удалось создать черновик', err,
}) 'createDraft.toast.failed',
if (axios.isAxiosError(err)) { 'Не удалось создать черновик',
const body = err.response?.data as ),
| { code?: string; message?: string }
| undefined
msg = body?.message ?? body?.code ?? msg
}
toast.error(msg)
},
}, },
) )
} }
@@ -135,15 +178,28 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
isOpen={open} isOpen={open}
onClose={onClose} onClose={onClose}
size="xl" size="xl"
title={t('createDraft.title', { defaultValue: 'Создать черновик схемы' })} title={
description={`${detail.name} · v${detail.schemaVersion}`} 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"> <div className="space-y-4">
<Alert variant="info"> <Alert variant="info">
{t('createDraft.intro', { {isEdit
defaultValue: ? t('createDraft.editIntro', {
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.', defaultValue:
})} 'После сохранения статус черновика вернётся в draft — отправьте его на ревью заново.',
})
: t('createDraft.intro', {
defaultValue:
'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.',
})}
</Alert> </Alert>
{/* Reason */} {/* Reason */}
@@ -158,7 +214,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)', defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)',
})} })}
rows={2} rows={2}
disabled={createMut.isPending} disabled={pending}
/> />
</div> </div>
@@ -174,7 +230,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleFormat} onClick={handleFormat}
disabled={createMut.isPending} disabled={pending}
leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />} leftIcon={<ArrowsClockwiseIcon weight="regular" size={14} />}
> >
{t('createDraft.format', { defaultValue: 'Format' })} {t('createDraft.format', { defaultValue: 'Format' })}
@@ -184,7 +240,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleValidate} onClick={handleValidate}
disabled={createMut.isPending} disabled={pending}
leftIcon={<CheckSquareIcon weight="regular" size={14} />} leftIcon={<CheckSquareIcon weight="regular" size={14} />}
> >
{t('createDraft.validate', { defaultValue: 'Validate' })} {t('createDraft.validate', { defaultValue: 'Validate' })}
@@ -201,7 +257,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
if (parseError) setParseError(null) if (parseError) setParseError(null)
}} }}
spellCheck={false} 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" 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 && ( {parseError && (
@@ -217,7 +273,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
type="button" type="button"
variant="ghost" variant="ghost"
onClick={onClose} onClick={onClose}
disabled={createMut.isPending} disabled={pending}
> >
{t('common.cancel', { defaultValue: 'Отмена' })} {t('common.cancel', { defaultValue: 'Отмена' })}
</Button> </Button>
@@ -225,11 +281,15 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro
type="button" type="button"
variant="primary" variant="primary"
onClick={handleSubmit} onClick={handleSubmit}
disabled={createMut.isPending || Boolean(parseError)} disabled={pending || Boolean(parseError)}
> >
{createMut.isPending {pending
? t('createDraft.creating', { defaultValue: 'Создание…' }) ? isEdit
: t('createDraft.submit', { defaultValue: 'Создать черновик' })} ? t('createDraft.updating', { defaultValue: 'Сохранение…' })
: t('createDraft.creating', { defaultValue: 'Создание…' })
: isEdit
? t('createDraft.submitEdit', { defaultValue: 'Сохранить' })
: t('createDraft.submit', { defaultValue: 'Создать черновик' })}
</Button> </Button>
</div> </div>
</div> </div>
@@ -5,6 +5,7 @@ import { Alert, Badge, Button, Drawer, LoadingBlock, TextArea, toast } from '@/u
import { import {
CheckIcon, CheckIcon,
PaperPlaneTiltIcon, PaperPlaneTiltIcon,
PencilSimpleIcon,
XCircleIcon, XCircleIcon,
ArrowsCounterClockwiseIcon, ArrowsCounterClockwiseIcon,
RocketIcon, RocketIcon,
@@ -12,10 +13,12 @@ import {
} from '@phosphor-icons/react' } from '@phosphor-icons/react'
import { import {
isActiveSchemaDraft, isActiveSchemaDraft,
type DictionaryDetail,
type SchemaDraft, type SchemaDraft,
type SchemaDraftStatus, type SchemaDraftStatus,
type SchemaDraftVerdict, type SchemaDraftVerdict,
} from '@/api/client' } from '@/api/client'
import { CreateSchemaDraftModal } from '@/components/workflow/CreateSchemaDraftModal'
import { useDictActiveSchemaDraft } from '@/api/queries' import { useDictActiveSchemaDraft } from '@/api/queries'
import { import {
useDecideSchemaDraft, useDecideSchemaDraft,
@@ -31,6 +34,9 @@ type Props = {
dictionaryName: string dictionaryName: string
/** Current live schemaVersion — passed для publish expectedHeadVersion check. */ /** Current live schemaVersion — passed для publish expectedHeadVersion check. */
currentHeadVersion: string | undefined currentHeadVersion: string | undefined
/** Optional full dict detail — нужен для edit modal seeding. Если не
* передан, edit button скрывается. */
detail?: DictionaryDetail
} }
/** /**
@@ -58,9 +64,11 @@ export function SchemaDraftDrawer({
onClose, onClose,
dictionaryName, dictionaryName,
currentHeadVersion, currentHeadVersion,
detail,
}: Props) { }: Props) {
const { t } = useTranslation() const { t } = useTranslation()
const [comment, setComment] = useState('') const [comment, setComment] = useState('')
const [editOpen, setEditOpen] = useState(false)
const { data: draft, isLoading, error } = useDictActiveSchemaDraft( const { data: draft, isLoading, error } = useDictActiveSchemaDraft(
open ? dictionaryName : undefined, open ? dictionaryName : undefined,
) )
@@ -241,9 +249,18 @@ export function SchemaDraftDrawer({
onDecide={onDecide} onDecide={onDecide}
onPublish={onPublish} onPublish={onPublish}
onWithdraw={onWithdraw} onWithdraw={onWithdraw}
onEdit={detail ? () => setEditOpen(true) : undefined}
disabled={anyPending} disabled={anyPending}
hasCurrentVersion={Boolean(currentHeadVersion)} hasCurrentVersion={Boolean(currentHeadVersion)}
/> />
{detail && (
<CreateSchemaDraftModal
open={editOpen}
onClose={() => setEditOpen(false)}
detail={detail}
editDraft={draft}
/>
)}
</> </>
)} )}
</div> </div>
@@ -380,6 +397,7 @@ function DraftActions({
onDecide, onDecide,
onPublish, onPublish,
onWithdraw, onWithdraw,
onEdit,
disabled, disabled,
hasCurrentVersion, hasCurrentVersion,
}: { }: {
@@ -388,6 +406,7 @@ function DraftActions({
onDecide: (v: SchemaDraftVerdict) => void onDecide: (v: SchemaDraftVerdict) => void
onPublish: () => void onPublish: () => void
onWithdraw: () => void onWithdraw: () => void
onEdit?: () => void
disabled: boolean disabled: boolean
hasCurrentVersion: boolean hasCurrentVersion: boolean
}) { }) {
@@ -411,21 +430,36 @@ function DraftActions({
return ( return (
<div className="flex flex-wrap items-center gap-2 pt-2 border-t border-line"> <div className="flex flex-wrap items-center gap-2 pt-2 border-t border-line">
{(status === 'draft' || status === 'changes_requested') && ( {(status === 'draft' || status === 'changes_requested') && (
<Button <>
variant="primary" {onEdit && (
size="sm" <Button
onClick={onSubmitReview} variant="ghost"
disabled={disabled} size="sm"
leftIcon={<PaperPlaneTiltIcon weight="regular" size={14} />} onClick={onEdit}
> disabled={disabled}
{status === 'draft' leftIcon={<PencilSimpleIcon weight="regular" size={14} />}
? t('workflow.schemaDraft.actions.submit', { >
defaultValue: 'Отправить на ревью', {t('workflow.schemaDraft.actions.edit', {
}) defaultValue: 'Редактировать',
: t('workflow.schemaDraft.actions.resubmit', {
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 && ( {status === 'review_pending' && canReview && (
<> <>
+16
View File
@@ -417,6 +417,7 @@ i18n
'changelog.kind.definition_create': 'Создан словарь', 'changelog.kind.definition_create': 'Создан словарь',
'changelog.kind.definition_update': 'Изменена схема', 'changelog.kind.definition_update': 'Изменена схема',
'changelog.kind.draft_create': 'Черновик создан', 'changelog.kind.draft_create': 'Черновик создан',
'changelog.kind.draft_update': 'Черновик обновлён',
'changelog.kind.draft_review': 'Отправлен на ревью', 'changelog.kind.draft_review': 'Отправлен на ревью',
'changelog.kind.draft_approve': 'Одобрен', 'changelog.kind.draft_approve': 'Одобрен',
'changelog.kind.draft_reject': 'Отклонён', 'changelog.kind.draft_reject': 'Отклонён',
@@ -491,6 +492,13 @@ i18n
'createDraft.creating': 'Создание…', 'createDraft.creating': 'Создание…',
'createDraft.toast.created': 'Черновик создан', 'createDraft.toast.created': 'Черновик создан',
'createDraft.toast.failed': 'Не удалось создать черновик', '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 === // === Schema review queue panel ===
'reviews.schema.title': 'Изменения схем', 'reviews.schema.title': 'Изменения схем',
'reviews.schema.label': 'Schema workflow', 'reviews.schema.label': 'Schema workflow',
@@ -1036,6 +1044,7 @@ i18n
'changelog.kind.definition_create': 'Dictionary created', 'changelog.kind.definition_create': 'Dictionary created',
'changelog.kind.definition_update': 'Schema updated', 'changelog.kind.definition_update': 'Schema updated',
'changelog.kind.draft_create': 'Draft created', 'changelog.kind.draft_create': 'Draft created',
'changelog.kind.draft_update': 'Draft updated',
'changelog.kind.draft_review': 'Sent for review', 'changelog.kind.draft_review': 'Sent for review',
'changelog.kind.draft_approve': 'Approved', 'changelog.kind.draft_approve': 'Approved',
'changelog.kind.draft_reject': 'Rejected', 'changelog.kind.draft_reject': 'Rejected',
@@ -1110,6 +1119,13 @@ i18n
'createDraft.creating': 'Creating…', 'createDraft.creating': 'Creating…',
'createDraft.toast.created': 'Draft created', 'createDraft.toast.created': 'Draft created',
'createDraft.toast.failed': 'Failed to create draft', '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 === // === Schema review queue panel ===
'reviews.schema.title': 'Schema changes', 'reviews.schema.title': 'Schema changes',
'reviews.schema.label': 'Schema workflow', 'reviews.schema.label': 'Schema workflow',
@@ -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) {}
@@ -55,6 +55,7 @@ public class ChangelogService {
"DEFINITION_CREATED", "DEFINITION_CREATED",
"DEFINITION_UPDATED", "DEFINITION_UPDATED",
"SCHEMA_DRAFT_CREATED", "SCHEMA_DRAFT_CREATED",
"SCHEMA_DRAFT_UPDATED",
"SCHEMA_DRAFT_REVIEW_REQUESTED", "SCHEMA_DRAFT_REVIEW_REQUESTED",
"SCHEMA_DRAFT_DECIDED", "SCHEMA_DRAFT_DECIDED",
"SCHEMA_DRAFT_PUBLISHED", "SCHEMA_DRAFT_PUBLISHED",
@@ -201,6 +202,7 @@ public class ChangelogService {
case "DEFINITION_CREATED" -> "definition_create"; case "DEFINITION_CREATED" -> "definition_create";
case "DEFINITION_UPDATED" -> "definition_update"; case "DEFINITION_UPDATED" -> "definition_update";
case "SCHEMA_DRAFT_CREATED" -> "draft_create"; case "SCHEMA_DRAFT_CREATED" -> "draft_create";
case "SCHEMA_DRAFT_UPDATED" -> "draft_update";
case "SCHEMA_DRAFT_REVIEW_REQUESTED" -> "draft_review"; case "SCHEMA_DRAFT_REVIEW_REQUESTED" -> "draft_review";
case "SCHEMA_DRAFT_DECIDED" -> switch (action == null ? "" : action) { case "SCHEMA_DRAFT_DECIDED" -> switch (action == null ? "" : action) {
case "DRAFT_APPROVE" -> "draft_approve"; case "DRAFT_APPROVE" -> "draft_approve";
@@ -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.DecisionRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest; 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.SubmitForReviewRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.UpdateSchemaDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
@@ -348,6 +349,65 @@ public class SchemaDraftService {
return saved; 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 статусы). */ /** Maker отзывает свой draft (только non-terminal статусы). */
@Transactional @Transactional
public DictionarySchemaDraft withdraw(UUID draftId) { public DictionarySchemaDraft withdraw(UUID draftId) {
@@ -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.PublishDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SchemaDraftResponse; 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.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.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow.SchemaDraftService; import cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow.SchemaDraftService;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -130,6 +131,23 @@ public class SchemaDraftController {
"publishedAt", draft.getPublishedAt()); "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. */ /** Withdraw — maker отменяет non-terminal draft. */
@DeleteMapping("/api/v1/dictionaries/{name}/drafts/{draftId}") @DeleteMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
public SchemaDraftResponse withdraw( public SchemaDraftResponse withdraw(