diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/DictionarySchemaDraft.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/DictionarySchemaDraft.java new file mode 100644 index 0000000..37b739f --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/DictionarySchemaDraft.java @@ -0,0 +1,179 @@ +package cloud.nstart.terravault.ordinis.domain.schemaworkflow; + +import com.fasterxml.jackson.databind.JsonNode; +import io.hypersistence.utils.hibernate.type.json.JsonType; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.PreUpdate; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.Type; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Dictionary schema-level approval workflow draft. Параллель к + * {@link cloud.nstart.terravault.ordinis.domain.draft.RecordDraft}, но + * proposes изменение SCHEMA (а не отдельной записи). + * + *
Lifecycle (см. {@link SchemaDraftStatus}): + * draft → review_pending → {approved, changes_requested, rejected, withdrawn} + * → published / withdrawn. + * + *
Один active draft per dict (Liquibase 0022 EXCLUDE constraint + * {@code schema_drafts_one_active_per_dict}) — schema changes coarse-grained, + * параллельное редактирование не имеет смысла. + * + *
{@code status} хранится lowercase в БД (см. migration check constraint + * — 'draft', 'review_pending', etc), поэтому используем String column + + * runtime convert через {@link SchemaDraftStatus#dbValue()} / + * {@link SchemaDraftStatus#fromDbValue(String)} вместо EnumType.STRING. + * Это match'ит naming convention из 0019/0020 record_drafts. + */ +@Entity +@Table(name = "dictionary_schema_drafts") +@EntityListeners(AuditingEntityListener.class) +public class DictionarySchemaDraft { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "dictionary_id", nullable = false) + private UUID dictionaryId; + + /** Полное proposed schema content (JSON Schema document). */ + @Type(JsonType.class) + @Column(name = "proposed_schema", columnDefinition = "jsonb", nullable = false) + private JsonNode proposedSchema; + + /** Live HEAD schema version, от которого ответвлён этот draft. */ + @Column(name = "branched_from_version", nullable = false, length = 32) + private String branchedFromVersion; + + @Column(name = "reason", columnDefinition = "TEXT") + private String reason; + + /** + * Назначенные reviewer'ы (JSONB array of user IDs). NULL = open pool — + * любой user с approve role может decide. Set только когда maker явно + * передаёт {@code reviewers} в submit-for-review request. + */ + @Type(JsonType.class) + @Column(name = "reviewer_assignments", columnDefinition = "jsonb") + private JsonNode reviewerAssignments; + + @Column(name = "review_note", columnDefinition = "TEXT") + private String reviewNote; + + /** Status хранится lowercase. Get/set через {@link #getStatus()} / {@link #setStatus(SchemaDraftStatus)}. */ + @Column(name = "status", nullable = false, length = 24) + private String statusDb; + + @Column(name = "maker_id", nullable = false, length = 128, updatable = false) + private String makerId; + + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @Column(name = "submitted_at") + private OffsetDateTime submittedAt; + + @Column(name = "decision_user_id", length = 128) + private String decisionUserId; + + @Column(name = "decision_at") + private OffsetDateTime decisionAt; + + @Column(name = "decision_comment", columnDefinition = "TEXT") + private String decisionComment; + + @Column(name = "published_at") + private OffsetDateTime publishedAt; + + @Column(name = "published_version", length = 32) + private String publishedVersion; + + @Column(name = "publish_note", columnDefinition = "TEXT") + private String publishNote; + + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @Version + @Column(name = "version") + private Long version; + + protected DictionarySchemaDraft() {} + + public DictionarySchemaDraft( + UUID id, + UUID dictionaryId, + JsonNode proposedSchema, + String branchedFromVersion, + String makerId, + String reason) { + this.id = id; + this.dictionaryId = dictionaryId; + this.proposedSchema = proposedSchema; + this.branchedFromVersion = branchedFromVersion; + this.makerId = makerId; + this.reason = reason; + this.statusDb = SchemaDraftStatus.DRAFT.dbValue(); + var now = OffsetDateTime.now(); + this.createdAt = now; + this.updatedAt = now; + } + + @PrePersist + void prePersist() { + if (createdAt == null) createdAt = OffsetDateTime.now(); + if (updatedAt == null) updatedAt = createdAt; + if (statusDb == null) statusDb = SchemaDraftStatus.DRAFT.dbValue(); + } + + @PreUpdate + void preUpdate() { + updatedAt = OffsetDateTime.now(); + } + + public UUID getId() { return id; } + public UUID getDictionaryId() { return dictionaryId; } + public JsonNode getProposedSchema() { return proposedSchema; } + public String getBranchedFromVersion() { return branchedFromVersion; } + public String getReason() { return reason; } + public JsonNode getReviewerAssignments() { return reviewerAssignments; } + public String getReviewNote() { return reviewNote; } + + public SchemaDraftStatus getStatus() { return SchemaDraftStatus.fromDbValue(statusDb); } + public void setStatus(SchemaDraftStatus s) { this.statusDb = s.dbValue(); } + + public String getMakerId() { return makerId; } + public OffsetDateTime getCreatedAt() { return createdAt; } + public OffsetDateTime getSubmittedAt() { return submittedAt; } + public String getDecisionUserId() { return decisionUserId; } + public OffsetDateTime getDecisionAt() { return decisionAt; } + public String getDecisionComment() { return decisionComment; } + public OffsetDateTime getPublishedAt() { return publishedAt; } + public String getPublishedVersion() { return publishedVersion; } + public String getPublishNote() { return publishNote; } + public OffsetDateTime getUpdatedAt() { return updatedAt; } + public Long getVersion() { return version; } + + public void setProposedSchema(JsonNode v) { this.proposedSchema = v; } + public void setReason(String v) { this.reason = v; } + public void setReviewerAssignments(JsonNode v) { this.reviewerAssignments = v; } + public void setReviewNote(String v) { this.reviewNote = v; } + public void setSubmittedAt(OffsetDateTime v) { this.submittedAt = v; } + public void setDecisionUserId(String v) { this.decisionUserId = v; } + public void setDecisionAt(OffsetDateTime v) { this.decisionAt = v; } + public void setDecisionComment(String v) { this.decisionComment = v; } + public void setPublishedAt(OffsetDateTime v) { this.publishedAt = v; } + public void setPublishedVersion(String v) { this.publishedVersion = v; } + public void setPublishNote(String v) { this.publishNote = v; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftRepository.java new file mode 100644 index 0000000..0b56091 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftRepository.java @@ -0,0 +1,58 @@ +package cloud.nstart.terravault.ordinis.domain.schemaworkflow; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +/** + * Spring Data repository для schema workflow drafts. + * + *
Все queries фильтруют по {@code statusDb} string (lowercase в БД) —
+ * см. {@link DictionarySchemaDraft} комментарий про status mapping.
+ * Поэтому передаём DB-value напрямую (через
+ * {@link SchemaDraftStatus#dbValue()}), не enum constant.
+ */
+public interface SchemaDraftRepository extends JpaRepository Параллель к record-level {@link cloud.nstart.terravault.ordinis.domain.draft.DraftStatus},
+ * но для schema-level changes (один draft = одно proposed schema state).
+ *
+ * Strings в БД lowercase (см. migration 0022 check constraint), JPA
+ * {@code @Enumerated(EnumType.STRING)} mapping использует enum name — мы
+ * храним в БД lowercase напрямую через
+ * {@link com.fasterxml.jackson.annotation.JsonValue}-like преобразование
+ * в Spring Data converter (см. также 0020 fix для record_drafts).
+ */
+public enum SchemaDraftStatus {
+ DRAFT,
+ REVIEW_PENDING,
+ CHANGES_REQUESTED,
+ APPROVED,
+ REJECTED,
+ PUBLISHED,
+ WITHDRAWN;
+
+ /** Terminal статусы — нельзя transition'нуть дальше. */
+ public boolean isTerminal() {
+ return this == PUBLISHED || this == REJECTED || this == WITHDRAWN;
+ }
+
+ /** Активный (non-terminal) — пользователь видит draft, может действовать. */
+ public boolean isActive() {
+ return !isTerminal();
+ }
+
+ /** Lowercase representation for DB column storage. */
+ public String dbValue() {
+ return name().toLowerCase();
+ }
+
+ /** Inverse of {@link #dbValue()} — parse from DB string. */
+ public static SchemaDraftStatus fromDbValue(String s) {
+ if (s == null) return null;
+ return SchemaDraftStatus.valueOf(s.toUpperCase());
+ }
+}
diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0022-schema-workflow.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0022-schema-workflow.xml
new file mode 100644
index 0000000..f06a89f
--- /dev/null
+++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0022-schema-workflow.xml
@@ -0,0 +1,140 @@
+
+ Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts}.
+ *
+ * {@code fromVersion} обязателен и сверяется с текущей
+ * {@code DictionaryDefinition.schemaVersion} — если расходится → 409
+ * ({@code version_mismatch}, кто-то ещё опубликовал свой draft пока этот
+ * пользователь готовился).
+ *
+ * {@code proposedSchema} — full JSON Schema document (replace, не patch).
+ * Если null — backend копирует current HEAD schema (maker edit'ит позже
+ * через update endpoint, который пока не реализован — Phase 1 will add).
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record CreateSchemaDraftRequest(
+ /** Current HEAD schema version that this draft branches from. */
+ @NotBlank String fromVersion,
+ /** Optional maker rationale shown reviewer'ам. */
+ String reason,
+ /** Full proposed JSON Schema. */
+ @NotNull JsonNode proposedSchema) {}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/DecisionRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/DecisionRequest.java
new file mode 100644
index 0000000..25bc570
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/DecisionRequest.java
@@ -0,0 +1,31 @@
+package cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import jakarta.validation.constraints.NotNull;
+
+/**
+ * Reviewer's decision for a draft in REVIEW_PENDING.
+ *
+ * Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/decision}.
+ *
+ * Transitions:
+ * Self-approve forbidden: backend проверяет maker_id != decision_user_id.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record DecisionRequest(
+ @NotNull Verdict decision,
+ String comment) {
+
+ public enum Verdict {
+ APPROVE,
+ REQUEST_CHANGES,
+ REJECT
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/PublishDraftRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/PublishDraftRequest.java
new file mode 100644
index 0000000..9ffc129
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/PublishDraftRequest.java
@@ -0,0 +1,24 @@
+package cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import jakarta.validation.constraints.NotBlank;
+
+/**
+ * Publish approved draft → live HEAD.
+ *
+ * Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/publish}.
+ *
+ * {@code expectedHeadVersion} — optimistic lock против текущего
+ * {@code DictionaryDefinition.schemaVersion}. Если кто-то другой опубликовал
+ * свой draft пока этот approved'ил → 409 ({@code version_mismatch}, refetch
+ * + re-publish).
+ *
+ * Default bump: minor (1.4.0 → 1.5.0). Если schema diff содержит breaking
+ * keys (type/format/required removed/x-references changed) — backend bump'нет
+ * major (1.4.0 → 2.0.0). Detection в SchemaDraftService.computeNextVersion.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record PublishDraftRequest(
+ @NotBlank String expectedHeadVersion,
+ /** Optional release note shown в changelog. */
+ String publishNote) {}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SchemaDraftResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SchemaDraftResponse.java
new file mode 100644
index 0000000..4a29c8e
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SchemaDraftResponse.java
@@ -0,0 +1,69 @@
+package cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow;
+
+import cloud.nstart.terravault.ordinis.domain.schemaworkflow.DictionarySchemaDraft;
+import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus;
+import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fasterxml.jackson.databind.JsonNode;
+
+import java.time.OffsetDateTime;
+import java.util.UUID;
+
+/**
+ * API representation для {@link DictionarySchemaDraft}. Status returned
+ * lowercase string ({@code "draft"}, {@code "review_pending"} etc) чтобы
+ * match'ило API spec из docs/integration/api/pending-endpoints.md §3.
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record SchemaDraftResponse(
+ UUID draftId,
+ UUID dictionaryId,
+ String dictionaryName,
+ String status,
+ String branchedFromVersion,
+ JsonNode proposedSchema,
+ String reason,
+ String makerId,
+ OffsetDateTime createdAt,
+ OffsetDateTime submittedAt,
+ JsonNode reviewers,
+ String reviewNote,
+ String decisionUserId,
+ OffsetDateTime decisionAt,
+ String decisionComment,
+ OffsetDateTime publishedAt,
+ String publishedVersion,
+ String publishNote,
+ Long version) {
+
+ public static SchemaDraftResponse from(DictionarySchemaDraft d, String dictionaryName) {
+ return new SchemaDraftResponse(
+ d.getId(),
+ d.getDictionaryId(),
+ dictionaryName,
+ d.getStatus().dbValue(),
+ d.getBranchedFromVersion(),
+ d.getProposedSchema(),
+ d.getReason(),
+ d.getMakerId(),
+ d.getCreatedAt(),
+ d.getSubmittedAt(),
+ d.getReviewerAssignments(),
+ d.getReviewNote(),
+ d.getDecisionUserId(),
+ d.getDecisionAt(),
+ d.getDecisionComment(),
+ d.getPublishedAt(),
+ d.getPublishedVersion(),
+ d.getPublishNote(),
+ d.getVersion());
+ }
+
+ /** Convenience overload — dictionary name can be NULL if not yet resolved. */
+ public static SchemaDraftResponse from(DictionarySchemaDraft d) {
+ return from(d, null);
+ }
+
+ public SchemaDraftStatus statusEnum() {
+ return SchemaDraftStatus.fromDbValue(status);
+ }
+}
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SubmitForReviewRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SubmitForReviewRequest.java
new file mode 100644
index 0000000..d589ee9
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SubmitForReviewRequest.java
@@ -0,0 +1,22 @@
+package cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+
+import java.util.List;
+
+/**
+ * Submit draft → review_pending state.
+ *
+ * Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/review}.
+ *
+ * {@code reviewers} — optional list of user IDs которые должны review'нуть.
+ * NULL/пустой = open pool (любой user с approve role). Конкретные reviewer'ы
+ * получат webhook notification + они приоритетно видят draft в своей queue
+ * (frontend Phase 1 will surface это).
+ */
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public record SubmitForReviewRequest(
+ /** Optional explicit reviewer IDs (Keycloak subject claim). NULL = pool. */
+ List State machine: см. {@link SchemaDraftStatus}.
+ *
+ * Endpoints (см. {@link cloud.nstart.terravault.ordinis.restapi.web.SchemaDraftController}):
+ * Все mutator-методы emit'ят outbox event ({@code SchemaDraft*}) и audit
+ * (через {@link DictionaryDefinitionService} updateSchema path для publish).
+ *
+ * Outbox event types следуют existing naming convention (PascalCase):
+ * {@code SchemaDraftCreated}, {@code SchemaDraftReviewRequested},
+ * {@code SchemaDraftDecided}, {@code SchemaDraftPublished},
+ * {@code SchemaDraftWithdrawn}. Frontend webhook router маппит их на
+ * {@code dict.draft.*} category (см. webhook subscriptions table).
+ */
+@Service
+public class SchemaDraftService {
+
+ private static final Logger log = LoggerFactory.getLogger(SchemaDraftService.class);
+
+ private final SchemaDraftRepository repository;
+ private final DictionaryDefinitionService definitionService;
+ private final OutboxRecorder outboxRecorder;
+ private final ObjectMapper objectMapper;
+
+ public SchemaDraftService(
+ SchemaDraftRepository repository,
+ DictionaryDefinitionService definitionService,
+ OutboxRecorder outboxRecorder,
+ ObjectMapper objectMapper) {
+ this.repository = repository;
+ this.definitionService = definitionService;
+ this.outboxRecorder = outboxRecorder;
+ this.objectMapper = objectMapper;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Reads
+ // ─────────────────────────────────────────────────────────────────────
+
+ @Transactional(readOnly = true)
+ public DictionarySchemaDraft findById(UUID id) {
+ return repository.findById(id).orElseThrow(() -> OrdinisException.notFound(
+ "schema_draft_not_found",
+ "Schema draft " + id + " не найден."));
+ }
+
+ @Transactional(readOnly = true)
+ public List Только maker может submit'нуть свой draft. Reviewer'ы — opt-in список
+ * (NULL = pool, любой approver видит в queue).
+ */
+ @Transactional
+ public DictionarySchemaDraft submitForReview(UUID draftId, SubmitForReviewRequest req) {
+ DictionarySchemaDraft draft = findById(draftId);
+ assertMakerActor(draft, "submit_review");
+
+ SchemaDraftStatus current = draft.getStatus();
+ if (current != SchemaDraftStatus.DRAFT && current != SchemaDraftStatus.CHANGES_REQUESTED) {
+ throw OrdinisException.conflict(
+ "invalid_transition",
+ "Draft " + draftId + " status=" + current.dbValue()
+ + " — submit_review возможен только из draft или changes_requested.");
+ }
+
+ draft.setStatus(SchemaDraftStatus.REVIEW_PENDING);
+ draft.setSubmittedAt(OffsetDateTime.now());
+ draft.setReviewNote(req.note());
+ draft.setReviewerAssignments(toJsonArray(req.reviewers()));
+
+ DictionarySchemaDraft saved = repository.save(draft);
+ DictionaryDefinition def = definitionService.findById(saved.getDictionaryId());
+ emitEvent("SchemaDraftReviewRequested", saved, def, Map.of(
+ "reviewers", req.reviewers() == null ? List.of() : req.reviewers(),
+ "note", req.note() == null ? "" : req.note()));
+ log.info("schema_draft_review_pending id={} dict={} reviewers={}",
+ saved.getId(), def.getName(), req.reviewers());
+ return saved;
+ }
+
+ /**
+ * Reviewer decision: approve / request_changes / reject.
+ *
+ * Self-approve запрещён (БД constraint + early check в коде для better
+ * error message). Reviewer должен быть != maker.
+ */
+ @Transactional
+ public DictionarySchemaDraft decide(UUID draftId, DecisionRequest req) {
+ DictionarySchemaDraft draft = findById(draftId);
+ String reviewerId = currentUserId();
+
+ if (draft.getStatus() != SchemaDraftStatus.REVIEW_PENDING) {
+ throw OrdinisException.conflict(
+ "invalid_transition",
+ "Draft " + draftId + " status=" + draft.getStatus().dbValue()
+ + " — decision требует review_pending.");
+ }
+ if (draft.getMakerId().equals(reviewerId)) {
+ throw OrdinisException.conflict(
+ "self_approve_forbidden",
+ "Maker '" + reviewerId + "' не может decide свой draft (maker-checker).");
+ }
+
+ SchemaDraftStatus nextStatus = switch (req.decision()) {
+ case APPROVE -> SchemaDraftStatus.APPROVED;
+ case REQUEST_CHANGES -> SchemaDraftStatus.CHANGES_REQUESTED;
+ case REJECT -> SchemaDraftStatus.REJECTED;
+ };
+
+ draft.setStatus(nextStatus);
+ draft.setDecisionUserId(reviewerId);
+ draft.setDecisionAt(OffsetDateTime.now());
+ draft.setDecisionComment(req.comment());
+
+ DictionarySchemaDraft saved = repository.save(draft);
+ DictionaryDefinition def = definitionService.findById(saved.getDictionaryId());
+ emitEvent("SchemaDraftDecided", saved, def, Map.of(
+ "decision", req.decision().name().toLowerCase(),
+ "reviewerId", reviewerId,
+ "comment", req.comment() == null ? "" : req.comment()));
+ log.info("schema_draft_decided id={} dict={} decision={} reviewer={}",
+ saved.getId(), def.getName(), req.decision(), reviewerId);
+ return saved;
+ }
+
+ /**
+ * Publish approved draft → live schema. Bumps version (minor by default).
+ *
+ * Optimistic lock: {@code expectedHeadVersion} проверяется против
+ * {@code definition.schemaVersion}. Если другой draft опубликовался
+ * первым → 409 ({@code version_mismatch}).
+ *
+ * SERIALIZABLE isolation — нужна consistency между update'ом
+ * definition.schemaJson + draft status'а в одной транзакции.
+ */
+ @Transactional(isolation = Isolation.SERIALIZABLE)
+ public DictionarySchemaDraft publish(UUID draftId, PublishDraftRequest req) {
+ DictionarySchemaDraft draft = findById(draftId);
+ DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
+
+ if (draft.getStatus() != SchemaDraftStatus.APPROVED) {
+ throw OrdinisException.conflict(
+ "invalid_transition",
+ "Draft " + draftId + " status=" + draft.getStatus().dbValue()
+ + " — publish требует approved.");
+ }
+ if (!def.getSchemaVersion().equals(req.expectedHeadVersion())) {
+ throw OrdinisException.conflict(
+ "version_mismatch",
+ "expectedHeadVersion=" + req.expectedHeadVersion()
+ + " не совпадает с current HEAD " + def.getSchemaVersion()
+ + ". Кто-то другой опубликовал свой draft — refetch и retry.");
+ }
+
+ String newVersion = computeNextVersion(def.getSchemaVersion(), def.getSchemaJson(),
+ draft.getProposedSchema());
+
+ // Apply schema через repository напрямую — мы избегаем
+ // DictionaryDefinitionService.updateSchema потому что он принимает
+ // CreateDictionaryRequest и заново валидирует supportedLocales etc;
+ // здесь нам нужно только set'нуть schemaJson + version + emit
+ // definition-level outbox через тот же recorder для согласованности.
+ def.setSchemaJson(draft.getProposedSchema());
+ def.setSchemaVersion(newVersion);
+ // updated_at установится AuditingEntityListener'ом.
+
+ OffsetDateTime publishedAt = OffsetDateTime.now();
+ draft.setStatus(SchemaDraftStatus.PUBLISHED);
+ draft.setPublishedAt(publishedAt);
+ draft.setPublishedVersion(newVersion);
+ draft.setPublishNote(req.publishNote());
+
+ DictionarySchemaDraft saved = repository.save(draft);
+ emitEvent("SchemaDraftPublished", saved, def, Map.of(
+ "newVersion", newVersion,
+ "publishedAt", publishedAt.toString(),
+ "publishNote", req.publishNote() == null ? "" : req.publishNote()));
+ log.info("schema_draft_published id={} dict={} newVersion={}",
+ saved.getId(), def.getName(), newVersion);
+ return saved;
+ }
+
+ /** Maker отзывает свой draft (только non-terminal статусы). */
+ @Transactional
+ public DictionarySchemaDraft withdraw(UUID draftId) {
+ DictionarySchemaDraft draft = findById(draftId);
+ assertMakerActor(draft, "withdraw");
+ if (draft.getStatus().isTerminal()) {
+ throw OrdinisException.conflict(
+ "invalid_transition",
+ "Draft " + draftId + " уже в terminal status'е " + draft.getStatus().dbValue()
+ + " — withdraw невозможен.");
+ }
+
+ draft.setStatus(SchemaDraftStatus.WITHDRAWN);
+ DictionarySchemaDraft saved = repository.save(draft);
+ DictionaryDefinition def = definitionService.findById(saved.getDictionaryId());
+ emitEvent("SchemaDraftWithdrawn", saved, def, Map.of());
+ log.info("schema_draft_withdrawn id={} dict={} maker={}",
+ saved.getId(), def.getName(), saved.getMakerId());
+ return saved;
+ }
+
+ // ─────────────────────────────────────────────────────────────────────
+ // Helpers
+ // ─────────────────────────────────────────────────────────────────────
+
+ private void assertMakerActor(DictionarySchemaDraft draft, String op) {
+ String actor = currentUserId();
+ if (!draft.getMakerId().equals(actor)) {
+ throw OrdinisException.forbidden(
+ "maker_only",
+ "Operation '" + op + "' возможна только для maker'а draft'а ("
+ + draft.getMakerId() + "), actor=" + actor + ".");
+ }
+ }
+
+ /**
+ * Compute next schema version. Default: minor bump (1.4.0 → 1.5.0).
+ * Future improvement (Phase 1): detect breaking keys в diff (type changes,
+ * required removals, x-references changes) → major bump.
+ *
+ * Сейчас простая эвристика — minor unless schema diff содержит явные
+ * breaking keys в top-level properties. Полная семантика — будущее.
+ */
+ String computeNextVersion(String currentVersion, JsonNode oldSchema, JsonNode newSchema) {
+ String[] parts = currentVersion.split("\\.");
+ if (parts.length != 3) {
+ // Fallback на patch bump если version не semver — лучше чем падать.
+ return currentVersion + ".1";
+ }
+ boolean breaking = detectBreakingChange(oldSchema, newSchema);
+ int major = Integer.parseInt(parts[0]);
+ int minor = Integer.parseInt(parts[1]);
+ if (breaking) {
+ return (major + 1) + ".0.0";
+ }
+ return major + "." + (minor + 1) + ".0";
+ }
+
+ /**
+ * Heuristic breaking detection: required property removed OR
+ * existing property type changed. Conservative — false negatives OK
+ * (publisher может всегда override через explicit version в future).
+ */
+ private boolean detectBreakingChange(JsonNode oldSchema, JsonNode newSchema) {
+ if (oldSchema == null || newSchema == null) return false;
+
+ // Removed required property?
+ JsonNode oldRequired = oldSchema.path("required");
+ JsonNode newRequired = newSchema.path("required");
+ if (oldRequired.isArray()) {
+ for (JsonNode item : oldRequired) {
+ String k = item.asText();
+ boolean stillRequired = false;
+ if (newRequired.isArray()) {
+ for (JsonNode n : newRequired) {
+ if (n.asText().equals(k)) {
+ stillRequired = true;
+ break;
+ }
+ }
+ }
+ if (!stillRequired) return true;
+ }
+ }
+
+ // Changed property type?
+ JsonNode oldProps = oldSchema.path("properties");
+ JsonNode newProps = newSchema.path("properties");
+ if (oldProps.isObject() && newProps.isObject()) {
+ var fields = oldProps.fieldNames();
+ while (fields.hasNext()) {
+ String fname = fields.next();
+ JsonNode oldType = oldProps.path(fname).path("type");
+ JsonNode newType = newProps.path(fname).path("type");
+ if (!newType.isMissingNode() && !oldType.isMissingNode()
+ && !oldType.asText().equals(newType.asText())) {
+ return true;
+ }
+ }
+ }
+ return false;
+ }
+
+ private JsonNode toJsonArray(List Routes:
+ * {@code {name}} в path передаётся для красивых URL'ов + кэширования
+ * frontend query keys; внутри service resolve'им dict_id через
+ * {@link DictionaryDefinitionService#findByName}.
+ */
+@RestController
+public class SchemaDraftController {
+
+ private final SchemaDraftService draftService;
+ private final DictionaryDefinitionService definitionService;
+
+ public SchemaDraftController(
+ SchemaDraftService draftService,
+ DictionaryDefinitionService definitionService) {
+ this.draftService = draftService;
+ this.definitionService = definitionService;
+ }
+
+ /**
+ * Create draft от current HEAD schema. Returns 201 с full draft state.
+ * Returns 202 Accepted — async wait для reviewer'а decision.
+ */
+ @PostMapping("/api/v1/dictionaries/{name}/drafts/{draftId}/review")
+ @ResponseStatus(HttpStatus.ACCEPTED)
+ public SchemaDraftResponse submitForReview(
+ @PathVariable String name,
+ @PathVariable UUID draftId,
+ @Valid @RequestBody(required = false) SubmitForReviewRequest req) {
+ SubmitForReviewRequest body = req == null ? new SubmitForReviewRequest(null, null) : req;
+ DictionarySchemaDraft draft = draftService.submitForReview(draftId, body);
+ return SchemaDraftResponse.from(draft, name);
+ }
+
+ /**
+ * Reviewer decision (approve / request_changes / reject).
+ * Bump'ит schema version (minor default, major на breaking changes).
+ * JDK 25 Mockito incompat → in-memory stubs (см. record-level
+ * DraftServiceTest, same pattern). Auth simулируем через
+ * SecurityContextHolder set/clear в каждом тесте.
+ */
+class SchemaDraftServiceTest {
+
+ private static final ObjectMapper OM = new ObjectMapper();
+
+ private final UUID dictId = UUID.randomUUID();
+ private DictionaryDefinition def;
+ private DictionaryDefinitionService defService;
+ private StubSchemaDraftRepo repo;
+ private OutboxRecorder outbox;
+ private SchemaDraftService service;
+ private AtomicReferenceTransitions
+ *
+ * DRAFT — maker создал, ещё не submit'нул
+ * → REVIEW_PENDING (submit for review)
+ * → WITHDRAWN (maker discards)
+ *
+ * REVIEW_PENDING — ждёт reviewer decision
+ * → APPROVED (reviewer.decision=approve)
+ * → CHANGES_REQUESTED (reviewer.decision=request_changes)
+ * → REJECTED (reviewer.decision=reject)
+ * → WITHDRAWN (maker recall)
+ *
+ * CHANGES_REQUESTED — maker должен доработать
+ * → REVIEW_PENDING (re-submit)
+ * → WITHDRAWN (maker abandons)
+ *
+ * APPROVED — готов к публикации
+ * → PUBLISHED (publisher publishes, bump version)
+ * → WITHDRAWN (maker withdraws even после approve, edge case)
+ *
+ * PUBLISHED — terminal (changes live)
+ * REJECTED — terminal (kept for audit)
+ * WITHDRAWN — terminal (kept for audit)
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ *
+ */
+ @Transactional
+ public DictionarySchemaDraft create(String dictionaryName, CreateSchemaDraftRequest req) {
+ DictionaryDefinition def = definitionService.findByName(dictionaryName);
+
+ // Optimistic version check: maker должен fetch'нуть HEAD прежде чем
+ // создавать draft. Если кто-то опубликовал свой draft пока этот maker
+ // готовился — он refetch'нёт через 409.
+ if (!def.getSchemaVersion().equals(req.fromVersion())) {
+ throw OrdinisException.conflict(
+ "version_mismatch",
+ "fromVersion=" + req.fromVersion() + " не совпадает с current HEAD "
+ + def.getSchemaVersion() + ". Refetch и retry.");
+ }
+
+ // Early check для better error message (БД constraint всё равно отловит race).
+ repository.findActiveByDictionary(def.getId()).ifPresent(existing -> {
+ throw OrdinisException.conflict(
+ "active_draft_exists",
+ "Активный draft " + existing.getId() + " (status=" + existing.getStatus().dbValue()
+ + ") уже существует для " + dictionaryName + ". Закройте его перед созданием нового.");
+ });
+
+ DictionarySchemaDraft draft = new DictionarySchemaDraft(
+ UUID.randomUUID(),
+ def.getId(),
+ req.proposedSchema(),
+ req.fromVersion(),
+ currentUserId(),
+ req.reason());
+
+ try {
+ DictionarySchemaDraft saved = repository.save(draft);
+ emitEvent("SchemaDraftCreated", saved, def, Map.of(
+ "fromVersion", req.fromVersion()));
+ log.info("schema_draft_created id={} dict={} maker={}",
+ saved.getId(), dictionaryName, saved.getMakerId());
+ return saved;
+ } catch (DataIntegrityViolationException e) {
+ // Race с EXCLUDE constraint — другой thread параллельно создал draft.
+ throw OrdinisException.conflict(
+ "active_draft_exists",
+ "Активный draft уже существует для " + dictionaryName
+ + " (concurrent create).");
+ }
+ }
+
+ /**
+ * Maker submit'ит draft на review. Transitions: DRAFT → REVIEW_PENDING,
+ * CHANGES_REQUESTED → REVIEW_PENDING (re-submit после доработки).
+ *
+ *
+ * POST /api/v1/dictionaries/{name}/drafts
+ * POST /api/v1/dictionaries/{name}/drafts/{draftId}/review
+ * POST /api/v1/dictionaries/{name}/drafts/{draftId}/decision
+ * POST /api/v1/dictionaries/{name}/drafts/{draftId}/publish
+ * DELETE /api/v1/dictionaries/{name}/drafts/{draftId} (withdraw)
+ * GET /api/v1/dictionaries/{name}/drafts
+ * GET /api/v1/dictionaries/{name}/drafts/active
+ * GET /api/v1/dictionaries/{name}/drafts/{draftId}
+ * GET /api/v1/admin/schema-reviews/pending (pool queue)
+ *
+ *
+ *
+ *
+ */
+ @PostMapping("/api/v1/dictionaries/{name}/drafts")
+ @ResponseStatus(HttpStatus.CREATED)
+ public SchemaDraftResponse create(
+ @PathVariable String name,
+ @Valid @RequestBody CreateSchemaDraftRequest req) {
+ DictionarySchemaDraft draft = draftService.create(name, req);
+ return SchemaDraftResponse.from(draft, name);
+ }
+
+ /**
+ * Submit draft → review_pending. Transitions: DRAFT → REVIEW_PENDING,
+ * CHANGES_REQUESTED → REVIEW_PENDING (re-submit).
+ *
+ *
+ *
+ */
+ @PostMapping("/api/v1/dictionaries/{name}/drafts/{draftId}/decision")
+ public SchemaDraftResponse decide(
+ @PathVariable String name,
+ @PathVariable UUID draftId,
+ @Valid @RequestBody DecisionRequest req) {
+ DictionarySchemaDraft draft = draftService.decide(draftId, req);
+ return SchemaDraftResponse.from(draft, name);
+ }
+
+ /**
+ * Publish approved draft → live HEAD.
+ *
+ *
+ *
+ */
+ @PostMapping("/api/v1/dictionaries/{name}/drafts/{draftId}/publish")
+ public Map S save(S entity) {
+ // Mimic EXCLUDE constraint: max one active draft per dict.
+ var active = findActiveByDictionary(entity.getDictionaryId());
+ if (active.isPresent() && !active.get().getId().equals(entity.getId())) {
+ throw new org.springframework.dao.DataIntegrityViolationException(
+ "schema_drafts_one_active_per_dict");
+ }
+ store.put(entity.getId(), entity);
+ return entity;
+ }
+
+ @Override
+ public Optional List saveAll(Iterable entities) {
+ List out = new ArrayList<>();
+ for (S e : entities) out.add(save(e));
+ return out;
+ }
+ @Override public List saveAllAndFlush(Iterable entities) { return saveAll(entities); }
+ @Override public List List findAll(Example ex) { throw new UnsupportedOperationException(); }
+ @Override public List findAll(Example ex, Sort s) { throw new UnsupportedOperationException(); }
+ @Override public Page findAll(Example ex, Pageable p) { throw new UnsupportedOperationException(); }
+ @Override public long count(Example ex) { return 0; }
+ @Override public boolean exists(Example ex) { return false; }
+ @Override public Optional findOne(Example ex) { return Optional.empty(); }
+ @Override public R findBy(Example ex, Function