# Database Design - Ultimate History Map

Tài liệu này mô tả database hiện tại của project theo code đang chạy trong
`BackEnd/db/polygons.js` và schema thực tế đã kiểm tra bằng `PRAGMA`.

- Engine: SQLite
- Driver: `better-sqlite3`
- DB file runtime: `BackEnd/data/polygons.db`
- Schema init/migration runtime: `BackEnd/db/polygons.js`
- Domain read API: `BackEnd/routes/entities.js`, `BackEnd/routes/geometries.js`
- Section workflow API: `BackEnd/routes/sections.js`
- Cập nhật: `2026-04-19`

## 1. Tổng Quan

Database hiện chia thành 2 lớp dữ liệu:

1. Published data
   - Dữ liệu đã được duyệt.
   - Map, search và entity browser đọc trực tiếp từ các bảng này.
   - Gồm `entities`, `geometries`, `entity_geometries`.

2. Section review workflow
   - Dữ liệu editor/version control/review.
   - Commit và submission lưu full JSON snapshot, không lưu diff.
   - Gồm `sections`, `section_states`, `section_commits`, `section_submissions`.

Quy tắc hiện tại:

- `entities` và `geometries` chỉ được ghi qua workflow section review.
- API domain hiện chỉ expose read cho entity/geometry.
- Editor tạo snapshot, commit snapshot vào `section_commits`, submit sang `section_submissions`.
- Chỉ khi reviewer approve thì snapshot mới được apply vào published tables.
- Reject không đổi published data.
- Conflict protection hiện dựa trên `base_updated_at`, `base_hash`, `base_links_hash` nếu snapshot có gửi.

## 2. ERD

```mermaid
erDiagram
    entities {
        TEXT id PK
        TEXT name
        TEXT slug UK
        TEXT description
        TEXT type_id
        INTEGER status
        INTEGER is_deleted
        TEXT created_at
        TEXT updated_at
    }

    geometries {
        TEXT id PK
        TEXT type
        INTEGER is_deleted
        TEXT draw_geometry
        TEXT binding
        INTEGER time_start
        INTEGER time_end
        REAL bbox_min_lng
        REAL bbox_min_lat
        REAL bbox_max_lng
        REAL bbox_max_lat
        TEXT created_at
        TEXT updated_at
    }

    entity_geometries {
        TEXT entity_id PK, FK
        TEXT geometry_id PK, FK
        TEXT created_at
    }

    sections {
        TEXT id PK
        TEXT title
        TEXT description
        TEXT user_id
        TEXT created_by
        TEXT created_at
        TEXT updated_at
    }

    section_states {
        TEXT section_id PK, FK
        TEXT status
        TEXT head_commit_id
        INTEGER version
        TEXT locked_by
        TEXT locked_at
        TEXT lock_expires_at
        TEXT updated_at
    }

    section_commits {
        TEXT id PK
        TEXT section_id FK
        TEXT parent_commit_id FK
        INTEGER commit_no
        TEXT kind
        TEXT restored_from_commit_id FK
        TEXT created_by
        TEXT created_at
        TEXT title
        TEXT note
        TEXT snapshot_json
        TEXT snapshot_hash
    }

    section_submissions {
        TEXT id PK
        TEXT section_id FK
        TEXT commit_id FK
        TEXT submitted_by
        TEXT submitted_at
        TEXT status
        TEXT reviewed_by
        TEXT reviewed_at
        TEXT review_note
        TEXT snapshot_json
        TEXT snapshot_hash
    }

    entities ||--o{ entity_geometries : links
    geometries ||--o{ entity_geometries : links
    sections ||--|| section_states : state
    sections ||--o{ section_commits : commits
    section_commits ||--o{ section_commits : parent
    section_commits ||--o{ section_submissions : submitted
    sections ||--o{ section_submissions : submissions
```

Ghi chú:

- `section_states.head_commit_id` là liên kết logic tới `section_commits.id`, nhưng DB hiện không đặt foreign key cho cột này.
- `section_commits.parent_commit_id` và `restored_from_commit_id` có foreign key tới chính `section_commits.id`.
- `section_submissions.commit_id` có foreign key tới `section_commits.id`.

## 3. Published Data

### 3.1 `entities`

Lưu entity đã được duyệt. API đọc từ bảng này:

- `GET /entities`
- `GET /entities/search`
- `GET /entities/:id`

| Cột | Kiểu | Ràng buộc/default | Ý nghĩa |
| --- | --- | --- | --- |
| `id` | `TEXT` | `PRIMARY KEY` | ID entity |
| `name` | `TEXT` | `NOT NULL` | Tên entity |
| `slug` | `TEXT` | `UNIQUE`, nullable | Slug tìm kiếm/URL |
| `description` | `TEXT` | nullable | Mô tả |
| `type_id` | `TEXT` | `NOT NULL DEFAULT 'country'` | Semantic type của entity |
| `status` | `INTEGER` | `DEFAULT 1` | Trạng thái nghiệp vụ |
| `is_deleted` | `INTEGER` | `NOT NULL DEFAULT 0` | Soft delete |
| `created_at` | `TEXT` | nullable | ISO datetime |
| `updated_at` | `TEXT` | nullable | ISO datetime |

Index:

| Index | Cột | Loại |
| --- | --- | --- |
| `idx_entities_slug` | `slug` | unique |
| `idx_entities_name` | `name` | normal |

Read behavior:

- Entity read API luôn lọc `is_deleted = 0`.
- `GET /entities` search optional qua `q`, match `name` hoặc `slug` bằng `LIKE`.
- `GET /entities/search` search qua `name`, có `limit`, max 100.
- Response có `geometry_count` từ `entity_geometries`.

Apply snapshot behavior:

- `operation = reference`: không ghi DB.
- `operation = create`: insert entity mới.
- `operation = update` hoặc `replace`: update entity hiện có.
- `operation = delete`: set `is_deleted = 1`, đồng thời xóa link trong `entity_geometries`.
- Nếu snapshot có `base_updated_at`, backend so với `entities.updated_at`.
- Nếu snapshot có `base_hash`, backend tính hash row hiện tại để bắt conflict.

### 3.2 `geometries`

Lưu geometry đã được duyệt. API đọc chính:

- `GET /geometries?minLng=&minLat=&maxLng=&maxLat=&time=&entity_id=`

| Cột | Kiểu | Ràng buộc/default | Ý nghĩa |
| --- | --- | --- | --- |
| `id` | `TEXT` | `PRIMARY KEY` | ID geometry |
| `type` | `TEXT` | nullable | Semantic type dùng render FE |
| `is_deleted` | `INTEGER` | `NOT NULL DEFAULT 0` | Soft delete |
| `draw_geometry` | `TEXT` | `NOT NULL` | GeoJSON geometry serialize JSON |
| `binding` | `TEXT` | nullable | JSON array geometry id được bind |
| `time_start` | `INTEGER` | nullable | Năm bắt đầu |
| `time_end` | `INTEGER` | nullable | Năm kết thúc |
| `bbox_min_lng` | `REAL` | nullable | BBox min longitude |
| `bbox_min_lat` | `REAL` | nullable | BBox min latitude |
| `bbox_max_lng` | `REAL` | nullable | BBox max longitude |
| `bbox_max_lat` | `REAL` | nullable | BBox max latitude |
| `created_at` | `TEXT` | nullable | ISO datetime |
| `updated_at` | `TEXT` | nullable | ISO datetime |

Read behavior:

- BBox query bắt buộc có đủ `minLng`, `minLat`, `maxLng`, `maxLat`.
- Backend lọc `g.is_deleted = 0`.
- Time filter optional:
  - Pass nếu `time_start IS NULL OR time_start <= time`.
  - Pass nếu `time_end IS NULL OR time_end >= time`.
- Entity filter optional qua `entity_id`, check link active trong `entity_geometries` và `entities`.
- Response là GeoJSON `FeatureCollection`.
- `properties.type` ưu tiên `geometries.type`.
- Nếu `geometries.type` là legacy token `line` hoặc `path`, backend fallback sang `entity.type_id` của primary entity.
- `properties.binding` parse từ JSON string trong `geometries.binding`.

Apply snapshot behavior:

- `operation = reference`: không ghi DB.
- `operation = create`: insert geometry mới.
- `operation = update` hoặc `replace`: update geometry hiện có.
- `operation = delete`: set `is_deleted = 1`, xóa link trong `entity_geometries`, và gỡ geometry id đó khỏi `binding` của các geometry khác.
- `draw_geometry` nhận từ `snapshot.draw_geometry` hoặc fallback `snapshot.geometry`.
- Nếu snapshot không gửi `bbox`, backend tự tính bằng `getBBox`.
- `binding` được normalize thành array string, bỏ rỗng, dedupe, không cho chứa chính id của geometry.
- Nếu snapshot có `base_updated_at`, backend so với `geometries.updated_at`.
- Nếu snapshot có `base_hash`, backend tính hash row hiện tại để bắt conflict.

### 3.3 `entity_geometries`

Bảng many-to-many giữa entity và geometry.

| Cột | Kiểu | Ràng buộc/default | Ý nghĩa |
| --- | --- | --- | --- |
| `entity_id` | `TEXT` | `NOT NULL`, `PRIMARY KEY(entity_id, geometry_id)`, `FK -> entities(id) ON DELETE CASCADE` | Entity được gắn |
| `geometry_id` | `TEXT` | `NOT NULL`, `PRIMARY KEY(entity_id, geometry_id)`, `FK -> geometries(id) ON DELETE CASCADE` | Geometry được gắn |
| `created_at` | `TEXT` | nullable | ISO datetime |

Index:

| Index | Cột | Loại |
| --- | --- | --- |
| `idx_entity_geometries_geometry_id` | `geometry_id` | normal |
| `idx_entity_geometries_entity_id` | `entity_id` | normal |

Apply `link_scopes` behavior:

- Mỗi scope cần `geometry_id`.
- Geometry phải tồn tại và `is_deleted = 0`.
- `entity_ids` không được rỗng.
- Tất cả entity trong `entity_ids` phải tồn tại và `is_deleted = 0`.
- Nếu scope có `base_links_hash`, backend hash links hiện tại để bắt conflict.
- Apply bằng cách xóa toàn bộ links cũ theo `geometry_id`, sau đó insert lại danh sách `entity_ids`.

## 4. Section Review Workflow

### 4.1 `sections`

Đại diện cho một workspace/section editor.

| Cột | Kiểu | Ràng buộc/default | Ý nghĩa |
| --- | --- | --- | --- |
| `id` | `TEXT` | `PRIMARY KEY` | ID section |
| `title` | `TEXT` | `NOT NULL` | Tên section |
| `description` | `TEXT` | nullable | Mô tả |
| `user_id` | `TEXT` | nullable | Owner/user liên quan |
| `created_by` | `TEXT` | nullable | Actor tạo section |
| `created_at` | `TEXT` | `NOT NULL` | ISO datetime |
| `updated_at` | `TEXT` | `NOT NULL` | ISO datetime |

Behavior:

- `POST /sections` tạo section.
- Sau khi tạo section, backend tạo kèm row trong `section_states` với status `editing`.
- `GET /sections` trả section kèm `state`.

### 4.2 `section_states`

Lưu trạng thái hiện tại của section.

| Cột | Kiểu | Ràng buộc/default | Ý nghĩa |
| --- | --- | --- | --- |
| `section_id` | `TEXT` | `PRIMARY KEY`, `FK -> sections(id) ON DELETE CASCADE` | Section |
| `status` | `TEXT` | `NOT NULL DEFAULT 'editing'` | Trạng thái section |
| `head_commit_id` | `TEXT` | nullable | Commit head hiện tại, link logic tới `section_commits.id` |
| `version` | `INTEGER` | `NOT NULL DEFAULT 0` | Optimistic version |
| `locked_by` | `TEXT` | nullable | Actor đang giữ lock |
| `locked_at` | `TEXT` | nullable | ISO datetime lock |
| `lock_expires_at` | `TEXT` | nullable | ISO datetime hết hạn lock |
| `updated_at` | `TEXT` | `NOT NULL` | ISO datetime |

Index:

| Index | Cột | Loại |
| --- | --- | --- |
| `idx_section_states_status` | `status`, `updated_at` | normal |

Status hiện dùng:

- `editing`
- `submitted`
- `approved`
- `rejected`

Ý nghĩa `status`:

| Status | Ý nghĩa |
| --- | --- |
| `editing` | Section đang ở trạng thái chỉnh sửa. Editor có thể mở section, acquire lock, tạo commit mới, restore commit và submit commit hiện tại để review. Đây là trạng thái mặc định khi tạo section mới. |
| `submitted` | Section đã được submit để review từ `head_commit_id` hiện tại. Trong trạng thái này không tạo commit/restore mới cho section cho tới khi submission được xử lý. Submit cũng release lock editor. |
| `approved` | Submission mới nhất đã được reviewer approve và snapshot đã được apply vào published tables (`entities`, `geometries`, `entity_geometries`). Published data đã thay đổi theo snapshot được duyệt. |
| `rejected` | Submission mới nhất bị reviewer reject. Published data không đổi. Section có thể quay lại chỉnh sửa bằng commit/restore mới; commit mới sẽ set status về `editing`. |

Lock behavior:

- Lock TTL: 15 phút.
- `GET /sections/:sectionId/editor?user_id=...` sẽ acquire lock nếu gửi actor.
- Lock hợp lệ nếu trống, cùng actor, hoặc đã hết hạn.
- `POST /sections/:sectionId/lock` acquire lock explicit.
- `POST /sections/:sectionId/unlock` release lock nếu không bị user khác giữ.

State transition:

| Action | Điều kiện chính | State sau action |
| --- | --- | --- |
| create section | title hợp lệ | `editing` |
| commit | state `editing` hoặc `rejected`, lock hợp lệ | `editing` |
| restore | state `editing` hoặc `rejected`, lock hợp lệ | `editing` |
| submit | state `editing`, có commit | `submitted` |
| approve | submission `pending`, apply snapshot thành công | `approved` |
| reject | submission `pending` | `rejected` |

### 4.3 `section_commits`

Lưu version history của section. Mỗi commit là một full snapshot.

| Cột | Kiểu | Ràng buộc/default | Ý nghĩa |
| --- | --- | --- | --- |
| `id` | `TEXT` | `PRIMARY KEY` | ID commit |
| `section_id` | `TEXT` | `NOT NULL`, `FK -> sections(id) ON DELETE CASCADE` | Section |
| `parent_commit_id` | `TEXT` | nullable, `FK -> section_commits(id)` | Commit head trước đó |
| `commit_no` | `INTEGER` | `NOT NULL` | Số thứ tự commit trong section |
| `kind` | `TEXT` | `NOT NULL DEFAULT 'manual'` | `manual` hoặc `restore` |
| `restored_from_commit_id` | `TEXT` | nullable, `FK -> section_commits(id)` | Commit nguồn khi restore |
| `created_by` | `TEXT` | `NOT NULL` | Actor tạo commit |
| `created_at` | `TEXT` | `NOT NULL` | ISO datetime |
| `title` | `TEXT` | nullable | Title commit |
| `note` | `TEXT` | nullable | Ghi chú |
| `snapshot_json` | `TEXT` | `NOT NULL` | Full snapshot JSON string |
| `snapshot_hash` | `TEXT` | nullable | `sha256:<hex>` |

Index:

| Index | Cột | Loại |
| --- | --- | --- |
| `idx_section_commits_no` | `section_id`, `commit_no` | unique |
| `idx_section_commits_section_time` | `section_id`, `created_at` | normal |

Behavior:

- `POST /sections/:sectionId/commits` tạo commit `manual`.
- `POST /sections/:sectionId/restore` tạo commit `restore`, copy snapshot từ commit nguồn.
- Commit không apply vào published data.
- `commit_no` tăng theo từng section.
- Commit mới set `section_states.head_commit_id`, tăng `section_states.version`, set status về `editing`.
- `expected_version` và `expected_head_commit_id` là optional optimistic checks.

### 4.4 `section_submissions`

Lưu submission được gửi đi review. Submission copy snapshot từ commit tại thời điểm submit.

| Cột | Kiểu | Ràng buộc/default | Ý nghĩa |
| --- | --- | --- | --- |
| `id` | `TEXT` | `PRIMARY KEY` | ID submission |
| `section_id` | `TEXT` | `NOT NULL`, `FK -> sections(id) ON DELETE CASCADE` | Section |
| `commit_id` | `TEXT` | `NOT NULL`, `FK -> section_commits(id)` | Commit được submit |
| `submitted_by` | `TEXT` | `NOT NULL` | Actor submit |
| `submitted_at` | `TEXT` | `NOT NULL` | ISO datetime |
| `status` | `TEXT` | `NOT NULL DEFAULT 'pending'` | Trạng thái review |
| `reviewed_by` | `TEXT` | nullable | Actor review |
| `reviewed_at` | `TEXT` | nullable | ISO datetime review |
| `review_note` | `TEXT` | nullable | Ghi chú review hoặc conflict message |
| `snapshot_json` | `TEXT` | `NOT NULL` | Snapshot copy từ commit |
| `snapshot_hash` | `TEXT` | nullable | `sha256:<hex>` |

Index:

| Index | Cột | Loại |
| --- | --- | --- |
| `idx_section_submissions_section_status` | `section_id`, `status`, `submitted_at` | normal |

Status hiện dùng:

- `pending`
- `approved`
- `rejected`
- `conflicted`

Behavior:

- `POST /sections/:sectionId/submit` tạo submission `pending`.
- Submit yêu cầu section đang `editing` và có commit.
- Submit set section state sang `submitted` và release lock.
- `GET /sections/:sectionId/submissions` trả submission history của section.
- `POST /submissions/:submissionId/approve` chỉ nhận submission `pending`.
- `POST /submissions/:submissionId/reject` chỉ nhận submission `pending`.
- Approve verify `snapshot_hash`, parse snapshot, apply vào published data trong transaction.
- Reject chỉ cập nhật submission và section state.
- Nếu approve gặp conflict trong quá trình apply, backend set submission thành `conflicted`.

## 5. Snapshot Format

Backend lưu snapshot trong `section_commits.snapshot_json` và
`section_submissions.snapshot_json`.

FE hiện gửi thêm `editor_feature_collection` để editor/reviewer render lại draft đúng trạng thái.
Backend không apply trực tiếp trường này vào published tables, nhưng reviewer UI cần nó để preview.

Shape đang dùng:

```json
{
  "schema_version": 1,
  "section": {
    "id": "section-id",
    "title": "Section title"
  },
  "editor_feature_collection": {
    "type": "FeatureCollection",
    "features": []
  },
  "entities": [
    {
      "operation": "create",
      "id": "entity-id",
      "name": "Entity name",
      "slug": "entity-slug",
      "description": null,
      "type_id": "country",
      "status": 1,
      "is_deleted": 0,
      "base_updated_at": "optional ISO datetime",
      "base_hash": "optional sha256 hash"
    }
  ],
  "geometries": [
    {
      "operation": "create",
      "id": "geometry-id",
      "type": "country",
      "draw_geometry": {
        "type": "Polygon",
        "coordinates": []
      },
      "binding": ["other-geometry-id"],
      "time_start": null,
      "time_end": null,
      "bbox": {
        "min_lng": 0,
        "min_lat": 0,
        "max_lng": 1,
        "max_lat": 1
      },
      "is_deleted": 0,
      "base_updated_at": "optional ISO datetime",
      "base_hash": "optional sha256 hash"
    }
  ],
  "link_scopes": [
    {
      "operation": "replace",
      "geometry_id": "geometry-id",
      "entity_ids": ["entity-id"],
      "base_links_hash": "optional sha256 hash"
    }
  ]
}
```

Operation hợp lệ:

- `create`
- `update`
- `delete`
- `reference`
- `replace`, backend normalize thành `update` trong entity/geometry apply, còn link scope dùng như replace semantics.

Validation hiện tại:

- Snapshot phải là object.
- Entity operation phải hợp lệ.
- Entity `create/update/replace` cần `name`.
- Slug trong cùng snapshot không được trùng theo lowercase.
- Geometry operation phải hợp lệ.
- Geometry `delete/reference` không cần `draw_geometry`.
- Geometry `create/update/replace` cần `draw_geometry` hoặc `geometry`.
- `time_start <= time_end` nếu cả hai cùng có giá trị.
- `bbox` nếu có thì phải gồm number hợp lệ và min <= max.
- `binding` nếu có phải là mảng id, không được chứa chính geometry id.
- Mỗi `link_scope` cần `entity_ids` không rỗng.

## 6. Luồng Dữ Liệu Chính

### 6.1 Đọc map

1. FE gọi `GET /geometries` với bbox bắt buộc.
2. Backend query `geometries` active theo bbox, optional time và optional entity.
3. Backend load links từ `entity_geometries` và `entities`.
4. Backend trả GeoJSON `FeatureCollection`.

### 6.2 Tạo hoặc sửa dữ liệu trong editor

1. FE mở section bằng `GET /sections/:sectionId/editor`.
2. Backend trả section, state, head commit và snapshot.
3. FE chỉnh draft local.
4. FE build full snapshot.
5. FE gọi `POST /sections/:sectionId/commits`.
6. Backend validate snapshot và insert `section_commits`.
7. Published data chưa thay đổi.

### 6.3 Submit section

1. FE gọi `POST /sections/:sectionId/submit`.
2. Backend lấy commit id từ body hoặc `section_states.head_commit_id`.
3. Backend copy snapshot từ commit sang `section_submissions`.
4. Backend set section state thành `submitted`.
5. Backend release lock.

### 6.4 Duyệt section

1. Reviewer UI `/submited` load `GET /sections`, rồi load submissions theo từng section.
2. Reviewer xem snapshot preview từ `editor_feature_collection`.
3. Reviewer approve hoặc reject.
4. Approve apply snapshot vào `entities`, `geometries`, `entity_geometries`.
5. Reject không apply published data.

## 7. Runtime Migration

`BackEnd/db/polygons.js` vừa init schema mới, vừa tự xử lý một số database cũ.

Runtime migration hiện có:

- `ensureColumn("entities", "status", "INTEGER DEFAULT 1")`
- `ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0")`
- `ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'")`
- `ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0")`
- `ensureColumn("geometries", "binding", "TEXT")`
- `ensureColumn("sections", "user_id", "TEXT")`
- Drop/rebuild `entities` nếu còn cột deprecated `kind`.
- Drop/rebuild `geometries` nếu còn cột deprecated `kind` hoặc `line_mode`.
- Migrate legacy `geometries.type` nếu rỗng hoặc là `line/path`, bằng cách lấy `type_id` từ active entity đầu tiên đang link.

Lưu ý:

- Project chưa có bảng migrations riêng.
- Migration chạy khi backend require `BackEnd/db/polygons.js`.
- Rebuild table tạm tắt foreign keys rồi bật lại sau transaction.

## 8. Index Tổng Hợp

| Table | Index | Columns | Unique |
| --- | --- | --- | --- |
| `entities` | `idx_entities_slug` | `slug` | yes |
| `entities` | `idx_entities_name` | `name` | no |
| `entity_geometries` | `idx_entity_geometries_geometry_id` | `geometry_id` | no |
| `entity_geometries` | `idx_entity_geometries_entity_id` | `entity_id` | no |
| `section_states` | `idx_section_states_status` | `status`, `updated_at` | no |
| `section_commits` | `idx_section_commits_no` | `section_id`, `commit_no` | yes |
| `section_commits` | `idx_section_commits_section_time` | `section_id`, `created_at` | no |
| `section_submissions` | `idx_section_submissions_section_status` | `section_id`, `status`, `submitted_at` | no |

## 9. Không Có Trong DB Hiện Tại

Các phần sau chưa tồn tại trong schema hiện tại:

- `users` table.
- Auth/JWT/session table.
- Role/permission table.
- Migration version table.
- Audit log riêng ngoài `section_commits` và `section_submissions`.
- Direct draft table. Draft editor nằm trong snapshot JSON.

## 10. Checklist Khi Sửa Schema

Khi thêm hoặc đổi field published:

1. Update table init trong `BackEnd/db/polygons.js`.
2. Thêm migration runtime nếu DB cũ cần nâng cấp.
3. Update apply logic trong `BackEnd/routes/sections.js`.
4. Update hash conflict logic nếu field ảnh hưởng conflict.
5. Update read mapper trong `BackEnd/routes/entities.js` hoặc `BackEnd/routes/geometries.js`.
6. Update FE snapshot builder nếu field đến từ editor.
7. Update reviewer preview nếu field cần hiển thị ở `/submited`.
8. Update `DB.md`, `api.md`, `api_use.md` nếu API/schema thay đổi.

Khi đổi workflow section:

1. Update `section_states` status transition.
2. Update create/commit/restore/submit/review routes.
3. Update FE editor và reviewer UI.
4. Giữ invariant: commit không apply published data, approve mới apply.
