feat(api): schema workflow API — backend #3 (drafts/review/decision/publish)
This commit is contained in:
+179
@@ -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; }
|
||||
}
|
||||
+58
@@ -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);
|
||||
}
|
||||
+69
@@ -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>
|
||||
|
||||
+29
@@ -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) {}
|
||||
+31
@@ -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
|
||||
}
|
||||
}
|
||||
+24
@@ -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) {}
|
||||
+69
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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) {}
|
||||
+456
@@ -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();
|
||||
}
|
||||
}
|
||||
+196
@@ -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());
|
||||
}
|
||||
}
|
||||
+478
@@ -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(); }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user