From b3a9104d5469bb2e9c57360ff9fbbee64f29a779 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Tue, 12 May 2026 11:33:01 +0000 Subject: [PATCH] =?UTF-8?q?feat(schema-workflow):=20PATCH=20/drafts/{id}?= =?UTF-8?q?=20=E2=80=94=20edit=20=D0=B1=D0=B5=D0=B7=20recreate=20(A)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ordinis-admin-ui/src/api/client.ts | 13 ++ ordinis-admin-ui/src/api/mutations.ts | 30 ++++ .../src/components/changelog/kind-meta.tsx | 4 + .../src/components/editor/WorkflowBanner.tsx | 1 + .../workflow/CreateSchemaDraftModal.tsx | 134 +++++++++++++----- .../components/workflow/SchemaDraftDrawer.tsx | 62 ++++++-- ordinis-admin-ui/src/i18n.ts | 16 +++ .../UpdateSchemaDraftRequest.java | 34 +++++ .../restapi/service/ChangelogService.java | 2 + .../schemaworkflow/SchemaDraftService.java | 60 ++++++++ .../restapi/web/SchemaDraftController.java | 18 +++ 11 files changed, 323 insertions(+), 51 deletions(-) create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/UpdateSchemaDraftRequest.java diff --git a/ordinis-admin-ui/src/api/client.ts b/ordinis-admin-ui/src/api/client.ts index 671173e..607defd 100644 --- a/ordinis-admin-ui/src/api/client.ts +++ b/ordinis-admin-ui/src/api/client.ts @@ -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 diff --git a/ordinis-admin-ui/src/api/mutations.ts b/ordinis-admin-ui/src/api/mutations.ts index 4be6d64..9b94198 100644 --- a/ordinis-admin-ui/src/api/mutations.ts +++ b/ordinis-admin-ui/src/api/mutations.ts @@ -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 => { + const { data } = await apiClient.patch( + `/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() diff --git a/ordinis-admin-ui/src/components/changelog/kind-meta.tsx b/ordinis-admin-ui/src/components/changelog/kind-meta.tsx index f2f26e6..19f855d 100644 --- a/ordinis-admin-ui/src/components/changelog/kind-meta.tsx +++ b/ordinis-admin-ui/src/components/changelog/kind-meta.tsx @@ -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: 'Отклонён', diff --git a/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx b/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx index 743bc50..4696572 100644 --- a/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx +++ b/ordinis-admin-ui/src/components/editor/WorkflowBanner.tsx @@ -179,6 +179,7 @@ function SchemaDraftBanner({ onClose={() => setDrawerOpen(false)} dictionaryName={detail.name} currentHeadVersion={detail.schemaVersion} + detail={detail} /> ) diff --git a/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx index 8ba1c2b..cc44d34 100644 --- a/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx +++ b/ordinis-admin-ui/src/components/workflow/CreateSchemaDraftModal.tsx @@ -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. * */ -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(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}` + } >
- {t('createDraft.intro', { - defaultValue: - 'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.', - })} + {isEdit + ? t('createDraft.editIntro', { + defaultValue: + 'После сохранения статус черновика вернётся в draft — отправьте его на ревью заново.', + }) + : t('createDraft.intro', { + defaultValue: + 'Изменения сохранятся как черновик от текущей версии. Live-схема не меняется до публикации после ревью.', + })} {/* Reason */} @@ -158,7 +214,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro defaultValue: 'Зачем эти изменения нужны (видно ревьюеру)', })} rows={2} - disabled={createMut.isPending} + disabled={pending} />
@@ -174,7 +230,7 @@ export function CreateSchemaDraftModal({ open, onClose, detail, onCreated }: Pro variant="ghost" size="sm" onClick={handleFormat} - disabled={createMut.isPending} + disabled={pending} leftIcon={} > {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={} > {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: 'Отмена' })} @@ -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: 'Создать черновик' })} diff --git a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx index 0b089dd..3cb2114 100644 --- a/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx +++ b/ordinis-admin-ui/src/components/workflow/SchemaDraftDrawer.tsx @@ -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 && ( + setEditOpen(false)} + detail={detail} + editDraft={draft} + /> + )} )} @@ -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 (
{(status === 'draft' || status === 'changes_requested') && ( - + + )} + + )} {status === 'review_pending' && canReview && ( <> diff --git a/ordinis-admin-ui/src/i18n.ts b/ordinis-admin-ui/src/i18n.ts index 16d83d0..f0666d2 100644 --- a/ordinis-admin-ui/src/i18n.ts +++ b/ordinis-admin-ui/src/i18n.ts @@ -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', diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/UpdateSchemaDraftRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/UpdateSchemaDraftRequest.java new file mode 100644 index 0000000..44c07b1 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/UpdateSchemaDraftRequest.java @@ -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 + * существующего. + * + *

Endpoint: {@code PATCH /api/v1/dictionaries/{name}/drafts/{draftId}}. + * + *

Status-gate (enforced backend'ом): + *

    + *
  • {@code DRAFT} — OK.
  • + *
  • {@code CHANGES_REQUESTED} — OK; status переводится обратно в + * {@code DRAFT} автоматически (maker должен ре-submit).
  • + *
  • {@code REVIEW_PENDING} / {@code APPROVED} — 409 invalid_transition. + * Pending review не должен ловить moving target; approved content + * freezing до publish.
  • + *
  • Terminal — 409.
  • + *
+ * + *

Все поля 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) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java index 28c3bf7..f259287 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/ChangelogService.java @@ -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"; diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java index 48cbffe..3795670 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java @@ -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. + * + *

Status-gate: только {@code DRAFT} и {@code CHANGES_REQUESTED}. + * Из {@code REVIEW_PENDING} update запрещён — pending review должен + * сначала разрешиться decision'ом (reviewer не должен ловить moving + * target). Из {@code APPROVED} тоже запрещён — после approve content + * заморожен до publish (иначе одобрили одну схему, опубликовали другую). + * + *

Side effect: если status был {@code CHANGES_REQUESTED}, переводим + * обратно в {@code DRAFT} чтобы maker должен был осознанно submit'нуть + * на ревью заново. Это match'ит логичную ментальную модель «правки → + * новый draft state → re-submit». + * + *

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) { diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java index dc72d31..e5ff9b2 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java @@ -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). + * + *

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(