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:
+126
@@ -0,0 +1,126 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<databaseChangeLog
|
||||
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||
|
||||
<!--
|
||||
Approval Workflow v2 — Phase 0: schema foundation.
|
||||
|
||||
Decisions made (auto, per design doc recommendations):
|
||||
- D1=A: per-dict policy через колонки на dictionary_definitions.
|
||||
- D2=A: legacy POST на approval-required dict → 409 (handled в service).
|
||||
- D3=A: pool model для reviewer'ов (no assigned column в schema).
|
||||
- D4=A: soft-archive rejected drafts (status='rejected' kept).
|
||||
|
||||
Layered model (Approach A из design doc):
|
||||
- record_drafts (DRAFT layer) — pending/approved/rejected/withdrawn
|
||||
- dictionary_records (LIVE layer) — only approved versions
|
||||
- ON APPROVE: COPY draft → live + DELETE draft + outbox event
|
||||
|
||||
Source: ~/.gstack/projects/claude/zimin-main-design-approval-workflow-v2-20260507-121632.md
|
||||
-->
|
||||
<changeSet id="0019-1-record-drafts" author="ordinis">
|
||||
<comment>record_drafts table — DRAFT layer для maker-checker workflow</comment>
|
||||
<createTable tableName="record_drafts">
|
||||
<column name="id" type="UUID" defaultValueComputed="gen_random_uuid()">
|
||||
<constraints primaryKey="true" nullable="false"/>
|
||||
</column>
|
||||
<column name="dictionary_id" type="UUID">
|
||||
<constraints nullable="false" foreignKeyName="fk_draft_dictionary"
|
||||
references="dictionary_definitions(id)"/>
|
||||
</column>
|
||||
<column name="business_key" type="VARCHAR(256)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<!-- NULL для CREATE; точка истории для UPDATE/CLOSE (live record id). -->
|
||||
<column name="parent_record_id" type="UUID"/>
|
||||
<column name="operation" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
|
||||
<!-- Payload (для CREATE/UPDATE — full record; для CLOSE — пусто). -->
|
||||
<column name="data" type="JSONB"/>
|
||||
<column name="geometry" type="GEOMETRY(Geometry, 4326)"/>
|
||||
<column name="proposed_valid_from" type="TIMESTAMPTZ"/>
|
||||
<column name="proposed_valid_to" type="TIMESTAMPTZ"/>
|
||||
|
||||
<!-- Workflow state. -->
|
||||
<column name="status" type="VARCHAR(16)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="maker_id" type="VARCHAR(128)">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="maker_comment" type="TEXT"/>
|
||||
<column name="submitted_at" type="TIMESTAMPTZ" defaultValueComputed="NOW()">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="reviewer_id" type="VARCHAR(128)"/>
|
||||
<column name="reviewed_at" type="TIMESTAMPTZ"/>
|
||||
<column name="review_comment" type="TEXT"/>
|
||||
<column name="version" type="BIGINT" defaultValueNumeric="0">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
</createTable>
|
||||
|
||||
<sql>
|
||||
ALTER TABLE record_drafts
|
||||
ADD CONSTRAINT record_drafts_status_check
|
||||
CHECK (status IN ('pending', 'approved', 'rejected', 'withdrawn'));
|
||||
</sql>
|
||||
<sql>
|
||||
ALTER TABLE record_drafts
|
||||
ADD CONSTRAINT record_drafts_operation_check
|
||||
CHECK (operation IN ('CREATE', 'UPDATE', 'CLOSE'));
|
||||
</sql>
|
||||
|
||||
<!-- D3 pool model + D4 self-approval guard:
|
||||
maker_id != reviewer_id если оба set'ы. -->
|
||||
<sql>
|
||||
ALTER TABLE record_drafts
|
||||
ADD CONSTRAINT record_drafts_no_self_approve
|
||||
CHECK (reviewer_id IS NULL OR maker_id != reviewer_id);
|
||||
</sql>
|
||||
|
||||
<!-- Только один pending draft на business_key одновременно
|
||||
(concurrent edits = последний wins на submit, prevents merge conflicts). -->
|
||||
<sql>
|
||||
ALTER TABLE record_drafts
|
||||
ADD CONSTRAINT record_drafts_one_pending_per_key
|
||||
EXCLUDE USING btree (
|
||||
dictionary_id WITH =,
|
||||
business_key WITH =
|
||||
) WHERE (status = 'pending');
|
||||
</sql>
|
||||
|
||||
<createIndex indexName="idx_drafts_pending_by_dict" tableName="record_drafts">
|
||||
<column name="dictionary_id"/>
|
||||
<column name="submitted_at"/>
|
||||
</createIndex>
|
||||
<createIndex indexName="idx_drafts_by_maker" tableName="record_drafts">
|
||||
<column name="maker_id"/>
|
||||
<column name="submitted_at" descending="true"/>
|
||||
</createIndex>
|
||||
<createIndex indexName="idx_drafts_pending_global" tableName="record_drafts">
|
||||
<column name="status"/>
|
||||
<column name="submitted_at"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
<changeSet id="0019-2-dict-approval-policy" author="ordinis">
|
||||
<comment>Per-dictionary opt-in: approval_required + min reviewer role (D1=A)</comment>
|
||||
<addColumn tableName="dictionary_definitions">
|
||||
<column name="approval_required" type="BOOLEAN" defaultValueBoolean="false">
|
||||
<constraints nullable="false"/>
|
||||
</column>
|
||||
<column name="approval_min_role" type="VARCHAR(64)"/>
|
||||
</addColumn>
|
||||
<createIndex indexName="idx_dict_def_approval_required"
|
||||
tableName="dictionary_definitions">
|
||||
<column name="approval_required"/>
|
||||
</createIndex>
|
||||
</changeSet>
|
||||
|
||||
</databaseChangeLog>
|
||||
@@ -28,5 +28,6 @@
|
||||
<include file="changes/0016-record-dependents-index.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0017-redis-projection-flag.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0018-trgm-search-index.xml" relativeToChangelogFile="true"/>
|
||||
<include file="changes/0019-approval-workflow-v2.xml" relativeToChangelogFile="true"/>
|
||||
|
||||
</databaseChangeLog>
|
||||
|
||||
Reference in New Issue
Block a user