finish refactor pre merge
This commit is contained in:
604
DB.md
Normal file
604
DB.md
Normal file
@@ -0,0 +1,604 @@
|
|||||||
|
# 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.
|
||||||
573
api.md
Normal file
573
api.md
Normal file
@@ -0,0 +1,573 @@
|
|||||||
|
# API - Ultimate History Map (Backend)
|
||||||
|
|
||||||
|
Tài liệu này mô tả backend API theo đúng hành vi hiện tại của code trong `BackEnd/index.js` và `BackEnd/routes/*`.
|
||||||
|
|
||||||
|
- Backend: Express
|
||||||
|
- Base URL local (hardcode): `http://localhost:3000`
|
||||||
|
- DB nghiệp vụ: SQLite `BackEnd/data/polygons.db`
|
||||||
|
- Tile data: MBTiles `BackEnd/data/map.mbtiles` (vector) và `BackEnd/data/raster.mbtiles` (raster)
|
||||||
|
- Auth/permission: chưa có
|
||||||
|
- CORS: mở toàn bộ (`cors()`)
|
||||||
|
- Update: 2026-04-21
|
||||||
|
|
||||||
|
## 1. Envelope Response (Rất Quan Trọng)
|
||||||
|
|
||||||
|
Mọi endpoint JSON được mount qua `envelopeResponses` sẽ trả về envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "success",
|
||||||
|
"data": {},
|
||||||
|
"message": "OK",
|
||||||
|
"errors": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Khi lỗi (`HTTP >= 400`), envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "error",
|
||||||
|
"data": null,
|
||||||
|
"message": "Some message",
|
||||||
|
"errors": ["Some message"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Quy tắc bọc envelope hiện tại:
|
||||||
|
|
||||||
|
- Nếu handler `res.json({ error: "..." })` thì `message/errors` sẽ lấy từ `error`.
|
||||||
|
- Nếu handler `res.json({ message: "..." })` thì `message/errors` sẽ lấy từ `message`.
|
||||||
|
- Nếu handler trả `res.json([...])` hoặc `res.json({ ...domain... })` thì payload nằm trong `data`.
|
||||||
|
|
||||||
|
Ngoại lệ KHÔNG envelope:
|
||||||
|
|
||||||
|
- `GET /` trả plain text.
|
||||||
|
- `GET /docs` trả Swagger UI (HTML).
|
||||||
|
- `GET /docs.json` trả raw OpenAPI JSON (không bọc envelope).
|
||||||
|
- Tile binary `GET /tiles/:z/:x/:y`, `GET /raster-tiles/:z/:x/:y` trả binary khi success (vì dùng `res.send`). Các lỗi tile (400/404) vẫn là JSON nên vẫn bị bọc envelope.
|
||||||
|
|
||||||
|
## 2. Actor (user_id) Và “anonymous”
|
||||||
|
|
||||||
|
Backend có cơ chế normalize actor:
|
||||||
|
|
||||||
|
- Đọc actor từ body/query/header tùy endpoint (`user_id`, `user`, `created_by`, `submitted_by`, `reviewed_by`, header `x-user-id`).
|
||||||
|
- Hiện tại `normalizeActor()` luôn fallback sang chuỗi `"anonymous"` nếu thiếu.
|
||||||
|
|
||||||
|
Hệ quả:
|
||||||
|
|
||||||
|
- Thực tế các field actor **không bắt buộc** (dù code có vài chỗ check `"… is required"` nhưng check đó gần như không bao giờ nổ do luôn fallback `"anonymous"`).
|
||||||
|
- Lock/ownership khi thiếu actor sẽ bị “dính” vào cùng một user `"anonymous"` (dễ gây va chạm).
|
||||||
|
|
||||||
|
Khuyến nghị cho FE/clients: luôn gửi một actor id ổn định (ví dụ user id) để lock và audit có ý nghĩa.
|
||||||
|
|
||||||
|
## 3. System API
|
||||||
|
|
||||||
|
### `GET /`
|
||||||
|
|
||||||
|
Health check.
|
||||||
|
|
||||||
|
- `200` (text): `GIS server running`
|
||||||
|
|
||||||
|
### `GET /docs`
|
||||||
|
|
||||||
|
Swagger UI.
|
||||||
|
|
||||||
|
### `GET /docs.json`
|
||||||
|
|
||||||
|
OpenAPI JSON (raw, không envelope).
|
||||||
|
|
||||||
|
## 4. Tile API (MBTiles)
|
||||||
|
|
||||||
|
### `GET /tiles/metadata/info`
|
||||||
|
|
||||||
|
Vector MBTiles metadata từ `map.mbtiles` (table `metadata`).
|
||||||
|
|
||||||
|
- `200` JSON envelope, trong `data` là object key/value metadata.
|
||||||
|
|
||||||
|
### `GET /tiles/:z/:x/:y`
|
||||||
|
|
||||||
|
Vector tile binary (XYZ -> TMS).
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. Parse `z/x/y` thành integer; sai -> `400`.
|
||||||
|
2. Convert `y` từ XYZ sang TMS: `tmsY = (1 << z) - 1 - y`.
|
||||||
|
3. Query `tiles` table theo `(zoom_level, tile_column, tile_row)`.
|
||||||
|
4. Không có row -> `404`.
|
||||||
|
5. Set header:
|
||||||
|
- `Content-Type` dựa trên `metadata.format` (`pbf` -> `application/x-protobuf`).
|
||||||
|
- Nếu format `pbf` thì set `Content-Encoding: gzip` (tile data trong MBTiles thường đã gzip sẵn).
|
||||||
|
6. `res.send(tile_data)` (binary).
|
||||||
|
|
||||||
|
### `GET /raster-tiles/metadata/info`
|
||||||
|
|
||||||
|
Raster MBTiles metadata từ `raster.mbtiles`.
|
||||||
|
|
||||||
|
- `200` JSON envelope, `data` là metadata object.
|
||||||
|
|
||||||
|
### `GET /raster-tiles/:z/:x/:y`
|
||||||
|
|
||||||
|
Raster tile binary (XYZ -> TMS).
|
||||||
|
|
||||||
|
Luồng xử lý tương tự `/tiles/:z/:x/:y`, khác ở `Content-Type`:
|
||||||
|
|
||||||
|
- `png` -> `image/png`
|
||||||
|
- `jpg|jpeg` -> `image/jpeg`
|
||||||
|
- `webp` -> `image/webp`
|
||||||
|
- khác -> `application/octet-stream`
|
||||||
|
|
||||||
|
## 5. Published Entity API (Read-only)
|
||||||
|
|
||||||
|
Published entities lấy từ table `entities` với filter `is_deleted = 0`.
|
||||||
|
|
||||||
|
### Entity Object (trong envelope `data`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "entity-id",
|
||||||
|
"name": "Vietnam",
|
||||||
|
"slug": "vietnam",
|
||||||
|
"description": null,
|
||||||
|
"type_id": "country",
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2026-04-17T10:00:00.000Z",
|
||||||
|
"updated_at": "2026-04-17T10:00:00.000Z",
|
||||||
|
"geometry_count": 3
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `GET /entities?q=...`
|
||||||
|
|
||||||
|
List entities, optional search theo `name` hoặc `slug` bằng `LIKE`.
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. `q` được trim; nếu rỗng thì list toàn bộ.
|
||||||
|
2. Query `entities` + LEFT JOIN `entity_geometries` để tính `geometry_count`.
|
||||||
|
3. Sort theo `name COLLATE NOCASE ASC`.
|
||||||
|
|
||||||
|
### `GET /entities/search?name=...&limit=...`
|
||||||
|
|
||||||
|
Search entity theo `name` bằng `LIKE`.
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. `name` rỗng -> trả `[]`.
|
||||||
|
2. `limit` default `25`, max `100`.
|
||||||
|
3. Query `entities` + LEFT JOIN `entity_geometries` để tính `geometry_count`.
|
||||||
|
|
||||||
|
### `GET /entities/:id`
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. Query entity theo `id` và `is_deleted = 0`.
|
||||||
|
2. Không tồn tại -> `404` (`error: "Entity not found"`).
|
||||||
|
|
||||||
|
## 6. Published Geometry API (Read-only)
|
||||||
|
|
||||||
|
Published geometries lấy từ table `geometries` với filter `is_deleted = 0`.
|
||||||
|
|
||||||
|
### `GET /geometries`
|
||||||
|
|
||||||
|
Query geometries theo bbox/time/entity.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
|
||||||
|
| Field | Bắt buộc | Type | Ghi chú |
|
||||||
|
| --- | ---: | --- | --- |
|
||||||
|
| `minLng` | Có | number | bbox min longitude |
|
||||||
|
| `minLat` | Có | number | bbox min latitude |
|
||||||
|
| `maxLng` | Có | number | bbox max longitude |
|
||||||
|
| `maxLat` | Có | number | bbox max latitude |
|
||||||
|
| `time` | Không | number | parse int; filter temporal range |
|
||||||
|
| `entity_id` | Không | string | lọc geometry có link entity này |
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. Validate bbox là số hữu hạn; fail -> `400` (`Missing/invalid bbox`).
|
||||||
|
2. Nếu có `time` (không rỗng) thì parse số; fail -> `400` (`Invalid time`).
|
||||||
|
3. Query geometries theo:
|
||||||
|
- bbox intersects
|
||||||
|
- nếu có `time` thì pass khi:
|
||||||
|
- `time_start` null hoặc `time_start <= time`
|
||||||
|
- `time_end` null hoặc `time_end >= time`
|
||||||
|
- nếu có `entity_id` thì dùng `EXISTS` trên `entity_geometries` + `entities.is_deleted = 0`
|
||||||
|
4. Load links entity cho các geometry id trả về, giữ thứ tự theo `entity_geometries.rowid ASC`.
|
||||||
|
5. Build GeoJSON FeatureCollection:
|
||||||
|
- `feature.geometry` parse từ `geometries.draw_geometry` (JSON string).
|
||||||
|
- `properties.binding` được parse/normalize từ `geometries.binding` (JSON string hoặc fallback).
|
||||||
|
- `properties.entity_ids/entity_names/...` dựa trên link table.
|
||||||
|
- `properties.type`:
|
||||||
|
- ưu tiên `geometries.type` nếu khác null và không phải legacy `line|path`
|
||||||
|
- nếu legacy `line|path` thì fallback sang `entities.type_id` của primary entity.
|
||||||
|
|
||||||
|
### Geometry Feature (trong envelope `data`)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"properties": {
|
||||||
|
"id": "geometry-id",
|
||||||
|
"type": "country",
|
||||||
|
"time_start": 1802,
|
||||||
|
"time_end": 1884,
|
||||||
|
"binding": [],
|
||||||
|
"entity_id": "entity-id",
|
||||||
|
"entity_ids": ["entity-id"],
|
||||||
|
"entity_name": "Vietnam",
|
||||||
|
"entity_names": ["Vietnam"],
|
||||||
|
"entity_type_id": "country"
|
||||||
|
},
|
||||||
|
"geometry": { "type": "Polygon", "coordinates": [] }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Section Workflow API (Write path chính)
|
||||||
|
|
||||||
|
Không có API mutate trực tiếp published `entities/geometries`. Mọi thay đổi đi qua section workflow:
|
||||||
|
|
||||||
|
1. Editor mở section (`GET /sections/:id/editor`) để lấy snapshot và lock.
|
||||||
|
2. Editor commit snapshot (`POST /sections/:id/commits` hoặc `/restore`).
|
||||||
|
3. Editor submit head (`POST /sections/:id/submit`) -> tạo submission pending.
|
||||||
|
4. Reviewer approve/reject (`POST /submissions/:id/...`).
|
||||||
|
5. Chỉ approve mới apply snapshot vào published tables.
|
||||||
|
|
||||||
|
### 7.1. Data Model (khái niệm)
|
||||||
|
|
||||||
|
- `sections`: metadata của draft space.
|
||||||
|
- `section_states`: trạng thái và lock, version, head commit.
|
||||||
|
- `section_commits`: lịch sử snapshot (không apply).
|
||||||
|
- `section_submissions`: snapshot pending/review.
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- `section_states.status`: `editing | submitted | approved | rejected`
|
||||||
|
- `section_submissions.status`: `pending | approved | rejected | conflicted`
|
||||||
|
|
||||||
|
Lock:
|
||||||
|
|
||||||
|
- TTL 15 phút (`LOCK_TTL_MS = 15 * 60 * 1000`).
|
||||||
|
- Lock được check theo `locked_by` và `lock_expires_at`.
|
||||||
|
|
||||||
|
Optimistic concurrency:
|
||||||
|
|
||||||
|
- Client có thể gửi `expected_version` và/hoặc `expected_head_commit_id` khi commit/restore.
|
||||||
|
- Sai -> `409` (`Section version changed` / `Section head commit changed`).
|
||||||
|
|
||||||
|
### 7.2. `GET /sections`
|
||||||
|
|
||||||
|
List sections + state (LEFT JOIN). Sort `sections.updated_at DESC`.
|
||||||
|
|
||||||
|
### 7.3. `POST /sections`
|
||||||
|
|
||||||
|
Tạo section và tạo luôn state (status `editing`, version `0`).
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. Validate `title` là string non-empty (trim). Sai -> `400` (`title is required`).
|
||||||
|
2. `id` nếu thiếu thì server tự sinh UUID.
|
||||||
|
3. Insert vào `sections`.
|
||||||
|
4. `ensureSectionState(section_id)` (insert `section_states` nếu chưa có).
|
||||||
|
5. SQLite constraint (trùng id) -> `409` (`Section id already exists`).
|
||||||
|
|
||||||
|
### 7.4. `GET /sections/:sectionId/editor`
|
||||||
|
|
||||||
|
Mở editor và (có side-effect) acquire lock.
|
||||||
|
|
||||||
|
Actor lấy từ query/header:
|
||||||
|
|
||||||
|
- `?user_id=...` hoặc `?user=...`
|
||||||
|
- header `x-user-id`
|
||||||
|
|
||||||
|
Luồng xử lý (transaction):
|
||||||
|
|
||||||
|
1. Load section; không có -> `404` (`Section not found`).
|
||||||
|
2. `ensureSectionState`.
|
||||||
|
3. `assertLockAvailable(state, actor, now)`:
|
||||||
|
- OK nếu chưa lock, hoặc lock bởi chính actor, hoặc lock đã expire.
|
||||||
|
- Fail -> `409` (`Section is locked by another user`).
|
||||||
|
4. Acquire/refresh lock cho actor (set `locked_by/locked_at/lock_expires_at`).
|
||||||
|
5. Nếu có `head_commit_id` thì load commit đó.
|
||||||
|
6. Response:
|
||||||
|
- `section`: object normalized
|
||||||
|
- `state`: object normalized
|
||||||
|
- `commit`: normalized commit (không include snapshot)
|
||||||
|
- `snapshot`:
|
||||||
|
- nếu có head commit: parse `snapshot_json`
|
||||||
|
- nếu chưa có commit: trả empty snapshot `{ schema_version, section, entities:[], geometries:[], link_scopes:[] }`
|
||||||
|
|
||||||
|
Ghi chú: vì actor fallback `"anonymous"`, gọi endpoint này mà không gửi user id vẫn tạo lock (locked_by = `"anonymous"`).
|
||||||
|
|
||||||
|
### 7.5. `POST /sections/:sectionId/lock`
|
||||||
|
|
||||||
|
Acquire/refresh lock (tách riêng, không trả snapshot).
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. Load section; không có -> `404`.
|
||||||
|
2. `ensureSectionState`.
|
||||||
|
3. `assertLockAvailable`.
|
||||||
|
4. Acquire lock và trả `state`.
|
||||||
|
|
||||||
|
### 7.6. `POST /sections/:sectionId/unlock`
|
||||||
|
|
||||||
|
Release lock.
|
||||||
|
|
||||||
|
Luồng xử lý:
|
||||||
|
|
||||||
|
1. Load section; không có -> `404`.
|
||||||
|
2. `ensureSectionState`.
|
||||||
|
3. Nếu `state.locked_by` tồn tại và khác actor hiện tại -> `409` (`Section is locked by another user`).
|
||||||
|
4. Clear lock fields.
|
||||||
|
|
||||||
|
### 7.7. `GET /sections/:sectionId/commits?include_snapshot=1`
|
||||||
|
|
||||||
|
List commit history (sort `commit_no DESC`).
|
||||||
|
|
||||||
|
- `include_snapshot=1|true` -> mỗi commit có thêm `snapshot` (parsed JSON).
|
||||||
|
|
||||||
|
### 7.8. `POST /sections/:sectionId/commits`
|
||||||
|
|
||||||
|
Tạo commit mới từ snapshot editor.
|
||||||
|
|
||||||
|
Body:
|
||||||
|
|
||||||
|
- `snapshot` (object) hoặc `snapshot_json` (string JSON) là bắt buộc.
|
||||||
|
- Actor thường gửi qua `created_by` hoặc `user_id` hoặc header `x-user-id`.
|
||||||
|
- Optional:
|
||||||
|
- `expected_version`
|
||||||
|
- `expected_head_commit_id`
|
||||||
|
- `title`, `note`
|
||||||
|
|
||||||
|
Luồng xử lý (transaction):
|
||||||
|
|
||||||
|
1. Load section; không có -> `404`.
|
||||||
|
2. Normalize snapshot input:
|
||||||
|
- `snapshot_json` không parse được -> `400` (`snapshot_json must be valid JSON`)
|
||||||
|
- thiếu snapshot -> `400` (`snapshot is required`)
|
||||||
|
3. `ensureSectionState`.
|
||||||
|
4. `assertCanEdit`:
|
||||||
|
- status phải là `editing` hoặc `rejected`, không thì `409` (`Section is not editable`)
|
||||||
|
- lock phải available, không thì `409` (`Section is locked by another user`)
|
||||||
|
5. `assertExpectedState` nếu client có gửi expected fields.
|
||||||
|
6. `validateSnapshot`:
|
||||||
|
- contract level (bắt buộc có `snapshot.section.id/title`, entity/geometry ids, link_scope shape)
|
||||||
|
- rule bổ sung: name required (trừ delete/reference), slug không trùng (case-insensitive), time range hợp lệ, bbox nếu có phải hợp lệ, binding không chứa self id, link_scope entity_ids không rỗng.
|
||||||
|
7. Insert `section_commits`:
|
||||||
|
- `parent_commit_id = state.head_commit_id`
|
||||||
|
- `commit_no` auto tăng
|
||||||
|
- `snapshot_hash = sha256(JSON)`
|
||||||
|
8. Update `section_states`:
|
||||||
|
- `status = 'editing'`
|
||||||
|
- `head_commit_id = newCommitId`
|
||||||
|
- `version = version + 1`
|
||||||
|
9. Update `sections.updated_at`.
|
||||||
|
|
||||||
|
Response `201`: `{ commit, state }` (commit có `snapshot`).
|
||||||
|
|
||||||
|
### 7.9. `POST /sections/:sectionId/restore`
|
||||||
|
|
||||||
|
Restore một commit cũ bằng cách tạo commit mới `kind = "restore"`.
|
||||||
|
|
||||||
|
Luồng xử lý giống `/commits` nhưng snapshot lấy từ commit nguồn:
|
||||||
|
|
||||||
|
1. Validate `commit_id`/`restore_commit_id`; thiếu -> `400` (`commit_id is required`).
|
||||||
|
2. Check commit nguồn thuộc section; không có -> `404` (`Commit not found`).
|
||||||
|
3. Insert commit mới với `restored_from_commit_id = source.id`.
|
||||||
|
4. Update head commit + bump version.
|
||||||
|
|
||||||
|
### 7.10. `POST /sections/:sectionId/submit`
|
||||||
|
|
||||||
|
Submit head commit hiện tại -> tạo submission pending, set state `submitted`, release lock.
|
||||||
|
|
||||||
|
Luồng xử lý (transaction):
|
||||||
|
|
||||||
|
1. Load section; không có -> `404`.
|
||||||
|
2. `ensureSectionState`.
|
||||||
|
3. `assertLockAvailable` (chỉ check không bị user khác lock).
|
||||||
|
4. status phải là `editing`, không thì `409` (`Section is not editable`).
|
||||||
|
5. `head_commit_id` phải có, không thì `400` (`Section has no head commit to submit`).
|
||||||
|
6. Load commit head; không có -> `404` (`Commit not found`).
|
||||||
|
7. Insert `section_submissions` (status `pending`) copy snapshot từ commit head.
|
||||||
|
8. Update `section_states`:
|
||||||
|
- `status = 'submitted'`
|
||||||
|
- clear lock fields.
|
||||||
|
|
||||||
|
Response `201`: `SectionSubmission` (có `snapshot`).
|
||||||
|
|
||||||
|
### 7.11. `GET /sections/:sectionId/submissions?include_snapshot=1`
|
||||||
|
|
||||||
|
List submission history của section (sort `submitted_at DESC`).
|
||||||
|
|
||||||
|
## 8. Review API (`/submissions`)
|
||||||
|
|
||||||
|
Review endpoints được mount “public” qua `/submissions` nhưng implementation nằm trong `BackEnd/routes/sections.js`:
|
||||||
|
|
||||||
|
- `POST /submissions/:submissionId/approve`
|
||||||
|
- `POST /submissions/:submissionId/reject`
|
||||||
|
|
||||||
|
### 8.1. `POST /submissions/:submissionId/reject`
|
||||||
|
|
||||||
|
Luồng xử lý (transaction):
|
||||||
|
|
||||||
|
1. Load submission; không có -> `404` (`Submission not found`).
|
||||||
|
2. Submission phải `pending`, không thì `409` (`Submission is not pending`).
|
||||||
|
3. Update submission:
|
||||||
|
- `status = rejected`
|
||||||
|
- set `reviewed_by/reviewed_at/review_note`
|
||||||
|
4. Update `section_states.status = rejected`.
|
||||||
|
5. Không apply published tables.
|
||||||
|
|
||||||
|
Response `200`: `SectionSubmission` (có `snapshot`).
|
||||||
|
|
||||||
|
### 8.2. `POST /submissions/:submissionId/approve`
|
||||||
|
|
||||||
|
Luồng xử lý (transaction):
|
||||||
|
|
||||||
|
1. Load submission; không có -> `404`.
|
||||||
|
2. Submission phải `pending`, không thì `409`.
|
||||||
|
3. Verify snapshot hash:
|
||||||
|
- nếu `submission.snapshot_hash` tồn tại và khác `sha256(submission.snapshot_json)` -> `409` (`Submission snapshot hash mismatch`).
|
||||||
|
4. Parse snapshot JSON.
|
||||||
|
5. `applySnapshotToPublished(snapshot, now)` theo thứ tự:
|
||||||
|
- apply `entities`
|
||||||
|
- apply `geometries`
|
||||||
|
- apply `link_scopes` (replace links)
|
||||||
|
6. Update submission:
|
||||||
|
- `status = approved`
|
||||||
|
- set `reviewed_by/reviewed_at/review_note`
|
||||||
|
7. Update `section_states.status = approved`.
|
||||||
|
|
||||||
|
Conflict handling đặc biệt:
|
||||||
|
|
||||||
|
- Nếu trong lúc apply snapshot phát hiện conflict (409) thì backend:
|
||||||
|
- (best-effort) update `section_submissions.status = conflicted` và set `review_note = <conflict message>` nếu submission vẫn còn `pending`.
|
||||||
|
- trả `409` cho client.
|
||||||
|
|
||||||
|
## 9. Snapshot Format Và Apply Semantics
|
||||||
|
|
||||||
|
Commit/submission snapshot là full JSON object. Backend quan tâm các mảng:
|
||||||
|
|
||||||
|
- `entities[]`
|
||||||
|
- `geometries[]`
|
||||||
|
- `link_scopes[]`
|
||||||
|
|
||||||
|
Ngoài ra FE có thể kèm `editor_feature_collection` để preview (backend chỉ normalize, không apply vào DB).
|
||||||
|
|
||||||
|
### 9.1. Snapshot Shape (rút gọn)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"section": { "id": "section-id", "title": "Section title" },
|
||||||
|
"editor_feature_collection": { "type": "FeatureCollection", "features": [] },
|
||||||
|
"entities": [],
|
||||||
|
"geometries": [],
|
||||||
|
"link_scopes": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Operations (case-insensitive):
|
||||||
|
|
||||||
|
- `create | update | delete | reference | replace`
|
||||||
|
- `replace` được normalize thành `update` cho entity/geometry.
|
||||||
|
|
||||||
|
### 9.2. Validate Snapshot (400)
|
||||||
|
|
||||||
|
Backend validate ở `/commits` và trước khi approve:
|
||||||
|
|
||||||
|
- `snapshot.section.id` và `snapshot.section.title` bắt buộc.
|
||||||
|
- Mỗi entity/geometry trong snapshot bắt buộc có `id` và `operation` hợp lệ.
|
||||||
|
- Entity:
|
||||||
|
- `name` bắt buộc nếu không phải `delete|reference`
|
||||||
|
- `slug` (nếu có) không được trùng trong cùng snapshot (case-insensitive)
|
||||||
|
- Geometry:
|
||||||
|
- nếu `create|update|replace` thì bắt buộc có `draw_geometry` hoặc `geometry`
|
||||||
|
- nếu có cả `time_start/time_end` thì `time_start <= time_end`
|
||||||
|
- `bbox` nếu gửi thì phải hợp lệ; nếu không gửi, approve sẽ tự tính bbox từ geometry
|
||||||
|
- `binding` không được chứa chính `geometry.id`
|
||||||
|
- Link scope:
|
||||||
|
- `geometry_id` bắt buộc, `entity_ids` phải là array
|
||||||
|
- `entity_ids` sau normalize không được rỗng
|
||||||
|
|
||||||
|
### 9.3. Apply Snapshot (Approve)
|
||||||
|
|
||||||
|
#### Entities
|
||||||
|
|
||||||
|
- `create`: INSERT `entities` (type_id default `"country"` nếu thiếu), `created_at/updated_at = now`.
|
||||||
|
- `update|replace`: UPDATE toàn bộ fields; có thể check conflict bằng `base_updated_at` và/hoặc `base_hash` (nếu snapshot item gửi).
|
||||||
|
- `delete`: set `entities.is_deleted = 1`, xóa mọi link trong `entity_geometries`.
|
||||||
|
- `reference`: bỏ qua.
|
||||||
|
|
||||||
|
#### Geometries
|
||||||
|
|
||||||
|
- `create`: INSERT `geometries`:
|
||||||
|
- `draw_geometry` được stringify JSON
|
||||||
|
- `bbox`: nếu snapshot có `bbox` hợp lệ thì dùng, nếu không thì compute bằng `getBBox(draw_geometry)`
|
||||||
|
- `binding`: normalize array id và tự loại self id
|
||||||
|
- `update|replace`: UPDATE toàn bộ fields tương tự create (có check conflict bằng base fields).
|
||||||
|
- `delete`:
|
||||||
|
- set `geometries.is_deleted = 1`
|
||||||
|
- delete links trong `entity_geometries` theo `geometry_id`
|
||||||
|
- remove mọi reference tới geometry đó trong `binding` của các geometry khác.
|
||||||
|
- `reference`: bỏ qua.
|
||||||
|
|
||||||
|
#### Link scopes (replace entity links theo geometry)
|
||||||
|
|
||||||
|
- Geometry phải tồn tại và `is_deleted = 0`, nếu không -> conflict (409).
|
||||||
|
- `entity_ids` không rỗng, và mọi entity id phải tồn tại + `is_deleted = 0`, nếu không -> `400`.
|
||||||
|
- Nếu snapshot gửi `base_links_hash`:
|
||||||
|
- backend hash current links (sorted by entity_id) và so sánh; mismatch -> conflict (409).
|
||||||
|
- Apply bằng cách:
|
||||||
|
- `DELETE FROM entity_geometries WHERE geometry_id = ?`
|
||||||
|
- rồi `INSERT` lại toàn bộ link theo thứ tự `entity_ids` gửi lên.
|
||||||
|
|
||||||
|
### 9.4. Conflict Semantics (409)
|
||||||
|
|
||||||
|
Nhóm lỗi conflict (status 409, `err.isConflict = true`) xảy ra khi approve apply:
|
||||||
|
|
||||||
|
- Update/delete entity/geometry nhưng record không tồn tại.
|
||||||
|
- `base_updated_at` mismatch hoặc `base_hash` mismatch.
|
||||||
|
- Link scope geometry không tồn tại/đã deleted.
|
||||||
|
- `base_links_hash` mismatch.
|
||||||
|
|
||||||
|
## 10. Status Code Summary
|
||||||
|
|
||||||
|
| Status | Khi nào |
|
||||||
|
| --- | --- |
|
||||||
|
| `200` | OK (read/review) |
|
||||||
|
| `201` | Created (section/commit/restore/submit) |
|
||||||
|
| `400` | Query/body/snapshot không hợp lệ |
|
||||||
|
| `404` | Resource không tồn tại |
|
||||||
|
| `409` | Lock conflict, stale version/head, state không editable, conflict khi apply, hoặc DB constraint |
|
||||||
|
| `500` | Lỗi server |
|
||||||
|
|
||||||
|
## 11. Luồng Tham Chiếu (End-to-End)
|
||||||
|
|
||||||
|
### 11.1. Editor Flow
|
||||||
|
|
||||||
|
1. List section: `GET /sections`.
|
||||||
|
2. Open editor (và lock): `GET /sections/:sectionId/editor?user_id=<actor>`.
|
||||||
|
3. User edit local -> tạo snapshot.
|
||||||
|
4. Commit: `POST /sections/:sectionId/commits` (khuyến nghị gửi `expected_version` + `expected_head_commit_id` từ state).
|
||||||
|
5. (Optional) Restore: `POST /sections/:sectionId/restore`.
|
||||||
|
6. Submit: `POST /sections/:sectionId/submit`.
|
||||||
|
|
||||||
|
### 11.2. Reviewer Flow
|
||||||
|
|
||||||
|
1. List sections: `GET /sections`.
|
||||||
|
2. Với từng section: `GET /sections/:sectionId/submissions?include_snapshot=1`.
|
||||||
|
3. Approve/reject:
|
||||||
|
- `POST /submissions/:submissionId/approve`
|
||||||
|
- `POST /submissions/:submissionId/reject`
|
||||||
|
|
||||||
|
## 12. Giới Hạn / “Gotchas” Hiện Tại
|
||||||
|
|
||||||
|
- Không có auth/permission.
|
||||||
|
- Actor fallback `"anonymous"` làm “required actor” không thực sự enforced; lock và audit dễ sai nếu client không gửi user id.
|
||||||
|
- `GET /sections/:id/editor` luôn acquire lock (side-effect). Nếu bạn chỉ muốn đọc snapshot mà không lock, hiện chưa có endpoint riêng.
|
||||||
|
- Không có pagination cho list sections/commits/submissions.
|
||||||
|
- Reviewer UI nếu cần list submissions toàn cục: hiện chưa có endpoint `GET /submissions` (phải gom theo section).
|
||||||
|
- Conflict protection (`base_hash/base_updated_at/base_links_hash`) chỉ hiệu quả nếu FE gửi các field này.
|
||||||
|
|
||||||
Reference in New Issue
Block a user