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
@@ -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_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";
@@ -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) {
@@ -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(