chore: init backend and ignore local artifacts
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
/data
|
||||
.idea/
|
||||
node_modules/
|
||||
242
db/polygons.js
Normal file
242
db/polygons.js
Normal file
@@ -0,0 +1,242 @@
|
||||
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',
|
||||
kind TEXT,
|
||||
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();
|
||||
|
||||
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");
|
||||
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();
|
||||
|
||||
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 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;
|
||||
}
|
||||
35
index.js
Normal file
35
index.js
Normal file
@@ -0,0 +1,35 @@
|
||||
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 { openApiSpec } = require("./swagger");
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// serve MBTiles and geometry CRUD
|
||||
app.use("/tiles", tileRoutes);
|
||||
app.use("/raster-tiles", rasterTileRoutes);
|
||||
app.use("/geometries", geoRoutes);
|
||||
app.use("/entities", entityRoutes);
|
||||
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");
|
||||
});
|
||||
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"
|
||||
}
|
||||
}
|
||||
345
routes/entities.js
Normal file
345
routes/entities.js
Normal file
@@ -0,0 +1,345 @@
|
||||
const express = require("express");
|
||||
const crypto = require("crypto");
|
||||
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));
|
||||
});
|
||||
|
||||
router.post("/", (req, res) => {
|
||||
const name = normalizeRequiredString(req.body?.name);
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: "name is required" });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const slug = normalizeOptionalString(req.body?.slug) || toSlug(name);
|
||||
const description = normalizeOptionalString(req.body?.description);
|
||||
const typeId = normalizeTypeId(req.body?.type_id);
|
||||
const status = normalizeOptionalNumber(req.body?.status) ?? 1;
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO entities (
|
||||
id, name, slug, description, type_id, kind, status, is_deleted, created_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
typeId,
|
||||
null,
|
||||
status,
|
||||
now,
|
||||
now
|
||||
);
|
||||
} catch (err) {
|
||||
if (isSqliteConstraint(err)) {
|
||||
return res.status(409).json({ error: "Entity name/slug must be unique" });
|
||||
}
|
||||
console.error("Create entity failed", err);
|
||||
return res.status(500).json({ error: "Create entity failed" });
|
||||
}
|
||||
|
||||
const created = 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 = ?
|
||||
GROUP BY e.id
|
||||
`).get(id);
|
||||
|
||||
res.status(201).json(normalizeEntityRow(created));
|
||||
});
|
||||
|
||||
router.put("/:id", (req, res) => {
|
||||
const existing = db.prepare(`
|
||||
SELECT *
|
||||
FROM entities
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).get(req.params.id);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Entity not found" });
|
||||
}
|
||||
|
||||
const hasName = Object.prototype.hasOwnProperty.call(req.body || {}, "name");
|
||||
const hasSlug = Object.prototype.hasOwnProperty.call(req.body || {}, "slug");
|
||||
const hasDescription = Object.prototype.hasOwnProperty.call(req.body || {}, "description");
|
||||
const hasTypeId = Object.prototype.hasOwnProperty.call(req.body || {}, "type_id");
|
||||
const hasStatus = Object.prototype.hasOwnProperty.call(req.body || {}, "status");
|
||||
|
||||
if (!hasName && !hasSlug && !hasDescription && !hasTypeId && !hasStatus) {
|
||||
return res.status(400).json({ error: "No updatable field provided" });
|
||||
}
|
||||
|
||||
const name = hasName ? normalizeRequiredString(req.body?.name) : existing.name;
|
||||
if (!name) {
|
||||
return res.status(400).json({ error: "name cannot be empty" });
|
||||
}
|
||||
|
||||
const slug = hasSlug ? normalizeOptionalString(req.body?.slug) : existing.slug;
|
||||
const description = hasDescription ? normalizeOptionalString(req.body?.description) : existing.description;
|
||||
const typeId = hasTypeId ? normalizeTypeId(req.body?.type_id) : existing.type_id;
|
||||
const status = hasStatus
|
||||
? (normalizeOptionalNumber(req.body?.status) ?? existing.status)
|
||||
: existing.status;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
db.prepare(`
|
||||
UPDATE entities
|
||||
SET name = ?,
|
||||
slug = ?,
|
||||
description = ?,
|
||||
type_id = ?,
|
||||
kind = ?,
|
||||
status = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
typeId,
|
||||
null,
|
||||
status,
|
||||
now,
|
||||
req.params.id
|
||||
);
|
||||
} catch (err) {
|
||||
if (isSqliteConstraint(err)) {
|
||||
return res.status(409).json({ error: "Entity name/slug must be unique" });
|
||||
}
|
||||
console.error("Update entity failed", err);
|
||||
return res.status(500).json({ error: "Update entity failed" });
|
||||
}
|
||||
|
||||
const updated = 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);
|
||||
|
||||
res.json(normalizeEntityRow(updated));
|
||||
});
|
||||
|
||||
router.delete("/:id", (req, res) => {
|
||||
const entityId = req.params.id;
|
||||
const existing = db.prepare(`
|
||||
SELECT id
|
||||
FROM entities
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).get(entityId);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Entity not found" });
|
||||
}
|
||||
|
||||
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) {
|
||||
const previewIds = orphanedGeometryRows.slice(0, 10).map((row) => row.geometry_id);
|
||||
const suffix = orphanedGeometryRows.length > 10 ? ", ..." : "";
|
||||
return res.status(409).json({
|
||||
error: `Cannot delete entity. Reassign ${orphanedGeometryRows.length} linked geometries first: ${previewIds.join(", ")}${suffix}`,
|
||||
});
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const deleted = db.transaction((id, timestamp) => {
|
||||
const result = db.prepare(`
|
||||
UPDATE entities
|
||||
SET is_deleted = 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).run(timestamp, id);
|
||||
|
||||
if (!result.changes) {
|
||||
return false;
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
DELETE FROM entity_geometries
|
||||
WHERE entity_id = ?
|
||||
`).run(id);
|
||||
|
||||
return true;
|
||||
})(entityId, now);
|
||||
|
||||
if (!deleted) {
|
||||
return res.status(404).json({ error: "Entity not found" });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
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 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 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);
|
||||
}
|
||||
|
||||
function toSlug(value) {
|
||||
return value
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 120) || null;
|
||||
}
|
||||
|
||||
function isSqliteConstraint(err) {
|
||||
const message = typeof err?.message === "string" ? err.message : "";
|
||||
return message.includes("UNIQUE constraint failed");
|
||||
}
|
||||
829
routes/geometries.js
Normal file
829
routes/geometries.js
Normal file
@@ -0,0 +1,829 @@
|
||||
const express = require("express");
|
||||
const crypto = require("crypto");
|
||||
const db = require("../db/polygons");
|
||||
const { getBBox } = require("../utils/bbox");
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// =======================
|
||||
// Create geometry
|
||||
// =======================
|
||||
router.post("/", (req, res) => {
|
||||
const { geometry } = req.body || {};
|
||||
if (!geometry) {
|
||||
return res.status(400).json({ error: "Missing geometry" });
|
||||
}
|
||||
|
||||
let temporalRange;
|
||||
let entityPayload;
|
||||
let bindingPayload;
|
||||
try {
|
||||
temporalRange = normalizeTemporalRange(req.body?.time_start, req.body?.time_end);
|
||||
entityPayload = extractEntityIdsFromPayload(req.body || {});
|
||||
bindingPayload = extractBindingFromPayload(req.body || {});
|
||||
ensureGeometryHasEntities(entityPayload.entityIds);
|
||||
validateEntityIdsExist(entityPayload.entityIds);
|
||||
} catch (err) {
|
||||
return res.status(err.status || 400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const id = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
const bbox = getBBox(geometry);
|
||||
const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, id);
|
||||
const geometryType = resolveGeometryType(req.body?.type, entityPayload.entityIds);
|
||||
|
||||
try {
|
||||
const tx = db.transaction(() => {
|
||||
db.prepare(`
|
||||
INSERT INTO geometries (
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
id,
|
||||
geometryType,
|
||||
JSON.stringify(geometry),
|
||||
serializeBindingIds(bindingIds),
|
||||
temporalRange.timeStart,
|
||||
temporalRange.timeEnd,
|
||||
bbox.minLng,
|
||||
bbox.minLat,
|
||||
bbox.maxLng,
|
||||
bbox.maxLat,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
syncGeometryEntityLinks(id, entityPayload.entityIds, now);
|
||||
});
|
||||
|
||||
tx();
|
||||
} catch (err) {
|
||||
if (err.status) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error("Create geometry failed:", err);
|
||||
return res.status(500).json({ error: "Create geometry failed" });
|
||||
}
|
||||
|
||||
res.json({ id });
|
||||
});
|
||||
|
||||
// =======================
|
||||
// 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);
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
// =======================
|
||||
// Update geometry
|
||||
// =======================
|
||||
router.put("/:id", (req, res) => {
|
||||
const { geometry } = req.body || {};
|
||||
if (!geometry) {
|
||||
return res.status(400).json({ error: "Missing geometry" });
|
||||
}
|
||||
|
||||
const existing = db.prepare(`
|
||||
SELECT id, type, time_start, time_end, binding
|
||||
FROM geometries
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).get(req.params.id);
|
||||
|
||||
if (!existing) {
|
||||
return res.status(404).json({ error: "Not found" });
|
||||
}
|
||||
|
||||
const hasTimeStart = Object.prototype.hasOwnProperty.call(req.body || {}, "time_start");
|
||||
const hasTimeEnd = Object.prototype.hasOwnProperty.call(req.body || {}, "time_end");
|
||||
const hasType = Object.prototype.hasOwnProperty.call(req.body || {}, "type");
|
||||
|
||||
let temporalRange;
|
||||
let entityPayload;
|
||||
let nextEntityIds;
|
||||
let bindingPayload;
|
||||
let nextBindingIds;
|
||||
let nextType;
|
||||
try {
|
||||
temporalRange = normalizeTemporalRange(
|
||||
hasTimeStart ? req.body?.time_start : existing.time_start,
|
||||
hasTimeEnd ? req.body?.time_end : existing.time_end
|
||||
);
|
||||
|
||||
entityPayload = extractEntityIdsFromPayload(req.body || {});
|
||||
nextEntityIds = entityPayload.provided
|
||||
? entityPayload.entityIds
|
||||
: getLinkedEntityIdsByGeometryId(req.params.id);
|
||||
|
||||
nextType = hasType
|
||||
? resolveGeometryType(req.body?.type, nextEntityIds, existing.type)
|
||||
: resolveGeometryType(null, nextEntityIds, existing.type);
|
||||
|
||||
bindingPayload = extractBindingFromPayload(req.body || {});
|
||||
nextBindingIds = bindingPayload.provided
|
||||
? bindingPayload.bindingIds
|
||||
: parseBindingIds(existing.binding);
|
||||
nextBindingIds = sanitizeBindingIdsForGeometry(nextBindingIds, req.params.id);
|
||||
|
||||
ensureGeometryHasEntities(nextEntityIds);
|
||||
validateEntityIdsExist(nextEntityIds);
|
||||
} catch (err) {
|
||||
return res.status(err.status || 400).json({ error: err.message });
|
||||
}
|
||||
|
||||
const bbox = getBBox(geometry);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const tx = db.transaction(() => {
|
||||
const result = db.prepare(`
|
||||
UPDATE geometries
|
||||
SET type = ?,
|
||||
draw_geometry = ?,
|
||||
binding = ?,
|
||||
time_start = ?,
|
||||
time_end = ?,
|
||||
bbox_min_lng = ?,
|
||||
bbox_min_lat = ?,
|
||||
bbox_max_lng = ?,
|
||||
bbox_max_lat = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).run(
|
||||
nextType,
|
||||
JSON.stringify(geometry),
|
||||
serializeBindingIds(nextBindingIds),
|
||||
temporalRange.timeStart,
|
||||
temporalRange.timeEnd,
|
||||
bbox.minLng,
|
||||
bbox.minLat,
|
||||
bbox.maxLng,
|
||||
bbox.maxLat,
|
||||
now,
|
||||
req.params.id
|
||||
);
|
||||
|
||||
if (!result.changes) {
|
||||
throw createValidationError("Not found", 404);
|
||||
}
|
||||
|
||||
if (entityPayload.provided) {
|
||||
syncGeometryEntityLinks(req.params.id, nextEntityIds, now);
|
||||
}
|
||||
});
|
||||
|
||||
tx();
|
||||
} catch (err) {
|
||||
if (err.status) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error("Update geometry failed:", err);
|
||||
return res.status(500).json({ error: "Update geometry failed" });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// =======================
|
||||
// Soft-delete geometry
|
||||
// =======================
|
||||
router.delete("/:id", (req, res) => {
|
||||
const now = new Date().toISOString();
|
||||
const tx = db.transaction(() => {
|
||||
const result = db.prepare(`
|
||||
UPDATE geometries
|
||||
SET is_deleted = 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).run(now, req.params.id);
|
||||
|
||||
if (!result.changes) {
|
||||
throw createValidationError("Not found", 404);
|
||||
}
|
||||
|
||||
db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(req.params.id);
|
||||
removeBindingReferenceFromAll(req.params.id, now);
|
||||
});
|
||||
|
||||
try {
|
||||
tx();
|
||||
} catch (err) {
|
||||
if (err.status) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error("Delete geometry failed:", err);
|
||||
return res.status(500).json({ error: "Delete geometry failed" });
|
||||
}
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// =======================
|
||||
// Apply batch of create/update/delete (used by FE Save)
|
||||
// =======================
|
||||
router.post("/batch", (req, res) => {
|
||||
const { changes } = req.body || {};
|
||||
if (!Array.isArray(changes)) {
|
||||
return res.status(400).json({ error: "changes must be an array" });
|
||||
}
|
||||
|
||||
const now = new Date().toISOString();
|
||||
|
||||
try {
|
||||
const tx = db.transaction((items) => {
|
||||
for (const change of items) {
|
||||
const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action");
|
||||
const action = normalizeBatchAction(hasAction ? change.action : change?.type);
|
||||
|
||||
if (!change || !action) {
|
||||
throw createValidationError("Invalid change entry");
|
||||
}
|
||||
|
||||
if (action === "create") {
|
||||
const feature = change.feature;
|
||||
if (!feature || !feature.properties || !feature.properties.id || !feature.geometry) {
|
||||
throw createValidationError("Invalid create payload");
|
||||
}
|
||||
|
||||
const temporalRange = normalizeTemporalRange(
|
||||
feature.properties.time_start,
|
||||
feature.properties.time_end
|
||||
);
|
||||
const entityPayload = extractEntityIdsFromPayload(feature.properties || {});
|
||||
const entityIds = entityPayload.entityIds;
|
||||
ensureGeometryHasEntities(entityIds);
|
||||
validateEntityIdsExist(entityIds);
|
||||
|
||||
const bbox = getBBox(feature.geometry);
|
||||
const geometryId = String(feature.properties.id);
|
||||
const bindingPayload = extractBindingFromPayload(feature.properties || {});
|
||||
const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, geometryId);
|
||||
const geometryType = resolveGeometryType(feature.properties?.type, entityIds);
|
||||
|
||||
db.prepare(`
|
||||
INSERT OR REPLACE INTO geometries (
|
||||
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
|
||||
)
|
||||
VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((
|
||||
SELECT created_at FROM geometries WHERE id = ?
|
||||
), ?), ?)
|
||||
`).run(
|
||||
geometryId,
|
||||
geometryType,
|
||||
JSON.stringify(feature.geometry),
|
||||
serializeBindingIds(bindingIds),
|
||||
temporalRange.timeStart,
|
||||
temporalRange.timeEnd,
|
||||
bbox.minLng,
|
||||
bbox.minLat,
|
||||
bbox.maxLng,
|
||||
bbox.maxLat,
|
||||
geometryId,
|
||||
now,
|
||||
now
|
||||
);
|
||||
|
||||
syncGeometryEntityLinks(geometryId, entityIds, now);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === "update") {
|
||||
const { id, geometry } = change;
|
||||
if (!id || !geometry) {
|
||||
throw createValidationError("Invalid update payload");
|
||||
}
|
||||
|
||||
const existing = db.prepare(`
|
||||
SELECT id, type, time_start, time_end, binding
|
||||
FROM geometries
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).get(id);
|
||||
|
||||
if (!existing) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasTimeStart = Object.prototype.hasOwnProperty.call(change, "time_start");
|
||||
const hasTimeEnd = Object.prototype.hasOwnProperty.call(change, "time_end");
|
||||
const hasType = hasAction && Object.prototype.hasOwnProperty.call(change, "type");
|
||||
const temporalRange = normalizeTemporalRange(
|
||||
hasTimeStart ? change.time_start : existing.time_start,
|
||||
hasTimeEnd ? change.time_end : existing.time_end
|
||||
);
|
||||
const bindingPayload = extractBindingFromPayload(change);
|
||||
const nextBindingIds = sanitizeBindingIdsForGeometry(
|
||||
bindingPayload.provided
|
||||
? bindingPayload.bindingIds
|
||||
: parseBindingIds(existing.binding),
|
||||
id
|
||||
);
|
||||
|
||||
const entityPayload = extractEntityIdsFromPayload(change);
|
||||
const nextEntityIds = entityPayload.provided
|
||||
? entityPayload.entityIds
|
||||
: getLinkedEntityIdsByGeometryId(id);
|
||||
ensureGeometryHasEntities(nextEntityIds);
|
||||
validateEntityIdsExist(nextEntityIds);
|
||||
const nextType = hasType
|
||||
? resolveGeometryType(change.type, nextEntityIds, existing.type)
|
||||
: resolveGeometryType(null, nextEntityIds, existing.type);
|
||||
|
||||
const bbox = getBBox(geometry);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE geometries
|
||||
SET type = ?,
|
||||
draw_geometry = ?,
|
||||
binding = ?,
|
||||
time_start = ?,
|
||||
time_end = ?,
|
||||
bbox_min_lng = ?,
|
||||
bbox_min_lat = ?,
|
||||
bbox_max_lng = ?,
|
||||
bbox_max_lat = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).run(
|
||||
nextType,
|
||||
JSON.stringify(geometry),
|
||||
serializeBindingIds(nextBindingIds),
|
||||
temporalRange.timeStart,
|
||||
temporalRange.timeEnd,
|
||||
bbox.minLng,
|
||||
bbox.minLat,
|
||||
bbox.maxLng,
|
||||
bbox.maxLat,
|
||||
now,
|
||||
id
|
||||
);
|
||||
|
||||
if (entityPayload.provided) {
|
||||
syncGeometryEntityLinks(id, nextEntityIds, now);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (action === "delete") {
|
||||
if (!change.id) {
|
||||
throw createValidationError("Invalid delete payload");
|
||||
}
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE geometries
|
||||
SET is_deleted = 1,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).run(now, change.id);
|
||||
|
||||
if (result.changes) {
|
||||
db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(change.id);
|
||||
removeBindingReferenceFromAll(change.id, now);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw createValidationError(`Unknown change type: ${String(action)}`);
|
||||
}
|
||||
});
|
||||
|
||||
tx(changes);
|
||||
res.json({ success: true, applied: changes.length });
|
||||
} catch (err) {
|
||||
if (err.status) {
|
||||
return res.status(err.status).json({ error: err.message });
|
||||
}
|
||||
console.error("Batch apply error:", err);
|
||||
res.status(500).json({ error: "Batch apply failed" });
|
||||
}
|
||||
});
|
||||
|
||||
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 normalizeOptionalYear(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) {
|
||||
throw createValidationError("time_start/time_end must be numbers");
|
||||
}
|
||||
return Math.trunc(num);
|
||||
}
|
||||
|
||||
function normalizeGeometryType(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const normalized = String(value).trim().toLowerCase();
|
||||
return normalized.length ? normalized : null;
|
||||
}
|
||||
|
||||
function resolveGeometryType(requestedType, entityIds, fallbackType = null) {
|
||||
const normalizedRequestedType = normalizeGeometryType(requestedType);
|
||||
if (normalizedRequestedType && !isLegacyLineModeToken(normalizedRequestedType)) {
|
||||
return normalizedRequestedType;
|
||||
}
|
||||
|
||||
const derivedType = deriveGeometryTypeFromEntityIds(entityIds);
|
||||
if (derivedType) return derivedType;
|
||||
|
||||
const normalizedFallbackType = normalizeGeometryType(fallbackType);
|
||||
if (normalizedFallbackType && !isLegacyLineModeToken(normalizedFallbackType)) {
|
||||
return normalizedFallbackType;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deriveGeometryTypeFromEntityIds(entityIds) {
|
||||
if (!Array.isArray(entityIds) || !entityIds.length) return null;
|
||||
const primaryEntityId = normalizeEntityId(entityIds[0]);
|
||||
if (!primaryEntityId) return null;
|
||||
|
||||
const row = db.prepare(`
|
||||
SELECT type_id
|
||||
FROM entities
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
LIMIT 1
|
||||
`).get(primaryEntityId);
|
||||
|
||||
return normalizeGeometryType(row?.type_id);
|
||||
}
|
||||
|
||||
function isLegacyLineModeToken(value) {
|
||||
return value === "line" || value === "path";
|
||||
}
|
||||
|
||||
function normalizeTemporalRange(timeStartValue, timeEndValue) {
|
||||
const timeStart = normalizeOptionalYear(timeStartValue);
|
||||
const timeEnd = normalizeOptionalYear(timeEndValue);
|
||||
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
|
||||
throw createValidationError("time_start must be <= time_end");
|
||||
}
|
||||
return { timeStart, timeEnd };
|
||||
}
|
||||
|
||||
function normalizeEntityId(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const normalized = String(value).trim();
|
||||
return normalized || null;
|
||||
}
|
||||
|
||||
function normalizeEntityIds(rawEntityIds) {
|
||||
if (rawEntityIds === undefined) return [];
|
||||
if (rawEntityIds === null) return [];
|
||||
if (!Array.isArray(rawEntityIds)) {
|
||||
throw createValidationError("entity_ids must be an array");
|
||||
}
|
||||
|
||||
const deduped = [];
|
||||
const seen = new Set();
|
||||
for (const rawId of rawEntityIds) {
|
||||
const entityId = normalizeEntityId(rawId);
|
||||
if (!entityId || seen.has(entityId)) continue;
|
||||
seen.add(entityId);
|
||||
deduped.push(entityId);
|
||||
}
|
||||
return deduped;
|
||||
}
|
||||
|
||||
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 extractBindingFromPayload(payload) {
|
||||
const hasBinding = Object.prototype.hasOwnProperty.call(payload || {}, "binding");
|
||||
if (!hasBinding) {
|
||||
return {
|
||||
provided: false,
|
||||
bindingIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provided: true,
|
||||
bindingIds: normalizeBindingIds(payload.binding),
|
||||
};
|
||||
}
|
||||
|
||||
function sanitizeBindingIdsForGeometry(bindingIds, geometryId) {
|
||||
const selfId = String(geometryId);
|
||||
return bindingIds.filter((bindingId) => bindingId !== selfId);
|
||||
}
|
||||
|
||||
function serializeBindingIds(bindingIds) {
|
||||
return bindingIds.length ? JSON.stringify(bindingIds) : null;
|
||||
}
|
||||
|
||||
function extractEntityIdsFromPayload(payload) {
|
||||
const hasEntityIds = Object.prototype.hasOwnProperty.call(payload || {}, "entity_ids");
|
||||
const hasEntityId = Object.prototype.hasOwnProperty.call(payload || {}, "entity_id");
|
||||
if (!hasEntityIds && !hasEntityId) {
|
||||
return {
|
||||
provided: false,
|
||||
entityIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
const fromArray = hasEntityIds ? normalizeEntityIds(payload.entity_ids) : [];
|
||||
const singleEntityId = hasEntityId ? normalizeEntityId(payload.entity_id) : null;
|
||||
|
||||
const entityIds = [...fromArray];
|
||||
if (singleEntityId && !entityIds.includes(singleEntityId)) {
|
||||
entityIds.unshift(singleEntityId);
|
||||
}
|
||||
|
||||
return {
|
||||
provided: true,
|
||||
entityIds,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureGeometryHasEntities(entityIds) {
|
||||
if (!Array.isArray(entityIds) || !entityIds.length) {
|
||||
throw createValidationError("geometry must be linked to at least one entity");
|
||||
}
|
||||
}
|
||||
|
||||
function validateEntityIdsExist(entityIds) {
|
||||
if (!entityIds.length) {
|
||||
throw createValidationError("geometry must be linked to at least one entity");
|
||||
}
|
||||
|
||||
const placeholders = entityIds.map(() => "?").join(",");
|
||||
const rows = db.prepare(`
|
||||
SELECT id
|
||||
FROM entities
|
||||
WHERE is_deleted = 0
|
||||
AND id IN (${placeholders})
|
||||
`).all(...entityIds);
|
||||
|
||||
const found = new Set(rows.map((row) => row.id));
|
||||
const missing = entityIds.filter((entityId) => !found.has(entityId));
|
||||
if (missing.length) {
|
||||
throw createValidationError(`Entity not found: ${missing.join(", ")}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getLinkedEntityIdsByGeometryId(geometryId) {
|
||||
const rows = db.prepare(`
|
||||
SELECT eg.entity_id
|
||||
FROM entity_geometries eg
|
||||
JOIN entities e
|
||||
ON e.id = eg.entity_id
|
||||
AND e.is_deleted = 0
|
||||
WHERE eg.geometry_id = ?
|
||||
ORDER BY eg.rowid ASC
|
||||
`).all(String(geometryId));
|
||||
|
||||
return rows.map((row) => row.entity_id);
|
||||
}
|
||||
|
||||
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 syncGeometryEntityLinks(geometryId, entityIds, now) {
|
||||
db.prepare(`
|
||||
DELETE FROM entity_geometries
|
||||
WHERE geometry_id = ?
|
||||
`).run(String(geometryId));
|
||||
|
||||
for (const entityId of entityIds) {
|
||||
db.prepare(`
|
||||
INSERT INTO entity_geometries (entity_id, geometry_id, created_at)
|
||||
VALUES (?, ?, ?)
|
||||
`).run(entityId, String(geometryId), now);
|
||||
}
|
||||
}
|
||||
|
||||
function removeBindingReferenceFromAll(removedGeometryId, now) {
|
||||
const removedId = String(removedGeometryId);
|
||||
|
||||
const rows = db.prepare(`
|
||||
SELECT id, binding
|
||||
FROM geometries
|
||||
WHERE is_deleted = 0
|
||||
AND binding IS NOT NULL
|
||||
AND binding != ''
|
||||
`).all();
|
||||
|
||||
for (const row of rows) {
|
||||
const currentBindingIds = parseBindingIds(row.binding);
|
||||
if (!currentBindingIds.length) continue;
|
||||
|
||||
const nextBindingIds = currentBindingIds.filter((bindingId) => bindingId !== removedId);
|
||||
if (nextBindingIds.length === currentBindingIds.length) continue;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE geometries
|
||||
SET binding = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?
|
||||
AND is_deleted = 0
|
||||
`).run(serializeBindingIds(nextBindingIds), now, row.id);
|
||||
}
|
||||
}
|
||||
|
||||
function createValidationError(message, status = 400) {
|
||||
const err = new Error(message);
|
||||
err.status = status;
|
||||
return err;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
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).send("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).send("Tile not found");
|
||||
}
|
||||
|
||||
res.setHeader("Content-Type", contentType);
|
||||
res.send(tile.tile_data);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
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).send("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).send("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;
|
||||
729
swagger.js
Normal file
729
swagger.js
Normal file
@@ -0,0 +1,729 @@
|
||||
const openApiSpec = {
|
||||
openapi: "3.0.3",
|
||||
info: {
|
||||
title: "Ultimate History Map API",
|
||||
version: "1.0.0",
|
||||
description: "API docs for tiles, geometries, and entities.",
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: "http://localhost:3000",
|
||||
description: "Local",
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{ name: "System", description: "Health and meta endpoints" },
|
||||
{ name: "Tiles", description: "Vector and raster tile endpoints" },
|
||||
{ name: "Geometries", description: "Geometry CRUD and batch save" },
|
||||
{ name: "Entities", description: "Entity CRUD" },
|
||||
],
|
||||
components: {
|
||||
schemas: {
|
||||
ErrorResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
required: ["error"],
|
||||
},
|
||||
SuccessResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
},
|
||||
required: ["success"],
|
||||
},
|
||||
Entity: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
slug: { type: "string", nullable: true },
|
||||
description: { type: "string", nullable: true },
|
||||
type_id: { type: "string" },
|
||||
status: { type: "number" },
|
||||
created_at: { type: "string", format: "date-time", nullable: true },
|
||||
updated_at: { type: "string", format: "date-time", nullable: true },
|
||||
geometry_count: { type: "number" },
|
||||
},
|
||||
required: ["id", "name", "type_id", "geometry_count"],
|
||||
},
|
||||
EntityCreateInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
slug: { type: "string", nullable: true },
|
||||
description: { type: "string", nullable: true },
|
||||
type_id: { type: "string", nullable: true },
|
||||
status: { type: "number", nullable: true },
|
||||
},
|
||||
required: ["name"],
|
||||
},
|
||||
EntityUpdateInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
name: { type: "string" },
|
||||
slug: { type: "string", nullable: true },
|
||||
description: { type: "string", nullable: true },
|
||||
type_id: { type: "string" },
|
||||
status: { type: "number" },
|
||||
},
|
||||
},
|
||||
GeoJSONGeometry: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: {
|
||||
type: "string",
|
||||
enum: [
|
||||
"Point",
|
||||
"MultiPoint",
|
||||
"LineString",
|
||||
"MultiLineString",
|
||||
"Polygon",
|
||||
"MultiPolygon",
|
||||
],
|
||||
},
|
||||
coordinates: {
|
||||
type: "array",
|
||||
items: {},
|
||||
},
|
||||
},
|
||||
required: ["type", "coordinates"],
|
||||
},
|
||||
GeometryFeature: {
|
||||
type: "object",
|
||||
properties: {
|
||||
type: { type: "string", enum: ["Feature"] },
|
||||
properties: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
type: { type: "string", nullable: true },
|
||||
time_start: { type: "number", nullable: true },
|
||||
time_end: { type: "number", nullable: true },
|
||||
binding: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
entity_id: { type: "string", nullable: true },
|
||||
entity_ids: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
entity_name: { type: "string", nullable: true },
|
||||
entity_names: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
entity_type_id: { type: "string", nullable: true },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
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"],
|
||||
},
|
||||
GeometryUpsertInput: {
|
||||
type: "object",
|
||||
properties: {
|
||||
geometry: { $ref: "#/components/schemas/GeoJSONGeometry" },
|
||||
type: { type: "string", nullable: true },
|
||||
time_start: { type: "number", nullable: true },
|
||||
time_end: { type: "number", nullable: true },
|
||||
binding: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
entity_id: { type: "string", nullable: true },
|
||||
entity_ids: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["geometry"],
|
||||
},
|
||||
GeometryCreateResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
},
|
||||
required: ["id"],
|
||||
},
|
||||
BatchCreateChange: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", enum: ["create"] },
|
||||
feature: { $ref: "#/components/schemas/GeometryFeature" },
|
||||
},
|
||||
required: ["action", "feature"],
|
||||
},
|
||||
BatchUpdateChange: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", enum: ["update"] },
|
||||
id: { type: "string" },
|
||||
geometry: { $ref: "#/components/schemas/GeoJSONGeometry" },
|
||||
type: { type: "string", nullable: true },
|
||||
time_start: { type: "number", nullable: true },
|
||||
time_end: { type: "number", nullable: true },
|
||||
binding: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
entity_id: { type: "string", nullable: true },
|
||||
entity_ids: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
},
|
||||
},
|
||||
required: ["action", "id", "geometry"],
|
||||
},
|
||||
BatchDeleteChange: {
|
||||
type: "object",
|
||||
properties: {
|
||||
action: { type: "string", enum: ["delete"] },
|
||||
id: { type: "string" },
|
||||
},
|
||||
required: ["action", "id"],
|
||||
},
|
||||
GeometryBatchPayload: {
|
||||
type: "object",
|
||||
properties: {
|
||||
changes: {
|
||||
type: "array",
|
||||
items: {
|
||||
oneOf: [
|
||||
{ $ref: "#/components/schemas/BatchCreateChange" },
|
||||
{ $ref: "#/components/schemas/BatchUpdateChange" },
|
||||
{ $ref: "#/components/schemas/BatchDeleteChange" },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["changes"],
|
||||
},
|
||||
GeometryBatchResponse: {
|
||||
type: "object",
|
||||
properties: {
|
||||
success: { type: "boolean" },
|
||||
applied: { type: "number" },
|
||||
},
|
||||
required: ["success", "applied"],
|
||||
},
|
||||
MetadataResponse: {
|
||||
type: "object",
|
||||
additionalProperties: {
|
||||
oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
paths: {
|
||||
"/": {
|
||||
get: {
|
||||
tags: ["System"],
|
||||
summary: "Health check",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Server status text",
|
||||
content: {
|
||||
"text/plain": {
|
||||
schema: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/tiles/metadata/info": {
|
||||
get: {
|
||||
tags: ["Tiles"],
|
||||
summary: "Get vector tiles metadata",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MBTiles metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/MetadataResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/tiles/{z}/{x}/{y}": {
|
||||
get: {
|
||||
tags: ["Tiles"],
|
||||
summary: "Get vector tile by XYZ",
|
||||
parameters: [
|
||||
{ name: "z", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "x", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "y", in: "path", required: true, schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tile binary",
|
||||
content: {
|
||||
"application/x-protobuf": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
"image/png": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
"image/jpeg": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
"application/octet-stream": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid tile coordinates",
|
||||
},
|
||||
404: {
|
||||
description: "Tile not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/raster-tiles/metadata/info": {
|
||||
get: {
|
||||
tags: ["Tiles"],
|
||||
summary: "Get raster tiles metadata",
|
||||
responses: {
|
||||
200: {
|
||||
description: "MBTiles metadata",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/MetadataResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/raster-tiles/{z}/{x}/{y}": {
|
||||
get: {
|
||||
tags: ["Tiles"],
|
||||
summary: "Get raster tile by XYZ",
|
||||
parameters: [
|
||||
{ name: "z", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "x", in: "path", required: true, schema: { type: "integer" } },
|
||||
{ name: "y", in: "path", required: true, schema: { type: "integer" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Tile binary",
|
||||
content: {
|
||||
"image/png": { schema: { type: "string", format: "binary" } },
|
||||
"image/jpeg": { schema: { type: "string", format: "binary" } },
|
||||
"image/webp": { schema: { type: "string", format: "binary" } },
|
||||
"application/octet-stream": { schema: { type: "string", format: "binary" } },
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid tile coordinates",
|
||||
},
|
||||
404: {
|
||||
description: "Tile not found",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/entities": {
|
||||
get: {
|
||||
tags: ["Entities"],
|
||||
summary: "List entities",
|
||||
parameters: [
|
||||
{
|
||||
name: "q",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "string" },
|
||||
description: "Search by name or slug",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Entity list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { $ref: "#/components/schemas/Entity" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Entities"],
|
||||
summary: "Create entity",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/EntityCreateInput" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
201: {
|
||||
description: "Entity created",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Entity" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid payload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "Unique conflict",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/entities/search": {
|
||||
get: {
|
||||
tags: ["Entities"],
|
||||
summary: "Search entities by name",
|
||||
parameters: [
|
||||
{
|
||||
name: "name",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description: "Entity name keyword",
|
||||
},
|
||||
{
|
||||
name: "limit",
|
||||
in: "query",
|
||||
required: false,
|
||||
schema: { type: "integer", minimum: 1, maximum: 100 },
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Matched entity list",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "array",
|
||||
items: { $ref: "#/components/schemas/Entity" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/entities/{id}": {
|
||||
get: {
|
||||
tags: ["Entities"],
|
||||
summary: "Get entity by id",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Entity",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Entity" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
put: {
|
||||
tags: ["Entities"],
|
||||
summary: "Update entity",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/EntityUpdateInput" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Entity updated",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Entity" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid payload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "Unique conflict",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Entities"],
|
||||
summary: "Soft-delete entity",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Soft-delete success",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SuccessResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "Entity is still the last active link of one or more geometries",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/geometries": {
|
||||
get: {
|
||||
tags: ["Geometries"],
|
||||
summary: "Query geometries by bbox, time, and entity",
|
||||
parameters: [
|
||||
{ name: "minLng", in: "query", required: true, schema: { type: "number" } },
|
||||
{ name: "minLat", in: "query", required: true, schema: { type: "number" } },
|
||||
{ name: "maxLng", in: "query", required: true, schema: { type: "number" } },
|
||||
{ name: "maxLat", in: "query", required: true, schema: { type: "number" } },
|
||||
{ name: "time", in: "query", required: false, schema: { type: "integer" } },
|
||||
{ name: "entity_id", in: "query", required: false, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Feature collection",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/GeometryFeatureCollection" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid query",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
post: {
|
||||
tags: ["Geometries"],
|
||||
summary: "Create geometry",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/GeometryUpsertInput" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Created geometry id",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/GeometryCreateResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid payload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Entity not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/geometries/{id}": {
|
||||
put: {
|
||||
tags: ["Geometries"],
|
||||
summary: "Update geometry",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/GeometryUpsertInput" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Updated",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SuccessResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid payload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
delete: {
|
||||
tags: ["Geometries"],
|
||||
summary: "Soft-delete geometry",
|
||||
parameters: [
|
||||
{ name: "id", in: "path", required: true, schema: { type: "string" } },
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Soft-delete success",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/SuccessResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "Not found",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"/geometries/batch": {
|
||||
post: {
|
||||
tags: ["Geometries"],
|
||||
summary: "Apply batch create/update/delete geometries",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/GeometryBatchPayload" },
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description: "Batch applied",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/GeometryBatchResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid payload",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/ErrorResponse" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
openApiSpec,
|
||||
};
|
||||
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