From 01fc5771f28a750573c811ba481ac023464bbc1d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B0=D0=BD=D0=B4=D1=80=20?= =?UTF-8?q?=D0=97=D0=B8=D0=BC=D0=B8=D0=BD?= Date: Mon, 11 May 2026 23:11:56 +0000 Subject: [PATCH] =?UTF-8?q?feat(api):=20schema=20workflow=20API=20?= =?UTF-8?q?=E2=80=94=20backend=20#3=20(drafts/review/decision/publish)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../schemaworkflow/DictionarySchemaDraft.java | 179 +++++++ .../schemaworkflow/SchemaDraftRepository.java | 58 +++ .../schemaworkflow/SchemaDraftStatus.java | 69 +++ .../changes/0022-schema-workflow.xml | 140 +++++ .../main/resources/db/changelog/master.xml | 1 + .../CreateSchemaDraftRequest.java | 29 ++ .../dto/schemaworkflow/DecisionRequest.java | 31 ++ .../schemaworkflow/PublishDraftRequest.java | 24 + .../schemaworkflow/SchemaDraftResponse.java | 69 +++ .../SubmitForReviewRequest.java | 22 + .../schemaworkflow/SchemaDraftService.java | 456 +++++++++++++++++ .../restapi/web/SchemaDraftController.java | 196 +++++++ .../SchemaDraftServiceTest.java | 478 ++++++++++++++++++ 13 files changed, 1752 insertions(+) create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/DictionarySchemaDraft.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftRepository.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftStatus.java create mode 100644 ordinis-migrations/src/main/resources/db/changelog/changes/0022-schema-workflow.xml create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/CreateSchemaDraftRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/DecisionRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/PublishDraftRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SchemaDraftResponse.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SubmitForReviewRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftServiceTest.java diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/DictionarySchemaDraft.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/DictionarySchemaDraft.java new file mode 100644 index 0000000..37b739f --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/DictionarySchemaDraft.java @@ -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 (а не отдельной записи). + * + *

Lifecycle (см. {@link SchemaDraftStatus}): + * draft → review_pending → {approved, changes_requested, rejected, withdrawn} + * → published / withdrawn. + * + *

Один active draft per dict (Liquibase 0022 EXCLUDE constraint + * {@code schema_drafts_one_active_per_dict}) — schema changes coarse-grained, + * параллельное редактирование не имеет смысла. + * + *

{@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; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftRepository.java new file mode 100644 index 0000000..0b56091 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftRepository.java @@ -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. + * + *

Все queries фильтруют по {@code statusDb} string (lowercase в БД) — + * см. {@link DictionarySchemaDraft} комментарий про status mapping. + * Поэтому передаём DB-value напрямую (через + * {@link SchemaDraftStatus#dbValue()}), не enum constant. + */ +public interface SchemaDraftRepository extends JpaRepository { + + /** + * 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 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 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 findPendingReview(Pageable pageable); + + /** Maker's own drafts (любой status). */ + @Query(""" + SELECT d FROM DictionarySchemaDraft d + WHERE d.makerId = :makerId + ORDER BY d.createdAt DESC + """) + Page findByMaker(@Param("makerId") String makerId, Pageable pageable); +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftStatus.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftStatus.java new file mode 100644 index 0000000..035f9e8 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/schemaworkflow/SchemaDraftStatus.java @@ -0,0 +1,69 @@ +package cloud.nstart.terravault.ordinis.domain.schemaworkflow; + +/** + * Schema workflow draft state machine. + * + *

Параллель к record-level {@link cloud.nstart.terravault.ordinis.domain.draft.DraftStatus}, + * но для schema-level changes (один draft = одно proposed schema state). + * + *

Transitions

+ *
+ *   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)
+ * 
+ * + *

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()); + } +} diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0022-schema-workflow.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0022-schema-workflow.xml new file mode 100644 index 0000000..f06a89f --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0022-schema-workflow.xml @@ -0,0 +1,140 @@ + + + + + + + dictionary_schema_drafts — schema-level approval workflow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE dictionary_schema_drafts + ADD CONSTRAINT schema_drafts_status_check + CHECK (status IN ('draft', 'review_pending', 'changes_requested', + 'approved', 'rejected', 'published', 'withdrawn')); + + + + + ALTER TABLE dictionary_schema_drafts + ADD CONSTRAINT schema_drafts_no_self_approve + CHECK (decision_user_id IS NULL OR maker_id != decision_user_id); + + + + + 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')); + + + + + + + + + + + + + + + + + + + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index c83b3d8..9ce0d6f 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -31,5 +31,6 @@ + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/CreateSchemaDraftRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/CreateSchemaDraftRequest.java new file mode 100644 index 0000000..bf2216d --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/CreateSchemaDraftRequest.java @@ -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. + * + *

Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts}. + * + *

{@code fromVersion} обязателен и сверяется с текущей + * {@code DictionaryDefinition.schemaVersion} — если расходится → 409 + * ({@code version_mismatch}, кто-то ещё опубликовал свой draft пока этот + * пользователь готовился). + * + *

{@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) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/DecisionRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/DecisionRequest.java new file mode 100644 index 0000000..25bc570 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/DecisionRequest.java @@ -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. + * + *

Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/decision}. + * + *

Transitions: + *

+ * + *

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 + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/PublishDraftRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/PublishDraftRequest.java new file mode 100644 index 0000000..9ffc129 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/PublishDraftRequest.java @@ -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. + * + *

Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/publish}. + * + *

{@code expectedHeadVersion} — optimistic lock против текущего + * {@code DictionaryDefinition.schemaVersion}. Если кто-то другой опубликовал + * свой draft пока этот approved'ил → 409 ({@code version_mismatch}, refetch + * + re-publish). + * + *

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) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SchemaDraftResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SchemaDraftResponse.java new file mode 100644 index 0000000..4a29c8e --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SchemaDraftResponse.java @@ -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); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SubmitForReviewRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SubmitForReviewRequest.java new file mode 100644 index 0000000..d589ee9 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/schemaworkflow/SubmitForReviewRequest.java @@ -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. + * + *

Endpoint: {@code POST /api/v1/dictionaries/{name}/drafts/{draftId}/review}. + * + *

{@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 reviewers, + /** Optional maker note для reviewer (что важно проверить). */ + String note) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java new file mode 100644 index 0000000..d958c91 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftService.java @@ -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. + * + *

State machine: см. {@link SchemaDraftStatus}. + * + *

Endpoints (см. {@link cloud.nstart.terravault.ordinis.restapi.web.SchemaDraftController}): + *

+ * + *

Все mutator-методы emit'ят outbox event ({@code SchemaDraft*}) и audit + * (через {@link DictionaryDefinitionService} updateSchema path для publish). + * + *

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 listByDictionary(String dictionaryName) { + DictionaryDefinition def = definitionService.findByName(dictionaryName); + return repository.findByDictionary(def.getId()); + } + + @Transactional(readOnly = true) + public java.util.Optional findActive(String dictionaryName) { + DictionaryDefinition def = definitionService.findByName(dictionaryName); + return repository.findActiveByDictionary(def.getId()); + } + + @Transactional(readOnly = true) + public Page listPendingReview(Pageable pageable) { + return repository.findPendingReview(pageable); + } + + // ───────────────────────────────────────────────────────────────────── + // Mutators (state machine transitions) + // ───────────────────────────────────────────────────────────────────── + + /** + * Maker создаёт draft от текущего HEAD schema. + * + *

+ */ + @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 после доработки). + * + *

Только 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. + * + *

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). + * + *

Optimistic lock: {@code expectedHeadVersion} проверяется против + * {@code definition.schemaVersion}. Если другой draft опубликовался + * первым → 409 ({@code version_mismatch}). + * + *

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. + * + *

Сейчас простая эвристика — 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 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 extras) { + Map 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(); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java new file mode 100644 index 0000000..dc72d31 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/SchemaDraftController.java @@ -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). + * + *

Routes: + *

+ *  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)
+ * 
+ * + *

{@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. + *

+ */ + @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). + * + *

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). + *

+ */ + @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. + * + *

Bump'ит schema version (minor default, major на breaking changes). + *

+ */ + @PostMapping("/api/v1/dictionaries/{name}/drafts/{draftId}/publish") + public Map 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 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 globalQueue( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + var pg = draftService.listPendingReview(PageRequest.of(page, Math.min(size, 200))); + List 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()); + } +} diff --git a/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftServiceTest.java new file mode 100644 index 0000000..fe416fb --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/schemaworkflow/SchemaDraftServiceTest.java @@ -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}. + * + *

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 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 stub — always returns null чтобы skip tracing path в record(). + org.springframework.beans.factory.ObjectProvider 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 { + @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 store = new java.util.LinkedHashMap<>(); + + @Override + public 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 findById(UUID id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findActiveByDictionary(UUID dictionaryId) { + return store.values().stream() + .filter(d -> d.getDictionaryId().equals(dictionaryId) && d.getStatus().isActive()) + .findFirst(); + } + + @Override + public List findByDictionary(UUID dictionaryId) { + return store.values().stream() + .filter(d -> d.getDictionaryId().equals(dictionaryId)) + .toList(); + } + + @Override + public Page 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 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 List saveAll(Iterable entities) { + List out = new ArrayList<>(); + for (S e : entities) out.add(save(e)); + return out; + } + @Override public List saveAllAndFlush(Iterable entities) { return saveAll(entities); } + @Override public List findAll() { return store.values().stream().toList(); } + @Override public List findAll(Sort sort) { return findAll(); } + @Override public Page findAll(Pageable pageable) { throw new UnsupportedOperationException(); } + @Override public List findAllById(Iterable 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 ids) { ids.forEach(store::remove); } + @Override public void deleteAll(Iterable 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 entities) { deleteAll(entities); } + @Override public void deleteAllByIdInBatch(Iterable 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 List findAll(Example ex) { throw new UnsupportedOperationException(); } + @Override public List findAll(Example ex, Sort s) { throw new UnsupportedOperationException(); } + @Override public Page findAll(Example ex, Pageable p) { throw new UnsupportedOperationException(); } + @Override public long count(Example ex) { return 0; } + @Override public boolean exists(Example ex) { return false; } + @Override public Optional findOne(Example ex) { return Optional.empty(); } + @Override public R findBy(Example ex, Function, R> fn) { throw new UnsupportedOperationException(); } + } +}