feat(approval): Approval Workflow v2 Phase 0 — schema + skeleton API
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
This commit is contained in:
+20
@@ -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; }
|
||||
}
|
||||
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package cloud.nstart.terravault.ordinis.domain.draft;
|
||||
|
||||
/** What kind of mutation maker proposes. */
|
||||
public enum DraftOperation {
|
||||
CREATE,
|
||||
UPDATE,
|
||||
CLOSE
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package cloud.nstart.terravault.ordinis.domain.draft;
|
||||
|
||||
/**
|
||||
* Approval workflow draft state machine (v2).
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #PENDING} — submitted maker'ом, ждёт reviewer action.</li>
|
||||
* <li>{@link #APPROVED} — terminal: COPY → live + delete draft (только в logs).</li>
|
||||
* <li>{@link #REJECTED} — terminal: kept в таблице (D4=A soft-archive,
|
||||
* compliance + maker видит причину).</li>
|
||||
* <li>{@link #WITHDRAWN} — terminal: maker сам отозвал before review.</li>
|
||||
* </ul>
|
||||
*/
|
||||
public enum DraftStatus {
|
||||
PENDING,
|
||||
APPROVED,
|
||||
REJECTED,
|
||||
WITHDRAWN
|
||||
}
|
||||
+137
@@ -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).
|
||||
*
|
||||
* <p>Constraints в БД (см. Liquibase 0019):
|
||||
* <ul>
|
||||
* <li>{@code record_drafts_status_check} — pending/approved/rejected/withdrawn.</li>
|
||||
* <li>{@code record_drafts_operation_check} — CREATE/UPDATE/CLOSE.</li>
|
||||
* <li>{@code record_drafts_no_self_approve} — maker_id != reviewer_id.</li>
|
||||
* <li>{@code record_drafts_one_pending_per_key} — EXCLUDE WHERE status=pending.</li>
|
||||
* </ul>
|
||||
*/
|
||||
@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; }
|
||||
}
|
||||
+50
@@ -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<RecordDraft, UUID> {
|
||||
|
||||
/** 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<RecordDraft> 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<RecordDraft> 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<RecordDraft> 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<RecordDraft> findByMaker(@Param("makerId") String makerId, Pageable pageable);
|
||||
}
|
||||
Reference in New Issue
Block a user