change snapshot commit to new format
This commit is contained in:
@@ -1,40 +1,42 @@
|
|||||||
# Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndAdmin)
|
# Commit Snapshot (`commits.snapshot_json`) - Chuẩn Hiện Tại (FrontEndUser / UHM)
|
||||||
|
|
||||||
Tài liệu này mô tả **commit snapshot** được `FrontEndAdmin` tạo ra khi bấm **Commit** trong `/editor`, và được lưu vào `BackEndGo.commits.snapshot_json` (JSONB).
|
Tài liệu này mô tả **snapshot_json** mà `FrontEndUser` (module UHM editor) tạo ra khi bấm **Commit** trong `/editor/[id]`, và gửi lên endpoint `POST /projects/{id}/commits`.
|
||||||
|
|
||||||
Nguồn tham chiếu trong code:
|
Nguồn tham chiếu trong code (FrontEndUser):
|
||||||
|
|
||||||
- Type snapshot: `FrontEndAdmin/src/uhm/types/sections.ts` (`EditorSnapshot`)
|
- Types:
|
||||||
- Build snapshot: `FrontEndAdmin/src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`)
|
- `src/uhm/types/sections.ts` (`EditorSnapshot`, `EntityWikiLinkSnapshot`)
|
||||||
|
- `src/uhm/types/geo.ts` (`FeatureCollection`, `GeometrySnapshot`, `GeometryEntitySnapshot`)
|
||||||
|
- `src/uhm/types/entities.ts` (`EntitySnapshot`)
|
||||||
|
- `src/uhm/types/wiki.ts` (`WikiSnapshot`)
|
||||||
|
- Build/normalize snapshot:
|
||||||
|
- `src/uhm/lib/editor/snapshot/editorSnapshot.ts` (`buildEditorSnapshot`, `normalizeEditorSnapshot`)
|
||||||
|
|
||||||
## 1) Tổng Quan Schema
|
## 1) Root Shape
|
||||||
|
|
||||||
Snapshot hiện tại:
|
FE hiện tại không dùng `schema_version`. `snapshot_json` là một object có các phần sau:
|
||||||
|
|
||||||
- Không có `schema_version`.
|
|
||||||
- Không lưu `section` (entity trong DB là `projects`; project được xác định bằng context record `commits.project_id`).
|
|
||||||
- Không dùng `ref:{id}` nữa: **`id` là canonical**, `source:"ref"` nghĩa là tham chiếu theo `id`.
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export type CommitSnapshot = {
|
export type EditorSnapshot = {
|
||||||
editor_feature_collection?: FeatureCollection;
|
editor_feature_collection?: FeatureCollection;
|
||||||
|
|
||||||
entities?: EntitySnapshot[];
|
entities?: EntitySnapshot[];
|
||||||
geometries?: GeometrySnapshot[];
|
geometries?: GeometrySnapshot[];
|
||||||
|
geometry_entity?: GeometryEntitySnapshot[];
|
||||||
wikis?: WikiSnapshot[];
|
wikis?: WikiSnapshot[];
|
||||||
|
entity_wiki?: EntityWikiLinkSnapshot[];
|
||||||
geometry_entity?: GeometryEntitySnapshot[]; // geometry ↔ entity (many-to-many)
|
|
||||||
entity_wiki?: EntityWikiLinkSnapshot[]; // entity ↔ wiki
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 1.1 Type đầy đủ (TypeScript)
|
Lưu ý:
|
||||||
|
|
||||||
Đây là bản type "đúng để BEGo implement chuyển đổi snapshot → DB". FE có thể gửi thêm field legacy (xem mục 6), nhưng BE nên normalize theo các type dưới đây.
|
- FE có thể **đọc** cả `entity_wiki` và legacy alias `entity_wikis` khi load snapshot (normalize), nhưng khi commit FE ghi `entity_wiki`.
|
||||||
|
- `editor_feature_collection` là nguồn để render editor/map. Các join table (`geometry_entity`, `entity_wiki`) là nguồn quan hệ.
|
||||||
|
|
||||||
|
## 2) Types (TypeScript) - Đúng Theo FE Hiện Tại
|
||||||
|
|
||||||
|
### 2.1 GeoJSON (editor_feature_collection)
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
// ---- GeoJSON ----
|
|
||||||
|
|
||||||
export type Geometry =
|
export type Geometry =
|
||||||
| { type: "Point"; coordinates: [number, number] }
|
| { type: "Point"; coordinates: [number, number] }
|
||||||
| { type: "MultiPoint"; coordinates: [number, number][] }
|
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||||
@@ -43,7 +45,7 @@ export type Geometry =
|
|||||||
| { type: "Polygon"; coordinates: [number, number][][] }
|
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||||
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||||
|
|
||||||
export type FeatureId = string | number; // FE hiện dùng UUIDv7 string
|
export type FeatureId = string | number;
|
||||||
|
|
||||||
export type FeatureProperties = {
|
export type FeatureProperties = {
|
||||||
id: FeatureId;
|
id: FeatureId;
|
||||||
@@ -51,10 +53,9 @@ export type FeatureProperties = {
|
|||||||
geometry_preset?: string | null;
|
geometry_preset?: string | null;
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
binding?: string[]; // entity ids used as "binding filter"
|
binding?: string[];
|
||||||
|
|
||||||
// Legacy UI fields. FE persist snapshot hiện tại KHONG gửi các field này,
|
// UI-only / legacy fields (FE sẽ strip khi persist snapshot):
|
||||||
// nhưng BE nên ignore nếu gặp trong snapshot cũ:
|
|
||||||
entity_id?: string | null;
|
entity_id?: string | null;
|
||||||
entity_ids?: string[];
|
entity_ids?: string[];
|
||||||
entity_name?: string | null;
|
entity_name?: string | null;
|
||||||
@@ -72,9 +73,11 @@ export type FeatureCollection = {
|
|||||||
type: "FeatureCollection";
|
type: "FeatureCollection";
|
||||||
features: Feature[];
|
features: Feature[];
|
||||||
};
|
};
|
||||||
|
```
|
||||||
|
|
||||||
// ---- Snapshot rows ----
|
### 2.2 Snapshot rows
|
||||||
|
|
||||||
|
```ts
|
||||||
export type SnapshotSource = "inline" | "ref";
|
export type SnapshotSource = "inline" | "ref";
|
||||||
|
|
||||||
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
export type EntitySnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
@@ -82,7 +85,7 @@ export type GeometrySnapshotOperation = "create" | "update" | "delete" | "refere
|
|||||||
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
export type WikiSnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
export type EntitySnapshot = {
|
export type EntitySnapshot = {
|
||||||
id: string; // UUIDv7 string (canonical)
|
id: string;
|
||||||
source: SnapshotSource;
|
source: SnapshotSource;
|
||||||
operation?: EntitySnapshotOperation;
|
operation?: EntitySnapshotOperation;
|
||||||
name?: string;
|
name?: string;
|
||||||
@@ -94,13 +97,12 @@ export type EntitySnapshot = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export type GeometrySnapshot = {
|
export type GeometrySnapshot = {
|
||||||
id: string; // UUIDv7 string (canonical)
|
id: string;
|
||||||
source: SnapshotSource;
|
source: SnapshotSource;
|
||||||
operation?: GeometrySnapshotOperation;
|
operation?: GeometrySnapshotOperation;
|
||||||
|
|
||||||
// Present when source:"inline" (draft features)
|
|
||||||
type?: string | null;
|
type?: string | null;
|
||||||
draw_geometry?: Geometry;
|
draw_geometry?: Geometry;
|
||||||
|
geometry?: Geometry; // legacy
|
||||||
binding?: string[];
|
binding?: string[];
|
||||||
time_start?: number | null;
|
time_start?: number | null;
|
||||||
time_end?: number | null;
|
time_end?: number | null;
|
||||||
@@ -110,22 +112,27 @@ export type GeometrySnapshot = {
|
|||||||
max_lng: number;
|
max_lng: number;
|
||||||
max_lat: number;
|
max_lat: number;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
base_updated_at?: string;
|
base_updated_at?: string;
|
||||||
base_hash?: string;
|
base_hash?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// FE stores wiki doc as a string (commonly HTML; in some flows it may be a JSON-stringified editor payload).
|
||||||
|
export type WikiDoc = string | null;
|
||||||
|
|
||||||
export type WikiSnapshot = {
|
export type WikiSnapshot = {
|
||||||
id: string; // UUIDv7 string (canonical)
|
id: string;
|
||||||
source: SnapshotSource;
|
source: SnapshotSource;
|
||||||
operation?: WikiSnapshotOperation;
|
operation?: WikiSnapshotOperation;
|
||||||
title: string;
|
title: string;
|
||||||
doc: unknown; // tiptap JSON (inline) hoặc null (ref)
|
slug?: string | null;
|
||||||
|
doc: WikiDoc;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
};
|
};
|
||||||
|
```
|
||||||
|
|
||||||
// ---- Join tables ----
|
### 2.3 Join tables
|
||||||
|
|
||||||
|
```ts
|
||||||
export type GeometryEntitySnapshot = {
|
export type GeometryEntitySnapshot = {
|
||||||
geometry_id: string;
|
geometry_id: string;
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
@@ -135,149 +142,73 @@ export type GeometryEntitySnapshot = {
|
|||||||
export type EntityWikiLinkSnapshot = {
|
export type EntityWikiLinkSnapshot = {
|
||||||
entity_id: string;
|
entity_id: string;
|
||||||
wiki_id: string;
|
wiki_id: string;
|
||||||
// If missing, BE should treat as "reference" (active link) for backwards-compat.
|
operation?: "reference" | "binding" | "delete";
|
||||||
operation?: "reference" | "delete";
|
|
||||||
};
|
|
||||||
|
|
||||||
// ---- Root ----
|
|
||||||
|
|
||||||
export type CommitSnapshot = {
|
|
||||||
editor_feature_collection?: FeatureCollection;
|
|
||||||
entities?: EntitySnapshot[];
|
|
||||||
geometries?: GeometrySnapshot[];
|
|
||||||
wikis?: WikiSnapshot[];
|
|
||||||
geometry_entity?: GeometryEntitySnapshot[];
|
|
||||||
entity_wiki?: EntityWikiLinkSnapshot[];
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
## 2) Quy Ước `source` và `operation`
|
## 3) Quy Ước FE Khi Build Snapshot (buildEditorSnapshot)
|
||||||
|
|
||||||
### 2.1 `source` (bắt buộc)
|
### 3.1 Feature.properties entity fields bị strip
|
||||||
|
|
||||||
`source` bắt buộc là một trong:
|
Khi persist snapshot, FE chủ động xoá các field denormalize trên feature properties:
|
||||||
|
`entity_id`, `entity_ids`, `entity_name`, `entity_names`, `entity_type_id`.
|
||||||
|
|
||||||
- `inline`: dữ liệu được embed trong snapshot_json.
|
Quan hệ geometry ↔ entity chỉ nằm ở `geometry_entity[]`.
|
||||||
- `ref`: dữ liệu là tham chiếu (theo `id`), cần fetch bên ngoài nếu muốn đầy đủ.
|
|
||||||
|
|
||||||
FE hiện tại luôn ghi `source` cho `entities[]`, `geometries[]`, `wikis[]`.
|
### 3.2 entities[]
|
||||||
|
|
||||||
### 2.2 `operation` (tùy chọn)
|
FE cố gắng đảm bảo mọi entity có `name` không rỗng (fallback sang `id`) và có `source`.
|
||||||
|
|
||||||
`operation` là tùy chọn. Khi **không có** `operation` thì hiểu là:
|
`operation` được dùng như "delta" trong commit:
|
||||||
|
|
||||||
- row được đưa vào snapshot như **project context** (hoặc không đổi trong commit này),
|
- `"create"|"update"|"delete"`: thay đổi record entity
|
||||||
- commit này không sửa record, và cũng không cần đánh dấu là `"reference"` để làm “đầu mối nối”.
|
- `"reference"`: đưa entity vào context snapshot (pin/link) nhưng commit không sửa record entity
|
||||||
|
|
||||||
`operation` có thể xuất hiện ở:
|
### 3.3 geometries[]
|
||||||
|
|
||||||
- `entities[].operation`: `create` | `update` | `delete` | `reference`
|
FE sinh 1 `GeometrySnapshot` cho mỗi feature đang tồn tại trong `editor_feature_collection.features[]`:
|
||||||
- `geometries[].operation`: `create` | `update` | `delete` | `reference`
|
|
||||||
- `wikis[].operation`: `create` | `update` | `delete` | `reference`
|
|
||||||
|
|
||||||
`geometry_entity[]` không có `operation` (join table state).
|
|
||||||
|
|
||||||
`entity_wiki[]` dùng `operation:"binding"|"delete"` để biểu diễn link/unlink **trong snapshot** (không phải delete trong DB).
|
|
||||||
|
|
||||||
## 3) Ý Nghĩa Từng Phần
|
|
||||||
|
|
||||||
### 3.1 `editor_feature_collection`
|
|
||||||
|
|
||||||
GeoJSON `FeatureCollection` là nguồn để:
|
|
||||||
|
|
||||||
- render map trong editor,
|
|
||||||
- làm cơ sở build `geometries[]` và join table `geometry_entity[]`.
|
|
||||||
|
|
||||||
Lưu ý quan trọng:
|
|
||||||
|
|
||||||
- Snapshot persist **không lưu** các field entity denormalize trên `feature.properties`:
|
|
||||||
`entity_id/entity_ids/entity_name/entity_names/entity_type_id`.
|
|
||||||
- Quan hệ geometry ↔ entity nằm ở `geometry_entity[]`.
|
|
||||||
- Khi load commit vào editor, FE có thể rehydrate `entity_ids/entity_id` lên features từ `geometry_entity[]` để UI hoạt động, nhưng đó không phải dữ liệu persist.
|
|
||||||
|
|
||||||
### 3.2 `entities[]`
|
|
||||||
|
|
||||||
`entities[]` là danh sách entity liên quan tới project/commit. Mỗi row có `source` và có thể có/không có `operation`.
|
|
||||||
|
|
||||||
FE build `entities[]` từ:
|
|
||||||
|
|
||||||
1. Pending entities tạo mới trong editor:
|
|
||||||
`source:"inline"`, `operation:"create"`.
|
|
||||||
|
|
||||||
2. Entity được user “pin” vào project từ search (không gắn geometry, không link wiki):
|
|
||||||
`source:"ref"`, không có `operation`.
|
|
||||||
|
|
||||||
3. Entities xuất hiện trong `geometry_entity[]`:
|
|
||||||
`source:"ref"`, `operation:"reference"`.
|
|
||||||
|
|
||||||
4. Entities xuất hiện trong `entity_wiki[]`:
|
|
||||||
`source:"ref"`, `operation:"reference"`.
|
|
||||||
|
|
||||||
### 3.3 `geometries[]`
|
|
||||||
|
|
||||||
Mỗi `Feature` trong `editor_feature_collection.features[]` sinh 1 `GeometrySnapshot` row:
|
|
||||||
|
|
||||||
- `id = String(feature.properties.id)`
|
- `id = String(feature.properties.id)`
|
||||||
- `source:"inline"`
|
- `source:"inline"`
|
||||||
- `draw_geometry = feature.geometry`
|
- `draw_geometry = feature.geometry`
|
||||||
- kèm `type`, `binding`, `time_start/time_end`, `bbox` (nếu tính được)
|
- `binding`, `time_start`, `time_end`, `bbox` (nếu tính được)
|
||||||
|
- `type`: FE hiện gửi **string code** (geo_type smallint) dưới dạng string
|
||||||
|
- `operation`:
|
||||||
|
- `"create"` nếu geometry mới
|
||||||
|
- `"update"` nếu geometry thay đổi
|
||||||
|
- `undefined` nếu geometry không đổi
|
||||||
|
|
||||||
`operation` cho geometry:
|
Nếu feature bị xoá khỏi draft, FE thêm 1 row:
|
||||||
|
|
||||||
- `create`: feature mới
|
|
||||||
- `update`: feature thay đổi
|
|
||||||
- (không có `operation`): feature không đổi (không delta trong commit)
|
|
||||||
|
|
||||||
Nếu feature bị xoá khỏi draft, FE thêm 1 delete row:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "id": "g_1", "source": "ref", "operation": "delete" }
|
{ "id": "…", "source": "ref", "operation": "delete" }
|
||||||
```
|
```
|
||||||
|
|
||||||
Lưu ý: geometry `operation:"delete"` **không xuất hiện trên map**, vì map render theo `editor_feature_collection.features[]`.
|
### 3.4 geometry_entity[]
|
||||||
|
|
||||||
Gợi ý cho BE khi apply vào DB:
|
`geometry_entity` là danh sách quan hệ many-to-many geometry ↔ entity. Mỗi row là một cặp:
|
||||||
|
|
||||||
- Có thể coi `editor_feature_collection` là state hiện tại để render/map.
|
|
||||||
- `geometries[]` là "rows + deltas": sẽ có 1 row cho mỗi feature đang tồn tại trong draft (có/không có `operation`), và có thể có thêm các row `operation:"delete"` để xoá geometry khỏi project state.
|
|
||||||
|
|
||||||
### 3.4 `geometry_entity[]` (join table Geometry ↔ Entity)
|
|
||||||
|
|
||||||
Join table many-to-many giữa geometry và entity. Mỗi cặp geometry↔entity là một row:
|
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
{ geometry_id: string; entity_id: string }
|
{ geometry_id: string; entity_id: string }
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3.5 `wikis[]`
|
### 3.5 wikis[]
|
||||||
|
|
||||||
Danh sách wiki của project tại thời điểm commit:
|
- Wiki `source:"ref"` (được add từ search): FE set `operation:"reference"` và `doc:null`.
|
||||||
|
- Wiki `source:"inline"` (được tạo/sửa trong editor):
|
||||||
|
- nếu UI set explicit `create|update|delete` thì giữ nguyên
|
||||||
|
- nếu không có operation:
|
||||||
|
- wiki mới: FE coi là `"create"`
|
||||||
|
- wiki cũ không đổi: FE gán `"reference"`
|
||||||
|
- wiki cũ có đổi nội dung: FE gán `"update"`
|
||||||
|
|
||||||
- Wiki tạo mới: `source:"inline"`, `operation:"create"`, `doc` là HTML string (Quill).
|
### 3.6 entity_wiki[]
|
||||||
- Wiki sửa: `source:"inline"`, `operation:"update"`, `doc` là HTML string (Quill).
|
|
||||||
- Wiki không đổi: thường không có `operation`.
|
|
||||||
- Wiki add từ search (wiki đã có trong DB): `source:"ref"`, `operation:"reference"`, `doc` có thể là `null`.
|
|
||||||
|
|
||||||
### 3.6 `entity_wiki[]` (join table Entity ↔ Wiki)
|
Type trong FE cho UI state cho phép `"binding"` và `"delete"`.
|
||||||
|
|
||||||
```ts
|
Khi build snapshot để commit, FE map link “đang bật” về `"reference"` để tương thích với backend (một số backend chỉ chấp nhận `"reference"|"delete"`).
|
||||||
export type EntityWikiLinkSnapshot = {
|
|
||||||
entity_id: string;
|
|
||||||
wiki_id: string;
|
|
||||||
// New semantics:
|
|
||||||
// - binding: link active
|
|
||||||
// - delete: link removed in this snapshot
|
|
||||||
// Backwards-compat: older snapshots may use "reference" meaning link active.
|
|
||||||
operation?: "binding" | "delete" | "reference";
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
Toggle link trong UI:
|
## 4) Ví Dụ snapshot_json (rút gọn)
|
||||||
|
|
||||||
- Toggle ON (bind): `{ operation: "binding" }` (or legacy `"reference"`)
|
|
||||||
- Untick checkbox: `{ operation: "delete" }`
|
|
||||||
|
|
||||||
## 4) Ví Dụ JSON (rút gọn)
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
@@ -286,73 +217,32 @@ Toggle link trong UI:
|
|||||||
"features": [
|
"features": [
|
||||||
{
|
{
|
||||||
"type": "Feature",
|
"type": "Feature",
|
||||||
"properties": {
|
"properties": { "id": "019e…", "type": "country", "time_start": 1000, "time_end": 1500 },
|
||||||
"id": "g_1",
|
"geometry": { "type": "Polygon", "coordinates": [[[100, 10], [101, 10], [101, 11], [100, 10]]] }
|
||||||
"type": "city",
|
|
||||||
"time_start": 1200,
|
|
||||||
"time_end": 1300,
|
|
||||||
"binding": []
|
|
||||||
},
|
|
||||||
"geometry": { "type": "Point", "coordinates": [105.8, 21.0] }
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"entities": [
|
"entities": [
|
||||||
{ "id": "e_2", "source": "ref", "name": "Pinned Entity" },
|
{ "id": "019e…", "source": "inline", "operation": "reference", "name": "ent1", "description": null, "status": 1 }
|
||||||
{ "id": "e_1", "source": "ref", "operation": "reference", "name": "Ha Noi", "status": 1 }
|
|
||||||
],
|
],
|
||||||
"geometries": [
|
"geometries": [
|
||||||
{
|
{ "id": "019e…", "source": "inline", "operation": "update", "type": "9", "draw_geometry": { "type": "Polygon", "coordinates": [] }, "binding": [], "time_start": 1000, "time_end": 1500, "bbox": null }
|
||||||
"id": "g_1",
|
|
||||||
"source": "inline",
|
|
||||||
"operation": "update",
|
|
||||||
"type": "city",
|
|
||||||
"draw_geometry": { "type": "Point", "coordinates": [105.8, 21.0] },
|
|
||||||
"binding": [],
|
|
||||||
"time_start": 1200,
|
|
||||||
"time_end": 1300,
|
|
||||||
"bbox": { "min_lng": 105.8, "min_lat": 21.0, "max_lng": 105.8, "max_lat": 21.0 }
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"geometry_entity": [
|
"geometry_entity": [
|
||||||
{ "geometry_id": "g_1", "entity_id": "e_1" }
|
{ "geometry_id": "019e…", "entity_id": "019e…" }
|
||||||
],
|
],
|
||||||
"wikis": [
|
"wikis": [
|
||||||
{
|
{ "id": "019e…", "source": "ref", "operation": "reference", "title": "Existing wiki", "doc": null, "updated_at": "2026-05-08T00:00:00.000Z" }
|
||||||
"id": "w_inline_1",
|
|
||||||
"source": "inline",
|
|
||||||
"operation": "create",
|
|
||||||
"title": "Overview",
|
|
||||||
"doc": "<p>Overview</p>"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "019d...wiki_from_db",
|
|
||||||
"source": "ref",
|
|
||||||
"operation": "reference",
|
|
||||||
"title": "Existing Wiki (DB)",
|
|
||||||
"doc": null
|
|
||||||
}
|
|
||||||
],
|
],
|
||||||
"entity_wiki": [
|
"entity_wiki": [
|
||||||
{ "entity_id": "e_1", "wiki_id": "w_inline_1", "operation": "binding" }
|
{ "entity_id": "019e…", "wiki_id": "019e…", "operation": "reference" }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
## 5) Notes Cho BackEnd (Normalize + Compat)
|
## 5) Compat Notes (khi load snapshot cũ)
|
||||||
|
|
||||||
BE nên normalize trước khi convert snapshot → DB:
|
FE normalize khi load snapshot:
|
||||||
|
|
||||||
- Ignore toàn bộ field entity denormalize trên `feature.properties` (nếu có): `entity_id/entity_ids/entity_name/entity_names/entity_type_id`. Quan hệ geometry↔entity lấy từ `geometry_entity[]`.
|
- Nếu thấy `entity_wikis` (plural) sẽ đọc như `entity_wiki`.
|
||||||
- `entity_wiki[].operation`:
|
- Nếu join link có `operation:"reference"` thì FE coi như link active (UI biểu diễn như “binding”).
|
||||||
- `"binding"` (or legacy `"reference"`): link active
|
|
||||||
- `"delete"`: link removed trong snapshot
|
|
||||||
- missing: treat as `"binding"` (compat)
|
|
||||||
|
|
||||||
## 6) Legacy Compatibility (nếu gặp snapshot cũ)
|
|
||||||
|
|
||||||
FE đã từng gửi các field legacy; BE có thể gặp nếu đang xử lý commit cũ:
|
|
||||||
|
|
||||||
- `entity_wikis` (plural) thay vì `entity_wiki` (singular): treat như nhau.
|
|
||||||
- `ref:{id}` trong `entities/geometries/wikis`: ignore (id canonical).
|
|
||||||
- `is_deleted` trong join table entity↔wiki: map sang `operation:"delete"` khi `is_deleted==1`, ngược lại `"binding"` (or legacy `"reference"`).
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export default function Page() {
|
|||||||
const [blockedPendingSubmissionId, setBlockedPendingSubmissionId] = useState<string | null>(null);
|
const [blockedPendingSubmissionId, setBlockedPendingSubmissionId] = useState<string | null>(null);
|
||||||
const [searchKind, setSearchKind] = useState<UnifiedSearchKind>("entity");
|
const [searchKind, setSearchKind] = useState<UnifiedSearchKind>("entity");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
|
const [searchQueryDraft, setSearchQueryDraft] = useState("");
|
||||||
const [wikiSearchResults, setWikiSearchResults] = useState<Wiki[]>([]);
|
const [wikiSearchResults, setWikiSearchResults] = useState<Wiki[]>([]);
|
||||||
const [isWikiSearching, setIsWikiSearching] = useState(false);
|
const [isWikiSearching, setIsWikiSearching] = useState(false);
|
||||||
const [geoSearchResults, setGeoSearchResults] = useState<EntityGeometriesSearchItem[]>([]);
|
const [geoSearchResults, setGeoSearchResults] = useState<EntityGeometriesSearchItem[]>([]);
|
||||||
@@ -181,6 +182,10 @@ export default function Page() {
|
|||||||
() => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
|
() => mergeEntitySearchResults(entityCatalog, snapshotEntitiesAsEntities),
|
||||||
[entityCatalog, snapshotEntitiesAsEntities]
|
[entityCatalog, snapshotEntitiesAsEntities]
|
||||||
);
|
);
|
||||||
|
const entitiesRef = useRef(entities);
|
||||||
|
useEffect(() => {
|
||||||
|
entitiesRef.current = entities;
|
||||||
|
}, [entities]);
|
||||||
|
|
||||||
const snapshotEntitiesVisible = useMemo(() => {
|
const snapshotEntitiesVisible = useMemo(() => {
|
||||||
const byId = new globalThis.Map<string, EntitySnapshot>();
|
const byId = new globalThis.Map<string, EntitySnapshot>();
|
||||||
@@ -347,7 +352,9 @@ export default function Page() {
|
|||||||
setEntityStatus(null);
|
setEntityStatus(null);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ApiError) {
|
if (err instanceof ApiError) {
|
||||||
if (err.status === 401 || err.status === 400) {
|
// Only bounce to login when the session is truly unauthenticated.
|
||||||
|
// Token refresh is handled centrally; if we still get 401 here, refresh likely failed/expired.
|
||||||
|
if (err.status === 401) {
|
||||||
router.replace("/signin");
|
router.replace("/signin");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -401,10 +408,14 @@ export default function Page() {
|
|||||||
async function ensureAuthenticated() {
|
async function ensureAuthenticated() {
|
||||||
try {
|
try {
|
||||||
await fetchCurrentUser();
|
await fetchCurrentUser();
|
||||||
} catch {
|
} catch (err) {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
// Follow the same behavior as the rest of FrontEndUser: unauthenticated -> /signin.
|
if (err instanceof ApiError && err.status === 401) {
|
||||||
router.replace("/signin");
|
// Only redirect when refresh token/session is no longer usable.
|
||||||
|
router.replace("/signin");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
console.error("Ensure authenticated failed", err);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -482,7 +493,7 @@ export default function Page() {
|
|||||||
const requestId = ++entitySearchRequestRef.current;
|
const requestId = ++entitySearchRequestRef.current;
|
||||||
const timeoutId = window.setTimeout(async () => {
|
const timeoutId = window.setTimeout(async () => {
|
||||||
const keywordLower = keyword.toLowerCase();
|
const keywordLower = keyword.toLowerCase();
|
||||||
const localMatches = entities
|
const localMatches = entitiesRef.current
|
||||||
.filter((entity) =>
|
.filter((entity) =>
|
||||||
entity.name.toLowerCase().includes(keywordLower) ||
|
entity.name.toLowerCase().includes(keywordLower) ||
|
||||||
(entity.description || "").toLowerCase().includes(keywordLower)
|
(entity.description || "").toLowerCase().includes(keywordLower)
|
||||||
@@ -530,7 +541,6 @@ export default function Page() {
|
|||||||
}, [
|
}, [
|
||||||
searchKind,
|
searchKind,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
entities,
|
|
||||||
setEntityCatalog,
|
setEntityCatalog,
|
||||||
setEntitySearchResults,
|
setEntitySearchResults,
|
||||||
setIsEntitySearchLoading,
|
setIsEntitySearchLoading,
|
||||||
@@ -954,11 +964,6 @@ export default function Page() {
|
|||||||
}));
|
}));
|
||||||
setEntityStatus(null);
|
setEntityStatus(null);
|
||||||
setEntityFormStatus("Đã tạo entity mới (local). Commit khi sẵn sàng.");
|
setEntityFormStatus("Đã tạo entity mới (local). Commit khi sẵn sàng.");
|
||||||
|
|
||||||
if (selectedFeature) {
|
|
||||||
setSearchKind("entity");
|
|
||||||
setSearchQuery(name);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
setIsEntitySubmitting(false);
|
setIsEntitySubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -1104,12 +1109,14 @@ export default function Page() {
|
|||||||
onKindChange={(next) => {
|
onKindChange={(next) => {
|
||||||
setSearchKind(next);
|
setSearchKind(next);
|
||||||
setSearchQuery("");
|
setSearchQuery("");
|
||||||
|
setSearchQueryDraft("");
|
||||||
}}
|
}}
|
||||||
query={searchQuery}
|
query={searchQuery}
|
||||||
onQueryChange={setSearchQuery}
|
onQueryChange={setSearchQuery}
|
||||||
|
onLocalQueryChange={setSearchQueryDraft}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{searchKind === "entity" && searchQuery.trim().length > 0 ? (
|
{searchKind === "entity" && searchQueryDraft.trim().length > 0 ? (
|
||||||
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Entity Results</div>
|
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Entity Results</div>
|
||||||
@@ -1165,7 +1172,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{searchKind === "wiki" && searchQuery.trim().length > 0 ? (
|
{searchKind === "wiki" && searchQueryDraft.trim().length > 0 ? (
|
||||||
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Wiki Results</div>
|
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Wiki Results</div>
|
||||||
@@ -1221,7 +1228,7 @@ export default function Page() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{searchKind === "geo" && searchQuery.trim().length > 0 ? (
|
{searchKind === "geo" && searchQueryDraft.trim().length > 0 ? (
|
||||||
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
<div style={{ padding: 10, background: "#0b1220", borderRadius: 8, border: "1px solid #1f2937" }}>
|
||||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
|
||||||
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Geo Results</div>
|
<div style={{ fontWeight: 700, fontSize: 13, color: "white" }}>Geo Results</div>
|
||||||
|
|||||||
78
src/auth/tokenStore.ts
Normal file
78
src/auth/tokenStore.ts
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
export type StoredTokens = {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LS_KEY = "uhm_auth_tokens_v1";
|
||||||
|
|
||||||
|
let cached: StoredTokens | null = null;
|
||||||
|
|
||||||
|
function safeParseTokens(raw: string | null): StoredTokens | null {
|
||||||
|
if (!raw) return null;
|
||||||
|
try {
|
||||||
|
const v = JSON.parse(raw) as Partial<StoredTokens>;
|
||||||
|
if (!v || typeof v !== "object") return null;
|
||||||
|
if (typeof v.access_token !== "string" || typeof v.refresh_token !== "string") return null;
|
||||||
|
if (!v.access_token.trim() || !v.refresh_token.trim()) return null;
|
||||||
|
return { access_token: v.access_token, refresh_token: v.refresh_token };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoredTokens(): StoredTokens | null {
|
||||||
|
if (cached) return cached;
|
||||||
|
if (typeof window === "undefined") return null;
|
||||||
|
cached = safeParseTokens(window.localStorage.getItem(LS_KEY));
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setStoredTokens(tokens: StoredTokens | null): void {
|
||||||
|
cached = tokens;
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
if (!tokens) {
|
||||||
|
window.localStorage.removeItem(LS_KEY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
window.localStorage.setItem(LS_KEY, JSON.stringify(tokens));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAccessToken(): string | null {
|
||||||
|
return getStoredTokens()?.access_token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRefreshToken(): string | null {
|
||||||
|
return getStoredTokens()?.refresh_token ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearStoredTokens(): void {
|
||||||
|
setStoredTokens(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper for dealing with CommonResponse where token payload shape is not strictly typed.
|
||||||
|
export function extractTokensFromResponsePayload(payload: any): StoredTokens | null {
|
||||||
|
const data = payload?.data ?? payload;
|
||||||
|
// Common shapes observed in various backends:
|
||||||
|
// - { status: true, data: { access_token, refresh_token } }
|
||||||
|
// - { data: { tokens: { access_token, refresh_token } } }
|
||||||
|
// - { data: { token: <access>, refresh_token } }
|
||||||
|
// - { accessToken, refreshToken }
|
||||||
|
const tokenContainer = data?.tokens ?? data?.token_set ?? data;
|
||||||
|
|
||||||
|
const access =
|
||||||
|
tokenContainer?.access_token ??
|
||||||
|
tokenContainer?.accessToken ??
|
||||||
|
tokenContainer?.token ??
|
||||||
|
tokenContainer?.access ??
|
||||||
|
null;
|
||||||
|
|
||||||
|
const refresh =
|
||||||
|
tokenContainer?.refresh_token ??
|
||||||
|
tokenContainer?.refreshToken ??
|
||||||
|
tokenContainer?.refresh ??
|
||||||
|
null;
|
||||||
|
if (typeof access === "string" && typeof refresh === "string" && access.trim() && refresh.trim()) {
|
||||||
|
return { access_token: access, refresh_token: refresh };
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,10 +1,18 @@
|
|||||||
import axios from "axios"
|
import axios from "axios"
|
||||||
import { API_URL_ROOT } from "../../api"
|
import { API_URL_ROOT } from "../../api"
|
||||||
|
import {
|
||||||
|
clearStoredTokens,
|
||||||
|
extractTokensFromResponsePayload,
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
setStoredTokens,
|
||||||
|
} from "@/auth/tokenStore"
|
||||||
|
|
||||||
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn"
|
const baseURL = API_URL_ROOT || "https://history-api.kain.id.vn"
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL,
|
baseURL,
|
||||||
|
// Support both cookie-based auth (httpOnly) and Bearer JWT.
|
||||||
withCredentials: true
|
withCredentials: true
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -19,12 +27,36 @@ const processQueue = (error?: any) => {
|
|||||||
queue = []
|
queue = []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = getAccessToken()
|
||||||
|
if (token) {
|
||||||
|
const headers: any = config.headers || {}
|
||||||
|
// Do not override if caller set Authorization explicitly (case-insensitive).
|
||||||
|
const already =
|
||||||
|
typeof headers.get === "function"
|
||||||
|
? headers.get("Authorization")
|
||||||
|
: headers.Authorization || headers.authorization
|
||||||
|
if (!already) {
|
||||||
|
if (typeof headers.set === "function") headers.set("Authorization", `Bearer ${token}`)
|
||||||
|
else headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
config.headers = headers
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
api.interceptors.response.use(
|
api.interceptors.response.use(
|
||||||
(res) => res,
|
(res) => {
|
||||||
|
// Opportunistically persist tokens from signin/refresh responses.
|
||||||
|
const tokens = extractTokensFromResponsePayload(res?.data)
|
||||||
|
if (tokens) setStoredTokens(tokens)
|
||||||
|
return res
|
||||||
|
},
|
||||||
async (err) => {
|
async (err) => {
|
||||||
const originalRequest = err.config
|
const originalRequest = err.config
|
||||||
|
|
||||||
if (err.response?.status === 401 && !originalRequest._retry) {
|
const url = String(originalRequest?.url || "")
|
||||||
|
if (err.response?.status === 401 && !originalRequest._retry && !url.includes("/auth/")) {
|
||||||
if (isRefreshing) {
|
if (isRefreshing) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
queue.push({
|
queue.push({
|
||||||
@@ -38,19 +70,55 @@ api.interceptors.response.use(
|
|||||||
isRefreshing = true
|
isRefreshing = true
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await axios.post(
|
const refreshToken = getRefreshToken()
|
||||||
`${baseURL}/auth/refresh`,
|
|
||||||
{},
|
const tryHeaderRefresh = async () => {
|
||||||
{ withCredentials: true }
|
if (!refreshToken) return null
|
||||||
)
|
return axios.post(
|
||||||
|
`${baseURL}/auth/refresh`,
|
||||||
|
{},
|
||||||
|
{ headers: { Authorization: `Bearer ${refreshToken}` } }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const tryCookieRefresh = async () => {
|
||||||
|
return axios.post(`${baseURL}/auth/refresh`, {}, { withCredentials: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
let refreshRes: any = null
|
||||||
|
try {
|
||||||
|
refreshRes = (await tryHeaderRefresh()) || (await tryCookieRefresh())
|
||||||
|
} catch (e: any) {
|
||||||
|
// If header-based refresh fails (wrong token type), fall back to cookie refresh.
|
||||||
|
if (refreshToken && e?.response?.status === 401) {
|
||||||
|
refreshRes = await tryCookieRefresh()
|
||||||
|
} else {
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextTokens = extractTokensFromResponsePayload(refreshRes?.data)
|
||||||
|
if (nextTokens) setStoredTokens(nextTokens)
|
||||||
|
// Some backends may return only a new access token; keep refresh token.
|
||||||
|
else {
|
||||||
|
const maybeAccess = (refreshRes?.data?.data?.access_token ??
|
||||||
|
refreshRes?.data?.access_token) as unknown
|
||||||
|
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||||
|
// Keep refresh token if we have one; otherwise rely on cookies.
|
||||||
|
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
processQueue()
|
processQueue()
|
||||||
|
|
||||||
return api(originalRequest)
|
return api(originalRequest)
|
||||||
} catch (refreshErr) {
|
} catch (refreshErr: any) {
|
||||||
processQueue(refreshErr)
|
processQueue(refreshErr)
|
||||||
|
// Only force logout when refresh token/session is truly invalid (401).
|
||||||
window.location.href = "/signin"
|
if (refreshErr?.response?.status === 401) {
|
||||||
|
clearStoredTokens()
|
||||||
|
window.location.href = "/signin"
|
||||||
|
}
|
||||||
|
|
||||||
return Promise.reject(refreshErr)
|
return Promise.reject(refreshErr)
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import api from "@/config/config";
|
import api from "@/config/config";
|
||||||
import { API } from "../../api";
|
import { API } from "../../api";
|
||||||
|
import { clearStoredTokens, extractTokensFromResponsePayload, setStoredTokens } from "@/auth/tokenStore";
|
||||||
|
|
||||||
export const apiCreateOTP = async (email: string) => {
|
export const apiCreateOTP = async (email: string) => {
|
||||||
const token_type = 2;
|
const token_type = 2;
|
||||||
@@ -23,11 +24,14 @@ export const apiSignUp = async (payload: any) => {
|
|||||||
|
|
||||||
export const apiLogout = async () => {
|
export const apiLogout = async () => {
|
||||||
const response = await api.post(API.Auth.LOGOUT);
|
const response = await api.post(API.Auth.LOGOUT);
|
||||||
|
clearStoredTokens();
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const apiSignIn = async (payload: any) => {
|
export const apiSignIn = async (payload: any) => {
|
||||||
const response = await api.post(API.Auth.SIGNIN, payload);
|
const response = await api.post(API.Auth.SIGNIN, payload);
|
||||||
|
const tokens = extractTokensFromResponsePayload(response?.data);
|
||||||
|
if (tokens) setStoredTokens(tokens);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
import { jsonRequestInit, requestJson } from "@/uhm/api/http";
|
||||||
|
import { clearStoredTokens, setStoredTokens } from "@/auth/tokenStore";
|
||||||
|
|
||||||
export type AuthTokens = {
|
export type AuthTokens = {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
@@ -18,14 +19,15 @@ export async function signIn(email: string, password: string): Promise<AuthToken
|
|||||||
const res = await requestJson<AuthTokens>(
|
const res = await requestJson<AuthTokens>(
|
||||||
API_ENDPOINTS.authSignin,
|
API_ENDPOINTS.authSignin,
|
||||||
jsonRequestInit("POST", { email, password }),
|
jsonRequestInit("POST", { email, password }),
|
||||||
// Sign-in sets httpOnly cookies in BackEndGo.
|
|
||||||
{ skipAuth: true }
|
{ skipAuth: true }
|
||||||
);
|
);
|
||||||
|
if (res?.access_token && res?.refresh_token) setStoredTokens(res);
|
||||||
return res;
|
return res;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(): Promise<void> {
|
export async function logout(): Promise<void> {
|
||||||
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
|
await requestJson(API_ENDPOINTS.authLogout, { method: "POST" });
|
||||||
|
clearStoredTokens();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
export async function fetchCurrentUser(): Promise<CurrentUser> {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { ApiEnvelope } from "@/uhm/types/api";
|
import type { ApiEnvelope } from "@/uhm/types/api";
|
||||||
import { API_ENDPOINTS } from "@/uhm/api/config";
|
import { API_ENDPOINTS } from "@/uhm/api/config";
|
||||||
|
import { getAccessToken, getRefreshToken, setStoredTokens, type StoredTokens, extractTokensFromResponsePayload } from "@/auth/tokenStore";
|
||||||
|
|
||||||
export class ApiError extends Error {
|
export class ApiError extends Error {
|
||||||
status: number;
|
status: number;
|
||||||
@@ -15,12 +16,12 @@ export class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BackEndGo auth flow: cookie-based (httpOnly access_token/refresh_token).
|
// History API auth flow supports Bearer JWT and (in some deployments) cookie-based sessions.
|
||||||
// We intentionally do not store bearer tokens in localStorage in this FE.
|
|
||||||
|
|
||||||
type RequestJsonOptions = {
|
type RequestJsonOptions = {
|
||||||
skipAuth?: boolean;
|
skipAuth?: boolean;
|
||||||
skipRefresh?: boolean;
|
skipRefresh?: boolean;
|
||||||
|
authToken?: string | null; // Override bearer token (used for refresh).
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function requestJson<T>(
|
export async function requestJson<T>(
|
||||||
@@ -142,25 +143,76 @@ function stringifyPayload(payload: unknown): string {
|
|||||||
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
|
function withAuthHeaders(init: RequestInit | undefined, options?: RequestJsonOptions): RequestInit | undefined {
|
||||||
const baseInit: RequestInit = {
|
const baseInit: RequestInit = {
|
||||||
...init,
|
...init,
|
||||||
// Always include cookies (BackEndGo sets httpOnly access_token/refresh_token cookies).
|
|
||||||
credentials: init?.credentials ?? "include",
|
credentials: init?.credentials ?? "include",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cookie-based auth only.
|
const headers = new Headers(baseInit.headers || undefined);
|
||||||
// Keep the function so call sites don't change, but never inject Authorization headers.
|
|
||||||
|
const override = options?.authToken;
|
||||||
|
if (override) {
|
||||||
|
headers.set("Authorization", `Bearer ${override}`);
|
||||||
|
return { ...baseInit, headers };
|
||||||
|
}
|
||||||
|
|
||||||
if (options?.skipAuth) return baseInit;
|
if (options?.skipAuth) return baseInit;
|
||||||
return baseInit;
|
|
||||||
|
const access = getAccessToken();
|
||||||
|
if (access) headers.set("Authorization", `Bearer ${access}`);
|
||||||
|
return { ...baseInit, headers };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let refreshInFlight: Promise<boolean> | null = null;
|
||||||
|
|
||||||
async function tryRefreshTokens(): Promise<boolean> {
|
async function tryRefreshTokens(): Promise<boolean> {
|
||||||
|
// Single-flight refresh for concurrent 401s.
|
||||||
|
if (refreshInFlight) return refreshInFlight;
|
||||||
|
refreshInFlight = (async () => {
|
||||||
try {
|
try {
|
||||||
await requestJson(
|
const refreshToken = getRefreshToken();
|
||||||
API_ENDPOINTS.authRefresh,
|
|
||||||
{ method: "POST" },
|
// Try header-based refresh first (per swagger), but fall back to cookie-based refresh if needed.
|
||||||
{ skipAuth: true, skipRefresh: true }
|
let payload: unknown;
|
||||||
);
|
try {
|
||||||
return true;
|
payload = await requestJsonInternal<unknown>(
|
||||||
|
API_ENDPOINTS.authRefresh,
|
||||||
|
{ method: "POST" },
|
||||||
|
refreshToken
|
||||||
|
? { skipRefresh: true, authToken: refreshToken }
|
||||||
|
: { skipRefresh: true, skipAuth: true }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (refreshToken && err instanceof ApiError && err.status === 401) {
|
||||||
|
payload = await requestJsonInternal<unknown>(
|
||||||
|
API_ENDPOINTS.authRefresh,
|
||||||
|
{ method: "POST" },
|
||||||
|
{ skipRefresh: true, skipAuth: true }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = extractTokensFromResponsePayload(payload) as StoredTokens | null;
|
||||||
|
if (next) {
|
||||||
|
setStoredTokens(next);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: if server returns only access_token, keep existing refresh token (if any).
|
||||||
|
const maybeAccess = (payload as any)?.access_token ?? (payload as any)?.data?.access_token;
|
||||||
|
if (typeof maybeAccess === "string" && maybeAccess.trim()) {
|
||||||
|
if (refreshToken) setStoredTokens({ access_token: maybeAccess, refresh_token: refreshToken });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
})();
|
||||||
|
try {
|
||||||
|
return await refreshInFlight;
|
||||||
|
} finally {
|
||||||
|
refreshInFlight = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ type EngineBinding = {
|
|||||||
clearSelection?: () => void;
|
clearSelection?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAP_PROJECTION_STORAGE_KEY = "uhm:mapProjection";
|
||||||
|
|
||||||
|
function applyMapProjection(map: maplibregl.Map, isGlobe: boolean) {
|
||||||
|
map.setProjection({ type: isGlobe ? "globe" : "mercator" });
|
||||||
|
}
|
||||||
|
|
||||||
export default function Map({
|
export default function Map({
|
||||||
mode,
|
mode,
|
||||||
draft,
|
draft,
|
||||||
@@ -106,6 +112,15 @@ export default function Map({
|
|||||||
const [zoomLevel, setZoomLevel] = useState(2);
|
const [zoomLevel, setZoomLevel] = useState(2);
|
||||||
// Min/max zoom dùng cho slider và clamp thao tác zoom.
|
// Min/max zoom dùng cho slider và clamp thao tác zoom.
|
||||||
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
const [zoomBounds, setZoomBounds] = useState({ min: MAP_MIN_ZOOM, max: MAP_MAX_ZOOM });
|
||||||
|
// Projection mode: phang (mercator) vs hinh cau (globe).
|
||||||
|
const [isGlobeProjection, setIsGlobeProjection] = useState(() => {
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
try {
|
||||||
|
return window.localStorage.getItem(MAP_PROJECTION_STORAGE_KEY) === "globe";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần.
|
// Engine chỉnh sửa polygon (kéo đỉnh/insert đỉnh), chỉ khởi tạo 1 lần.
|
||||||
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
const editingEngineRef = useRef<ReturnType<typeof createEditingEngine> | null>(null);
|
||||||
@@ -120,6 +135,18 @@ export default function Map({
|
|||||||
// Lưu mode trước đó để cancel engine đúng lúc khi switch mode.
|
// Lưu mode trước đó để cancel engine đúng lúc khi switch mode.
|
||||||
const previousModeRef = useRef<MapProps["mode"]>(mode);
|
const previousModeRef = useRef<MapProps["mode"]>(mode);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
MAP_PROJECTION_STORAGE_KEY,
|
||||||
|
isGlobeProjection ? "globe" : "mercator"
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}, [isGlobeProjection]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fitToDraftBoundsRef.current = fitToDraftBounds;
|
fitToDraftBoundsRef.current = fitToDraftBounds;
|
||||||
}, [fitToDraftBounds]);
|
}, [fitToDraftBounds]);
|
||||||
@@ -1034,6 +1061,32 @@ export default function Map({
|
|||||||
};
|
};
|
||||||
}, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]);
|
}, [allowGeometryEditing, applyDraftToMap, tryCenterToUserLocation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const map = mapRef.current;
|
||||||
|
if (!map) return;
|
||||||
|
const apply = () => {
|
||||||
|
// Map instance có thể đã bị replace/unmount trước khi event fire.
|
||||||
|
if (mapRef.current !== map) return;
|
||||||
|
// setProjection sẽ throw nếu style chưa load xong.
|
||||||
|
if (typeof map.isStyleLoaded === "function" && !map.isStyleLoaded()) return;
|
||||||
|
applyMapProjection(map, isGlobeProjection);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nếu style đã sẵn sàng thì apply ngay.
|
||||||
|
if (typeof map.isStyleLoaded === "function" && map.isStyleLoaded()) {
|
||||||
|
apply();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chưa load xong: đợi load/style.load.
|
||||||
|
map.once("load", apply);
|
||||||
|
map.once("style.load", apply);
|
||||||
|
return () => {
|
||||||
|
map.off("load", apply);
|
||||||
|
map.off("style.load", apply);
|
||||||
|
};
|
||||||
|
}, [isGlobeProjection]);
|
||||||
|
|
||||||
const handleZoomByStep = (delta: number) => {
|
const handleZoomByStep = (delta: number) => {
|
||||||
const map = mapRef.current;
|
const map = mapRef.current;
|
||||||
if (!map) return;
|
if (!map) return;
|
||||||
@@ -1122,6 +1175,69 @@ export default function Map({
|
|||||||
pointerEvents: "auto",
|
pointerEvents: "auto",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<label
|
||||||
|
title={
|
||||||
|
isGlobeProjection
|
||||||
|
? "Dang o che do hinh cau (globe)"
|
||||||
|
: "Dang o che do trai phang (flat)"
|
||||||
|
}
|
||||||
|
style={{
|
||||||
|
display: "inline-flex",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: "8px",
|
||||||
|
padding: "0 6px",
|
||||||
|
userSelect: "none",
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isGlobeProjection}
|
||||||
|
onChange={(e) => setIsGlobeProjection(e.target.checked)}
|
||||||
|
aria-label="Toggle globe projection"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
style={{
|
||||||
|
position: "relative",
|
||||||
|
width: "42px",
|
||||||
|
height: "22px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
border: "1px solid rgba(148, 163, 184, 0.45)",
|
||||||
|
background: isGlobeProjection
|
||||||
|
? "rgba(56, 189, 248, 0.30)"
|
||||||
|
: "rgba(148, 163, 184, 0.18)",
|
||||||
|
boxShadow: "inset 0 0 0 1px rgba(15, 23, 42, 0.35)",
|
||||||
|
transition: "background 160ms ease",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: "2px",
|
||||||
|
left: isGlobeProjection ? "22px" : "2px",
|
||||||
|
width: "18px",
|
||||||
|
height: "18px",
|
||||||
|
borderRadius: "999px",
|
||||||
|
background: isGlobeProjection ? "#38bdf8" : "#e2e8f0",
|
||||||
|
boxShadow: "0 1px 3px rgba(0, 0, 0, 0.35)",
|
||||||
|
transition: "left 160ms ease, background 160ms ease",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: "12px",
|
||||||
|
color: isGlobeProjection ? "#7dd3fc" : "#cbd5e1",
|
||||||
|
fontWeight: 700,
|
||||||
|
minWidth: "40px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isGlobeProjection ? "Globe" : "Flat"}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleZoomByStep(-0.8)}
|
onClick={() => handleZoomByStep(-0.8)}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { CSSProperties } from "react";
|
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||||
|
|
||||||
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
|
export type UnifiedSearchKind = "entity" | "wiki" | "geo";
|
||||||
|
|
||||||
@@ -10,9 +10,53 @@ type Props = {
|
|||||||
query: string;
|
query: string;
|
||||||
onQueryChange: (query: string) => void;
|
onQueryChange: (query: string) => void;
|
||||||
disabledGeo?: boolean;
|
disabledGeo?: boolean;
|
||||||
|
debounceMs?: number;
|
||||||
|
onLocalQueryChange?: (query: string) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UnifiedSearchBar({ kind, onKindChange, query, onQueryChange, disabledGeo }: Props) {
|
export default function UnifiedSearchBar({
|
||||||
|
kind,
|
||||||
|
onKindChange,
|
||||||
|
query,
|
||||||
|
onQueryChange,
|
||||||
|
disabledGeo,
|
||||||
|
debounceMs = 300,
|
||||||
|
onLocalQueryChange,
|
||||||
|
}: Props) {
|
||||||
|
// Local input state to avoid propagating query changes (and triggering API) on every keystroke.
|
||||||
|
const [localQuery, setLocalQuery] = useState(query);
|
||||||
|
const debounceTimerRef = useRef<number | null>(null);
|
||||||
|
|
||||||
|
// Keep local input in sync when parent updates `query` externally (e.g. reset, preset, navigation).
|
||||||
|
useEffect(() => {
|
||||||
|
setLocalQuery(query);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onLocalQueryChange?.(localQuery);
|
||||||
|
}, [localQuery, onLocalQueryChange]);
|
||||||
|
|
||||||
|
// Debounce propagation upwards.
|
||||||
|
useEffect(() => {
|
||||||
|
if (localQuery === query) return;
|
||||||
|
|
||||||
|
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = window.setTimeout(() => {
|
||||||
|
onQueryChange(localQuery);
|
||||||
|
}, debounceMs);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
};
|
||||||
|
}, [localQuery, query, onQueryChange, debounceMs]);
|
||||||
|
|
||||||
|
const commitNow = () => {
|
||||||
|
if (debounceTimerRef.current != null) window.clearTimeout(debounceTimerRef.current);
|
||||||
|
debounceTimerRef.current = null;
|
||||||
|
if (localQuery !== query) onQueryChange(localQuery);
|
||||||
|
};
|
||||||
|
|
||||||
const selectStyle: CSSProperties = {
|
const selectStyle: CSSProperties = {
|
||||||
width: 110,
|
width: 110,
|
||||||
border: "1px solid #1f2937",
|
border: "1px solid #1f2937",
|
||||||
@@ -74,8 +118,12 @@ export default function UnifiedSearchBar({ kind, onKindChange, query, onQueryCha
|
|||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={localQuery}
|
||||||
onChange={(e) => onQueryChange(e.target.value)}
|
onChange={(e) => setLocalQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") commitNow();
|
||||||
|
}}
|
||||||
|
onBlur={() => commitNow()}
|
||||||
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
|
placeholder={kind === "entity" ? "Nhập tên entity…" : kind === "wiki" ? "Nhập title wiki…" : "Nhập tên entity…"}
|
||||||
style={inputStyle}
|
style={inputStyle}
|
||||||
aria-label="Search query"
|
aria-label="Search query"
|
||||||
|
|||||||
99
src/uhm/docs/commit_request.sample.json
Normal file
99
src/uhm/docs/commit_request.sample.json
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
{
|
||||||
|
"snapshot_json": {
|
||||||
|
"editor_feature_collection": {
|
||||||
|
"type": "FeatureCollection",
|
||||||
|
"features": [
|
||||||
|
{
|
||||||
|
"type": "Feature",
|
||||||
|
"geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[102.00266149687627, 23.428776811740278],
|
||||||
|
[112.17600134062593, 23.166423207385705],
|
||||||
|
[109.14377477812644, 17.104884122540454],
|
||||||
|
[100.55246618437678, 18.15190838931096],
|
||||||
|
[102.00266149687627, 23.428776811740278]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
||||||
|
"type": "country",
|
||||||
|
"time_start": 1000,
|
||||||
|
"time_end": 1500,
|
||||||
|
"geometry_preset": "polygon"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
||||||
|
"source": "inline",
|
||||||
|
"operation": "reference",
|
||||||
|
"name": "ent1",
|
||||||
|
"slug": null,
|
||||||
|
"description": null,
|
||||||
|
"status": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"geometries": [
|
||||||
|
{
|
||||||
|
"id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
||||||
|
"source": "inline",
|
||||||
|
"operation": "update",
|
||||||
|
"type": "9",
|
||||||
|
"draw_geometry": {
|
||||||
|
"type": "Polygon",
|
||||||
|
"coordinates": [
|
||||||
|
[
|
||||||
|
[102.00266149687627, 23.428776811740278],
|
||||||
|
[112.17600134062593, 23.166423207385705],
|
||||||
|
[109.14377477812644, 17.104884122540454],
|
||||||
|
[100.55246618437678, 18.15190838931096],
|
||||||
|
[102.00266149687627, 23.428776811740278]
|
||||||
|
]
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"binding": [],
|
||||||
|
"time_start": 1000,
|
||||||
|
"time_end": 1500,
|
||||||
|
"bbox": {
|
||||||
|
"min_lng": 100.55246618437678,
|
||||||
|
"min_lat": 17.104884122540454,
|
||||||
|
"max_lng": 112.17600134062593,
|
||||||
|
"max_lat": 23.428776811740278
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"geometry_entity": [
|
||||||
|
{
|
||||||
|
"geometry_id": "019e0363-9a8d-72a9-b1b1-552e1113ae53",
|
||||||
|
"entity_id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
||||||
|
"base_links_hash": ""
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"wikis": [
|
||||||
|
{
|
||||||
|
"id": "019e0363-dfce-769c-920a-cf23c7755463",
|
||||||
|
"source": "inline",
|
||||||
|
"operation": "reference",
|
||||||
|
"title": "wiki1",
|
||||||
|
"slug": null,
|
||||||
|
"doc": "<p>example</p>",
|
||||||
|
"updated_at": "2026-05-08T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entity_wiki": [
|
||||||
|
{
|
||||||
|
"entity_id": "019e0364-10ca-7282-840a-c0e6f4069807",
|
||||||
|
"wiki_id": "019e0363-dfce-769c-920a-cf23c7755463",
|
||||||
|
"operation": "reference",
|
||||||
|
"is_deleted": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"edit_summary": "Edit 2026-05-08T00:00:00.000Z"
|
||||||
|
}
|
||||||
|
|
||||||
178
src/uhm/docs/commit_snapshot.contract.json
Normal file
178
src/uhm/docs/commit_snapshot.contract.json
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "uhm.commit_snapshot.contract",
|
||||||
|
"title": "History API Commit Snapshot (snapshot_json) - Contract",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"editor_feature_collection",
|
||||||
|
"entities",
|
||||||
|
"geometries",
|
||||||
|
"geometry_entity",
|
||||||
|
"wikis",
|
||||||
|
"entity_wiki"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"editor_feature_collection": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["type", "features"],
|
||||||
|
"properties": {
|
||||||
|
"type": { "const": "FeatureCollection" },
|
||||||
|
"features": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/feature" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entities": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/entitySnapshot" }
|
||||||
|
},
|
||||||
|
"geometries": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/geometrySnapshot" }
|
||||||
|
},
|
||||||
|
"geometry_entity": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/geometryEntitySnapshot" }
|
||||||
|
},
|
||||||
|
"wikis": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/wikiSnapshot" }
|
||||||
|
},
|
||||||
|
"entity_wiki": {
|
||||||
|
"type": "array",
|
||||||
|
"items": { "$ref": "#/$defs/entityWikiLinkSnapshot" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"uuidv7": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "UUID v7 string",
|
||||||
|
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
||||||
|
},
|
||||||
|
"feature": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["type", "geometry", "properties"],
|
||||||
|
"properties": {
|
||||||
|
"type": { "const": "Feature" },
|
||||||
|
"geometry": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "GeoJSON geometry. Backend expects JSON raw payload."
|
||||||
|
},
|
||||||
|
"properties": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": true,
|
||||||
|
"required": ["id"],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"description": "Geometry id. FE uses uuidv7 strings but backend accepts 'any' for editor_feature_collection properties.id",
|
||||||
|
"oneOf": [{ "$ref": "#/$defs/uuidv7" }, { "type": "string" }, { "type": "number" }]
|
||||||
|
},
|
||||||
|
"type": { "type": "string" },
|
||||||
|
"geometry_preset": { "type": "string" },
|
||||||
|
"time_start": { "type": "number" },
|
||||||
|
"time_end": { "type": "number" },
|
||||||
|
"binding": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"entity_id": { "type": "string" },
|
||||||
|
"entity_ids": { "type": "array", "items": { "type": "string" } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entitySnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "name"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/uuidv7" },
|
||||||
|
"source": { "type": "string", "enum": ["inline", "ref"] },
|
||||||
|
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
||||||
|
"name": { "type": "string" },
|
||||||
|
"slug": { "type": ["string", "null"] },
|
||||||
|
"description": { "type": ["string", "null"] },
|
||||||
|
"status": { "type": ["number", "null"], "enum": [0, 1, null] },
|
||||||
|
"time_start": { "type": ["number", "null"] },
|
||||||
|
"time_end": { "type": ["number", "null"] },
|
||||||
|
"base_updated_at": { "type": ["string", "null"] },
|
||||||
|
"base_hash": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometrySnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "type"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/uuidv7" },
|
||||||
|
"source": { "type": "string", "enum": ["inline", "ref"] },
|
||||||
|
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Backend expects a string; FE currently sends the geo_type smallint code as a string."
|
||||||
|
},
|
||||||
|
"draw_geometry": {
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"description": "GeoJSON geometry raw payload"
|
||||||
|
},
|
||||||
|
"binding": { "type": "array", "items": { "type": "string" } },
|
||||||
|
"time_start": { "type": ["number", "null"] },
|
||||||
|
"time_end": { "type": ["number", "null"] },
|
||||||
|
"bbox": {
|
||||||
|
"type": ["object", "null"],
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["min_lng", "min_lat", "max_lng", "max_lat"],
|
||||||
|
"properties": {
|
||||||
|
"min_lng": { "type": "number" },
|
||||||
|
"min_lat": { "type": "number" },
|
||||||
|
"max_lng": { "type": "number" },
|
||||||
|
"max_lat": { "type": "number" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"base_updated_at": { "type": ["string", "null"] },
|
||||||
|
"base_hash": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"geometryEntitySnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["geometry_id", "entity_id"],
|
||||||
|
"properties": {
|
||||||
|
"geometry_id": { "$ref": "#/$defs/uuidv7" },
|
||||||
|
"entity_id": { "$ref": "#/$defs/uuidv7" },
|
||||||
|
"base_links_hash": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"wikiSnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["id", "title"],
|
||||||
|
"properties": {
|
||||||
|
"id": { "$ref": "#/$defs/uuidv7" },
|
||||||
|
"source": { "type": "string", "enum": ["inline", "ref"] },
|
||||||
|
"operation": { "type": "string", "enum": ["create", "update", "delete", "reference"] },
|
||||||
|
"title": { "type": "string" },
|
||||||
|
"slug": { "type": ["string", "null"] },
|
||||||
|
"doc": { "type": ["string", "null"] },
|
||||||
|
"updated_at": { "type": ["string", "null"] }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"entityWikiLinkSnapshot": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": ["entity_id", "wiki_id"],
|
||||||
|
"properties": {
|
||||||
|
"entity_id": { "$ref": "#/$defs/uuidv7" },
|
||||||
|
"wiki_id": { "$ref": "#/$defs/uuidv7" },
|
||||||
|
"operation": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Server swagger.json currently enumerates ['reference','delete'] for this field. Some deployments may also accept 'binding'.",
|
||||||
|
"enum": ["reference", "delete", "binding"]
|
||||||
|
},
|
||||||
|
"is_deleted": { "type": ["number", "null"], "enum": [0, 1, null] }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
import { DEFAULT_ENTITY_TYPE_ID } from "@/uhm/lib/entityTypeOptions";
|
||||||
import { typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
|
import { geoTypeCodeToTypeKey, typeKeyToGeoTypeCode } from "@/uhm/lib/geoTypeMap";
|
||||||
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
|
||||||
import type { EntitySnapshot } from "@/uhm/types/entities";
|
import type { EntitySnapshot } from "@/uhm/types/entities";
|
||||||
|
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
|
||||||
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
import type { Feature, FeatureCollection, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
|
||||||
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
import type { EditorSnapshot, Section } from "@/uhm/types/sections";
|
||||||
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
import type { WikiSnapshot } from "@/uhm/types/wiki";
|
||||||
@@ -13,6 +14,15 @@ function isRecord(value: unknown): value is UnknownRecord {
|
|||||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sanitizeEntitySnapshotOperation(op: unknown): EntitySnapshotOperation {
|
||||||
|
if (typeof op !== "string") return "reference";
|
||||||
|
const v = op.trim();
|
||||||
|
if (v === "create" || v === "update" || v === "delete" || v === "reference") return v;
|
||||||
|
// Defensive: legacy/buggy data sometimes concatenates words (e.g. "reference delete").
|
||||||
|
// Never guess "delete" here; prefer non-destructive behavior.
|
||||||
|
return "reference";
|
||||||
|
}
|
||||||
|
|
||||||
function getStringId(value: unknown): string {
|
function getStringId(value: unknown): string {
|
||||||
if (typeof value === "string") return value;
|
if (typeof value === "string") return value;
|
||||||
if (typeof value === "number") return String(value);
|
if (typeof value === "number") return String(value);
|
||||||
@@ -176,7 +186,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
// store entity_id/entity_ids/entity_names on features anymore.
|
// store entity_id/entity_ids/entity_names on features anymore.
|
||||||
const fcForEditor: FeatureCollection | undefined = (() => {
|
const fcForEditor: FeatureCollection | undefined = (() => {
|
||||||
if (!fc) return undefined;
|
if (!fc) return undefined;
|
||||||
if (!geometryEntity && !migratedGeometryEntity) return fc;
|
const hasLinks = Boolean(geometryEntity || migratedGeometryEntity);
|
||||||
const links = geometryEntity || migratedGeometryEntity || [];
|
const links = geometryEntity || migratedGeometryEntity || [];
|
||||||
const byGeom = new Map<string, string[]>();
|
const byGeom = new Map<string, string[]>();
|
||||||
for (const row of links) {
|
for (const row of links) {
|
||||||
@@ -184,14 +194,47 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
|
|||||||
list.push(row.entity_id);
|
list.push(row.entity_id);
|
||||||
byGeom.set(row.geometry_id, list);
|
byGeom.set(row.geometry_id, list);
|
||||||
}
|
}
|
||||||
|
const entityNameById = new Map<string, string>();
|
||||||
|
for (const row of entities || []) {
|
||||||
|
const id = typeof row?.id === "string" ? row.id : "";
|
||||||
|
if (!id) continue;
|
||||||
|
const name = typeof (row as any)?.name === "string" ? String((row as any).name).trim() : "";
|
||||||
|
if (name) entityNameById.set(id, name);
|
||||||
|
}
|
||||||
|
const geometryById = new Map<string, GeometrySnapshot>();
|
||||||
|
for (const row of geometries || []) {
|
||||||
|
const id = typeof row?.id === "string" ? row.id : "";
|
||||||
|
if (!id) continue;
|
||||||
|
geometryById.set(id, row);
|
||||||
|
}
|
||||||
const cloned = JSON.parse(JSON.stringify(fc)) as FeatureCollection;
|
const cloned = JSON.parse(JSON.stringify(fc)) as FeatureCollection;
|
||||||
for (const feature of cloned.features) {
|
for (const feature of cloned.features) {
|
||||||
const gid = String(feature.properties.id);
|
const gid = String(feature.properties.id);
|
||||||
const entity_ids = byGeom.get(gid) || [];
|
const entity_ids = byGeom.get(gid) || [];
|
||||||
if (entity_ids.length) {
|
if (entity_ids.length || hasLinks) {
|
||||||
const props = feature.properties as unknown as UnknownRecord;
|
const props = feature.properties as unknown as UnknownRecord;
|
||||||
props.entity_ids = entity_ids;
|
props.entity_ids = entity_ids;
|
||||||
props.entity_id = entity_ids[0];
|
props.entity_id = entity_ids[0] || null;
|
||||||
|
|
||||||
|
// Generate denormalized names for UI/map usage.
|
||||||
|
const primaryId = entity_ids[0] || null;
|
||||||
|
const primaryName = primaryId ? (entityNameById.get(primaryId) || "") : "";
|
||||||
|
const names = entity_ids.map((id) => entityNameById.get(id) || "").filter((n) => n.length > 0);
|
||||||
|
props.entity_name = primaryName || null;
|
||||||
|
props.entity_names = names;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate geometry metadata onto feature properties (optional in persisted snapshot).
|
||||||
|
const geo = geometryById.get(gid) || null;
|
||||||
|
if (geo) {
|
||||||
|
const p = feature.properties as unknown as UnknownRecord;
|
||||||
|
// type (semantic key) is derived from geometries[].type (numeric code in string form).
|
||||||
|
const typeCode = typeof geo.type === "string" && geo.type.trim().length ? Number(geo.type) : NaN;
|
||||||
|
const typeKey = geoTypeCodeToTypeKey(Number.isFinite(typeCode) ? typeCode : null);
|
||||||
|
if (typeKey) p.type = typeKey;
|
||||||
|
if (Array.isArray(geo.binding) && geo.binding.length) p.binding = geo.binding;
|
||||||
|
if (typeof geo.time_start === "number") p.time_start = geo.time_start;
|
||||||
|
if (typeof geo.time_end === "number") p.time_end = geo.time_end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cloned;
|
return cloned;
|
||||||
@@ -273,12 +316,13 @@ export function buildEditorSnapshot(options: {
|
|||||||
? cloned.name.trim()
|
? cloned.name.trim()
|
||||||
: id;
|
: id;
|
||||||
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
const source: "inline" | "ref" = cloned.source === "inline" ? "inline" : "ref";
|
||||||
|
const operation = sanitizeEntitySnapshotOperation((cloned as any).operation);
|
||||||
entityRows.set(id, {
|
entityRows.set(id, {
|
||||||
...cloned,
|
...cloned,
|
||||||
id,
|
id,
|
||||||
source,
|
source,
|
||||||
name,
|
name,
|
||||||
operation: cloned.operation ?? "reference",
|
operation,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,6 +414,11 @@ export function buildEditorSnapshot(options: {
|
|||||||
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
const draftForSnapshot = JSON.parse(JSON.stringify(options.draft)) as FeatureCollection;
|
||||||
for (const feature of draftForSnapshot.features) {
|
for (const feature of draftForSnapshot.features) {
|
||||||
const p = feature.properties as unknown as UnknownRecord;
|
const p = feature.properties as unknown as UnknownRecord;
|
||||||
|
// Do not send generate-only fields on the API payload. These are re-generated on load.
|
||||||
|
delete p.type;
|
||||||
|
delete p.time_start;
|
||||||
|
delete p.time_end;
|
||||||
|
delete p.binding;
|
||||||
delete p.entity_id;
|
delete p.entity_id;
|
||||||
delete p.entity_ids;
|
delete p.entity_ids;
|
||||||
delete p.entity_name;
|
delete p.entity_name;
|
||||||
@@ -414,7 +463,8 @@ export function buildEditorSnapshot(options: {
|
|||||||
return cloned;
|
return cloned;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Inline wiki with no explicit operation: mark update only if changed; otherwise omit operation.
|
// Inline wiki with no explicit operation:
|
||||||
|
// Keep a valid operation value, because backend validation may require it (oneof).
|
||||||
if (!prev) {
|
if (!prev) {
|
||||||
// New wiki that somehow has no op set: treat as create.
|
// New wiki that somehow has no op set: treat as create.
|
||||||
cloned.operation = "create";
|
cloned.operation = "create";
|
||||||
@@ -431,7 +481,7 @@ export function buildEditorSnapshot(options: {
|
|||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
cloned.operation = changed ? "update" : undefined;
|
cloned.operation = changed ? "update" : "reference";
|
||||||
return cloned;
|
return cloned;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -440,16 +490,34 @@ export function buildEditorSnapshot(options: {
|
|||||||
.map((l) => ({
|
.map((l) => ({
|
||||||
entity_id: l.entity_id,
|
entity_id: l.entity_id,
|
||||||
wiki_id: l.wiki_id,
|
wiki_id: l.wiki_id,
|
||||||
operation: l.operation === "delete" ? "delete" : "binding",
|
// Backend API expects "reference" to indicate an active link (not "binding").
|
||||||
|
operation: l.operation === "delete" ? "delete" : "reference",
|
||||||
}));
|
}));
|
||||||
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
|
const entityWikis = dedupeAndSortEntityWiki(entityWikisRaw);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
editor_feature_collection: draftForSnapshot,
|
editor_feature_collection: draftForSnapshot,
|
||||||
entities: Array.from(entityRows.values()).sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
entities: Array.from(entityRows.values())
|
||||||
|
.map((e) => ({
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
operation: e.operation,
|
||||||
|
name: typeof e.name === "string" ? e.name : undefined,
|
||||||
|
description: typeof (e as any).description === "string" ? (e as any).description : (e as any).description ?? null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||||
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
geometries: geometries.slice().sort((a, b) => String(a.id).localeCompare(String(b.id))),
|
||||||
geometry_entity: geometryEntity,
|
geometry_entity: geometryEntity,
|
||||||
wikis: wikis.slice().sort((a, b) => a.id.localeCompare(b.id)),
|
wikis: wikis
|
||||||
|
.map((w) => ({
|
||||||
|
id: w.id,
|
||||||
|
source: w.source,
|
||||||
|
operation: w.operation,
|
||||||
|
title: w.title,
|
||||||
|
slug: (w as any).slug ?? null,
|
||||||
|
doc: (w as any).doc ?? null,
|
||||||
|
}))
|
||||||
|
.sort((a, b) => a.id.localeCompare(b.id)),
|
||||||
entity_wiki: entityWikis,
|
entity_wiki: entityWikis,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -481,7 +549,13 @@ function dedupeAndSortEntityWiki(rows: EntityWikiLinkSnapshot[]): EntityWikiLink
|
|||||||
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
const entity_id = typeof row.entity_id === "string" ? row.entity_id : "";
|
||||||
const wiki_id = typeof row.wiki_id === "string" ? row.wiki_id : "";
|
const wiki_id = typeof row.wiki_id === "string" ? row.wiki_id : "";
|
||||||
if (!entity_id || !wiki_id) continue;
|
if (!entity_id || !wiki_id) continue;
|
||||||
const operation = row.operation === "delete" ? "delete" : "binding";
|
const opRaw = row.operation;
|
||||||
|
const operation: EntityWikiLinkSnapshot["operation"] =
|
||||||
|
opRaw === "delete"
|
||||||
|
? "delete"
|
||||||
|
: opRaw === "binding" || opRaw === "reference"
|
||||||
|
? opRaw
|
||||||
|
: "reference";
|
||||||
const key = `${entity_id}::${wiki_id}`;
|
const key = `${entity_id}::${wiki_id}`;
|
||||||
if (seen.has(key)) continue;
|
if (seen.has(key)) continue;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
|
|||||||
116
src/uhm/types/commit_snapshot.ts
Normal file
116
src/uhm/types/commit_snapshot.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
|
||||||
|
// ---- Root request ----
|
||||||
|
|
||||||
|
export type CreateCommitRequest = {
|
||||||
|
snapshot_json: CommitSnapshot;
|
||||||
|
edit_summary: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Snapshot root ----
|
||||||
|
|
||||||
|
export type CommitSnapshot = {
|
||||||
|
editor_feature_collection: FeatureCollection;
|
||||||
|
entities: EntitySnapshot[];
|
||||||
|
geometries: GeometrySnapshot[];
|
||||||
|
geometry_entity: GeometryEntitySnapshot[];
|
||||||
|
wikis: WikiSnapshot[];
|
||||||
|
entity_wiki: EntityWikiLinkSnapshot[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- GeoJSON / FeatureCollection ----
|
||||||
|
|
||||||
|
export type Geometry =
|
||||||
|
| { type: "Point"; coordinates: [number, number] }
|
||||||
|
| { type: "MultiPoint"; coordinates: [number, number][] }
|
||||||
|
| { type: "LineString"; coordinates: [number, number][] }
|
||||||
|
| { type: "MultiLineString"; coordinates: [number, number][][] }
|
||||||
|
| { type: "Polygon"; coordinates: [number, number][][] }
|
||||||
|
| { type: "MultiPolygon"; coordinates: [number, number][][][] };
|
||||||
|
|
||||||
|
export type FeatureId = string | number;
|
||||||
|
|
||||||
|
export type FeatureProperties = {
|
||||||
|
id: FeatureId;
|
||||||
|
type?: string | null; //generate
|
||||||
|
geometry_preset?: string | null;
|
||||||
|
time_start?: number | null; //generate
|
||||||
|
time_end?: number | null; //generate
|
||||||
|
binding?: string[]; //generate
|
||||||
|
|
||||||
|
// Legacy/UI-only fields should not be relied on by the backend.
|
||||||
|
// FE strips these when building snapshot_json, but we keep them optional here
|
||||||
|
// because older snapshots may still contain them.
|
||||||
|
entity_id?: string | null; //generate
|
||||||
|
entity_ids?: string[]; //generate
|
||||||
|
entity_name?: string | null; //generate
|
||||||
|
entity_names?: string[]; //generate
|
||||||
|
entity_type_id?: string | null; //generate
|
||||||
|
};
|
||||||
|
|
||||||
|
export type Feature = {
|
||||||
|
type: "Feature";
|
||||||
|
properties: FeatureProperties;
|
||||||
|
geometry: Geometry;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FeatureCollection = {
|
||||||
|
type: "FeatureCollection";
|
||||||
|
features: Feature[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---- Snapshot rows ----
|
||||||
|
|
||||||
|
export type SnapshotSource = "inline" | "ref";
|
||||||
|
|
||||||
|
export type SnapshotOperation = "create" | "update" | "delete" | "reference";
|
||||||
|
|
||||||
|
export type EntitySnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: SnapshotOperation;
|
||||||
|
|
||||||
|
name?: string;
|
||||||
|
description?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometrySnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: SnapshotOperation;
|
||||||
|
type?: string | null;
|
||||||
|
draw_geometry?: Geometry;
|
||||||
|
binding?: string[];
|
||||||
|
time_start?: number | null;
|
||||||
|
time_end?: number | null;
|
||||||
|
bbox?: {
|
||||||
|
min_lng: number;
|
||||||
|
min_lat: number;
|
||||||
|
max_lng: number;
|
||||||
|
max_lat: number;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type GeometryEntitySnapshot = {
|
||||||
|
geometry_id: string;
|
||||||
|
entity_id: string;
|
||||||
|
operation?: "reference" | "delete" | "binding";
|
||||||
|
};
|
||||||
|
|
||||||
|
// FE stores wiki doc as a string (often HTML) or null for ref-only rows.
|
||||||
|
export type WikiDoc = string | null;
|
||||||
|
|
||||||
|
export type WikiSnapshot = {
|
||||||
|
id: string;
|
||||||
|
source: SnapshotSource;
|
||||||
|
operation?: SnapshotOperation;
|
||||||
|
|
||||||
|
title: string;
|
||||||
|
slug?: string | null;
|
||||||
|
doc: WikiDoc;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EntityWikiLinkSnapshot = {
|
||||||
|
entity_id: string;
|
||||||
|
wiki_id: string;
|
||||||
|
operation?: "reference" | "delete" | "binding";
|
||||||
|
};
|
||||||
@@ -6,9 +6,9 @@ export type EntityWikiLinkSnapshot = {
|
|||||||
entity_id: string;
|
entity_id: string;
|
||||||
wiki_id: string;
|
wiki_id: string;
|
||||||
// Relationship semantics (entity ↔ wiki).
|
// Relationship semantics (entity ↔ wiki).
|
||||||
// - binding: the link exists (assigned)
|
// - reference/binding: the link exists (assigned)
|
||||||
// - delete: the link is removed
|
// - delete: the link is removed
|
||||||
operation?: "binding" | "delete";
|
operation?: "reference" | "binding" | "delete";
|
||||||
};
|
};
|
||||||
|
|
||||||
// BackEndGo uses Projects/Commits/Submissions. "Section" is legacy naming in FE.
|
// BackEndGo uses Projects/Commits/Submissions. "Section" is legacy naming in FE.
|
||||||
|
|||||||
Reference in New Issue
Block a user