finish refactor pre merge

This commit is contained in:
taDuc
2026-04-21 16:07:17 +07:00
parent 6105a4c8da
commit f064b099be
2 changed files with 1177 additions and 0 deletions

573
api.md Normal file
View 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``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``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``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``snapshot.section.title` bắt buộc.
- Mỗi entity/geometry trong snapshot bắt buộc có `id``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.