From e438c4c8c891d64631216f9ca57e82ac18eeae3d Mon Sep 17 00:00:00 2001 From: "Zimin A.N." Date: Fri, 8 May 2026 13:08:21 +0300 Subject: [PATCH] =?UTF-8?q?feat(approval):=20Approval=20Workflow=20v2=20Ph?= =?UTF-8?q?ase=200=20=E2=80=94=20schema=20+=20skeleton=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approval Workflow v2 epic, Phase 0 foundation. Maker-checker pattern для критичных справочников через record_drafts отдельную таблицу (Approach A из design doc). Decisions made (auto, per design doc recommendations): - D1=A: per-dict policy через колонки на dictionary_definitions (approval_required + approval_min_role). - D2=A: legacy POST на approval-required dict вернёт 409 (Phase 1 wire'ит в DictionaryRecordService). - D3=A: pool model для reviewer'ов — все ordinis:approver видят queue. - D4=A: rejected drafts kept (status='rejected'); compliance audit trail. Migration 0019: - record_drafts table: * id, dictionary_id, business_key, parent_record_id, operation * data (jsonb), geometry, proposed_valid_from/to * status, maker_id, maker_comment, submitted_at * reviewer_id, reviewed_at, review_comment, version - 4 constraints: * status_check: pending/approved/rejected/withdrawn * operation_check: CREATE/UPDATE/CLOSE * no_self_approve: maker_id != reviewer_id * one_pending_per_key: EXCLUDE WHERE status=pending (concurrent edits → последний wins на submit, prevents merge conflicts) - 3 indices: pending_by_dict, by_maker, pending_global. - dictionary_definitions: +approval_required (BOOLEAN, default false), +approval_min_role (VARCHAR 64, optional Keycloak role). Backend: - Entity RecordDraft + Repository с JPQL queries для: * findPendingByKey (single pending lookup) * findPending (global queue для approver pool, paginated) * findPendingByDictionary (per-dict list) * findByMaker (own drafts tracking, paginated) - DTO SubmitDraftRequest, DraftResponse. - DraftService skeleton: * submit() — creates pending draft. 409 на exclusion violation. * findById, listPendingByDictionary, listPendingGlobal — read-only. * approve / reject / withdraw / updateDraft — Phase 1 stubs (комментарии). - DraftController с 4 endpoints: * POST /api/v1/dictionaries/{name}/records/drafts — submit * GET /api/v1/dictionaries/{name}/records/drafts — list per dict * GET /api/v1/drafts/{id} — single draft * GET /api/v1/admin/reviews/pending — global queue (paginated) Tests: - DraftServiceTest 6 unit tests: * submit creates with PENDING status * 409 на duplicate pending business_key (exclusion constraint) * invalid WKT → 400 * dict not found → 404 * findById unknown → 404 * listPendingByDictionary returns multiple pending - StubDraftRepo simulates DB exclusion constraint в memory. Verify: - mvn -pl ordinis-rest-api -am test: 112/112 PASS (+6 new). - mvn -P e2e -pl ordinis-app -am test: 20/20 PASS — migration 0019 applies clean, no regression. - helm/yaml configs не трогали — Phase 0 чистый schema + code. Phase 1 (next session) — full lifecycle: - DraftService.approve(): SERIALIZABLE tx с COPY draft → live + outbox RecordCreated/Updated/Deleted event + audit. Validate against schema ON APPROVE (могла schema измениться с момента submit'а). - reject() — mark status=rejected, kept (D4). - withdraw() — maker отзывает свой pending. - DictionaryRecordService.create/update/close: pre-check approval_required → 409 draft_required. JWT subject → maker_id. - Reviewer queue + diff viewer admin UI. Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md --- .../definition/DictionaryDefinition.java | 20 ++ .../ordinis/domain/draft/DraftOperation.java | 8 + .../ordinis/domain/draft/DraftStatus.java | 19 ++ .../ordinis/domain/draft/RecordDraft.java | 137 ++++++++++ .../domain/draft/RecordDraftRepository.java | 50 ++++ .../changes/0019-approval-workflow-v2.xml | 126 +++++++++ .../main/resources/db/changelog/master.xml | 1 + .../restapi/dto/draft/DraftResponse.java | 52 ++++ .../restapi/dto/draft/SubmitDraftRequest.java | 25 ++ .../restapi/service/draft/DraftService.java | 157 +++++++++++ .../ordinis/restapi/web/DraftController.java | 82 ++++++ .../service/draft/DraftServiceTest.java | 253 ++++++++++++++++++ 12 files changed, 930 insertions(+) create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftOperation.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftStatus.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraft.java create mode 100644 ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java create mode 100644 ordinis-migrations/src/main/resources/db/changelog/changes/0019-approval-workflow-v2.xml create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/DraftResponse.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/SubmitDraftRequest.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java create mode 100644 ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java create mode 100644 ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java index 6870117..7974cc7 100644 --- a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/definition/DictionaryDefinition.java @@ -76,6 +76,22 @@ public class DictionaryDefinition { @Column(name = "redis_projection_enabled", nullable = false) private boolean redisProjectionEnabled = false; + /** + * Per-dictionary approval workflow opt-in (Approval v2 epic, D1=A). + * False (default) — direct CRUD continues. True — все mutations должны + * пройти через draft → review → approve flow в record_drafts table. + */ + @Column(name = "approval_required", nullable = false) + private boolean approvalRequired = false; + + /** + * Optional минимальная Keycloak роль для reviewer'а на этот dict. + * Например {@code "ordinis:restricted"} для критичных справочников. + * NULL = любой пользователь с ролью {@code ordinis:approver}. + */ + @Column(name = "approval_min_role", length = 64) + private String approvalMinRole; + @CreatedDate @Column(name = "created_at", nullable = false, updatable = false) private OffsetDateTime createdAt; @@ -116,6 +132,8 @@ public class DictionaryDefinition { public String[] getSupportedLocales() { return supportedLocales; } public String getDefaultLocale() { return defaultLocale; } public boolean isRedisProjectionEnabled() { return redisProjectionEnabled; } + public boolean isApprovalRequired() { return approvalRequired; } + public String getApprovalMinRole() { return approvalMinRole; } public OffsetDateTime getCreatedAt() { return createdAt; } public OffsetDateTime getUpdatedAt() { return updatedAt; } public String getCreatedBy() { return createdBy; } @@ -130,4 +148,6 @@ public class DictionaryDefinition { public void setSupportedLocales(String[] v) { this.supportedLocales = v; } public void setDefaultLocale(String v) { this.defaultLocale = v; } public void setRedisProjectionEnabled(boolean v) { this.redisProjectionEnabled = v; } + public void setApprovalRequired(boolean v) { this.approvalRequired = v; } + public void setApprovalMinRole(String v) { this.approvalMinRole = v; } } diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftOperation.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftOperation.java new file mode 100644 index 0000000..49e06c8 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftOperation.java @@ -0,0 +1,8 @@ +package cloud.nstart.terravault.ordinis.domain.draft; + +/** What kind of mutation maker proposes. */ +public enum DraftOperation { + CREATE, + UPDATE, + CLOSE +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftStatus.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftStatus.java new file mode 100644 index 0000000..3c3e2cf --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/DraftStatus.java @@ -0,0 +1,19 @@ +package cloud.nstart.terravault.ordinis.domain.draft; + +/** + * Approval workflow draft state machine (v2). + * + * + */ +public enum DraftStatus { + PENDING, + APPROVED, + REJECTED, + WITHDRAWN +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraft.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraft.java new file mode 100644 index 0000000..b3b25a6 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraft.java @@ -0,0 +1,137 @@ +package cloud.nstart.terravault.ordinis.domain.draft; + +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.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import org.hibernate.annotations.Type; +import org.locationtech.jts.geom.Geometry; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.OffsetDateTime; +import java.util.UUID; + +/** + * Approval workflow draft (v2 epic). Sits в DRAFT layer — не виден existing + * read endpoints (`/dictionaries/{name}/records`) до approve'а. Approved + * drafts COPY'ятся в {@link cloud.nstart.terravault.ordinis.domain.record.DictionaryRecord} + * + delete'ятся (либо kept для status=approved log; design TBD Phase 1). + * + *

Constraints в БД (см. Liquibase 0019): + *

+ */ +@Entity +@Table(name = "record_drafts") +@EntityListeners(AuditingEntityListener.class) +public class RecordDraft { + + @Id + @Column(name = "id", nullable = false, updatable = false) + private UUID id; + + @Column(name = "dictionary_id", nullable = false) + private UUID dictionaryId; + + @Column(name = "business_key", nullable = false, length = 256) + private String businessKey; + + /** NULL для CREATE; точка истории live record для UPDATE/CLOSE. */ + @Column(name = "parent_record_id") + private UUID parentRecordId; + + @Enumerated(EnumType.STRING) + @Column(name = "operation", nullable = false, length = 16) + private DraftOperation operation; + + @Type(JsonType.class) + @Column(name = "data", columnDefinition = "jsonb") + private JsonNode data; + + @Column(name = "geometry", columnDefinition = "geometry(Geometry, 4326)") + private Geometry geometry; + + @Column(name = "proposed_valid_from") + private OffsetDateTime proposedValidFrom; + + @Column(name = "proposed_valid_to") + private OffsetDateTime proposedValidTo; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 16) + private DraftStatus status = DraftStatus.PENDING; + + @Column(name = "maker_id", nullable = false, length = 128, updatable = false) + private String makerId; + + @Column(name = "maker_comment", columnDefinition = "TEXT") + private String makerComment; + + @Column(name = "submitted_at", nullable = false, updatable = false) + private OffsetDateTime submittedAt; + + @Column(name = "reviewer_id", length = 128) + private String reviewerId; + + @Column(name = "reviewed_at") + private OffsetDateTime reviewedAt; + + @Column(name = "review_comment", columnDefinition = "TEXT") + private String reviewComment; + + @Version + @Column(name = "version") + private Long version; + + protected RecordDraft() {} + + public RecordDraft( + UUID id, UUID dictionaryId, String businessKey, + DraftOperation operation, String makerId) { + this.id = id; + this.dictionaryId = dictionaryId; + this.businessKey = businessKey; + this.operation = operation; + this.makerId = makerId; + this.submittedAt = OffsetDateTime.now(); + } + + public UUID getId() { return id; } + public UUID getDictionaryId() { return dictionaryId; } + public String getBusinessKey() { return businessKey; } + public UUID getParentRecordId() { return parentRecordId; } + public DraftOperation getOperation() { return operation; } + public JsonNode getData() { return data; } + public Geometry getGeometry() { return geometry; } + public OffsetDateTime getProposedValidFrom() { return proposedValidFrom; } + public OffsetDateTime getProposedValidTo() { return proposedValidTo; } + public DraftStatus getStatus() { return status; } + public String getMakerId() { return makerId; } + public String getMakerComment() { return makerComment; } + public OffsetDateTime getSubmittedAt() { return submittedAt; } + public String getReviewerId() { return reviewerId; } + public OffsetDateTime getReviewedAt() { return reviewedAt; } + public String getReviewComment() { return reviewComment; } + public Long getVersion() { return version; } + + public void setParentRecordId(UUID v) { this.parentRecordId = v; } + public void setData(JsonNode v) { this.data = v; } + public void setGeometry(Geometry v) { this.geometry = v; } + public void setProposedValidFrom(OffsetDateTime v) { this.proposedValidFrom = v; } + public void setProposedValidTo(OffsetDateTime v) { this.proposedValidTo = v; } + public void setStatus(DraftStatus v) { this.status = v; } + public void setMakerComment(String v) { this.makerComment = v; } + public void setReviewerId(String v) { this.reviewerId = v; } + public void setReviewedAt(OffsetDateTime v) { this.reviewedAt = v; } + public void setReviewComment(String v) { this.reviewComment = v; } +} diff --git a/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java new file mode 100644 index 0000000..5157665 --- /dev/null +++ b/ordinis-domain/src/main/java/cloud/nstart/terravault/ordinis/domain/draft/RecordDraftRepository.java @@ -0,0 +1,50 @@ +package cloud.nstart.terravault.ordinis.domain.draft; + +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; + +public interface RecordDraftRepository extends JpaRepository { + + /** Pending draft на business_key (only one — exclusion constraint). */ + @Query(""" + SELECT d FROM RecordDraft d + WHERE d.dictionaryId = :dictionaryId + AND d.businessKey = :businessKey + AND d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING + """) + Optional findPendingByKey( + @Param("dictionaryId") UUID dictionaryId, + @Param("businessKey") String businessKey); + + /** Global queue для approver pool (D3=A). */ + @Query(""" + SELECT d FROM RecordDraft d + WHERE d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING + ORDER BY d.submittedAt ASC + """) + Page findPending(Pageable pageable); + + /** Per-dictionary pending list. */ + @Query(""" + SELECT d FROM RecordDraft d + WHERE d.dictionaryId = :dictionaryId + AND d.status = cloud.nstart.terravault.ordinis.domain.draft.DraftStatus.PENDING + ORDER BY d.submittedAt ASC + """) + List findPendingByDictionary(@Param("dictionaryId") UUID dictionaryId); + + /** Drafts автора (любого status'а) — для UI tracking "что я отправил". */ + @Query(""" + SELECT d FROM RecordDraft d + WHERE d.makerId = :makerId + ORDER BY d.submittedAt DESC + """) + Page findByMaker(@Param("makerId") String makerId, Pageable pageable); +} diff --git a/ordinis-migrations/src/main/resources/db/changelog/changes/0019-approval-workflow-v2.xml b/ordinis-migrations/src/main/resources/db/changelog/changes/0019-approval-workflow-v2.xml new file mode 100644 index 0000000..0791c60 --- /dev/null +++ b/ordinis-migrations/src/main/resources/db/changelog/changes/0019-approval-workflow-v2.xml @@ -0,0 +1,126 @@ + + + + + + record_drafts table — DRAFT layer для maker-checker workflow + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ALTER TABLE record_drafts + ADD CONSTRAINT record_drafts_status_check + CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn')); + + + ALTER TABLE record_drafts + ADD CONSTRAINT record_drafts_operation_check + CHECK (operation IN ('CREATE', 'UPDATE', 'CLOSE')); + + + + + ALTER TABLE record_drafts + ADD CONSTRAINT record_drafts_no_self_approve + CHECK (reviewer_id IS NULL OR maker_id != reviewer_id); + + + + + ALTER TABLE record_drafts + ADD CONSTRAINT record_drafts_one_pending_per_key + EXCLUDE USING btree ( + dictionary_id WITH =, + business_key WITH = + ) WHERE (status = 'pending'); + + + + + + + + + + + + + + + + + + Per-dictionary opt-in: approval_required + min reviewer role (D1=A) + + + + + + + + + + + + diff --git a/ordinis-migrations/src/main/resources/db/changelog/master.xml b/ordinis-migrations/src/main/resources/db/changelog/master.xml index 781ea28..7b96e57 100644 --- a/ordinis-migrations/src/main/resources/db/changelog/master.xml +++ b/ordinis-migrations/src/main/resources/db/changelog/master.xml @@ -28,5 +28,6 @@ + diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/DraftResponse.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/DraftResponse.java new file mode 100644 index 0000000..7399fc2 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/DraftResponse.java @@ -0,0 +1,52 @@ +package cloud.nstart.terravault.ordinis.restapi.dto.draft; + +import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation; +import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus; +import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import org.locationtech.jts.io.WKTWriter; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record DraftResponse( + UUID id, + UUID dictionaryId, + String businessKey, + UUID parentRecordId, + DraftOperation operation, + JsonNode data, + String geometryWkt, + OffsetDateTime proposedValidFrom, + OffsetDateTime proposedValidTo, + DraftStatus status, + String makerId, + String makerComment, + OffsetDateTime submittedAt, + String reviewerId, + OffsetDateTime reviewedAt, + String reviewComment) { + + public static DraftResponse from(RecordDraft d) { + String wkt = d.getGeometry() == null ? null : new WKTWriter().write(d.getGeometry()); + return new DraftResponse( + d.getId(), + d.getDictionaryId(), + d.getBusinessKey(), + d.getParentRecordId(), + d.getOperation(), + d.getData(), + wkt, + d.getProposedValidFrom(), + d.getProposedValidTo(), + d.getStatus(), + d.getMakerId(), + d.getMakerComment(), + d.getSubmittedAt(), + d.getReviewerId(), + d.getReviewedAt(), + d.getReviewComment()); + } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/SubmitDraftRequest.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/SubmitDraftRequest.java new file mode 100644 index 0000000..d8495c3 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/dto/draft/SubmitDraftRequest.java @@ -0,0 +1,25 @@ +package cloud.nstart.terravault.ordinis.restapi.dto.draft; + +import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import java.time.OffsetDateTime; + +/** + * Maker submits a proposed change. Approval workflow v2 (D2=A: legacy POST на + * approval-required dict вернёт 409 с hint'ом use this endpoint). + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public record SubmitDraftRequest( + @NotBlank String businessKey, + @NotNull DraftOperation operation, + /** CREATE/UPDATE: новый payload. CLOSE: null OK. */ + JsonNode data, + String geometryWkt, + OffsetDateTime validFrom, + OffsetDateTime validTo, + /** Optional maker note. */ + String comment) {} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java new file mode 100644 index 0000000..2c4c48f --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftService.java @@ -0,0 +1,157 @@ +package cloud.nstart.terravault.ordinis.restapi.service.draft; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation; +import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus; +import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft; +import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository; +import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; +import org.locationtech.jts.geom.Geometry; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; +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.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.UUID; + +/** + * Approval workflow v2 — Phase 0 skeleton. + * + *

Текущий scope: submit + list + get (read-only operations + new draft + * insert). Полный lifecycle (approve / reject / withdraw + COPY-on-approve) + * — Phase 1. + * + *

Decisions (auto, per design doc): + *

    + *
  • D1=A: per-dictionary policy через колонки на dictionary_definitions.
  • + *
  • D2=A: existing endpoints вернут 409 если approval_required=true + * (handled в DictionaryRecordService — TBD Phase 1).
  • + *
  • D3=A: pool model для reviewer'ов (нет explicit assignment).
  • + *
  • D4=A: rejected drafts kept (status=rejected, kept в таблице).
  • + *
+ */ +@Service +public class DraftService { + + private static final Logger log = LoggerFactory.getLogger(DraftService.class); + + private final RecordDraftRepository draftRepo; + private final DictionaryDefinitionService definitionService; + private final ScopeContext scopeContext; + + public DraftService( + RecordDraftRepository draftRepo, + DictionaryDefinitionService definitionService, + ScopeContext scopeContext) { + this.draftRepo = draftRepo; + this.definitionService = definitionService; + this.scopeContext = scopeContext; + } + + /** + * Submit новой draft. Maker = current authenticated user. + * + *

Pre-conditions: + *

    + *
  • Dict существует и виден caller'у в scope.
  • + *
  • Нет другого pending draft на этот business_key (DB exclusion + * constraint обеспечивает; конфликт = 409).
  • + *
+ * + *

Validation схемы — Phase 1 (нужен RecordValidator + ReferenceValidator + * inject + apply on submit). Для skeleton принимаем data как-есть. + */ + @Transactional + public RecordDraft submit(String dictionaryName, SubmitDraftRequest req) { + DictionaryDefinition def = definitionService.findByName(dictionaryName); + String makerId = currentUserId(); + + var draft = new RecordDraft( + UUID.randomUUID(), + def.getId(), + req.businessKey(), + req.operation(), + makerId); + draft.setData(req.data()); + draft.setGeometry(parseWkt(req.geometryWkt())); + draft.setProposedValidFrom(req.validFrom()); + draft.setProposedValidTo(req.validTo()); + draft.setMakerComment(req.comment()); + + try { + RecordDraft saved = draftRepo.saveAndFlush(draft); + log.info("Draft submitted: dict={} bk={} op={} maker={}", + dictionaryName, req.businessKey(), req.operation(), makerId); + return saved; + } catch (DataIntegrityViolationException ex) { + // record_drafts_one_pending_per_key — exclusion violation. + throw OrdinisException.conflict( + "draft_already_pending", + "На запись business_key=" + req.businessKey() + " уже есть pending draft."); + } + } + + @Transactional(readOnly = true) + public RecordDraft findById(UUID id) { + return draftRepo.findById(id) + .orElseThrow(() -> OrdinisException.notFound( + "draft_not_found", "Draft not found: " + id)); + } + + @Transactional(readOnly = true) + public List listPendingByDictionary(String dictionaryName) { + DictionaryDefinition def = definitionService.findByName(dictionaryName); + return draftRepo.findPendingByDictionary(def.getId()); + } + + @Transactional(readOnly = true) + public Page listPendingGlobal(Pageable pageable) { + return draftRepo.findPending(pageable); + } + + // === Phase 1 stubs (will be implemented next) === + // approve(UUID draftId, String reviewComment) — checker action; COPY → live + mark approved + delete? TBD. + // reject(UUID draftId, String reviewComment) — checker action; mark rejected, kept (D4=A). + // withdraw(UUID draftId) — maker отзывает свой draft; mark withdrawn. + // updateDraft(UUID draftId, ...) — maker editing своего pending draft. + + // === Internals === + + /** Resolve current user from JWT subject. Если нет auth — fallback "anonymous". */ + private String currentUserId() { + var auth = org.springframework.security.core.context.SecurityContextHolder.getContext() + .getAuthentication(); + if (auth == null || !auth.isAuthenticated()) return "anonymous"; + return auth.getName(); + } + + private static Geometry parseWkt(String wkt) { + if (wkt == null || wkt.isBlank()) return null; + try { + Geometry g = new WKTReader().read(wkt); + g.setSRID(4326); + return g; + } catch (ParseException e) { + throw OrdinisException.badRequest( + "invalid_geometry_wkt", "Cannot parse WKT: " + e.getMessage()); + } + } + + /** ScopeContext kept for future scope-filter Phase 1 (когда draft list + * может ограничиваться по scope). Suppress unused warning сейчас. */ + @SuppressWarnings("unused") + private ScopeContext scopeContext() { return scopeContext; } + + /** Convenience overload: всё что нужно от datetime. */ + static OffsetDateTime nowUtc() { return OffsetDateTime.now(); } +} diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java new file mode 100644 index 0000000..1a2aa11 --- /dev/null +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DraftController.java @@ -0,0 +1,82 @@ +package cloud.nstart.terravault.ordinis.restapi.web; + +import cloud.nstart.terravault.ordinis.restapi.dto.draft.DraftResponse; +import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest; +import cloud.nstart.terravault.ordinis.restapi.service.draft.DraftService; +import jakarta.validation.Valid; +import org.springframework.data.domain.PageRequest; +import org.springframework.http.HttpStatus; +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.RequestMapping; +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; + +/** + * Approval workflow draft endpoints — Phase 0 skeleton. + * + *

Submit + list per dict + global queue + get by id. + * Approve / reject / withdraw / update — Phase 1. + * + *

Дизайн doc: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md + */ +@RestController +public class DraftController { + + private final DraftService draftService; + + public DraftController(DraftService draftService) { + this.draftService = draftService; + } + + /** + * Maker submits новый proposal. Returns 201 Created с full draft response. + * Если pending draft на этот business_key уже есть → 409 draft_already_pending. + */ + @PostMapping("/api/v1/dictionaries/{name}/records/drafts") + @ResponseStatus(HttpStatus.CREATED) + public DraftResponse submit( + @PathVariable String name, + @Valid @RequestBody SubmitDraftRequest req) { + return DraftResponse.from(draftService.submit(name, req)); + } + + /** Per-dictionary pending list. */ + @RequestMapping(method = org.springframework.web.bind.annotation.RequestMethod.GET, + value = "/api/v1/dictionaries/{name}/records/drafts") + public List listByDict(@PathVariable String name) { + return draftService.listPendingByDictionary(name).stream() + .map(DraftResponse::from) + .toList(); + } + + /** Single draft по id (для drilling в reviewer queue). */ + @GetMapping("/api/v1/drafts/{id}") + public DraftResponse getById(@PathVariable UUID id) { + return DraftResponse.from(draftService.findById(id)); + } + + /** + * Global reviewer queue (D3=A pool model). Все pending drafts отсортированы + * by submitted_at ASC (oldest first — fairness). + */ + @GetMapping("/api/v1/admin/reviews/pending") + public Map globalQueue( + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size) { + var pg = draftService.listPendingGlobal(PageRequest.of(page, Math.min(size, 200))); + return Map.of( + "items", pg.getContent().stream().map(DraftResponse::from).toList(), + "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/draft/DraftServiceTest.java b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java new file mode 100644 index 0000000..e014925 --- /dev/null +++ b/ordinis-rest-api/src/test/java/cloud/nstart/terravault/ordinis/restapi/service/draft/DraftServiceTest.java @@ -0,0 +1,253 @@ +package cloud.nstart.terravault.ordinis.restapi.service.draft; + +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; +import cloud.nstart.terravault.ordinis.domain.definition.DictionaryDefinition; +import cloud.nstart.terravault.ordinis.domain.draft.DraftOperation; +import cloud.nstart.terravault.ordinis.domain.draft.DraftStatus; +import cloud.nstart.terravault.ordinis.domain.draft.RecordDraft; +import cloud.nstart.terravault.ordinis.domain.draft.RecordDraftRepository; +import cloud.nstart.terravault.ordinis.restapi.dto.draft.SubmitDraftRequest; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; + +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +/** + * Unit tests {@link DraftService} Phase 0 skeleton. + * + *

JDK 25 + Mockito incompat для concrete classes — используем anonymous + * subclass для DictionaryDefinitionService и in-memory stub repository. + */ +class DraftServiceTest { + + private static final ObjectMapper OM = new ObjectMapper(); + + private final UUID dictId = UUID.randomUUID(); + private DictionaryDefinitionService defService; + private StubDraftRepo draftRepo; + private ScopeContext scopeContext; + private DraftService service; + + @BeforeEach + void setUp() { + DictionaryDefinition def = new DictionaryDefinition( + dictId, "test_dict", DataScope.PUBLIC, OM.createObjectNode()); + def.setApprovalRequired(true); + 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); + } + }; + draftRepo = new StubDraftRepo(); + scopeContext = new ScopeContext(null) { + @Override + public Set currentScopes() { + return Set.of(DataScope.PUBLIC); + } + }; + service = new DraftService(draftRepo, defService, scopeContext); + } + + @Test + void submit_createsDraftWithPendingStatus() throws Exception { + var req = new SubmitDraftRequest( + "BK-001", DraftOperation.CREATE, + OM.readTree("{\"name\":\"test\"}"), + null, null, null, "initial submit"); + + RecordDraft saved = service.submit("test_dict", req); + + assertThat(saved.getStatus()).isEqualTo(DraftStatus.PENDING); + assertThat(saved.getDictionaryId()).isEqualTo(dictId); + assertThat(saved.getBusinessKey()).isEqualTo("BK-001"); + assertThat(saved.getOperation()).isEqualTo(DraftOperation.CREATE); + assertThat(saved.getMakerComment()).isEqualTo("initial submit"); + // makerId = "anonymous" если auth context пустой (test env). + assertThat(saved.getMakerId()).isEqualTo("anonymous"); + assertThat(saved.getSubmittedAt()).isNotNull(); + } + + @Test + void submit_409WhenPendingExists() throws Exception { + var req = new SubmitDraftRequest( + "BK-001", DraftOperation.CREATE, + OM.readTree("{}"), null, null, null, null); + service.submit("test_dict", req); // first OK + + // Stub raises DataIntegrityViolationException на second insert с тем же business_key. + assertThatThrownBy(() -> service.submit("test_dict", req)) + .isInstanceOf(OrdinisException.class) + .satisfies(e -> assertThat(((OrdinisException) e).getCode()) + .isEqualTo("draft_already_pending")); + } + + @Test + void submit_invalidGeometryWkt_throwsBadRequest() throws Exception { + var req = new SubmitDraftRequest( + "BK-001", DraftOperation.CREATE, + OM.readTree("{}"), + "NOT-A-WKT-STRING", + null, null, null); + + assertThatThrownBy(() -> service.submit("test_dict", req)) + .isInstanceOf(OrdinisException.class) + .satisfies(e -> assertThat(((OrdinisException) e).getCode()) + .isEqualTo("invalid_geometry_wkt")); + } + + @Test + void submit_dictNotFound_throwsNotFound() throws Exception { + var req = new SubmitDraftRequest( + "BK-001", DraftOperation.CREATE, + OM.readTree("{}"), null, null, null, null); + + assertThatThrownBy(() -> service.submit("missing_dict", req)) + .isInstanceOf(OrdinisException.class) + .satisfies(e -> assertThat(((OrdinisException) e).getCode()) + .isEqualTo("dictionary_not_found")); + } + + @Test + void findById_unknownThrowsNotFound() { + UUID id = UUID.randomUUID(); + assertThatThrownBy(() -> service.findById(id)) + .isInstanceOf(OrdinisException.class) + .satisfies(e -> assertThat(((OrdinisException) e).getCode()) + .isEqualTo("draft_not_found")); + } + + @Test + void listPendingByDictionary_returnsOnlyPendingForThisDict() throws Exception { + service.submit("test_dict", new SubmitDraftRequest( + "BK-001", DraftOperation.CREATE, OM.readTree("{}"), + null, null, null, null)); + // Another business_key — should also be pending. + service.submit("test_dict", new SubmitDraftRequest( + "BK-002", DraftOperation.CREATE, OM.readTree("{}"), + null, null, null, null)); + + var list = service.listPendingByDictionary("test_dict"); + assertThat(list).hasSize(2); + assertThat(list).allSatisfy(d -> + assertThat(d.getStatus()).isEqualTo(DraftStatus.PENDING)); + } + + // === In-memory stub repo === + + /** Mimics DB exclusion constraint: one_pending_per_key per (dict, key). */ + private static class StubDraftRepo implements RecordDraftRepository { + private final java.util.Map store = new java.util.LinkedHashMap<>(); + + @Override + public RecordDraft saveAndFlush(RecordDraft draft) { + // Simulate exclusion constraint. + boolean conflict = store.values().stream().anyMatch(d -> + d.getStatus() == DraftStatus.PENDING + && d.getDictionaryId().equals(draft.getDictionaryId()) + && d.getBusinessKey().equals(draft.getBusinessKey()) + && !d.getId().equals(draft.getId())); + if (conflict) { + throw new DataIntegrityViolationException( + "record_drafts_one_pending_per_key"); + } + store.put(draft.getId(), draft); + return draft; + } + + @Override + public S save(S entity) { + saveAndFlush(entity); + return entity; + } + + @Override + public Optional findById(UUID id) { + return Optional.ofNullable(store.get(id)); + } + + @Override + public Optional findPendingByKey(UUID dictionaryId, String businessKey) { + return store.values().stream() + .filter(d -> d.getStatus() == DraftStatus.PENDING + && d.getDictionaryId().equals(dictionaryId) + && d.getBusinessKey().equals(businessKey)) + .findFirst(); + } + + @Override + public org.springframework.data.domain.Page findPending( + org.springframework.data.domain.Pageable pageable) { + var list = store.values().stream() + .filter(d -> d.getStatus() == DraftStatus.PENDING) + .toList(); + return new org.springframework.data.domain.PageImpl<>(list, pageable, list.size()); + } + + @Override + public java.util.List findPendingByDictionary(UUID dictionaryId) { + return store.values().stream() + .filter(d -> d.getStatus() == DraftStatus.PENDING + && d.getDictionaryId().equals(dictionaryId)) + .toList(); + } + + @Override + public org.springframework.data.domain.Page findByMaker( + String makerId, org.springframework.data.domain.Pageable pageable) { + var list = store.values().stream() + .filter(d -> d.getMakerId().equals(makerId)) + .toList(); + return new org.springframework.data.domain.PageImpl<>(list, pageable, list.size()); + } + + // Unused JpaRepository methods stubbed. + @Override public java.util.List findAll() { return store.values().stream().toList(); } + @Override public java.util.List findAll(org.springframework.data.domain.Sort sort) { return findAll(); } + @Override public org.springframework.data.domain.Page findAll(org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); } + @Override public java.util.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(RecordDraft 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 java.util.List saveAll(Iterable entities) { + java.util.List out = new java.util.ArrayList<>(); + for (S e : entities) out.add(save(e)); + return out; + } + @Override public java.util.List saveAllAndFlush(Iterable entities) { return saveAll(entities); } + @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 RecordDraft getOne(UUID id) { return store.get(id); } + @Override public RecordDraft getById(UUID id) { return store.get(id); } + @Override public RecordDraft getReferenceById(UUID id) { return store.get(id); } + @Override public java.util.List findAll(org.springframework.data.domain.Example example) { throw new UnsupportedOperationException(); } + @Override public java.util.List findAll(org.springframework.data.domain.Example example, org.springframework.data.domain.Sort sort) { throw new UnsupportedOperationException(); } + @Override public org.springframework.data.domain.Page findAll(org.springframework.data.domain.Example example, org.springframework.data.domain.Pageable pageable) { throw new UnsupportedOperationException(); } + @Override public long count(org.springframework.data.domain.Example example) { return 0; } + @Override public boolean exists(org.springframework.data.domain.Example example) { return false; } + @Override public Optional findOne(org.springframework.data.domain.Example example) { return Optional.empty(); } + @Override public R findBy(org.springframework.data.domain.Example example, java.util.function.Function, R> queryFunction) { throw new UnsupportedOperationException(); } + } + + /** ScopeContext getter — match service's protected accessor through field. */ + @SuppressWarnings("unused") + private final AtomicReference _silenceUnusedScope = new AtomicReference<>(); +}