# 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 = ` 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=`. 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.