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