This commit is contained in:
17
.gitea/workflows/build.yml
Normal file
17
.gitea/workflows/build.yml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name: Build and Release
|
||||||
|
run-name: ${{ gitea.actor }} build 🚀
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Deploy to Container
|
||||||
|
run: |
|
||||||
|
docker compose up -d --build --remove-orphans
|
||||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
.idea/
|
||||||
|
node_modules/
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:24-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
FROM node:24-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --omit=dev
|
||||||
|
|
||||||
|
COPY --from=builder /app/dist ./dist
|
||||||
|
COPY --from=builder /app/data ./data
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["node", "dist/index.js"]
|
||||||
BIN
data/land.mbtiles
Normal file
BIN
data/land.mbtiles
Normal file
Binary file not shown.
BIN
data/map.mbtiles
Normal file
BIN
data/map.mbtiles
Normal file
Binary file not shown.
BIN
data/raster.mbtiles
Normal file
BIN
data/raster.mbtiles
Normal file
Binary file not shown.
412
db/polygons.js
Normal file
412
db/polygons.js
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const dbPath = path.join(__dirname, "..", "data", "polygons.db");
|
||||||
|
const db = new Database(dbPath);
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS geometries (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT,
|
||||||
|
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
draw_geometry TEXT NOT NULL,
|
||||||
|
binding TEXT,
|
||||||
|
time_start INTEGER,
|
||||||
|
time_end INTEGER,
|
||||||
|
bbox_min_lng REAL,
|
||||||
|
bbox_min_lat REAL,
|
||||||
|
bbox_max_lng REAL,
|
||||||
|
bbox_max_lat REAL,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS entities (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
type_id TEXT NOT NULL DEFAULT 'country',
|
||||||
|
status INTEGER DEFAULT 1,
|
||||||
|
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS entity_geometries (
|
||||||
|
entity_id TEXT NOT NULL,
|
||||||
|
geometry_id TEXT NOT NULL,
|
||||||
|
created_at TEXT,
|
||||||
|
PRIMARY KEY (entity_id, geometry_id),
|
||||||
|
FOREIGN KEY (entity_id) REFERENCES entities(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (geometry_id) REFERENCES geometries(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS sections (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
user_id TEXT,
|
||||||
|
created_by TEXT,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS section_states (
|
||||||
|
section_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT NOT NULL DEFAULT 'editing',
|
||||||
|
head_commit_id TEXT,
|
||||||
|
version INTEGER NOT NULL DEFAULT 0,
|
||||||
|
locked_by TEXT,
|
||||||
|
locked_at TEXT,
|
||||||
|
lock_expires_at TEXT,
|
||||||
|
updated_at TEXT NOT NULL,
|
||||||
|
FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS section_commits (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
section_id TEXT NOT NULL,
|
||||||
|
parent_commit_id TEXT,
|
||||||
|
commit_no INTEGER NOT NULL,
|
||||||
|
kind TEXT NOT NULL DEFAULT 'manual',
|
||||||
|
restored_from_commit_id TEXT,
|
||||||
|
created_by TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
title TEXT,
|
||||||
|
note TEXT,
|
||||||
|
snapshot_json TEXT NOT NULL,
|
||||||
|
snapshot_hash TEXT,
|
||||||
|
FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (parent_commit_id) REFERENCES section_commits(id),
|
||||||
|
FOREIGN KEY (restored_from_commit_id) REFERENCES section_commits(id)
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE IF NOT EXISTS section_submissions (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
section_id TEXT NOT NULL,
|
||||||
|
commit_id TEXT NOT NULL,
|
||||||
|
submitted_by TEXT NOT NULL,
|
||||||
|
submitted_at TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
reviewed_by TEXT,
|
||||||
|
reviewed_at TEXT,
|
||||||
|
review_note TEXT,
|
||||||
|
snapshot_json TEXT NOT NULL,
|
||||||
|
snapshot_hash TEXT,
|
||||||
|
FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (commit_id) REFERENCES section_commits(id)
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
ensureColumn("entities", "status", "INTEGER DEFAULT 1");
|
||||||
|
ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'");
|
||||||
|
ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
||||||
|
ensureColumn("geometries", "binding", "TEXT");
|
||||||
|
ensureColumn("sections", "user_id", "TEXT");
|
||||||
|
dropEntityDeprecatedColumnsIfExists();
|
||||||
|
dropGeometryDeprecatedColumnsIfExists();
|
||||||
|
migrateLegacyGeometryTypeTokens();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_entities_slug
|
||||||
|
ON entities(slug)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entities_name
|
||||||
|
ON entities(name)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`DROP INDEX IF EXISTS idx_entity_geometries_geometry_id`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_geometries_geometry_id
|
||||||
|
ON entity_geometries(geometry_id)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_entity_geometries_entity_id
|
||||||
|
ON entity_geometries(entity_id)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_section_states_status
|
||||||
|
ON section_states(status, updated_at)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_section_commits_no
|
||||||
|
ON section_commits(section_id, commit_no)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_section_commits_section_time
|
||||||
|
ON section_commits(section_id, created_at)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_section_submissions_section_status
|
||||||
|
ON section_submissions(section_id, status, submitted_at)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
module.exports = db;
|
||||||
|
|
||||||
|
function ensureColumn(tableName, columnName, columnDefinition) {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(${tableName})`).all();
|
||||||
|
const hasColumn = columns.some((column) => column.name === columnName);
|
||||||
|
if (hasColumn) return;
|
||||||
|
db.prepare(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${columnDefinition}`).run();
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropGeometryDeprecatedColumnsIfExists() {
|
||||||
|
db.prepare(`DROP INDEX IF EXISTS idx_geometries_kind`).run();
|
||||||
|
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(geometries)`).all();
|
||||||
|
const hasKind = columns.some((column) => column.name === "kind");
|
||||||
|
const hasLineMode = columns.some((column) => column.name === "line_mode");
|
||||||
|
if (!hasKind && !hasLineMode) return;
|
||||||
|
rebuildGeometriesTableToCurrentSchema(columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropEntityDeprecatedColumnsIfExists() {
|
||||||
|
const columns = db.prepare(`PRAGMA table_info(entities)`).all();
|
||||||
|
const hasKind = columns.some((column) => column.name === "kind");
|
||||||
|
if (!hasKind) return;
|
||||||
|
rebuildEntitiesTableToCurrentSchema(columns);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildEntitiesTableToCurrentSchema(existingColumns = null) {
|
||||||
|
const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1;
|
||||||
|
const columns = existingColumns || db.prepare(`PRAGMA table_info(entities)`).all();
|
||||||
|
const hasSlug = columns.some((column) => column.name === "slug");
|
||||||
|
const hasDescription = columns.some((column) => column.name === "description");
|
||||||
|
const hasTypeId = columns.some((column) => column.name === "type_id");
|
||||||
|
const hasStatus = columns.some((column) => column.name === "status");
|
||||||
|
const hasIsDeleted = columns.some((column) => column.name === "is_deleted");
|
||||||
|
const hasCreatedAt = columns.some((column) => column.name === "created_at");
|
||||||
|
const hasUpdatedAt = columns.some((column) => column.name === "updated_at");
|
||||||
|
|
||||||
|
const slugSelect = hasSlug ? "slug" : "NULL";
|
||||||
|
const descriptionSelect = hasDescription ? "description" : "NULL";
|
||||||
|
const typeIdSelect = hasTypeId ? "type_id" : "'country'";
|
||||||
|
const statusSelect = hasStatus ? "status" : "1";
|
||||||
|
const isDeletedSelect = hasIsDeleted ? "is_deleted" : "0";
|
||||||
|
const createdAtSelect = hasCreatedAt ? "created_at" : "NULL";
|
||||||
|
const updatedAtSelect = hasUpdatedAt ? "updated_at" : "NULL";
|
||||||
|
|
||||||
|
if (foreignKeysEnabled) {
|
||||||
|
db.pragma("foreign_keys = OFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(`DROP TABLE IF EXISTS entities_new`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE entities_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
slug TEXT UNIQUE,
|
||||||
|
description TEXT,
|
||||||
|
type_id TEXT NOT NULL DEFAULT 'country',
|
||||||
|
status INTEGER DEFAULT 1,
|
||||||
|
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO entities_new (
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
type_id,
|
||||||
|
status,
|
||||||
|
is_deleted,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
${slugSelect},
|
||||||
|
${descriptionSelect},
|
||||||
|
${typeIdSelect},
|
||||||
|
${statusSelect},
|
||||||
|
${isDeletedSelect},
|
||||||
|
${createdAtSelect},
|
||||||
|
${updatedAtSelect}
|
||||||
|
FROM entities
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`DROP TABLE entities`).run();
|
||||||
|
db.prepare(`ALTER TABLE entities_new RENAME TO entities`).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
tx();
|
||||||
|
} finally {
|
||||||
|
if (foreignKeysEnabled) {
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function rebuildGeometriesTableToCurrentSchema(existingColumns = null) {
|
||||||
|
const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1;
|
||||||
|
const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all();
|
||||||
|
const hasType = columns.some((column) => column.name === "type");
|
||||||
|
const hasIsDeleted = columns.some((column) => column.name === "is_deleted");
|
||||||
|
const hasBinding = columns.some((column) => column.name === "binding");
|
||||||
|
const hasTimeStart = columns.some((column) => column.name === "time_start");
|
||||||
|
const hasTimeEnd = columns.some((column) => column.name === "time_end");
|
||||||
|
const hasBBoxMinLng = columns.some((column) => column.name === "bbox_min_lng");
|
||||||
|
const hasBBoxMinLat = columns.some((column) => column.name === "bbox_min_lat");
|
||||||
|
const hasBBoxMaxLng = columns.some((column) => column.name === "bbox_max_lng");
|
||||||
|
const hasBBoxMaxLat = columns.some((column) => column.name === "bbox_max_lat");
|
||||||
|
const hasCreatedAt = columns.some((column) => column.name === "created_at");
|
||||||
|
const hasUpdatedAt = columns.some((column) => column.name === "updated_at");
|
||||||
|
|
||||||
|
const typeSelect = hasType ? "type" : "NULL";
|
||||||
|
const isDeletedSelect = hasIsDeleted ? "is_deleted" : "0";
|
||||||
|
const bindingSelect = hasBinding ? "binding" : "NULL";
|
||||||
|
const timeStartSelect = hasTimeStart ? "time_start" : "NULL";
|
||||||
|
const timeEndSelect = hasTimeEnd ? "time_end" : "NULL";
|
||||||
|
const bboxMinLngSelect = hasBBoxMinLng ? "bbox_min_lng" : "NULL";
|
||||||
|
const bboxMinLatSelect = hasBBoxMinLat ? "bbox_min_lat" : "NULL";
|
||||||
|
const bboxMaxLngSelect = hasBBoxMaxLng ? "bbox_max_lng" : "NULL";
|
||||||
|
const bboxMaxLatSelect = hasBBoxMaxLat ? "bbox_max_lat" : "NULL";
|
||||||
|
const createdAtSelect = hasCreatedAt ? "created_at" : "NULL";
|
||||||
|
const updatedAtSelect = hasUpdatedAt ? "updated_at" : "NULL";
|
||||||
|
if (foreignKeysEnabled) {
|
||||||
|
db.pragma("foreign_keys = OFF");
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
db.prepare(`DROP TABLE IF EXISTS geometries_new`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
CREATE TABLE geometries_new (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
type TEXT,
|
||||||
|
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
|
draw_geometry TEXT NOT NULL,
|
||||||
|
binding TEXT,
|
||||||
|
time_start INTEGER,
|
||||||
|
time_end INTEGER,
|
||||||
|
bbox_min_lng REAL,
|
||||||
|
bbox_min_lat REAL,
|
||||||
|
bbox_max_lng REAL,
|
||||||
|
bbox_max_lat REAL,
|
||||||
|
created_at TEXT,
|
||||||
|
updated_at TEXT
|
||||||
|
)
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO geometries_new (
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
is_deleted,
|
||||||
|
draw_geometry,
|
||||||
|
binding,
|
||||||
|
time_start,
|
||||||
|
time_end,
|
||||||
|
bbox_min_lng,
|
||||||
|
bbox_min_lat,
|
||||||
|
bbox_max_lng,
|
||||||
|
bbox_max_lat,
|
||||||
|
created_at,
|
||||||
|
updated_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
${typeSelect},
|
||||||
|
${isDeletedSelect},
|
||||||
|
draw_geometry,
|
||||||
|
${bindingSelect},
|
||||||
|
${timeStartSelect},
|
||||||
|
${timeEndSelect},
|
||||||
|
${bboxMinLngSelect},
|
||||||
|
${bboxMinLatSelect},
|
||||||
|
${bboxMaxLngSelect},
|
||||||
|
${bboxMaxLatSelect},
|
||||||
|
${createdAtSelect},
|
||||||
|
${updatedAtSelect}
|
||||||
|
FROM geometries
|
||||||
|
`).run();
|
||||||
|
|
||||||
|
db.prepare(`DROP TABLE geometries`).run();
|
||||||
|
db.prepare(`ALTER TABLE geometries_new RENAME TO geometries`).run();
|
||||||
|
});
|
||||||
|
|
||||||
|
tx();
|
||||||
|
} finally {
|
||||||
|
if (foreignKeysEnabled) {
|
||||||
|
db.pragma("foreign_keys = ON");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrateLegacyGeometryTypeTokens() {
|
||||||
|
const geometryColumns = db.prepare(`PRAGMA table_info(geometries)`).all();
|
||||||
|
const hasType = geometryColumns.some((column) => column.name === "type");
|
||||||
|
if (!hasType) return;
|
||||||
|
|
||||||
|
const tx = db.transaction(() => {
|
||||||
|
const legacyRows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
g.id,
|
||||||
|
(
|
||||||
|
SELECT e.type_id
|
||||||
|
FROM entity_geometries eg
|
||||||
|
JOIN entities e
|
||||||
|
ON e.id = eg.entity_id
|
||||||
|
AND e.is_deleted = 0
|
||||||
|
WHERE eg.geometry_id = g.id
|
||||||
|
ORDER BY eg.rowid ASC
|
||||||
|
LIMIT 1
|
||||||
|
) AS primary_entity_type
|
||||||
|
FROM geometries g
|
||||||
|
WHERE g.type IS NULL
|
||||||
|
OR TRIM(g.type) = ''
|
||||||
|
OR LOWER(TRIM(g.type)) IN ('line', 'path')
|
||||||
|
`).all();
|
||||||
|
|
||||||
|
const updateTypeStmt = db.prepare(`
|
||||||
|
UPDATE geometries
|
||||||
|
SET type = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const row of legacyRows) {
|
||||||
|
const semanticType = normalizeText(row.primary_entity_type);
|
||||||
|
updateTypeStmt.run(semanticType, row.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tx();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeText(value) {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
return normalized.length ? normalized : null;
|
||||||
|
}
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
services:
|
||||||
|
history-temp-api:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: history-temp-api
|
||||||
|
restart: unless-stopped
|
||||||
|
ports:
|
||||||
|
- "3012:3000"
|
||||||
|
networks:
|
||||||
|
- history-temp-api-network
|
||||||
|
|
||||||
|
networks:
|
||||||
|
history-temp-api-network:
|
||||||
|
driver: bridge
|
||||||
42
index.js
Normal file
42
index.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const cors = require("cors");
|
||||||
|
const swaggerUi = require("swagger-ui-express");
|
||||||
|
|
||||||
|
const tileRoutes = require("./routes/tiles");
|
||||||
|
const rasterTileRoutes = require("./routes/rasterTiles");
|
||||||
|
const geoRoutes = require("./routes/geometries");
|
||||||
|
const entityRoutes = require("./routes/entities");
|
||||||
|
const sectionRoutes = require("./routes/sections");
|
||||||
|
const { openApiSpec } = require("./swagger");
|
||||||
|
const { envelopeResponses } = require("./utils/apiEnvelope");
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// serve MBTiles and geometry CRUD
|
||||||
|
app.use("/tiles", envelopeResponses, tileRoutes);
|
||||||
|
app.use("/raster-tiles", envelopeResponses, rasterTileRoutes);
|
||||||
|
app.use("/geometries", envelopeResponses, geoRoutes);
|
||||||
|
app.use("/entities", envelopeResponses, entityRoutes);
|
||||||
|
app.use("/sections", envelopeResponses, sectionRoutes);
|
||||||
|
app.use("/submissions", envelopeResponses, (req, res, next) => {
|
||||||
|
req.url = `/submissions${req.url}`;
|
||||||
|
sectionRoutes(req, res, next);
|
||||||
|
});
|
||||||
|
app.use("/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec, {
|
||||||
|
explorer: true,
|
||||||
|
}));
|
||||||
|
app.get("/docs.json", (req, res) => {
|
||||||
|
res.json(openApiSpec);
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/", (req, res) => {
|
||||||
|
res.send("GIS server running");
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(3000, () => {
|
||||||
|
console.log("🚀 http://localhost:3000");
|
||||||
|
console.log("📘 Swagger UI: http://localhost:3000/docs");
|
||||||
|
});
|
||||||
302
lib/entityBatch.js
Normal file
302
lib/entityBatch.js
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const db = require("../db/polygons");
|
||||||
|
|
||||||
|
function applyEntityBatchChanges(changes, options = {}) {
|
||||||
|
const now = options.now || new Date().toISOString();
|
||||||
|
const createdEntityIds = [];
|
||||||
|
|
||||||
|
for (const change of changes) {
|
||||||
|
const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action");
|
||||||
|
const action = normalizeBatchAction(hasAction ? change.action : change?.type);
|
||||||
|
|
||||||
|
if (!change || !action) {
|
||||||
|
throw createValidationError("Invalid entity change entry");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "create") {
|
||||||
|
const payload = normalizeEntityCreatePayload(change);
|
||||||
|
const id = payload.id || crypto.randomUUID();
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
INSERT INTO entities (
|
||||||
|
id, name, slug, description, type_id, status, is_deleted, created_at, updated_at
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||||
|
`).run(
|
||||||
|
id,
|
||||||
|
payload.name,
|
||||||
|
payload.slug,
|
||||||
|
payload.description,
|
||||||
|
payload.typeId,
|
||||||
|
payload.status,
|
||||||
|
now,
|
||||||
|
now
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (isSqliteConstraint(err)) {
|
||||||
|
throw createValidationError("Entity name/slug must be unique", 409);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
createdEntityIds.push(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "update") {
|
||||||
|
const payload = normalizeEntityUpdatePayload(change);
|
||||||
|
const existing = db.prepare(`
|
||||||
|
SELECT *
|
||||||
|
FROM entities
|
||||||
|
WHERE id = ?
|
||||||
|
AND is_deleted = 0
|
||||||
|
`).get(payload.id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasName = Object.prototype.hasOwnProperty.call(payload.fields, "name");
|
||||||
|
const hasSlug = Object.prototype.hasOwnProperty.call(payload.fields, "slug");
|
||||||
|
const hasDescription = Object.prototype.hasOwnProperty.call(payload.fields, "description");
|
||||||
|
const hasTypeId = Object.prototype.hasOwnProperty.call(payload.fields, "type_id");
|
||||||
|
const hasStatus = Object.prototype.hasOwnProperty.call(payload.fields, "status");
|
||||||
|
|
||||||
|
if (!hasName && !hasSlug && !hasDescription && !hasTypeId && !hasStatus) {
|
||||||
|
throw createValidationError("Invalid entity update payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const name = hasName ? normalizeRequiredString(payload.fields.name) : existing.name;
|
||||||
|
if (!name) {
|
||||||
|
throw createValidationError("name cannot be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
const slug = hasSlug ? normalizeOptionalString(payload.fields.slug) : existing.slug;
|
||||||
|
const description = hasDescription
|
||||||
|
? normalizeOptionalString(payload.fields.description)
|
||||||
|
: existing.description;
|
||||||
|
const typeId = hasTypeId ? normalizeTypeId(payload.fields.type_id) : existing.type_id;
|
||||||
|
const status = hasStatus
|
||||||
|
? (normalizeOptionalNumber(payload.fields.status) ?? existing.status)
|
||||||
|
: existing.status;
|
||||||
|
|
||||||
|
try {
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE entities
|
||||||
|
SET name = ?,
|
||||||
|
slug = ?,
|
||||||
|
description = ?,
|
||||||
|
type_id = ?,
|
||||||
|
status = ?,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND is_deleted = 0
|
||||||
|
`).run(
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
typeId,
|
||||||
|
status,
|
||||||
|
now,
|
||||||
|
payload.id
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (isSqliteConstraint(err)) {
|
||||||
|
throw createValidationError("Entity name/slug must be unique", 409);
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === "delete") {
|
||||||
|
const id = normalizeDeleteEntityId(change);
|
||||||
|
if (!id) {
|
||||||
|
throw createValidationError("Invalid entity delete payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = db.prepare(`
|
||||||
|
SELECT id
|
||||||
|
FROM entities
|
||||||
|
WHERE id = ?
|
||||||
|
AND is_deleted = 0
|
||||||
|
`).get(id);
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEntityCanBeDeleted(id);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE entities
|
||||||
|
SET is_deleted = 1,
|
||||||
|
updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
AND is_deleted = 0
|
||||||
|
`).run(now, id);
|
||||||
|
|
||||||
|
db.prepare(`
|
||||||
|
DELETE FROM entity_geometries
|
||||||
|
WHERE entity_id = ?
|
||||||
|
`).run(id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw createValidationError(`Unknown entity change type: ${String(action)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
applied: changes.length,
|
||||||
|
createdEntityIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
applyEntityBatchChanges,
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeEntityCreatePayload(change) {
|
||||||
|
const source = isPlainObject(change.entity) ? change.entity : change;
|
||||||
|
const name = normalizeRequiredString(source?.name);
|
||||||
|
if (!name) {
|
||||||
|
throw createValidationError("Invalid entity create payload: name is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = normalizeEntityId(source?.id);
|
||||||
|
const slug = Object.prototype.hasOwnProperty.call(source || {}, "slug")
|
||||||
|
? normalizeOptionalString(source.slug)
|
||||||
|
: toSlug(name);
|
||||||
|
const description = normalizeOptionalString(source?.description);
|
||||||
|
const typeId = normalizeTypeId(source?.type_id);
|
||||||
|
const status = normalizeOptionalNumber(source?.status) ?? 1;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
description,
|
||||||
|
typeId,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntityUpdatePayload(change) {
|
||||||
|
const source = isPlainObject(change.entity) ? change.entity : change;
|
||||||
|
const id = normalizeEntityId(source?.id ?? change?.id);
|
||||||
|
if (!id) {
|
||||||
|
throw createValidationError("Invalid entity update payload: id is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
fields: source,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeleteEntityId(change) {
|
||||||
|
const source = isPlainObject(change.entity) ? change.entity : null;
|
||||||
|
return normalizeEntityId(change?.id ?? source?.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertEntityCanBeDeleted(entityId) {
|
||||||
|
const orphanedGeometryRows = db.prepare(`
|
||||||
|
SELECT eg_target.geometry_id
|
||||||
|
FROM entity_geometries eg_target
|
||||||
|
JOIN geometries g_target
|
||||||
|
ON g_target.id = eg_target.geometry_id
|
||||||
|
AND g_target.is_deleted = 0
|
||||||
|
LEFT JOIN entity_geometries eg_other
|
||||||
|
ON eg_other.geometry_id = eg_target.geometry_id
|
||||||
|
AND eg_other.entity_id <> eg_target.entity_id
|
||||||
|
LEFT JOIN entities e_other
|
||||||
|
ON e_other.id = eg_other.entity_id
|
||||||
|
AND e_other.is_deleted = 0
|
||||||
|
WHERE eg_target.entity_id = ?
|
||||||
|
GROUP BY eg_target.geometry_id
|
||||||
|
HAVING COUNT(e_other.id) = 0
|
||||||
|
`).all(entityId);
|
||||||
|
|
||||||
|
if (!orphanedGeometryRows.length) return;
|
||||||
|
|
||||||
|
const previewIds = orphanedGeometryRows.slice(0, 10).map((row) => row.geometry_id);
|
||||||
|
const suffix = orphanedGeometryRows.length > 10 ? ", ..." : "";
|
||||||
|
throw createValidationError(
|
||||||
|
`Cannot delete entity. Reassign ${orphanedGeometryRows.length} linked geometries first: ${previewIds.join(", ")}${suffix}`,
|
||||||
|
409
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBatchAction(value) {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (normalized === "create" || normalized === "update" || normalized === "delete") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntityId(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRequiredString(value) {
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalString(value) {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (typeof value !== "string") return null;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed.length ? trimmed : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOptionalNumber(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return null;
|
||||||
|
return num;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTypeId(value) {
|
||||||
|
if (value === undefined || value === null || value === "") {
|
||||||
|
return "country";
|
||||||
|
}
|
||||||
|
const trimmed = String(value).trim().toLowerCase();
|
||||||
|
return trimmed || "country";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(value) {
|
||||||
|
return value
|
||||||
|
.normalize("NFKD")
|
||||||
|
.replace(/[\u0300-\u036f]/g, "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/^-+|-+$/g, "")
|
||||||
|
.slice(0, 120) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlainObject(value) {
|
||||||
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSqliteConstraint(error) {
|
||||||
|
if (!error || typeof error !== "object") return false;
|
||||||
|
const code = error.code || "";
|
||||||
|
if (code === "SQLITE_CONSTRAINT" || String(code).startsWith("SQLITE_CONSTRAINT_")) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const message = typeof error.message === "string" ? error.message : "";
|
||||||
|
return message.includes("UNIQUE constraint failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidationError(message, status = 400) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
1319
package-lock.json
generated
Normal file
1319
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "be-map",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"dev": "node index.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"dependencies": {
|
||||||
|
"better-sqlite3": "^12.8.0",
|
||||||
|
"cors": "^2.8.6",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"swagger-ui-express": "^5.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
103
routes/entities.js
Normal file
103
routes/entities.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const db = require("../db/polygons");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const search = typeof req.query.q === "string" ? req.query.q.trim() : "";
|
||||||
|
|
||||||
|
let sql = `
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(eg.geometry_id) AS geometry_count
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_geometries eg
|
||||||
|
ON eg.entity_id = e.id
|
||||||
|
WHERE e.is_deleted = 0
|
||||||
|
`;
|
||||||
|
const params = [];
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
sql += " AND (e.name LIKE ? OR e.slug LIKE ?)";
|
||||||
|
const pattern = `%${search}%`;
|
||||||
|
params.push(pattern, pattern);
|
||||||
|
}
|
||||||
|
|
||||||
|
sql += `
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.name COLLATE NOCASE ASC
|
||||||
|
`;
|
||||||
|
|
||||||
|
const rows = db.prepare(sql).all(...params);
|
||||||
|
res.json(rows.map(normalizeEntityRow));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/search", (req, res) => {
|
||||||
|
const name = typeof req.query.name === "string" ? req.query.name.trim() : "";
|
||||||
|
const limit = normalizeLimit(req.query.limit, 25, 100);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
return res.json([]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pattern = `%${name}%`;
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(eg.geometry_id) AS geometry_count
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_geometries eg
|
||||||
|
ON eg.entity_id = e.id
|
||||||
|
WHERE e.is_deleted = 0
|
||||||
|
AND e.name LIKE ?
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.name COLLATE NOCASE ASC
|
||||||
|
LIMIT ?
|
||||||
|
`).all(pattern, limit);
|
||||||
|
|
||||||
|
res.json(rows.map(normalizeEntityRow));
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:id", (req, res) => {
|
||||||
|
const row = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
e.*,
|
||||||
|
COUNT(eg.geometry_id) AS geometry_count
|
||||||
|
FROM entities e
|
||||||
|
LEFT JOIN entity_geometries eg
|
||||||
|
ON eg.entity_id = e.id
|
||||||
|
WHERE e.id = ?
|
||||||
|
AND e.is_deleted = 0
|
||||||
|
GROUP BY e.id
|
||||||
|
`).get(req.params.id);
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return res.status(404).json({ error: "Entity not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(normalizeEntityRow(row));
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
|
function normalizeEntityRow(row) {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
slug: row.slug,
|
||||||
|
description: row.description,
|
||||||
|
type_id: row.type_id,
|
||||||
|
status: row.status,
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
geometry_count: Number(row.geometry_count || 0),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeLimit(value, fallback = 25, max = 100) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return fallback;
|
||||||
|
const intValue = Math.trunc(num);
|
||||||
|
if (intValue <= 0) return fallback;
|
||||||
|
return Math.min(intValue, max);
|
||||||
|
}
|
||||||
223
routes/geometries.js
Normal file
223
routes/geometries.js
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const db = require("../db/polygons");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// Query by bbox/time(/entity)
|
||||||
|
// =======================
|
||||||
|
router.get("/", (req, res) => {
|
||||||
|
const minLng = Number(req.query.minLng);
|
||||||
|
const minLat = Number(req.query.minLat);
|
||||||
|
const maxLng = Number(req.query.maxLng);
|
||||||
|
const maxLat = Number(req.query.maxLat);
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Number.isFinite(minLng) ||
|
||||||
|
!Number.isFinite(minLat) ||
|
||||||
|
!Number.isFinite(maxLng) ||
|
||||||
|
!Number.isFinite(maxLat)
|
||||||
|
) {
|
||||||
|
return res.status(400).json({ error: "Missing/invalid bbox" });
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = null;
|
||||||
|
if (req.query.time !== undefined && req.query.time !== "") {
|
||||||
|
const parsed = Number(req.query.time);
|
||||||
|
if (!Number.isFinite(parsed)) {
|
||||||
|
return res.status(400).json({ error: "Invalid time" });
|
||||||
|
}
|
||||||
|
t = Math.trunc(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
let entityId = null;
|
||||||
|
try {
|
||||||
|
entityId = normalizeEntityId(req.query.entity_id);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(err.status || 400).json({ error: err.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
g.*
|
||||||
|
FROM geometries g
|
||||||
|
WHERE g.is_deleted = 0
|
||||||
|
AND g.bbox_max_lng >= ?
|
||||||
|
AND g.bbox_min_lng <= ?
|
||||||
|
AND g.bbox_max_lat >= ?
|
||||||
|
AND g.bbox_min_lat <= ?
|
||||||
|
AND (
|
||||||
|
? IS NULL
|
||||||
|
OR (
|
||||||
|
(g.time_start IS NULL OR g.time_start <= ?)
|
||||||
|
AND (g.time_end IS NULL OR g.time_end >= ?)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
? IS NULL
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM entity_geometries eg
|
||||||
|
JOIN entities e
|
||||||
|
ON e.id = eg.entity_id
|
||||||
|
AND e.is_deleted = 0
|
||||||
|
WHERE eg.geometry_id = g.id
|
||||||
|
AND eg.entity_id = ?
|
||||||
|
)
|
||||||
|
)
|
||||||
|
`).all(minLng, maxLng, minLat, maxLat, t, t, t, entityId, entityId);
|
||||||
|
|
||||||
|
const geometryIds = rows.map((row) => String(row.id));
|
||||||
|
const linksByGeometryId = loadGeometryLinksByGeometryId(geometryIds);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
type: "FeatureCollection",
|
||||||
|
features: rows.map((row) => {
|
||||||
|
const linkedEntities = linksByGeometryId.get(String(row.id)) || [];
|
||||||
|
return buildFeatureFromRow(row, linkedEntities);
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
|
||||||
|
function buildFeatureFromRow(row, linkedEntities = []) {
|
||||||
|
const entityIds = linkedEntities.map((entity) => entity.entity_id);
|
||||||
|
const primaryEntityId = entityIds[0] || null;
|
||||||
|
const orderedEntityIds = entityIds;
|
||||||
|
const orderedLinkedEntities = linkedEntities;
|
||||||
|
const primaryEntity =
|
||||||
|
orderedLinkedEntities.find((entity) => entity.entity_id === primaryEntityId) ||
|
||||||
|
orderedLinkedEntities[0] ||
|
||||||
|
null;
|
||||||
|
const storedGeometryType = normalizeGeometryType(row.type);
|
||||||
|
const semanticType = storedGeometryType && !isLegacyLineModeToken(storedGeometryType)
|
||||||
|
? storedGeometryType
|
||||||
|
: normalizeGeometryType(primaryEntity?.entity_type_id);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: "Feature",
|
||||||
|
properties: {
|
||||||
|
id: row.id,
|
||||||
|
type: semanticType || null,
|
||||||
|
time_start: row.time_start,
|
||||||
|
time_end: row.time_end,
|
||||||
|
binding: parseBindingIds(row.binding),
|
||||||
|
entity_id: primaryEntityId,
|
||||||
|
entity_ids: orderedEntityIds,
|
||||||
|
entity_name: primaryEntity?.entity_name || null,
|
||||||
|
entity_names: orderedLinkedEntities
|
||||||
|
.map((entity) => entity.entity_name)
|
||||||
|
.filter((name) => typeof name === "string" && name.length > 0),
|
||||||
|
entity_type_id: primaryEntity?.entity_type_id || null,
|
||||||
|
},
|
||||||
|
geometry: JSON.parse(row.draw_geometry),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGeometryType(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
return normalized.length ? normalized : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isLegacyLineModeToken(value) {
|
||||||
|
return value === "line" || value === "path";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntityId(value) {
|
||||||
|
if (value === undefined || value === null || value === "") return null;
|
||||||
|
const normalized = String(value).trim();
|
||||||
|
return normalized || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeBindingIds(rawBinding) {
|
||||||
|
if (rawBinding === undefined || rawBinding === null || rawBinding === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!Array.isArray(rawBinding)) {
|
||||||
|
throw createValidationError("binding must be an array");
|
||||||
|
}
|
||||||
|
|
||||||
|
const deduped = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const rawId of rawBinding) {
|
||||||
|
if (typeof rawId !== "string" && typeof rawId !== "number") {
|
||||||
|
throw createValidationError("binding must contain geometry ids as string/number");
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = String(rawId).trim();
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
deduped.push(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
return deduped;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBindingIds(rawBinding) {
|
||||||
|
if (rawBinding === undefined || rawBinding === null || rawBinding === "") {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(rawBinding)) {
|
||||||
|
try {
|
||||||
|
return normalizeBindingIds(rawBinding);
|
||||||
|
} catch (_err) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof rawBinding === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawBinding);
|
||||||
|
return normalizeBindingIds(parsed);
|
||||||
|
} catch (_err) {
|
||||||
|
const plain = rawBinding.trim();
|
||||||
|
return plain.length ? [plain] : [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadGeometryLinksByGeometryId(geometryIds) {
|
||||||
|
const map = new Map();
|
||||||
|
if (!geometryIds.length) return map;
|
||||||
|
|
||||||
|
const placeholders = geometryIds.map(() => "?").join(",");
|
||||||
|
const rows = db.prepare(`
|
||||||
|
SELECT
|
||||||
|
eg.geometry_id,
|
||||||
|
eg.entity_id,
|
||||||
|
e.name AS entity_name,
|
||||||
|
e.type_id AS entity_type_id
|
||||||
|
FROM entity_geometries eg
|
||||||
|
JOIN entities e
|
||||||
|
ON e.id = eg.entity_id
|
||||||
|
AND e.is_deleted = 0
|
||||||
|
WHERE eg.geometry_id IN (${placeholders})
|
||||||
|
ORDER BY eg.rowid ASC
|
||||||
|
`).all(...geometryIds);
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const geometryId = String(row.geometry_id);
|
||||||
|
const current = map.get(geometryId) || [];
|
||||||
|
current.push({
|
||||||
|
entity_id: row.entity_id,
|
||||||
|
entity_name: row.entity_name || null,
|
||||||
|
entity_type_id: row.entity_type_id || null,
|
||||||
|
});
|
||||||
|
map.set(geometryId, current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createValidationError(message, status = 400) {
|
||||||
|
const err = new Error(message);
|
||||||
|
err.status = status;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
57
routes/rasterTiles.js
Normal file
57
routes/rasterTiles.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
const mbtilesPath = path.join(__dirname, "..", "data", "raster.mbtiles");
|
||||||
|
const tileDb = new Database(mbtilesPath, { readonly: true });
|
||||||
|
|
||||||
|
const metadataRows = tileDb.prepare("SELECT name, value FROM metadata").all();
|
||||||
|
const metadata = {};
|
||||||
|
|
||||||
|
for (const row of metadataRows) {
|
||||||
|
metadata[row.name] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
let contentType = "application/octet-stream";
|
||||||
|
if (metadata.format === "png") {
|
||||||
|
contentType = "image/png";
|
||||||
|
} else if (metadata.format === "jpg" || metadata.format === "jpeg") {
|
||||||
|
contentType = "image/jpeg";
|
||||||
|
} else if (metadata.format === "webp") {
|
||||||
|
contentType = "image/webp";
|
||||||
|
}
|
||||||
|
|
||||||
|
router.get("/metadata/info", (req, res) => {
|
||||||
|
res.json(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
router.get("/:z/:x/:y", (req, res) => {
|
||||||
|
const z = Number(req.params.z);
|
||||||
|
const x = Number(req.params.x);
|
||||||
|
const y = Number(req.params.y);
|
||||||
|
|
||||||
|
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) {
|
||||||
|
return res.status(400).json({ error: "Invalid tile coordinates" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tmsY = (1 << z) - 1 - y;
|
||||||
|
|
||||||
|
const tile = tileDb.prepare(`
|
||||||
|
SELECT tile_data
|
||||||
|
FROM tiles
|
||||||
|
WHERE zoom_level = ?
|
||||||
|
AND tile_column = ?
|
||||||
|
AND tile_row = ?
|
||||||
|
`).get(z, x, tmsY);
|
||||||
|
|
||||||
|
if (!tile) {
|
||||||
|
return res.status(404).json({ error: "Tile not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
res.send(tile.tile_data);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
1142
routes/sections.js
Normal file
1142
routes/sections.js
Normal file
File diff suppressed because it is too large
Load Diff
79
routes/tiles.js
Normal file
79
routes/tiles.js
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
const express = require("express");
|
||||||
|
const Database = require("better-sqlite3");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// MBTiles DB (READONLY)
|
||||||
|
// =======================
|
||||||
|
const mbtilesPath = path.join(__dirname, "..", "data", "map.mbtiles");
|
||||||
|
const tileDb = new Database(mbtilesPath, { readonly: true });
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// 📊 METADATA
|
||||||
|
// =======================
|
||||||
|
const metadataRows = tileDb.prepare("SELECT name, value FROM metadata").all();
|
||||||
|
const metadata = {};
|
||||||
|
|
||||||
|
for (const row of metadataRows) {
|
||||||
|
metadata[row.name] = row.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// decide content-type
|
||||||
|
let contentType = "application/octet-stream";
|
||||||
|
|
||||||
|
if (metadata.format === "pbf") {
|
||||||
|
contentType = "application/x-protobuf";
|
||||||
|
} else if (metadata.format === "png") {
|
||||||
|
contentType = "image/png";
|
||||||
|
} else if (metadata.format === "jpg" || metadata.format === "jpeg") {
|
||||||
|
contentType = "image/jpeg";
|
||||||
|
}
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// METADATA API
|
||||||
|
// =======================
|
||||||
|
router.get("/metadata/info", (req, res) => {
|
||||||
|
res.json(metadata);
|
||||||
|
});
|
||||||
|
|
||||||
|
// =======================
|
||||||
|
// TILE API (XYZ → TMS)
|
||||||
|
// =======================
|
||||||
|
router.get("/:z/:x/:y", (req, res) => {
|
||||||
|
const z = Number(req.params.z);
|
||||||
|
const x = Number(req.params.x);
|
||||||
|
const y = Number(req.params.y);
|
||||||
|
|
||||||
|
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) {
|
||||||
|
return res.status(400).json({ error: "Invalid tile coordinates" });
|
||||||
|
}
|
||||||
|
|
||||||
|
// convert XYZ → TMS
|
||||||
|
const tmsY = (1 << z) - 1 - y;
|
||||||
|
|
||||||
|
const stmt = tileDb.prepare(`
|
||||||
|
SELECT tile_data
|
||||||
|
FROM tiles
|
||||||
|
WHERE zoom_level = ?
|
||||||
|
AND tile_column = ?
|
||||||
|
AND tile_row = ?
|
||||||
|
`);
|
||||||
|
|
||||||
|
const tile = stmt.get(z, x, tmsY);
|
||||||
|
|
||||||
|
if (!tile) {
|
||||||
|
return res.status(404).json({ error: "Tile not found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader("Content-Type", contentType);
|
||||||
|
|
||||||
|
if (metadata.format === "pbf") {
|
||||||
|
res.setHeader("Content-Encoding", "gzip");
|
||||||
|
}
|
||||||
|
|
||||||
|
res.send(tile.tile_data);
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
468
swagger.js
Normal file
468
swagger.js
Normal file
@@ -0,0 +1,468 @@
|
|||||||
|
const openApiSpec = {
|
||||||
|
openapi: "3.0.3",
|
||||||
|
info: {
|
||||||
|
title: "Ultimate History Map API",
|
||||||
|
version: "1.0.0",
|
||||||
|
description: "Read APIs plus section review workflow. Direct geometry/entity mutations are intentionally not exposed.",
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: "http://localhost:3000",
|
||||||
|
description: "Local",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
tags: [
|
||||||
|
{ name: "System", description: "Health and OpenAPI endpoints" },
|
||||||
|
{ name: "Tiles", description: "Vector and raster tile endpoints" },
|
||||||
|
{ name: "Geometries", description: "Published geometry read endpoints" },
|
||||||
|
{ name: "Entities", description: "Published entity read endpoints" },
|
||||||
|
{ name: "Sections", description: "Draft, commit, and submit workflow" },
|
||||||
|
{ name: "Submissions", description: "Submission review workflow" },
|
||||||
|
],
|
||||||
|
components: {
|
||||||
|
schemas: {
|
||||||
|
ErrorResponse: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["error"] },
|
||||||
|
data: { nullable: true },
|
||||||
|
message: { type: "string" },
|
||||||
|
errors: {
|
||||||
|
type: "array",
|
||||||
|
items: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["status", "data", "message", "errors"],
|
||||||
|
},
|
||||||
|
Entity: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
name: { type: "string" },
|
||||||
|
slug: { type: "string", nullable: true },
|
||||||
|
description: { type: "string", nullable: true },
|
||||||
|
type_id: { type: "string", nullable: true },
|
||||||
|
status: { type: "number", nullable: true },
|
||||||
|
geometry_count: { type: "number" },
|
||||||
|
created_at: { type: "string", nullable: true },
|
||||||
|
updated_at: { type: "string", nullable: true },
|
||||||
|
},
|
||||||
|
required: ["id", "name", "geometry_count"],
|
||||||
|
},
|
||||||
|
GeoJSONGeometry: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string" },
|
||||||
|
coordinates: { type: "array", items: {} },
|
||||||
|
},
|
||||||
|
required: ["type", "coordinates"],
|
||||||
|
},
|
||||||
|
GeometryFeature: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string", enum: ["Feature"] },
|
||||||
|
properties: { type: "object" },
|
||||||
|
geometry: { $ref: "#/components/schemas/GeoJSONGeometry" },
|
||||||
|
},
|
||||||
|
required: ["type", "properties", "geometry"],
|
||||||
|
},
|
||||||
|
GeometryFeatureCollection: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
type: { type: "string", enum: ["FeatureCollection"] },
|
||||||
|
features: {
|
||||||
|
type: "array",
|
||||||
|
items: { $ref: "#/components/schemas/GeometryFeature" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["type", "features"],
|
||||||
|
},
|
||||||
|
Section: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
title: { type: "string" },
|
||||||
|
description: { type: "string", nullable: true },
|
||||||
|
user_id: { type: "string", nullable: true },
|
||||||
|
created_by: { type: "string", nullable: true },
|
||||||
|
state: { type: "object" },
|
||||||
|
},
|
||||||
|
required: ["id", "title", "state"],
|
||||||
|
},
|
||||||
|
SectionCommit: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
section_id: { type: "string" },
|
||||||
|
commit_no: { type: "number" },
|
||||||
|
kind: { type: "string" },
|
||||||
|
snapshot: { type: "object", nullable: true },
|
||||||
|
},
|
||||||
|
required: ["id", "section_id", "commit_no", "kind"],
|
||||||
|
},
|
||||||
|
SectionSubmission: {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
section_id: { type: "string" },
|
||||||
|
commit_id: { type: "string" },
|
||||||
|
status: { type: "string" },
|
||||||
|
snapshot: { type: "object", nullable: true },
|
||||||
|
},
|
||||||
|
required: ["id", "section_id", "commit_id", "status"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
paths: {
|
||||||
|
"/": {
|
||||||
|
get: {
|
||||||
|
tags: ["System"],
|
||||||
|
summary: "Health check",
|
||||||
|
responses: { 200: { description: "Server is running" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/docs.json": {
|
||||||
|
get: {
|
||||||
|
tags: ["System"],
|
||||||
|
summary: "OpenAPI JSON",
|
||||||
|
responses: { 200: { description: "OpenAPI document" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/tiles/metadata/info": {
|
||||||
|
get: {
|
||||||
|
tags: ["Tiles"],
|
||||||
|
summary: "Vector tile metadata",
|
||||||
|
responses: { 200: { description: "MBTiles metadata" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/tiles/{z}/{x}/{y}": {
|
||||||
|
get: {
|
||||||
|
tags: ["Tiles"],
|
||||||
|
summary: "Vector tile",
|
||||||
|
parameters: tileParameters(),
|
||||||
|
responses: {
|
||||||
|
200: { description: "Tile binary" },
|
||||||
|
400: { description: "Invalid tile coordinates" },
|
||||||
|
404: { description: "Tile not found" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/raster-tiles/metadata/info": {
|
||||||
|
get: {
|
||||||
|
tags: ["Tiles"],
|
||||||
|
summary: "Raster tile metadata",
|
||||||
|
responses: { 200: { description: "MBTiles metadata" } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/raster-tiles/{z}/{x}/{y}": {
|
||||||
|
get: {
|
||||||
|
tags: ["Tiles"],
|
||||||
|
summary: "Raster tile",
|
||||||
|
parameters: tileParameters(),
|
||||||
|
responses: {
|
||||||
|
200: { description: "Tile binary" },
|
||||||
|
400: { description: "Invalid tile coordinates" },
|
||||||
|
404: { description: "Tile not found" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/geometries": {
|
||||||
|
get: {
|
||||||
|
tags: ["Geometries"],
|
||||||
|
summary: "Query published geometries by bbox, time, and entity",
|
||||||
|
parameters: [
|
||||||
|
queryParam("minLng", "number", true),
|
||||||
|
queryParam("minLat", "number", true),
|
||||||
|
queryParam("maxLng", "number", true),
|
||||||
|
queryParam("maxLat", "number", true),
|
||||||
|
queryParam("time", "integer", false),
|
||||||
|
queryParam("entity_id", "string", false),
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "Feature collection",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/GeometryFeatureCollection" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: responseRef("Invalid query"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/entities": {
|
||||||
|
get: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "List published entities",
|
||||||
|
parameters: [queryParam("q", "string", false)],
|
||||||
|
responses: {
|
||||||
|
200: arrayResponse("Entity list", "#/components/schemas/Entity"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/entities/search": {
|
||||||
|
get: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "Search published entities by name",
|
||||||
|
parameters: [
|
||||||
|
queryParam("name", "string", true),
|
||||||
|
queryParam("limit", "integer", false),
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: arrayResponse("Matched entities", "#/components/schemas/Entity"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/entities/{id}": {
|
||||||
|
get: {
|
||||||
|
tags: ["Entities"],
|
||||||
|
summary: "Get published entity by id",
|
||||||
|
parameters: [pathParam("id")],
|
||||||
|
responses: {
|
||||||
|
200: objectResponse("Entity", "#/components/schemas/Entity"),
|
||||||
|
404: responseRef("Entity not found"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/sections": {
|
||||||
|
get: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "List sections",
|
||||||
|
responses: { 200: arrayResponse("Sections", "#/components/schemas/Section") },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "Create section",
|
||||||
|
requestBody: jsonBody({
|
||||||
|
type: "object",
|
||||||
|
required: ["title"],
|
||||||
|
properties: {
|
||||||
|
id: { type: "string" },
|
||||||
|
title: { type: "string" },
|
||||||
|
description: { type: "string", nullable: true },
|
||||||
|
user_id: { type: "string" },
|
||||||
|
created_by: { type: "string" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
responses: {
|
||||||
|
201: objectResponse("Created section", "#/components/schemas/Section"),
|
||||||
|
400: responseRef("Invalid payload"),
|
||||||
|
409: responseRef("Section id already exists"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/sections/{sectionId}/editor": {
|
||||||
|
get: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "Open section editor and acquire lock",
|
||||||
|
parameters: [
|
||||||
|
pathParam("sectionId"),
|
||||||
|
queryParam("user_id", "string", false),
|
||||||
|
],
|
||||||
|
responses: {
|
||||||
|
200: { description: "Editor payload" },
|
||||||
|
404: responseRef("Section not found"),
|
||||||
|
409: responseRef("Section locked by another user"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/sections/{sectionId}/lock": sectionActorPost("Acquire section lock"),
|
||||||
|
"/sections/{sectionId}/unlock": sectionActorPost("Release section lock"),
|
||||||
|
"/sections/{sectionId}/commits": {
|
||||||
|
get: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "List section commits",
|
||||||
|
parameters: [
|
||||||
|
pathParam("sectionId"),
|
||||||
|
queryParam("include_snapshot", "string", false),
|
||||||
|
],
|
||||||
|
responses: { 200: arrayResponse("Commits", "#/components/schemas/SectionCommit") },
|
||||||
|
},
|
||||||
|
post: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "Create reviewed workflow commit",
|
||||||
|
description: "Direct geometry/entity mutations are not exposed. Commit snapshots are the mutation path for geometry/entity changes.",
|
||||||
|
parameters: [pathParam("sectionId")],
|
||||||
|
requestBody: jsonBody({
|
||||||
|
type: "object",
|
||||||
|
required: ["snapshot", "created_by"],
|
||||||
|
properties: {
|
||||||
|
snapshot: { type: "object" },
|
||||||
|
created_by: { type: "string" },
|
||||||
|
expected_version: { type: "number" },
|
||||||
|
expected_head_commit_id: { type: "string", nullable: true },
|
||||||
|
title: { type: "string", nullable: true },
|
||||||
|
note: { type: "string", nullable: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
responses: {
|
||||||
|
201: { description: "Created commit" },
|
||||||
|
400: responseRef("Invalid snapshot"),
|
||||||
|
409: responseRef("Section conflict"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/sections/{sectionId}/restore": {
|
||||||
|
post: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "Restore a previous commit",
|
||||||
|
parameters: [pathParam("sectionId")],
|
||||||
|
responses: {
|
||||||
|
201: { description: "Restore commit created" },
|
||||||
|
400: responseRef("Invalid payload"),
|
||||||
|
404: responseRef("Commit not found"),
|
||||||
|
409: responseRef("Section conflict"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/sections/{sectionId}/submit": {
|
||||||
|
post: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "Submit head commit for review",
|
||||||
|
parameters: [pathParam("sectionId")],
|
||||||
|
responses: {
|
||||||
|
201: objectResponse("Submission", "#/components/schemas/SectionSubmission"),
|
||||||
|
400: responseRef("Invalid payload"),
|
||||||
|
409: responseRef("Section conflict"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/sections/{sectionId}/submissions": {
|
||||||
|
get: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary: "List section submissions",
|
||||||
|
parameters: [
|
||||||
|
pathParam("sectionId"),
|
||||||
|
queryParam("include_snapshot", "string", false),
|
||||||
|
],
|
||||||
|
responses: { 200: arrayResponse("Submissions", "#/components/schemas/SectionSubmission") },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"/submissions/{submissionId}/approve": submissionReviewPost("Approve submission"),
|
||||||
|
"/submissions/{submissionId}/reject": submissionReviewPost("Reject submission"),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function pathParam(name) {
|
||||||
|
return { name, in: "path", required: true, schema: { type: "string" } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryParam(name, type, required) {
|
||||||
|
return { name, in: "query", required, schema: { type } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function tileParameters() {
|
||||||
|
return [pathParam("z"), pathParam("x"), pathParam("y")];
|
||||||
|
}
|
||||||
|
|
||||||
|
function responseRef(description) {
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function objectResponse(description, ref) {
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: successEnvelopeSchema({ $ref: ref }),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function arrayResponse(description, itemRef) {
|
||||||
|
return {
|
||||||
|
description,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: successEnvelopeSchema({
|
||||||
|
type: "array",
|
||||||
|
items: { $ref: itemRef },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function successEnvelopeSchema(dataSchema) {
|
||||||
|
return {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
status: { type: "string", enum: ["success"] },
|
||||||
|
data: dataSchema,
|
||||||
|
message: { type: "string" },
|
||||||
|
errors: {
|
||||||
|
type: "array",
|
||||||
|
items: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["status", "data", "message", "errors"],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonBody(schema) {
|
||||||
|
return {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": { schema },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sectionActorPost(summary) {
|
||||||
|
return {
|
||||||
|
post: {
|
||||||
|
tags: ["Sections"],
|
||||||
|
summary,
|
||||||
|
parameters: [pathParam("sectionId")],
|
||||||
|
requestBody: jsonBody({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
user_id: { type: "string" },
|
||||||
|
user: { type: "string" },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
responses: {
|
||||||
|
200: { description: "Updated section state" },
|
||||||
|
400: responseRef("user_id is required"),
|
||||||
|
404: responseRef("Section not found"),
|
||||||
|
409: responseRef("Section conflict"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function submissionReviewPost(summary) {
|
||||||
|
return {
|
||||||
|
post: {
|
||||||
|
tags: ["Submissions"],
|
||||||
|
summary,
|
||||||
|
parameters: [pathParam("submissionId")],
|
||||||
|
requestBody: jsonBody({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
reviewed_by: { type: "string" },
|
||||||
|
user_id: { type: "string" },
|
||||||
|
review_note: { type: "string", nullable: true },
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
responses: {
|
||||||
|
200: objectResponse("Updated submission", "#/components/schemas/SectionSubmission"),
|
||||||
|
404: responseRef("Submission not found"),
|
||||||
|
409: responseRef("Submission conflict"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
openApiSpec,
|
||||||
|
};
|
||||||
65
utils/apiEnvelope.js
Normal file
65
utils/apiEnvelope.js
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
function envelopeResponses(_req, res, next) {
|
||||||
|
const originalJson = res.json.bind(res);
|
||||||
|
|
||||||
|
res.json = (payload) => {
|
||||||
|
if (isApiEnvelope(payload)) {
|
||||||
|
return originalJson(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
const isError = res.statusCode >= 400;
|
||||||
|
const message = extractMessage(payload, isError);
|
||||||
|
const errors = extractErrors(payload, isError, message);
|
||||||
|
const data = isError ? null : payload;
|
||||||
|
|
||||||
|
return originalJson({
|
||||||
|
status: isError ? "error" : "success",
|
||||||
|
data,
|
||||||
|
message,
|
||||||
|
errors,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
next();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isApiEnvelope(payload) {
|
||||||
|
return Boolean(
|
||||||
|
payload &&
|
||||||
|
typeof payload === "object" &&
|
||||||
|
!Array.isArray(payload) &&
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, "status") &&
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, "data") &&
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, "message") &&
|
||||||
|
Object.prototype.hasOwnProperty.call(payload, "errors")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractMessage(payload, isError) {
|
||||||
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||||
|
if (typeof payload.message === "string" && payload.message.length) {
|
||||||
|
return payload.message;
|
||||||
|
}
|
||||||
|
if (typeof payload.error === "string" && payload.error.length) {
|
||||||
|
return payload.error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return isError ? "Request failed" : "OK";
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractErrors(payload, isError, message) {
|
||||||
|
if (!isError) return [];
|
||||||
|
if (payload && typeof payload === "object" && !Array.isArray(payload)) {
|
||||||
|
if (Array.isArray(payload.errors)) {
|
||||||
|
return payload.errors;
|
||||||
|
}
|
||||||
|
if (payload.error) {
|
||||||
|
return [payload.error];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return message ? [message] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
envelopeResponses,
|
||||||
|
};
|
||||||
45
utils/bbox.js
Normal file
45
utils/bbox.js
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
function getBBox(geometry) {
|
||||||
|
let minLng = Infinity, minLat = Infinity;
|
||||||
|
let maxLng = -Infinity, maxLat = -Infinity;
|
||||||
|
|
||||||
|
function process(coords) {
|
||||||
|
if (typeof coords[0] === "number") {
|
||||||
|
const [lng, lat] = coords;
|
||||||
|
|
||||||
|
minLng = Math.min(minLng, lng);
|
||||||
|
minLat = Math.min(minLat, lat);
|
||||||
|
maxLng = Math.max(maxLng, lng);
|
||||||
|
maxLat = Math.max(maxLat, lat);
|
||||||
|
} else {
|
||||||
|
coords.forEach(process);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 🔥 handle theo type cho rõ ràng
|
||||||
|
switch (geometry.type) {
|
||||||
|
case "Point":
|
||||||
|
process(geometry.coordinates);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MultiPoint":
|
||||||
|
case "LineString":
|
||||||
|
process(geometry.coordinates);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MultiLineString":
|
||||||
|
case "Polygon":
|
||||||
|
process(geometry.coordinates);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "MultiPolygon":
|
||||||
|
process(geometry.coordinates);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
throw new Error("Unsupported geometry type: " + geometry.type);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { minLng, minLat, maxLng, maxLat };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getBBox };
|
||||||
Reference in New Issue
Block a user