feat(api): schema workflow API — backend #3 (drafts/review/decision/publish)

This commit is contained in:
Александр Зимин
2026-05-11 23:11:56 +00:00
parent 61e4cf952e
commit 01fc5771f2
13 changed files with 1752 additions and 0 deletions
@@ -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 (а не отдельной записи).
*
* <p>Lifecycle (см. {@link SchemaDraftStatus}):
* draft → review_pending → {approved, changes_requested, rejected, withdrawn}
* → published / withdrawn.
*
* <p>Один active draft per dict (Liquibase 0022 EXCLUDE constraint
* {@code schema_drafts_one_active_per_dict}) — schema changes coarse-grained,
* параллельное редактирование не имеет смысла.
*
* <p>{@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; }
}
@@ -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.
*
* <p>Все queries фильтруют по {@code statusDb} string (lowercase в БД) —
* см. {@link DictionarySchemaDraft} комментарий про status mapping.
* Поэтому передаём DB-value напрямую (через
* {@link SchemaDraftStatus#dbValue()}), не enum constant.
*/
public interface SchemaDraftRepository extends JpaRepository<DictionarySchemaDraft, UUID> {
/**
* Active (non-terminal) draft для dict — most важный lookup для UI banner
* "у этого dict'а есть open draft, такой-то reviewer должен решить".
* Возвращает Optional т.к. EXCLUDE constraint гарантирует max 1.
*/
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.dictionaryId = :dictionaryId
AND d.statusDb IN ('draft', 'review_pending', 'changes_requested', 'approved')
""")
Optional<DictionarySchemaDraft> findActiveByDictionary(@Param("dictionaryId") UUID dictionaryId);
/** All drafts for a dictionary, sorted newest-first. */
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.dictionaryId = :dictionaryId
ORDER BY d.createdAt DESC
""")
List<DictionarySchemaDraft> findByDictionary(@Param("dictionaryId") UUID dictionaryId);
/** Global review queue (status=review_pending) для pool reviewer'ов. */
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.statusDb = 'review_pending'
ORDER BY d.submittedAt ASC
""")
Page<DictionarySchemaDraft> findPendingReview(Pageable pageable);
/** Maker's own drafts (любой status). */
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.makerId = :makerId
ORDER BY d.createdAt DESC
""")
Page<DictionarySchemaDraft> findByMaker(@Param("makerId") String makerId, Pageable pageable);
}
@@ -0,0 +1,69 @@
package cloud.nstart.terravault.ordinis.domain.schemaworkflow;
/**
* Schema workflow draft state machine.
*
* <p>Параллель к record-level {@link cloud.nstart.terravault.ordinis.domain.draft.DraftStatus},
* но для schema-level changes (один draft = одно proposed schema state).
*
* <h3>Transitions</h3>
* <pre>
* 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)
* </pre>
*
* <p>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());
}
}
@@ -0,0 +1,140 @@
<?xml version="1.0" encoding="UTF-8"?>
<databaseChangeLog
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
<!--
Dictionary schema workflow API — backend pending endpoint #3.
Параллельная архитектура к record_drafts (0019), но для schema-level
changes: дизайнер edit'ит JSON Schema → создаёт draft → submit'ит на
review → reviewer approve/reject/request_changes → publish bump'ит
semver + replace'ит live schema.
State machine:
draft → review_pending → {approved, changes_requested, rejected}
withdrawn ← draft (maker discard)
withdrawn ← review_pending (maker recall)
published ← approved (publish bump'ит version)
review_pending ← changes_requested (maker re-submit)
Decisions:
- Pool model для reviewer'ов (D3): любой user с approve role
может decide; reviewer_assignments JSONB (NULL = open pool).
- Self-approve forbidden: maker_id != decision_user_id constraint.
- Только один non-terminal draft per dict (EXCLUDE constraint
WHERE status NOT IN published/rejected/withdrawn).
- Optimistic lock на publish: expected_head_version проверяется
против dictionary_definitions.schema_version в service layer.
Source: docs/integration/api/pending-endpoints.md §3 (frontend spec).
-->
<changeSet id="0022-1-schema-drafts" author="ordinis">
<comment>dictionary_schema_drafts — schema-level approval workflow</comment>
<createTable tableName="dictionary_schema_drafts">
<column name="id" type="UUID" defaultValueComputed="gen_random_uuid()">
<constraints primaryKey="true" nullable="false"/>
</column>
<column name="dictionary_id" type="UUID">
<constraints nullable="false" foreignKeyName="fk_schema_draft_dictionary"
references="dictionary_definitions(id)"/>
</column>
<!-- Proposed schema content + base version pointer. -->
<column name="proposed_schema" type="JSONB">
<constraints nullable="false"/>
</column>
<column name="branched_from_version" type="VARCHAR(32)">
<constraints nullable="false"/>
</column>
<!-- Maker rationale; required чтобы reviewer понимал intent. -->
<column name="reason" type="TEXT"/>
<!-- Reviewer pool: NULL = anyone with approve role, JSONB array
если конкретный set. См. SubmitForReviewRequest.reviewers. -->
<column name="reviewer_assignments" type="JSONB"/>
<column name="review_note" type="TEXT"/>
<!-- Workflow state — enum check ниже. -->
<column name="status" type="VARCHAR(24)">
<constraints nullable="false"/>
</column>
<column name="maker_id" type="VARCHAR(128)">
<constraints nullable="false"/>
</column>
<column name="created_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
<column name="submitted_at" type="TIMESTAMPTZ"/>
<!-- Decision details (single decision — reviewer's verdict). -->
<column name="decision_user_id" type="VARCHAR(128)"/>
<column name="decision_at" type="TIMESTAMPTZ"/>
<column name="decision_comment" type="TEXT"/>
<!-- Publish metadata — set когда status=published. -->
<column name="published_at" type="TIMESTAMPTZ"/>
<column name="published_version" type="VARCHAR(32)"/>
<column name="publish_note" type="TEXT"/>
<column name="updated_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
<constraints nullable="false"/>
</column>
<column name="version" type="BIGINT" defaultValueNumeric="0">
<constraints nullable="false"/>
</column>
</createTable>
<sql>
ALTER TABLE dictionary_schema_drafts
ADD CONSTRAINT schema_drafts_status_check
CHECK (status IN ('draft', 'review_pending', 'changes_requested',
'approved', 'rejected', 'published', 'withdrawn'));
</sql>
<!-- Self-approve forbidden: D3 maker-checker pattern. -->
<sql>
ALTER TABLE dictionary_schema_drafts
ADD CONSTRAINT schema_drafts_no_self_approve
CHECK (decision_user_id IS NULL OR maker_id != decision_user_id);
</sql>
<!-- Один active draft per dict (concurrent editing на schema level
не имеет смысла — schema changes coarse-grained и serialized).
Terminal статусы (published/rejected/withdrawn) исключены —
можно создать новый draft после того как предыдущий closed. -->
<sql>
ALTER TABLE dictionary_schema_drafts
ADD CONSTRAINT schema_drafts_one_active_per_dict
EXCLUDE USING btree (
dictionary_id WITH =
) WHERE (status IN ('draft', 'review_pending', 'changes_requested', 'approved'));
</sql>
<!-- Listing by dict — самое частое чтение, см. SchemaDraftService.listByDictionary. -->
<createIndex indexName="idx_schema_drafts_by_dict" tableName="dictionary_schema_drafts">
<column name="dictionary_id"/>
<column name="created_at" descending="true"/>
</createIndex>
<!-- Maker's own drafts (self-tracking). -->
<createIndex indexName="idx_schema_drafts_by_maker" tableName="dictionary_schema_drafts">
<column name="maker_id"/>
<column name="created_at" descending="true"/>
</createIndex>
<!-- Global review queue (pool reviewer'ы видят review_pending across all dicts). -->
<createIndex indexName="idx_schema_drafts_review_queue" tableName="dictionary_schema_drafts">
<column name="status"/>
<column name="submitted_at"/>
</createIndex>
</changeSet>
</databaseChangeLog>
@@ -31,5 +31,6 @@
<include file="changes/0019-approval-workflow-v2.xml" relativeToChangelogFile="true"/>
<include file="changes/0020-fix-draft-status-case.xml" relativeToChangelogFile="true"/>
<include file="changes/0021-notifications.xml" relativeToChangelogFile="true"/>
<include file="changes/0022-schema-workflow.xml" relativeToChangelogFile="true"/>
</databaseChangeLog>
@@ -0,0 +1,29 @@
package cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
/**
* Создание нового schema draft'а от текущего HEAD.
*
* <p>Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts}.
*
* <p>{@code fromVersion} обязателен и сверяется с текущей
* {@code DictionaryDefinition.schemaVersion} если расходится 409
* ({@code version_mismatch}, кто-то ещё опубликовал свой draft пока этот
* пользователь готовился).
*
* <p>{@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) {}
@@ -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.
*
* <p>Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/decision}.
*
* <p>Transitions:
* <ul>
* <li>{@link Verdict#APPROVE} status=APPROVED, ждёт publish.</li>
* <li>{@link Verdict#REQUEST_CHANGES} status=CHANGES_REQUESTED, maker
* должен доработать и re-submit.</li>
* <li>{@link Verdict#REJECT} status=REJECTED (terminal).</li>
* </ul>
*
* <p>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
}
}
@@ -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.
*
* <p>Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/publish}.
*
* <p>{@code expectedHeadVersion} optimistic lock против текущего
* {@code DictionaryDefinition.schemaVersion}. Если кто-то другой опубликовал
* свой draft пока этот approved'ил 409 ({@code version_mismatch}, refetch
* + re-publish).
*
* <p>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) {}
@@ -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);
}
}
@@ -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.
*
* <p>Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/review}.
*
* <p>{@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<String> reviewers,
/** Optional maker note для reviewer (что важно проверить). */
String note) {}
@@ -0,0 +1,456 @@
package cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.DictionarySchemaDraft;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftRepository;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus;
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest;
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.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Dictionary schema workflow API backend pending endpoint #3.
*
* <p>State machine: см. {@link SchemaDraftStatus}.
*
* <p>Endpoints (см. {@link cloud.nstart.terravault.ordinis.restapi.web.SchemaDraftController}):
* <ul>
* <li>{@link #create} maker создаёт draft от current HEAD</li>
* <li>{@link #submitForReview} draft review_pending</li>
* <li>{@link #decide} reviewer approves / requests changes / rejects</li>
* <li>{@link #publish} approved draft live (bumps schema version)</li>
* <li>{@link #withdraw} maker отменяет non-terminal draft</li>
* </ul>
*
* <p>Все mutator-методы emit'ят outbox event ({@code SchemaDraft*}) и audit
* (через {@link DictionaryDefinitionService} updateSchema path для publish).
*
* <p>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<DictionarySchemaDraft> listByDictionary(String dictionaryName) {
DictionaryDefinition def = definitionService.findByName(dictionaryName);
return repository.findByDictionary(def.getId());
}
@Transactional(readOnly = true)
public java.util.Optional<DictionarySchemaDraft> findActive(String dictionaryName) {
DictionaryDefinition def = definitionService.findByName(dictionaryName);
return repository.findActiveByDictionary(def.getId());
}
@Transactional(readOnly = true)
public Page<DictionarySchemaDraft> listPendingReview(Pageable pageable) {
return repository.findPendingReview(pageable);
}
//
// Mutators (state machine transitions)
//
/**
* Maker создаёт draft от текущего HEAD schema.
*
* <ul>
* <li>Проверяет {@code fromVersion} == {@code definition.schemaVersion}
* (optimistic lock против concurrent publishes).</li>
* <li>Если уже есть active draft для этого dict'а 409
* ({@code active_draft_exists}) EXCLUDE constraint в БД дополнительно
* enforce'ит, но мы fail-early до DB hit'а.</li>
* </ul>
*/
@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 после доработки).
*
* <p>Только 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.
*
* <p>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).
*
* <p>Optimistic lock: {@code expectedHeadVersion} проверяется против
* {@code definition.schemaVersion}. Если другой draft опубликовался
* первым 409 ({@code version_mismatch}).
*
* <p>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.
*
* <p>Сейчас простая эвристика 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<String> items) {
if (items == null || items.isEmpty()) return null;
ArrayNode arr = objectMapper.createArrayNode();
items.forEach(arr::add);
return arr;
}
private void emitEvent(
String eventType,
DictionarySchemaDraft draft,
DictionaryDefinition def,
Map<String, Object> extras) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("draftId", draft.getId().toString());
payload.put("dictionaryId", def.getId().toString());
payload.put("dictionaryName", def.getName());
payload.put("status", draft.getStatus().dbValue());
payload.put("branchedFromVersion", draft.getBranchedFromVersion());
payload.put("makerId", draft.getMakerId());
payload.putAll(extras);
var envelope = EventEnvelope.of(
def.getName(),
toEventScope(def.getScope()),
def.getBundle(),
payload);
outboxRecorder.record(
eventType,
"DictionarySchemaDraft",
draft.getId().toString(),
def.getName(),
def.getScope(),
def.getBundle(),
"schema-draft:" + draft.getId(),
envelope);
}
private static cloud.nstart.terravault.ordinis.events.DataScope toEventScope(
cloud.nstart.terravault.ordinis.domain.DataScope domain) {
return cloud.nstart.terravault.ordinis.events.DataScope.valueOf(domain.name());
}
/** Resolve current user from Spring Security context (Keycloak subject). */
static String currentUserId() {
var auth = org.springframework.security.core.context.SecurityContextHolder
.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) return "anonymous";
return auth.getName();
}
}
@@ -0,0 +1,196 @@
package cloud.nstart.terravault.ordinis.restapi.web;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.DictionarySchemaDraft;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest;
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.SchemaDraftResponse;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.SubmitForReviewRequest;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow.SchemaDraftService;
import jakarta.validation.Valid;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* Schema workflow endpoints pending API #3 (см.
* docs/integration/api/pending-endpoints.md §3).
*
* <p>Routes:
* <pre>
* 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)
* </pre>
*
* <p>{@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.
* <ul>
* <li>409 {@code version_mismatch} fromVersion не = current HEAD.</li>
* <li>409 {@code active_draft_exists} другой draft уже non-terminal
* на этом dict'е.</li>
* </ul>
*/
@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).
*
* <p>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).
* <ul>
* <li>403 {@code self_approve_forbidden} actor == maker.</li>
* <li>409 {@code invalid_transition} draft не в review_pending.</li>
* </ul>
*/
@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.
*
* <p>Bump'ит schema version (minor default, major на breaking changes).
* <ul>
* <li>409 {@code invalid_transition} draft не approved.</li>
* <li>409 {@code version_mismatch} expectedHeadVersion stale.</li>
* </ul>
*/
@PostMapping("/api/v1/dictionaries/{name}/drafts/{draftId}/publish")
public Map<String, Object> publish(
@PathVariable String name,
@PathVariable UUID draftId,
@Valid @RequestBody PublishDraftRequest req) {
DictionarySchemaDraft draft = draftService.publish(draftId, req);
return Map.of(
"draft", SchemaDraftResponse.from(draft, name),
"newVersion", draft.getPublishedVersion(),
"publishedAt", draft.getPublishedAt());
}
/** Withdraw — maker отменяет non-terminal draft. */
@DeleteMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
public SchemaDraftResponse withdraw(
@PathVariable String name,
@PathVariable UUID draftId) {
DictionarySchemaDraft draft = draftService.withdraw(draftId);
return SchemaDraftResponse.from(draft, name);
}
/** List all drafts for a dictionary (newest first). */
@GetMapping("/api/v1/dictionaries/{name}/drafts")
public List<SchemaDraftResponse> list(@PathVariable String name) {
return draftService.listByDictionary(name).stream()
.map(d -> SchemaDraftResponse.from(d, name))
.toList();
}
/**
* Convenience endpoint: фронт хочет ОДИН query'ом узнать "есть ли active
* draft на этом dict'е и в каком он state'е". Returns 200 с draft body
* либо 404 если нет active.
*/
@GetMapping("/api/v1/dictionaries/{name}/drafts/active")
public SchemaDraftResponse active(@PathVariable String name) {
DictionarySchemaDraft draft = draftService.findActive(name).orElseThrow(() ->
cloud.nstart.terravault.ordinis.restapi.error.OrdinisException.notFound(
"no_active_draft",
"У справочника " + name + " нет активного draft'а."));
return SchemaDraftResponse.from(draft, name);
}
/** Single draft по id (drilling в reviewer queue). */
@GetMapping("/api/v1/dictionaries/{name}/drafts/{draftId}")
public SchemaDraftResponse getById(
@PathVariable String name,
@PathVariable UUID draftId) {
return SchemaDraftResponse.from(draftService.findById(draftId), name);
}
/**
* Global pool queue для approvers все review_pending drafts across all
* dicts, sorted oldest-first (fairness).
*/
@GetMapping("/api/v1/admin/schema-reviews/pending")
public Map<String, Object> globalQueue(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
var pg = draftService.listPendingReview(PageRequest.of(page, Math.min(size, 200)));
List<SchemaDraftResponse> items = pg.getContent().stream()
.map(d -> {
// Optionally enrich с dictionary name но это N+1 для большого queue;
// в pool view name может resolveить frontend через separate /dicts query.
DictionaryDefinition def = definitionService.findById(d.getDictionaryId());
return SchemaDraftResponse.from(d, def.getName());
})
.toList();
return Map.of(
"items", items,
"page", pg.getNumber(),
"size", pg.getSize(),
"totalElements", pg.getTotalElements(),
"totalPages", pg.getTotalPages());
}
}
@@ -0,0 +1,478 @@
package cloud.nstart.terravault.ordinis.restapi.service.schemaworkflow;
import cloud.nstart.terravault.ordinis.domain.DataScope;
import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.DictionarySchemaDraft;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftRepository;
import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest;
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.error.OrdinisException;
import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.data.domain.Example;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.repository.query.FluentQuery;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests {@link SchemaDraftService}.
*
* <p>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 AtomicReference<String> lastEventType;
@BeforeEach
void setUp() throws Exception {
def = new DictionaryDefinition(dictId, "test_dict", DataScope.PUBLIC,
OM.readTree("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"}}}"));
def.setSchemaVersion("1.4.0");
def.setBundle("test");
defService = new DictionaryDefinitionService(null, null) {
@Override
public DictionaryDefinition findByName(String name) {
if ("test_dict".equals(name)) return def;
throw OrdinisException.notFound("dictionary_not_found", "no: " + name);
}
@Override
public DictionaryDefinition findById(UUID id) {
if (id.equals(dictId)) return def;
throw OrdinisException.notFound("dictionary_not_found", "no id: " + id);
}
};
repo = new StubSchemaDraftRepo();
lastEventType = new AtomicReference<>();
// ObjectProvider<Tracer> stub always returns null чтобы skip tracing path в record().
org.springframework.beans.factory.ObjectProvider<io.micrometer.tracing.Tracer> nullTracer =
new StubTracerProvider();
outbox = new OutboxRecorder(null, OM, nullTracer) {
@Override
public cloud.nstart.terravault.ordinis.domain.outbox.OutboxEvent record(
String eventType, String aggregateType, String aggregateId,
String dictionaryName, DataScope scope, String bundle,
String dedupKey, Object envelope) {
lastEventType.set(eventType);
return null;
}
};
service = new SchemaDraftService(repo, defService, outbox, OM);
setUser("alice");
}
@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}
// create()
@Test
void create_happyPath() throws Exception {
JsonNode newSchema = OM.readTree("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"description\":{\"type\":\"string\"}}}");
var draft = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "add description field", newSchema));
assertThat(draft.getStatus()).isEqualTo(SchemaDraftStatus.DRAFT);
assertThat(draft.getMakerId()).isEqualTo("alice");
assertThat(draft.getBranchedFromVersion()).isEqualTo("1.4.0");
assertThat(draft.getReason()).isEqualTo("add description field");
assertThat(lastEventType.get()).isEqualTo("SchemaDraftCreated");
}
@Test
void create_versionMismatch_throws409() throws Exception {
JsonNode newSchema = OM.readTree("{}");
assertThatThrownBy(() -> service.create("test_dict",
new CreateSchemaDraftRequest("1.3.0", "stale", newSchema)))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("version_mismatch"));
}
@Test
void create_activeDraftExists_throws409() throws Exception {
JsonNode newSchema = OM.readTree("{}");
service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "first", newSchema));
assertThatThrownBy(() -> service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "second", newSchema)))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("active_draft_exists"));
}
// submitForReview()
@Test
void submitForReview_draftToReviewPending() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
var submitted = service.submitForReview(created.getId(),
new SubmitForReviewRequest(List.of("bob", "carol"), "check name field"));
assertThat(submitted.getStatus()).isEqualTo(SchemaDraftStatus.REVIEW_PENDING);
assertThat(submitted.getSubmittedAt()).isNotNull();
assertThat(submitted.getReviewNote()).isEqualTo("check name field");
assertThat(lastEventType.get()).isEqualTo("SchemaDraftReviewRequested");
}
@Test
void submitForReview_nullReviewers_uses_pool() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
var submitted = service.submitForReview(created.getId(),
new SubmitForReviewRequest(null, null));
assertThat(submitted.getReviewerAssignments()).isNull();
}
@Test
void submitForReview_nonMaker_forbidden() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
setUser("bob");
assertThatThrownBy(() -> service.submitForReview(created.getId(),
new SubmitForReviewRequest(null, null)))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("maker_only"));
}
@Test
void submitForReview_alreadyApproved_invalidTransition() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));
setUser("alice");
assertThatThrownBy(() -> service.submitForReview(created.getId(),
new SubmitForReviewRequest(null, null)))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("invalid_transition"));
}
@Test
void submitForReview_resubmitAfterChangesRequested() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
service.decide(created.getId(), new DecisionRequest(
DecisionRequest.Verdict.REQUEST_CHANGES, "rename"));
setUser("alice");
var resub = service.submitForReview(created.getId(),
new SubmitForReviewRequest(null, "fixed"));
assertThat(resub.getStatus()).isEqualTo(SchemaDraftStatus.REVIEW_PENDING);
}
// decide()
@Test
void decide_approve() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
var decided = service.decide(created.getId(),
new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));
assertThat(decided.getStatus()).isEqualTo(SchemaDraftStatus.APPROVED);
assertThat(decided.getDecisionUserId()).isEqualTo("bob");
assertThat(decided.getDecisionComment()).isEqualTo("ok");
assertThat(lastEventType.get()).isEqualTo("SchemaDraftDecided");
}
@Test
void decide_selfApprove_forbidden() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
// Still alice (maker) should be forbidden.
assertThatThrownBy(() -> service.decide(created.getId(),
new DecisionRequest(DecisionRequest.Verdict.APPROVE, "self")))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("self_approve_forbidden"));
}
@Test
void decide_notInReview_invalidTransition() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
setUser("bob");
assertThatThrownBy(() -> service.decide(created.getId(),
new DecisionRequest(DecisionRequest.Verdict.APPROVE, "")))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("invalid_transition"));
}
@Test
void decide_requestChanges() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
var d = service.decide(created.getId(),
new DecisionRequest(DecisionRequest.Verdict.REQUEST_CHANGES, "rename"));
assertThat(d.getStatus()).isEqualTo(SchemaDraftStatus.CHANGES_REQUESTED);
}
@Test
void decide_reject() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
var d = service.decide(created.getId(),
new DecisionRequest(DecisionRequest.Verdict.REJECT, "bad idea"));
assertThat(d.getStatus()).isEqualTo(SchemaDraftStatus.REJECTED);
}
// publish()
@Test
void publish_happyPath_bumpsMinor() throws Exception {
JsonNode newSchema = OM.readTree("{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"string\"},\"description\":{\"type\":\"string\"}}}");
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "add description", newSchema));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));
var published = service.publish(created.getId(),
new PublishDraftRequest("1.4.0", "added optional description"));
assertThat(published.getStatus()).isEqualTo(SchemaDraftStatus.PUBLISHED);
assertThat(published.getPublishedVersion()).isEqualTo("1.5.0");
assertThat(def.getSchemaVersion()).isEqualTo("1.5.0");
assertThat(def.getSchemaJson()).isEqualTo(newSchema);
assertThat(lastEventType.get()).isEqualTo("SchemaDraftPublished");
}
@Test
void publish_versionMismatch_throws409() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, ""));
assertThatThrownBy(() -> service.publish(created.getId(),
new PublishDraftRequest("1.3.0", "stale lock")))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("version_mismatch"));
}
@Test
void publish_notApproved_invalidTransition() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
// Skip submit + approve still in DRAFT state.
assertThatThrownBy(() -> service.publish(created.getId(),
new PublishDraftRequest("1.4.0", "skip queue")))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("invalid_transition"));
}
@Test
void publish_breakingChange_bumpsMajor() throws Exception {
// Old schema has required 'name' of type 'string'. New schema changes type number.
JsonNode breaking = OM.readTree(
"{\"type\":\"object\",\"properties\":{\"name\":{\"type\":\"number\"}}}");
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "type change", breaking));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.APPROVE, "ok"));
var published = service.publish(created.getId(),
new PublishDraftRequest("1.4.0", "breaking type change"));
assertThat(published.getPublishedVersion()).isEqualTo("2.0.0");
}
// withdraw()
@Test
void withdraw_happyPath() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
var withdrawn = service.withdraw(created.getId());
assertThat(withdrawn.getStatus()).isEqualTo(SchemaDraftStatus.WITHDRAWN);
assertThat(lastEventType.get()).isEqualTo("SchemaDraftWithdrawn");
}
@Test
void withdraw_terminalStatus_invalidTransition() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
service.submitForReview(created.getId(), new SubmitForReviewRequest(null, null));
setUser("bob");
service.decide(created.getId(), new DecisionRequest(DecisionRequest.Verdict.REJECT, "no"));
setUser("alice");
assertThatThrownBy(() -> service.withdraw(created.getId()))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("invalid_transition"));
}
@Test
void withdraw_nonMaker_forbidden() throws Exception {
var created = service.create("test_dict",
new CreateSchemaDraftRequest("1.4.0", "r", OM.readTree("{}")));
setUser("bob");
assertThatThrownBy(() -> service.withdraw(created.getId()))
.isInstanceOf(OrdinisException.class)
.satisfies(e -> assertThat(((OrdinisException) e).getCode())
.isEqualTo("maker_only"));
}
// helpers
private static void setUser(String username) {
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(username, "n/a", List.of()));
}
/** ObjectProvider stub — нужен ctor'у OutboxRecorder; всегда возвращает null tracer. */
private static class StubTracerProvider
implements org.springframework.beans.factory.ObjectProvider<io.micrometer.tracing.Tracer> {
@Override public io.micrometer.tracing.Tracer getObject() { return null; }
@Override public io.micrometer.tracing.Tracer getObject(Object... args) { return null; }
@Override public io.micrometer.tracing.Tracer getIfAvailable() { return null; }
@Override public io.micrometer.tracing.Tracer getIfUnique() { return null; }
}
/** In-memory stub repo with EXCLUDE-like active-draft constraint. */
private static class StubSchemaDraftRepo implements SchemaDraftRepository {
private final java.util.Map<UUID, DictionarySchemaDraft> store = new java.util.LinkedHashMap<>();
@Override
public <S extends DictionarySchemaDraft> 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<DictionarySchemaDraft> findById(UUID id) {
return Optional.ofNullable(store.get(id));
}
@Override
public Optional<DictionarySchemaDraft> findActiveByDictionary(UUID dictionaryId) {
return store.values().stream()
.filter(d -> d.getDictionaryId().equals(dictionaryId) && d.getStatus().isActive())
.findFirst();
}
@Override
public List<DictionarySchemaDraft> findByDictionary(UUID dictionaryId) {
return store.values().stream()
.filter(d -> d.getDictionaryId().equals(dictionaryId))
.toList();
}
@Override
public Page<DictionarySchemaDraft> findPendingReview(Pageable pageable) {
var list = store.values().stream()
.filter(d -> d.getStatus() == SchemaDraftStatus.REVIEW_PENDING)
.toList();
return new PageImpl<>(list, pageable, list.size());
}
@Override
public Page<DictionarySchemaDraft> findByMaker(String makerId, Pageable pageable) {
var list = store.values().stream()
.filter(d -> d.getMakerId().equals(makerId))
.toList();
return new PageImpl<>(list, pageable, list.size());
}
// Unused JpaRepository methods stubbed.
@Override public DictionarySchemaDraft saveAndFlush(DictionarySchemaDraft d) { return save(d); }
@Override public <S extends DictionarySchemaDraft> List<S> saveAll(Iterable<S> entities) {
List<S> out = new ArrayList<>();
for (S e : entities) out.add(save(e));
return out;
}
@Override public <S extends DictionarySchemaDraft> List<S> saveAllAndFlush(Iterable<S> entities) { return saveAll(entities); }
@Override public List<DictionarySchemaDraft> findAll() { return store.values().stream().toList(); }
@Override public List<DictionarySchemaDraft> findAll(Sort sort) { return findAll(); }
@Override public Page<DictionarySchemaDraft> findAll(Pageable pageable) { throw new UnsupportedOperationException(); }
@Override public List<DictionarySchemaDraft> findAllById(Iterable<UUID> ids) { throw new UnsupportedOperationException(); }
@Override public long count() { return store.size(); }
@Override public void deleteById(UUID id) { store.remove(id); }
@Override public void delete(DictionarySchemaDraft entity) { store.remove(entity.getId()); }
@Override public void deleteAllById(Iterable<? extends UUID> ids) { ids.forEach(store::remove); }
@Override public void deleteAll(Iterable<? extends DictionarySchemaDraft> entities) { entities.forEach(this::delete); }
@Override public void deleteAll() { store.clear(); }
@Override public boolean existsById(UUID id) { return store.containsKey(id); }
@Override public void flush() {}
@Override public void deleteAllInBatch(Iterable<DictionarySchemaDraft> entities) { deleteAll(entities); }
@Override public void deleteAllByIdInBatch(Iterable<UUID> ids) { deleteAllById(ids); }
@Override public void deleteAllInBatch() { deleteAll(); }
@Override public DictionarySchemaDraft getOne(UUID id) { return store.get(id); }
@Override public DictionarySchemaDraft getById(UUID id) { return store.get(id); }
@Override public DictionarySchemaDraft getReferenceById(UUID id) { return store.get(id); }
@Override public <S extends DictionarySchemaDraft> List<S> findAll(Example<S> ex) { throw new UnsupportedOperationException(); }
@Override public <S extends DictionarySchemaDraft> List<S> findAll(Example<S> ex, Sort s) { throw new UnsupportedOperationException(); }
@Override public <S extends DictionarySchemaDraft> Page<S> findAll(Example<S> ex, Pageable p) { throw new UnsupportedOperationException(); }
@Override public <S extends DictionarySchemaDraft> long count(Example<S> ex) { return 0; }
@Override public <S extends DictionarySchemaDraft> boolean exists(Example<S> ex) { return false; }
@Override public <S extends DictionarySchemaDraft> Optional<S> findOne(Example<S> ex) { return Optional.empty(); }
@Override public <S extends DictionarySchemaDraft, R> R findBy(Example<S> ex, Function<FluentQuery.FetchableFluentQuery<S>, R> fn) { throw new UnsupportedOperationException(); }
}
}