Merge branch 'feat/schema-workflow-api' into 'main'
feat(api): schema workflow API — backend #3 (drafts/review/decision/publish) See merge request 2-6/2-6-4/terravault/ordinis!113
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/0019-approval-workflow-v2.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0020-fix-draft-status-case.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/0021-notifications.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0022-schema-workflow.xml" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</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