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

This commit is contained in:
Александр Зимин
2026-05-11 23:11:56 +00:00
parent 61e4cf952e
commit 01fc5771f2
13 changed files with 1752 additions and 0 deletions
@@ -0,0 +1,179 @@
package cloud.nstart.terravault.ordinis.domain.schemaworkflow;
import com.fasterxml.jackson.databind.JsonNode;
import io.hypersistence.utils.hibernate.type.json.JsonType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EntityListeners;
import jakarta.persistence.Id;
import jakarta.persistence.PrePersist;
import jakarta.persistence.PreUpdate;
import jakarta.persistence.Table;
import jakarta.persistence.Version;
import org.hibernate.annotations.Type;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* Dictionary schema-level approval workflow draft. Параллель к
* {@link cloud.nstart.terravault.ordinis.domain.draft.RecordDraft}, но
* proposes изменение SCHEMA (а не отдельной записи).
*
* <p>Lifecycle (см. {@link SchemaDraftStatus}):
* draft → review_pending → {approved, changes_requested, rejected, withdrawn}
* → published / withdrawn.
*
* <p>Один active draft per dict (Liquibase 0022 EXCLUDE constraint
* {@code schema_drafts_one_active_per_dict}) — schema changes coarse-grained,
* параллельное редактирование не имеет смысла.
*
* <p>{@code status} хранится lowercase в БД (см. migration check constraint
* — 'draft', 'review_pending', etc), поэтому используем String column +
* runtime convert через {@link SchemaDraftStatus#dbValue()} /
* {@link SchemaDraftStatus#fromDbValue(String)} вместо EnumType.STRING.
* Это match'ит naming convention из 0019/0020 record_drafts.
*/
@Entity
@Table(name = "dictionary_schema_drafts")
@EntityListeners(AuditingEntityListener.class)
public class DictionarySchemaDraft {
@Id
@Column(name = "id", nullable = false, updatable = false)
private UUID id;
@Column(name = "dictionary_id", nullable = false)
private UUID dictionaryId;
/** Полное proposed schema content (JSON Schema document). */
@Type(JsonType.class)
@Column(name = "proposed_schema", columnDefinition = "jsonb", nullable = false)
private JsonNode proposedSchema;
/** Live HEAD schema version, от которого ответвлён этот draft. */
@Column(name = "branched_from_version", nullable = false, length = 32)
private String branchedFromVersion;
@Column(name = "reason", columnDefinition = "TEXT")
private String reason;
/**
* Назначенные reviewer'ы (JSONB array of user IDs). NULL = open pool —
* любой user с approve role может decide. Set только когда maker явно
* передаёт {@code reviewers} в submit-for-review request.
*/
@Type(JsonType.class)
@Column(name = "reviewer_assignments", columnDefinition = "jsonb")
private JsonNode reviewerAssignments;
@Column(name = "review_note", columnDefinition = "TEXT")
private String reviewNote;
/** Status хранится lowercase. Get/set через {@link #getStatus()} / {@link #setStatus(SchemaDraftStatus)}. */
@Column(name = "status", nullable = false, length = 24)
private String statusDb;
@Column(name = "maker_id", nullable = false, length = 128, updatable = false)
private String makerId;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "submitted_at")
private OffsetDateTime submittedAt;
@Column(name = "decision_user_id", length = 128)
private String decisionUserId;
@Column(name = "decision_at")
private OffsetDateTime decisionAt;
@Column(name = "decision_comment", columnDefinition = "TEXT")
private String decisionComment;
@Column(name = "published_at")
private OffsetDateTime publishedAt;
@Column(name = "published_version", length = 32)
private String publishedVersion;
@Column(name = "publish_note", columnDefinition = "TEXT")
private String publishNote;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@Version
@Column(name = "version")
private Long version;
protected DictionarySchemaDraft() {}
public DictionarySchemaDraft(
UUID id,
UUID dictionaryId,
JsonNode proposedSchema,
String branchedFromVersion,
String makerId,
String reason) {
this.id = id;
this.dictionaryId = dictionaryId;
this.proposedSchema = proposedSchema;
this.branchedFromVersion = branchedFromVersion;
this.makerId = makerId;
this.reason = reason;
this.statusDb = SchemaDraftStatus.DRAFT.dbValue();
var now = OffsetDateTime.now();
this.createdAt = now;
this.updatedAt = now;
}
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
if (updatedAt == null) updatedAt = createdAt;
if (statusDb == null) statusDb = SchemaDraftStatus.DRAFT.dbValue();
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
public UUID getId() { return id; }
public UUID getDictionaryId() { return dictionaryId; }
public JsonNode getProposedSchema() { return proposedSchema; }
public String getBranchedFromVersion() { return branchedFromVersion; }
public String getReason() { return reason; }
public JsonNode getReviewerAssignments() { return reviewerAssignments; }
public String getReviewNote() { return reviewNote; }
public SchemaDraftStatus getStatus() { return SchemaDraftStatus.fromDbValue(statusDb); }
public void setStatus(SchemaDraftStatus s) { this.statusDb = s.dbValue(); }
public String getMakerId() { return makerId; }
public OffsetDateTime getCreatedAt() { return createdAt; }
public OffsetDateTime getSubmittedAt() { return submittedAt; }
public String getDecisionUserId() { return decisionUserId; }
public OffsetDateTime getDecisionAt() { return decisionAt; }
public String getDecisionComment() { return decisionComment; }
public OffsetDateTime getPublishedAt() { return publishedAt; }
public String getPublishedVersion() { return publishedVersion; }
public String getPublishNote() { return publishNote; }
public OffsetDateTime getUpdatedAt() { return updatedAt; }
public Long getVersion() { return version; }
public void setProposedSchema(JsonNode v) { this.proposedSchema = v; }
public void setReason(String v) { this.reason = v; }
public void setReviewerAssignments(JsonNode v) { this.reviewerAssignments = v; }
public void setReviewNote(String v) { this.reviewNote = v; }
public void setSubmittedAt(OffsetDateTime v) { this.submittedAt = v; }
public void setDecisionUserId(String v) { this.decisionUserId = v; }
public void setDecisionAt(OffsetDateTime v) { this.decisionAt = v; }
public void setDecisionComment(String v) { this.decisionComment = v; }
public void setPublishedAt(OffsetDateTime v) { this.publishedAt = v; }
public void setPublishedVersion(String v) { this.publishedVersion = v; }
public void setPublishNote(String v) { this.publishNote = v; }
}
@@ -0,0 +1,58 @@
package cloud.nstart.terravault.ordinis.domain.schemaworkflow;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
/**
* Spring Data repository для schema workflow drafts.
*
* <p>Все queries фильтруют по {@code statusDb} string (lowercase в БД) —
* см. {@link DictionarySchemaDraft} комментарий про status mapping.
* Поэтому передаём DB-value напрямую (через
* {@link SchemaDraftStatus#dbValue()}), не enum constant.
*/
public interface SchemaDraftRepository extends JpaRepository<DictionarySchemaDraft, UUID> {
/**
* Active (non-terminal) draft для dict — most важный lookup для UI banner
* "у этого dict'а есть open draft, такой-то reviewer должен решить".
* Возвращает Optional т.к. EXCLUDE constraint гарантирует max 1.
*/
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.dictionaryId = :dictionaryId
AND d.statusDb IN ('draft', 'review_pending', 'changes_requested', 'approved')
""")
Optional<DictionarySchemaDraft> findActiveByDictionary(@Param("dictionaryId") UUID dictionaryId);
/** All drafts for a dictionary, sorted newest-first. */
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.dictionaryId = :dictionaryId
ORDER BY d.createdAt DESC
""")
List<DictionarySchemaDraft> findByDictionary(@Param("dictionaryId") UUID dictionaryId);
/** Global review queue (status=review_pending) для pool reviewer'ов. */
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.statusDb = 'review_pending'
ORDER BY d.submittedAt ASC
""")
Page<DictionarySchemaDraft> findPendingReview(Pageable pageable);
/** Maker's own drafts (любой status). */
@Query("""
SELECT d FROM DictionarySchemaDraft d
WHERE d.makerId = :makerId
ORDER BY d.createdAt DESC
""")
Page<DictionarySchemaDraft> findByMaker(@Param("makerId") String makerId, Pageable pageable);
}
@@ -0,0 +1,69 @@
package cloud.nstart.terravault.ordinis.domain.schemaworkflow;
/**
* Schema workflow draft state machine.
*
* <p>Параллель к record-level {@link cloud.nstart.terravault.ordinis.domain.draft.DraftStatus},
* но для schema-level changes (один draft = одно proposed schema state).
*
* <h3>Transitions</h3>
* <pre>
* DRAFT — maker создал, ещё не submit'нул
* → REVIEW_PENDING (submit for review)
* → WITHDRAWN (maker discards)
*
* REVIEW_PENDING — ждёт reviewer decision
* → APPROVED (reviewer.decision=approve)
* → CHANGES_REQUESTED (reviewer.decision=request_changes)
* → REJECTED (reviewer.decision=reject)
* → WITHDRAWN (maker recall)
*
* CHANGES_REQUESTED — maker должен доработать
* → REVIEW_PENDING (re-submit)
* → WITHDRAWN (maker abandons)
*
* APPROVED — готов к публикации
* → PUBLISHED (publisher publishes, bump version)
* → WITHDRAWN (maker withdraws even после approve, edge case)
*
* PUBLISHED — terminal (changes live)
* REJECTED — terminal (kept for audit)
* WITHDRAWN — terminal (kept for audit)
* </pre>
*
* <p>Strings в БД lowercase (см. migration 0022 check constraint), JPA
* {@code @Enumerated(EnumType.STRING)} mapping использует enum name — мы
* храним в БД lowercase напрямую через
* {@link com.fasterxml.jackson.annotation.JsonValue}-like преобразование
* в Spring Data converter (см. также 0020 fix для record_drafts).
*/
public enum SchemaDraftStatus {
DRAFT,
REVIEW_PENDING,
CHANGES_REQUESTED,
APPROVED,
REJECTED,
PUBLISHED,
WITHDRAWN;
/** Terminal статусы — нельзя transition'нуть дальше. */
public boolean isTerminal() {
return this == PUBLISHED || this == REJECTED || this == WITHDRAWN;
}
/** Активный (non-terminal) — пользователь видит draft, может действовать. */
public boolean isActive() {
return !isTerminal();
}
/** Lowercase representation for DB column storage. */
public String dbValue() {
return name().toLowerCase();
}
/** Inverse of {@link #dbValue()} — parse from DB string. */
public static SchemaDraftStatus fromDbValue(String s) {
if (s == null) return null;
return SchemaDraftStatus.valueOf(s.toUpperCase());
}
}