From 6f7e819aca57e934d19385356a547ba79e590129 Mon Sep 17 00:00:00 2001 From: taDuc Date: Mon, 13 Apr 2026 21:33:12 +0700 Subject: [PATCH 1/6] chore: init backend and ignore local artifacts --- .gitignore | 3 + db/polygons.js | 242 ++++++++ index.js | 35 ++ package-lock.json | 1319 +++++++++++++++++++++++++++++++++++++++++ package.json | 20 + routes/entities.js | 345 +++++++++++ routes/geometries.js | 829 ++++++++++++++++++++++++++ routes/rasterTiles.js | 57 ++ routes/tiles.js | 79 +++ swagger.js | 729 +++++++++++++++++++++++ utils/bbox.js | 45 ++ 11 files changed, 3703 insertions(+) create mode 100644 .gitignore create mode 100644 db/polygons.js create mode 100644 index.js create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 routes/entities.js create mode 100644 routes/geometries.js create mode 100644 routes/rasterTiles.js create mode 100644 routes/tiles.js create mode 100644 swagger.js create mode 100644 utils/bbox.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5d000a7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +/data +.idea/ +node_modules/ diff --git a/db/polygons.js b/db/polygons.js new file mode 100644 index 0000000..fb4085d --- /dev/null +++ b/db/polygons.js @@ -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; +} diff --git a/index.js b/index.js new file mode 100644 index 0000000..065b045 --- /dev/null +++ b/index.js @@ -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"); +}); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..7bad512 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1319 @@ +{ + "name": "be-map", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "be-map", + "version": "1.0.0", + "license": "ISC", + "dependencies": { + "better-sqlite3": "^12.8.0", + "cors": "^2.8.6", + "express": "^5.2.1", + "swagger-ui-express": "^5.0.1" + } + }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/better-sqlite3": { + "version": "12.8.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", + "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x || 25.x" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.2.tgz", + "integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^1.0.5", + "debug": "^4.4.3", + "http-errors": "^2.0.0", + "iconv-lite": "^0.7.0", + "on-finished": "^2.4.1", + "qs": "^6.14.1", + "raw-body": "^3.0.1", + "type-is": "^2.0.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, + "node_modules/content-disposition": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", + "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "peer": true, + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/node-abi": { + "version": "3.89.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.89.0.tgz", + "integrity": "sha512-6u9UwL0HlAl21+agMN3YAMXcKByMqwGx+pq+P76vii5f7hTPtKDp08/H9py6DY+cfDw7kQNTGEj/rly3IgbNQA==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.3.0.tgz", + "integrity": "sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/qs": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.0.tgz", + "integrity": "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.7.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", + "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.32.2", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.32.2.tgz", + "integrity": "sha512-t6Ns52nS8LU2hqi0+rezMjFO1ZrCsCrnommXrU7Nfrg2va2dWahdvM6TuSwzdHpG29v6BHJyU1c/UWFhgVZzVQ==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, + "node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, + "node_modules/type-is": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz", + "integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==", + "license": "MIT", + "dependencies": { + "content-type": "^1.0.5", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4244663 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/routes/entities.js b/routes/entities.js new file mode 100644 index 0000000..2f1426c --- /dev/null +++ b/routes/entities.js @@ -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"); +} diff --git a/routes/geometries.js b/routes/geometries.js new file mode 100644 index 0000000..e347506 --- /dev/null +++ b/routes/geometries.js @@ -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; +} diff --git a/routes/rasterTiles.js b/routes/rasterTiles.js new file mode 100644 index 0000000..4f8a629 --- /dev/null +++ b/routes/rasterTiles.js @@ -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; diff --git a/routes/tiles.js b/routes/tiles.js new file mode 100644 index 0000000..07b3546 --- /dev/null +++ b/routes/tiles.js @@ -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; diff --git a/swagger.js b/swagger.js new file mode 100644 index 0000000..69acd3c --- /dev/null +++ b/swagger.js @@ -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, +}; diff --git a/utils/bbox.js b/utils/bbox.js new file mode 100644 index 0000000..d7408d8 --- /dev/null +++ b/utils/bbox.js @@ -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 }; \ No newline at end of file From 5397bf9808d6f0a8ea46166d221d4b2e7037eae8 Mon Sep 17 00:00:00 2001 From: taDuc Date: Fri, 17 Apr 2026 20:55:33 +0700 Subject: [PATCH 2/6] pre updating version control --- db/polygons.js | 87 ++++++++++- lib/entityBatch.js | 302 +++++++++++++++++++++++++++++++++++ routes/entities.js | 35 ++++- routes/geometries.js | 365 ++++++++++++++++++++++++------------------- swagger.js | 183 ++++++++++++++++++++++ 5 files changed, 806 insertions(+), 166 deletions(-) create mode 100644 lib/entityBatch.js diff --git a/db/polygons.js b/db/polygons.js index fb4085d..3d7cbec 100644 --- a/db/polygons.js +++ b/db/polygons.js @@ -30,7 +30,6 @@ db.prepare(` 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, @@ -54,6 +53,7 @@ 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"); +dropEntityDeprecatedColumnsIfExists(); dropGeometryDeprecatedColumnsIfExists(); migrateLegacyGeometryTypeTokens(); @@ -98,6 +98,91 @@ function dropGeometryDeprecatedColumnsIfExists() { 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(); diff --git a/lib/entityBatch.js b/lib/entityBatch.js new file mode 100644 index 0000000..36c0812 --- /dev/null +++ b/lib/entityBatch.js @@ -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; +} diff --git a/routes/entities.js b/routes/entities.js index 2f1426c..967561f 100644 --- a/routes/entities.js +++ b/routes/entities.js @@ -1,6 +1,7 @@ const express = require("express"); const crypto = require("crypto"); const db = require("../db/polygons"); +const { applyEntityBatchChanges } = require("../lib/entityBatch"); const router = express.Router(); @@ -95,16 +96,15 @@ router.post("/", (req, res) => { try { db.prepare(` INSERT INTO entities ( - id, name, slug, description, type_id, kind, status, is_deleted, created_at, updated_at + id, name, slug, description, type_id, status, is_deleted, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) `).run( id, name, slug, description, typeId, - null, status, now, now @@ -173,7 +173,6 @@ router.put("/:id", (req, res) => { slug = ?, description = ?, type_id = ?, - kind = ?, status = ?, updated_at = ? WHERE id = ? @@ -182,7 +181,6 @@ router.put("/:id", (req, res) => { slug, description, typeId, - null, status, now, req.params.id @@ -277,6 +275,33 @@ router.delete("/:id", (req, res) => { res.json({ success: true }); }); +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) => applyEntityBatchChanges(items, { now })); + const result = tx(changes); + res.json({ + success: true, + applied: result.applied, + created_entity_ids: result.createdEntityIds, + }); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + if (isSqliteConstraint(err)) { + return res.status(409).json({ error: "Entity name/slug must be unique" }); + } + console.error("Batch entity apply failed", err); + res.status(500).json({ error: "Batch entity apply failed" }); + } +}); + module.exports = router; function normalizeEntityRow(row) { diff --git a/routes/geometries.js b/routes/geometries.js index e347506..abe8111 100644 --- a/routes/geometries.js +++ b/routes/geometries.js @@ -1,6 +1,7 @@ const express = require("express"); const crypto = require("crypto"); const db = require("../db/polygons"); +const { applyEntityBatchChanges } = require("../lib/entityBatch"); const { getBBox } = require("../utils/bbox"); const router = express.Router(); @@ -304,169 +305,10 @@ router.post("/batch", (req, res) => { } 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)}`); - } + applyGeometryBatchChanges(items, now); }); - tx(changes); res.json({ success: true, applied: changes.length }); } catch (err) { @@ -478,6 +320,209 @@ router.post("/batch", (req, res) => { } }); +// ======================= +// Apply entities + geometries in one transaction +// ======================= +router.post("/batch/combined", (req, res) => { + const entityChangesRaw = req.body?.entity_changes; + const geometryChangesRaw = req.body?.geometry_changes; + + if (entityChangesRaw !== undefined && !Array.isArray(entityChangesRaw)) { + return res.status(400).json({ error: "entity_changes must be an array" }); + } + if (geometryChangesRaw !== undefined && !Array.isArray(geometryChangesRaw)) { + return res.status(400).json({ error: "geometry_changes must be an array" }); + } + + const entityChanges = entityChangesRaw || []; + const geometryChanges = geometryChangesRaw || []; + const now = new Date().toISOString(); + + try { + const tx = db.transaction((entities, geometries) => { + const entityResult = applyEntityBatchChanges(entities, { now }); + applyGeometryBatchChanges(geometries, now); + return entityResult; + }); + + const entityResult = tx(entityChanges, geometryChanges); + res.json({ + success: true, + applied: entityChanges.length + geometryChanges.length, + entity_applied: entityChanges.length, + geometry_applied: geometryChanges.length, + created_entity_ids: entityResult.createdEntityIds, + }); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + console.error("Combined batch apply error:", err); + res.status(500).json({ error: "Combined batch apply failed" }); + } +}); + +function applyGeometryBatchChanges(items, now) { + 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)}`); + } +} + module.exports = router; function buildFeatureFromRow(row, linkedEntities = []) { diff --git a/swagger.js b/swagger.js index 69acd3c..5ecfd3d 100644 --- a/swagger.js +++ b/swagger.js @@ -69,6 +69,64 @@ const openApiSpec = { status: { type: "number" }, }, }, + EntityBatchCreateChange: { + type: "object", + properties: { + action: { type: "string", enum: ["create"] }, + entity: { $ref: "#/components/schemas/EntityCreateInput" }, + }, + required: ["action"], + }, + EntityBatchUpdateChange: { + type: "object", + properties: { + action: { type: "string", enum: ["update"] }, + id: { type: "string" }, + entity: { $ref: "#/components/schemas/EntityUpdateInput" }, + name: { type: "string" }, + slug: { type: "string", nullable: true }, + description: { type: "string", nullable: true }, + type_id: { type: "string" }, + status: { type: "number" }, + }, + required: ["action", "id"], + }, + EntityBatchDeleteChange: { + type: "object", + properties: { + action: { type: "string", enum: ["delete"] }, + id: { type: "string" }, + }, + required: ["action", "id"], + }, + EntityBatchPayload: { + type: "object", + properties: { + changes: { + type: "array", + items: { + oneOf: [ + { $ref: "#/components/schemas/EntityBatchCreateChange" }, + { $ref: "#/components/schemas/EntityBatchUpdateChange" }, + { $ref: "#/components/schemas/EntityBatchDeleteChange" }, + ], + }, + }, + }, + required: ["changes"], + }, + EntityBatchResponse: { + type: "object", + properties: { + success: { type: "boolean" }, + applied: { type: "number" }, + created_entity_ids: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["success", "applied", "created_entity_ids"], + }, GeoJSONGeometry: { type: "object", properties: { @@ -221,6 +279,51 @@ const openApiSpec = { }, required: ["success", "applied"], }, + CombinedBatchPayload: { + type: "object", + properties: { + entity_changes: { + type: "array", + items: { + oneOf: [ + { $ref: "#/components/schemas/EntityBatchCreateChange" }, + { $ref: "#/components/schemas/EntityBatchUpdateChange" }, + { $ref: "#/components/schemas/EntityBatchDeleteChange" }, + ], + }, + }, + geometry_changes: { + type: "array", + items: { + oneOf: [ + { $ref: "#/components/schemas/BatchCreateChange" }, + { $ref: "#/components/schemas/BatchUpdateChange" }, + { $ref: "#/components/schemas/BatchDeleteChange" }, + ], + }, + }, + }, + }, + CombinedBatchResponse: { + type: "object", + properties: { + success: { type: "boolean" }, + applied: { type: "number" }, + entity_applied: { type: "number" }, + geometry_applied: { type: "number" }, + created_entity_ids: { + type: "array", + items: { type: "string" }, + }, + }, + required: [ + "success", + "applied", + "entity_applied", + "geometry_applied", + "created_entity_ids", + ], + }, MetadataResponse: { type: "object", additionalProperties: { @@ -442,6 +545,46 @@ const openApiSpec = { }, }, }, + "/entities/batch": { + post: { + tags: ["Entities"], + summary: "Apply batch create/update/delete entities", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EntityBatchPayload" }, + }, + }, + }, + responses: { + 200: { + description: "Batch applied", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EntityBatchResponse" }, + }, + }, + }, + 400: { + description: "Invalid payload", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + 409: { + description: "Unique conflict or cannot delete due to orphaned geometries", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, "/entities/{id}": { get: { tags: ["Entities"], @@ -721,6 +864,46 @@ const openApiSpec = { }, }, }, + "/geometries/batch/combined": { + post: { + tags: ["Geometries", "Entities"], + summary: "Apply entity batch and geometry batch in one transaction", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CombinedBatchPayload" }, + }, + }, + }, + responses: { + 200: { + description: "Combined batch applied", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CombinedBatchResponse" }, + }, + }, + }, + 400: { + description: "Invalid payload", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + 409: { + description: "Conflict in entity changes (unique or orphan guard)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, }, }; From 34e709cea4d34cd52bf82efaab03cb755b59171b Mon Sep 17 00:00:00 2001 From: taDuc Date: Sun, 19 Apr 2026 00:13:22 +0700 Subject: [PATCH 3/6] reduce api | version control --- db/polygons.js | 85 ++++ index.js | 6 + routes/entities.js | 267 ---------- routes/geometries.js | 651 ------------------------ routes/sections.js | 1142 ++++++++++++++++++++++++++++++++++++++++++ swagger.js | 1058 +++++++++++--------------------------- 6 files changed, 1529 insertions(+), 1680 deletions(-) create mode 100644 routes/sections.js diff --git a/db/polygons.js b/db/polygons.js index 3d7cbec..0d3ac0d 100644 --- a/db/polygons.js +++ b/db/polygons.js @@ -48,11 +48,76 @@ db.prepare(` ) `).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(); @@ -79,6 +144,26 @@ db.prepare(` 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) { diff --git a/index.js b/index.js index 065b045..a1a61e8 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ 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 app = express(); @@ -18,6 +19,11 @@ app.use("/tiles", tileRoutes); app.use("/raster-tiles", rasterTileRoutes); app.use("/geometries", geoRoutes); app.use("/entities", entityRoutes); +app.use("/sections", sectionRoutes); +app.use("/submissions", (req, res, next) => { + req.url = `/submissions${req.url}`; + sectionRoutes(req, res, next); +}); app.use("/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec, { explorer: true, })); diff --git a/routes/entities.js b/routes/entities.js index 967561f..1c7cf56 100644 --- a/routes/entities.js +++ b/routes/entities.js @@ -1,7 +1,5 @@ const express = require("express"); -const crypto = require("crypto"); const db = require("../db/polygons"); -const { applyEntityBatchChanges } = require("../lib/entityBatch"); const router = express.Router(); @@ -80,228 +78,6 @@ router.get("/:id", (req, res) => { 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, status, is_deleted, created_at, updated_at - ) - VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) - `).run( - id, - name, - slug, - description, - typeId, - 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 = ?, - status = ?, - updated_at = ? - WHERE id = ? - `).run( - name, - slug, - description, - typeId, - 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 }); -}); - -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) => applyEntityBatchChanges(items, { now })); - const result = tx(changes); - res.json({ - success: true, - applied: result.applied, - created_entity_ids: result.createdEntityIds, - }); - } catch (err) { - if (err.status) { - return res.status(err.status).json({ error: err.message }); - } - if (isSqliteConstraint(err)) { - return res.status(409).json({ error: "Entity name/slug must be unique" }); - } - console.error("Batch entity apply failed", err); - res.status(500).json({ error: "Batch entity apply failed" }); - } -}); - module.exports = router; function normalizeEntityRow(row) { @@ -318,34 +94,6 @@ function normalizeEntityRow(row) { }; } -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; @@ -353,18 +101,3 @@ function normalizeLimit(value, fallback = 25, max = 100) { 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"); -} diff --git a/routes/geometries.js b/routes/geometries.js index abe8111..2bbc6b7 100644 --- a/routes/geometries.js +++ b/routes/geometries.js @@ -1,78 +1,8 @@ const express = require("express"); -const crypto = require("crypto"); const db = require("../db/polygons"); -const { applyEntityBatchChanges } = require("../lib/entityBatch"); -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) // ======================= @@ -149,380 +79,6 @@ router.get("/", (req, res) => { }); }); -// ======================= -// 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) => { - applyGeometryBatchChanges(items, now); - }); - 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" }); - } -}); - -// ======================= -// Apply entities + geometries in one transaction -// ======================= -router.post("/batch/combined", (req, res) => { - const entityChangesRaw = req.body?.entity_changes; - const geometryChangesRaw = req.body?.geometry_changes; - - if (entityChangesRaw !== undefined && !Array.isArray(entityChangesRaw)) { - return res.status(400).json({ error: "entity_changes must be an array" }); - } - if (geometryChangesRaw !== undefined && !Array.isArray(geometryChangesRaw)) { - return res.status(400).json({ error: "geometry_changes must be an array" }); - } - - const entityChanges = entityChangesRaw || []; - const geometryChanges = geometryChangesRaw || []; - const now = new Date().toISOString(); - - try { - const tx = db.transaction((entities, geometries) => { - const entityResult = applyEntityBatchChanges(entities, { now }); - applyGeometryBatchChanges(geometries, now); - return entityResult; - }); - - const entityResult = tx(entityChanges, geometryChanges); - res.json({ - success: true, - applied: entityChanges.length + geometryChanges.length, - entity_applied: entityChanges.length, - geometry_applied: geometryChanges.length, - created_entity_ids: entityResult.createdEntityIds, - }); - } catch (err) { - if (err.status) { - return res.status(err.status).json({ error: err.message }); - } - console.error("Combined batch apply error:", err); - res.status(500).json({ error: "Combined batch apply failed" }); - } -}); - -function applyGeometryBatchChanges(items, now) { - 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)}`); - } -} - module.exports = router; function buildFeatureFromRow(row, linkedEntities = []) { @@ -559,90 +115,22 @@ function buildFeatureFromRow(row, linkedEntities = []) { }; } -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 []; @@ -695,94 +183,6 @@ function parseBindingIds(rawBinding) { 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; @@ -816,59 +216,8 @@ function loadGeometryLinksByGeometryId(geometryIds) { 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; -} diff --git a/routes/sections.js b/routes/sections.js new file mode 100644 index 0000000..1f3b30e --- /dev/null +++ b/routes/sections.js @@ -0,0 +1,1142 @@ +const express = require("express"); +const crypto = require("crypto"); +const db = require("../db/polygons"); +const { getBBox } = require("../utils/bbox"); + +const router = express.Router(); +const LOCK_TTL_MS = 15 * 60 * 1000; + +router.get("/", (_req, res) => { + const rows = db.prepare(` + SELECT + s.*, + st.status, + st.head_commit_id, + st.version, + st.locked_by, + st.locked_at, + st.lock_expires_at + FROM sections s + LEFT JOIN section_states st + ON st.section_id = s.id + ORDER BY s.updated_at DESC + `).all(); + + res.json(rows.map(normalizeSectionRow)); +}); + +router.post("/", (req, res) => { + const title = normalizeRequiredString(req.body?.title); + if (!title) { + return res.status(400).json({ error: "title is required" }); + } + + const id = normalizeId(req.body?.id) || crypto.randomUUID(); + const now = new Date().toISOString(); + const description = normalizeOptionalString(req.body?.description); + const userId = normalizeActor(req.body?.user_id || req.body?.created_by); + const createdBy = normalizeActor(req.body?.created_by || req.body?.user_id); + + try { + const tx = db.transaction(() => { + db.prepare(` + INSERT INTO sections (id, title, description, user_id, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, title, description, userId, createdBy, now, now); + + ensureSectionState(id, now); + }); + tx(); + } catch (err) { + if (isSqliteConstraint(err)) { + return res.status(409).json({ error: "Section id already exists" }); + } + console.error("Create section failed", err); + return res.status(500).json({ error: "Create section failed" }); + } + + res.status(201).json(getSectionById(id)); +}); + +router.get("/:sectionId/editor", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.query.user_id || req.query.user || req.get("x-user-id")); + const now = new Date().toISOString(); + let state; + let commit = null; + + try { + const tx = db.transaction(() => { + state = ensureSectionState(section.id, now); + assertLockAvailable(state, actor, now); + if (actor) { + state = acquireLock(section.id, actor, now); + } + if (state.head_commit_id) { + commit = getCommitForSection(section.id, state.head_commit_id); + } + }); + tx(); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + console.error("Open editor failed", err); + return res.status(500).json({ error: "Open editor failed" }); + } + + res.json({ + section, + state: normalizeStateRow(state), + commit: commit ? normalizeCommitRow(commit, false) : null, + snapshot: commit ? parseSnapshotJson(commit.snapshot_json) : buildEmptySnapshot(section), + }); +}); + +router.post("/:sectionId/lock", (req, res) => { + const actor = normalizeActor(req.body?.user_id || req.body?.user || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "user_id is required" }); + } + + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const now = new Date().toISOString(); + try { + const state = db.transaction(() => { + const current = ensureSectionState(section.id, now); + assertLockAvailable(current, actor, now); + return acquireLock(section.id, actor, now); + })(); + res.json({ state: normalizeStateRow(state) }); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + console.error("Lock section failed", err); + res.status(500).json({ error: "Lock section failed" }); + } +}); + +router.post("/:sectionId/unlock", (req, res) => { + const actor = normalizeActor(req.body?.user_id || req.body?.user || req.get("x-user-id")); + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const state = ensureSectionState(section.id, new Date().toISOString()); + if (state.locked_by && actor && state.locked_by !== actor) { + return res.status(409).json({ error: "Section is locked by another user" }); + } + + db.prepare(` + UPDATE section_states + SET locked_by = NULL, + locked_at = NULL, + lock_expires_at = NULL, + updated_at = ? + WHERE section_id = ? + `).run(new Date().toISOString(), section.id); + + res.json({ success: true }); +}); + +router.get("/:sectionId/commits", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const includeSnapshot = req.query.include_snapshot === "1" || req.query.include_snapshot === "true"; + const rows = db.prepare(` + SELECT * + FROM section_commits + WHERE section_id = ? + ORDER BY commit_no DESC + `).all(section.id); + + res.json(rows.map((row) => normalizeCommitRow(row, includeSnapshot))); +}); + +router.post("/:sectionId/commits", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.body?.created_by || req.body?.user_id || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "created_by is required" }); + } + + const snapshotResult = normalizeSnapshotInput(req.body?.snapshot_json ?? req.body?.snapshot); + if (snapshotResult.error) { + return res.status(400).json({ error: snapshotResult.error }); + } + + try { + const result = db.transaction(() => { + const now = new Date().toISOString(); + const state = ensureSectionState(section.id, now); + assertCanEdit(state, actor, now); + assertExpectedState(state, req.body); + validateSnapshot(snapshotResult.snapshot); + + const commit = insertCommit({ + section, + state, + snapshotJson: snapshotResult.snapshotJson, + snapshotHash: hashString(snapshotResult.snapshotJson), + kind: "manual", + restoredFromCommitId: null, + actor, + title: normalizeOptionalString(req.body?.title), + note: normalizeOptionalString(req.body?.note), + now, + }); + + const nextState = setHeadCommit(section.id, commit.id, now); + return { commit, state: nextState }; + })(); + + res.status(201).json({ + commit: normalizeCommitRow(result.commit, true), + state: normalizeStateRow(result.state), + }); + } catch (err) { + handleRouteError(res, err, "Create commit failed"); + } +}); + +router.post("/:sectionId/restore", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.body?.created_by || req.body?.user_id || req.get("x-user-id")); + const restoreCommitId = normalizeId(req.body?.commit_id || req.body?.restore_commit_id); + if (!actor) { + return res.status(400).json({ error: "created_by is required" }); + } + if (!restoreCommitId) { + return res.status(400).json({ error: "commit_id is required" }); + } + + try { + const result = db.transaction(() => { + const now = new Date().toISOString(); + const state = ensureSectionState(section.id, now); + assertCanEdit(state, actor, now); + assertExpectedState(state, req.body); + + const sourceCommit = getCommitForSection(section.id, restoreCommitId); + if (!sourceCommit) { + throw createHttpError("Commit not found", 404); + } + + const commit = insertCommit({ + section, + state, + snapshotJson: sourceCommit.snapshot_json, + snapshotHash: sourceCommit.snapshot_hash || hashString(sourceCommit.snapshot_json), + kind: "restore", + restoredFromCommitId: sourceCommit.id, + actor, + title: normalizeOptionalString(req.body?.title) || `Restore #${sourceCommit.commit_no}`, + note: normalizeOptionalString(req.body?.note), + now, + }); + + const nextState = setHeadCommit(section.id, commit.id, now); + return { commit, state: nextState }; + })(); + + res.status(201).json({ + commit: normalizeCommitRow(result.commit, true), + state: normalizeStateRow(result.state), + }); + } catch (err) { + handleRouteError(res, err, "Restore section failed"); + } +}); + +router.post("/:sectionId/submit", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.body?.submitted_by || req.body?.user_id || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "submitted_by is required" }); + } + + try { + const submission = db.transaction(() => { + const now = new Date().toISOString(); + const state = ensureSectionState(section.id, now); + assertLockAvailable(state, actor, now); + if (state.status !== "editing") { + throw createHttpError("Section is not editable", 409); + } + + const commitId = normalizeId(req.body?.commit_id) || state.head_commit_id; + if (!commitId) { + throw createHttpError("Section has no commit to submit", 400); + } + + const commit = getCommitForSection(section.id, commitId); + if (!commit) { + throw createHttpError("Commit not found", 404); + } + + const id = crypto.randomUUID(); + db.prepare(` + INSERT INTO section_submissions ( + id, section_id, commit_id, submitted_by, submitted_at, status, + snapshot_json, snapshot_hash + ) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?) + `).run( + id, + section.id, + commit.id, + actor, + now, + commit.snapshot_json, + commit.snapshot_hash || hashString(commit.snapshot_json) + ); + + db.prepare(` + UPDATE section_states + SET status = 'submitted', + locked_by = NULL, + locked_at = NULL, + lock_expires_at = NULL, + updated_at = ? + WHERE section_id = ? + `).run(now, section.id); + + return getSubmissionById(id); + })(); + + res.status(201).json(normalizeSubmissionRow(submission, true)); + } catch (err) { + handleRouteError(res, err, "Submit section failed"); + } +}); + +router.get("/:sectionId/submissions", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const includeSnapshot = req.query.include_snapshot === "1" || req.query.include_snapshot === "true"; + const rows = db.prepare(` + SELECT * + FROM section_submissions + WHERE section_id = ? + ORDER BY submitted_at DESC + `).all(section.id); + + res.json(rows.map((row) => normalizeSubmissionRow(row, includeSnapshot))); +}); + +router.post("/submissions/:submissionId/approve", (req, res) => { + reviewSubmission(req, res, "approve"); +}); + +router.post("/submissions/:submissionId/reject", (req, res) => { + reviewSubmission(req, res, "reject"); +}); + +function reviewSubmission(req, res, action) { + const actor = normalizeActor(req.body?.reviewed_by || req.body?.user_id || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "reviewed_by is required" }); + } + + try { + const result = db.transaction(() => { + const submission = getSubmissionById(req.params.submissionId); + if (!submission) { + throw createHttpError("Submission not found", 404); + } + if (submission.status !== "pending") { + throw createHttpError("Submission is not pending", 409); + } + + const now = new Date().toISOString(); + const reviewNote = normalizeOptionalString(req.body?.review_note); + + if (action === "reject") { + updateSubmissionReview(submission.id, "rejected", actor, now, reviewNote); + db.prepare(` + UPDATE section_states + SET status = 'rejected', + updated_at = ? + WHERE section_id = ? + `).run(now, submission.section_id); + return { submission: getSubmissionById(submission.id) }; + } + + const expectedHash = submission.snapshot_hash; + if (expectedHash && expectedHash !== hashString(submission.snapshot_json)) { + throw createHttpError("Submission snapshot hash mismatch", 409); + } + + const snapshot = parseSnapshotJson(submission.snapshot_json); + applySnapshotToPublished(snapshot, now); + updateSubmissionReview(submission.id, "approved", actor, now, reviewNote); + db.prepare(` + UPDATE section_states + SET status = 'approved', + updated_at = ? + WHERE section_id = ? + `).run(now, submission.section_id); + + return { submission: getSubmissionById(submission.id) }; + })(); + + res.json(normalizeSubmissionRow(result.submission, true)); + } catch (err) { + if (err && err.isConflict && req.params.submissionId) { + const now = new Date().toISOString(); + db.prepare(` + UPDATE section_submissions + SET status = 'conflicted', + reviewed_by = ?, + reviewed_at = ?, + review_note = ? + WHERE id = ? + AND status = 'pending' + `).run(actor, now, err.message, req.params.submissionId); + } + handleRouteError(res, err, "Review submission failed"); + } +} + +function insertCommit(options) { + const nextNo = getNextCommitNo(options.section.id); + const id = crypto.randomUUID(); + db.prepare(` + INSERT INTO section_commits ( + id, section_id, parent_commit_id, commit_no, kind, restored_from_commit_id, + created_by, created_at, title, note, snapshot_json, snapshot_hash + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + options.section.id, + options.state.head_commit_id || null, + nextNo, + options.kind, + options.restoredFromCommitId, + options.actor, + options.now, + options.title, + options.note, + options.snapshotJson, + options.snapshotHash + ); + + return getCommitForSection(options.section.id, id); +} + +function applySnapshotToPublished(snapshot, now) { + validateSnapshot(snapshot); + const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + + for (const entity of entities) { + applyEntitySnapshot(entity, now); + } + + for (const geometry of geometries) { + applyGeometrySnapshot(geometry, now); + } + + for (const scope of linkScopes) { + applyLinkScopeSnapshot(scope, now); + } +} + +function applyEntitySnapshot(entity, now) { + const operation = normalizeOperation(entity?.operation); + if (operation === "reference") return; + + const id = normalizeId(entity?.id); + if (!id) throw createHttpError("Entity id is required in snapshot", 400); + + if (operation === "create") { + db.prepare(` + INSERT INTO entities ( + id, name, slug, description, type_id, status, is_deleted, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + normalizeRequiredString(entity.name), + normalizeOptionalString(entity.slug), + normalizeOptionalString(entity.description), + normalizeTypeId(entity.type_id), + normalizeInteger(entity.status, 1), + normalizeInteger(entity.is_deleted, 0), + now, + now + ); + return; + } + + const existing = db.prepare(`SELECT * FROM entities WHERE id = ?`).get(id); + assertBaseMatches("entity", id, existing, entity); + + if (operation === "delete") { + db.prepare(` + UPDATE entities + SET is_deleted = 1, + updated_at = ? + WHERE id = ? + `).run(now, id); + db.prepare(`DELETE FROM entity_geometries WHERE entity_id = ?`).run(id); + return; + } + + db.prepare(` + UPDATE entities + SET name = ?, + slug = ?, + description = ?, + type_id = ?, + status = ?, + is_deleted = ?, + updated_at = ? + WHERE id = ? + `).run( + normalizeRequiredString(entity.name), + normalizeOptionalString(entity.slug), + normalizeOptionalString(entity.description), + normalizeTypeId(entity.type_id), + normalizeInteger(entity.status, 1), + normalizeInteger(entity.is_deleted, 0), + now, + id + ); +} + +function applyGeometrySnapshot(geometry, now) { + const operation = normalizeOperation(geometry?.operation); + if (operation === "reference") return; + + const id = normalizeId(geometry?.id); + if (!id) throw createHttpError("Geometry id is required in snapshot", 400); + + if (operation === "delete") { + const existing = db.prepare(`SELECT * FROM geometries WHERE id = ?`).get(id); + assertBaseMatches("geometry", id, existing, geometry); + db.prepare(` + UPDATE geometries + SET is_deleted = 1, + updated_at = ? + WHERE id = ? + `).run(now, id); + db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(id); + removeBindingReferenceFromAll(id, now); + return; + } + + const drawGeometry = geometry.draw_geometry || geometry.geometry; + if (!drawGeometry) { + throw createHttpError("Geometry draw_geometry is required in snapshot", 400); + } + const temporalRange = normalizeTemporalRange(geometry.time_start, geometry.time_end); + const bbox = normalizeBBox(geometry.bbox) || getBBox(drawGeometry); + const binding = normalizeIdArray(geometry.binding).filter((bindingId) => bindingId !== id); + + if (operation === "create") { + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + normalizeOptionalString(geometry.type), + normalizeInteger(geometry.is_deleted, 0), + JSON.stringify(drawGeometry), + serializeIdArray(binding), + temporalRange.timeStart, + temporalRange.timeEnd, + bbox.minLng, + bbox.minLat, + bbox.maxLng, + bbox.maxLat, + now, + now + ); + return; + } + + const existing = db.prepare(`SELECT * FROM geometries WHERE id = ?`).get(id); + assertBaseMatches("geometry", id, existing, geometry); + + db.prepare(` + UPDATE geometries + SET type = ?, + is_deleted = ?, + draw_geometry = ?, + binding = ?, + time_start = ?, + time_end = ?, + bbox_min_lng = ?, + bbox_min_lat = ?, + bbox_max_lng = ?, + bbox_max_lat = ?, + updated_at = ? + WHERE id = ? + `).run( + normalizeOptionalString(geometry.type), + normalizeInteger(geometry.is_deleted, 0), + JSON.stringify(drawGeometry), + serializeIdArray(binding), + temporalRange.timeStart, + temporalRange.timeEnd, + bbox.minLng, + bbox.minLat, + bbox.maxLng, + bbox.maxLat, + now, + id + ); +} + +function applyLinkScopeSnapshot(scope, now) { + if (normalizeOperation(scope?.operation) === "reference") return; + + const geometryId = normalizeId(scope?.geometry_id); + if (!geometryId) throw createHttpError("link_scope geometry_id is required", 400); + + const existing = db.prepare(` + SELECT id + FROM geometries + WHERE id = ? + AND is_deleted = 0 + `).get(geometryId); + if (!existing) { + throw createConflictError(`Geometry not found for link scope: ${geometryId}`); + } + + const entityIds = normalizeIdArray(scope.entity_ids); + if (!entityIds.length) { + throw createHttpError("link_scope entity_ids must not be empty", 400); + } + + if (scope.base_links_hash) { + const currentHash = hashEntityLinks(geometryId); + if (scope.base_links_hash !== currentHash) { + throw createConflictError(`Links changed for geometry: ${geometryId}`); + } + } + + 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 createHttpError(`Entity not found: ${missing.join(", ")}`, 400); + } + + db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(geometryId); + for (const entityId of entityIds) { + db.prepare(` + INSERT INTO entity_geometries (entity_id, geometry_id, created_at) + VALUES (?, ?, ?) + `).run(entityId, geometryId, now); + } +} + +function validateSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw createHttpError("snapshot must be an object", 400); + } + + const slugSet = new Set(); + for (const entity of Array.isArray(snapshot.entities) ? snapshot.entities : []) { + const operation = normalizeOperation(entity?.operation); + if (!operation) throw createHttpError("Invalid entity operation", 400); + if (operation !== "delete" && operation !== "reference" && !normalizeRequiredString(entity?.name)) { + throw createHttpError("Entity name is required", 400); + } + const slug = normalizeOptionalString(entity?.slug); + if (slug) { + const key = slug.toLowerCase(); + if (slugSet.has(key)) throw createHttpError(`Duplicate slug in snapshot: ${slug}`, 400); + slugSet.add(key); + } + } + + for (const geometry of Array.isArray(snapshot.geometries) ? snapshot.geometries : []) { + const operation = normalizeOperation(geometry?.operation); + if (!operation) throw createHttpError("Invalid geometry operation", 400); + if (operation === "delete" || operation === "reference") continue; + + normalizeTemporalRange(geometry?.time_start, geometry?.time_end); + const bbox = normalizeBBox(geometry?.bbox); + if (geometry?.bbox && !bbox) throw createHttpError("Invalid geometry bbox", 400); + const binding = normalizeIdArray(geometry?.binding); + if (binding.includes(normalizeId(geometry?.id))) { + throw createHttpError("binding cannot contain self id", 400); + } + } + + for (const scope of Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []) { + const entityIds = normalizeIdArray(scope?.entity_ids); + if (!entityIds.length) { + throw createHttpError("link_scope entity_ids must not be empty", 400); + } + } +} + +function assertBaseMatches(kind, id, existing, snapshotItem) { + if (!existing) { + throw createConflictError(`${kind} not found: ${id}`); + } + if (snapshotItem.base_updated_at && existing.updated_at !== snapshotItem.base_updated_at) { + throw createConflictError(`${kind} changed: ${id}`); + } + if (snapshotItem.base_hash) { + const currentHash = kind === "entity" ? hashEntityRow(existing) : hashGeometryRow(existing); + if (snapshotItem.base_hash !== currentHash) { + throw createConflictError(`${kind} changed: ${id}`); + } + } +} + +function getSectionById(sectionId) { + const row = db.prepare(` + SELECT + s.*, + st.status, + st.head_commit_id, + st.version, + st.locked_by, + st.locked_at, + st.lock_expires_at + FROM sections s + LEFT JOIN section_states st + ON st.section_id = s.id + WHERE s.id = ? + `).get(sectionId); + + return row ? normalizeSectionRow(row) : null; +} + +function ensureSectionState(sectionId, now) { + let row = db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); + if (row) return row; + + db.prepare(` + INSERT INTO section_states (section_id, status, version, updated_at) + VALUES (?, 'editing', 0, ?) + `).run(sectionId, now); + + return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); +} + +function acquireLock(sectionId, actor, now) { + db.prepare(` + UPDATE section_states + SET locked_by = ?, + locked_at = ?, + lock_expires_at = ?, + updated_at = ? + WHERE section_id = ? + `).run(actor, now, new Date(Date.parse(now) + LOCK_TTL_MS).toISOString(), now, sectionId); + + return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); +} + +function assertCanEdit(state, actor, now) { + if (state.status !== "editing" && state.status !== "rejected") { + throw createHttpError("Section is not editable", 409); + } + assertLockAvailable(state, actor, now); +} + +function assertLockAvailable(state, actor, now) { + if (!state.locked_by) return; + if (actor && state.locked_by === actor) return; + if (state.lock_expires_at && Date.parse(state.lock_expires_at) <= Date.parse(now)) return; + throw createHttpError("Section is locked by another user", 409); +} + +function assertExpectedState(state, body) { + if (body?.expected_version !== undefined && Number(body.expected_version) !== Number(state.version)) { + throw createHttpError("Section version changed", 409); + } + if ( + body?.expected_head_commit_id !== undefined && + normalizeId(body.expected_head_commit_id) !== (state.head_commit_id || null) + ) { + throw createHttpError("Section head commit changed", 409); + } +} + +function setHeadCommit(sectionId, commitId, now) { + db.prepare(` + UPDATE section_states + SET status = 'editing', + head_commit_id = ?, + version = version + 1, + updated_at = ? + WHERE section_id = ? + `).run(commitId, now, sectionId); + + db.prepare(`UPDATE sections SET updated_at = ? WHERE id = ?`).run(now, sectionId); + return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); +} + +function getNextCommitNo(sectionId) { + const row = db.prepare(` + SELECT COALESCE(MAX(commit_no), 0) + 1 AS next_no + FROM section_commits + WHERE section_id = ? + `).get(sectionId); + return Number(row?.next_no || 1); +} + +function getCommitForSection(sectionId, commitId) { + return db.prepare(` + SELECT * + FROM section_commits + WHERE section_id = ? + AND id = ? + `).get(sectionId, commitId); +} + +function getSubmissionById(submissionId) { + return db.prepare(`SELECT * FROM section_submissions WHERE id = ?`).get(submissionId); +} + +function updateSubmissionReview(submissionId, status, actor, now, reviewNote) { + db.prepare(` + UPDATE section_submissions + SET status = ?, + reviewed_by = ?, + reviewed_at = ?, + review_note = ? + WHERE id = ? + `).run(status, actor, now, reviewNote, submissionId); +} + +function normalizeSectionRow(row) { + return { + id: row.id, + title: row.title, + description: row.description, + user_id: row.user_id || null, + created_by: row.created_by, + created_at: row.created_at, + updated_at: row.updated_at, + state: { + status: row.status || "editing", + head_commit_id: row.head_commit_id || null, + version: Number(row.version || 0), + locked_by: row.locked_by || null, + locked_at: row.locked_at || null, + lock_expires_at: row.lock_expires_at || null, + }, + }; +} + +function normalizeStateRow(row) { + return { + section_id: row.section_id, + status: row.status, + head_commit_id: row.head_commit_id || null, + version: Number(row.version || 0), + locked_by: row.locked_by || null, + locked_at: row.locked_at || null, + lock_expires_at: row.lock_expires_at || null, + updated_at: row.updated_at, + }; +} + +function normalizeCommitRow(row, includeSnapshot) { + const out = { + id: row.id, + section_id: row.section_id, + parent_commit_id: row.parent_commit_id || null, + commit_no: Number(row.commit_no), + kind: row.kind, + restored_from_commit_id: row.restored_from_commit_id || null, + created_by: row.created_by, + created_at: row.created_at, + title: row.title, + note: row.note, + snapshot_hash: row.snapshot_hash, + }; + if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json); + return out; +} + +function normalizeSubmissionRow(row, includeSnapshot) { + const out = { + id: row.id, + section_id: row.section_id, + commit_id: row.commit_id, + submitted_by: row.submitted_by, + submitted_at: row.submitted_at, + status: row.status, + reviewed_by: row.reviewed_by || null, + reviewed_at: row.reviewed_at || null, + review_note: row.review_note || null, + snapshot_hash: row.snapshot_hash, + }; + if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json); + return out; +} + +function buildEmptySnapshot(section) { + return { + schema_version: 1, + section: { + id: section.id, + title: section.title, + }, + entities: [], + geometries: [], + link_scopes: [], + }; +} + +function normalizeSnapshotInput(input) { + if (typeof input === "string") { + try { + const snapshot = JSON.parse(input); + return { snapshot, snapshotJson: JSON.stringify(snapshot) }; + } catch (_err) { + return { error: "snapshot_json must be valid JSON" }; + } + } + if (input && typeof input === "object" && !Array.isArray(input)) { + return { snapshot: input, snapshotJson: JSON.stringify(input) }; + } + return { error: "snapshot is required" }; +} + +function parseSnapshotJson(value) { + try { + return JSON.parse(value); + } catch (_err) { + return null; + } +} + +function normalizeOperation(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (["create", "update", "delete", "reference", "replace"].includes(normalized)) { + return normalized === "replace" ? "update" : normalized; + } + return null; +} + +function normalizeTemporalRange(timeStartValue, timeEndValue) { + const timeStart = normalizeOptionalInteger(timeStartValue); + const timeEnd = normalizeOptionalInteger(timeEndValue); + if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) { + throw createHttpError("time_start must be <= time_end", 400); + } + return { timeStart, timeEnd }; +} + +function normalizeBBox(value) { + if (!value || typeof value !== "object") return null; + const minLng = Number(value.min_lng ?? value.minLng); + const minLat = Number(value.min_lat ?? value.minLat); + const maxLng = Number(value.max_lng ?? value.maxLng); + const maxLat = Number(value.max_lat ?? value.maxLat); + if (![minLng, minLat, maxLng, maxLat].every(Number.isFinite)) return null; + if (minLng > maxLng || minLat > maxLat) return null; + return { minLng, minLat, maxLng, maxLat }; +} + +function normalizeIdArray(value) { + if (value === undefined || value === null || value === "") return []; + if (!Array.isArray(value)) throw createHttpError("Expected an array of ids", 400); + const seen = new Set(); + const ids = []; + for (const item of value) { + const id = normalizeId(item); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +function serializeIdArray(value) { + return value.length ? JSON.stringify(value) : null; +} + +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) { + let binding; + try { + binding = JSON.parse(row.binding); + } catch (_err) { + binding = []; + } + const next = normalizeIdArray(binding).filter((id) => id !== removedId); + if (next.length === binding.length) continue; + db.prepare(` + UPDATE geometries + SET binding = ?, + updated_at = ? + WHERE id = ? + `).run(serializeIdArray(next), now, row.id); + } +} + +function hashEntityLinks(geometryId) { + const rows = db.prepare(` + SELECT entity_id + FROM entity_geometries + WHERE geometry_id = ? + ORDER BY entity_id ASC + `).all(geometryId); + return hashObject(rows.map((row) => row.entity_id)); +} + +function hashEntityRow(row) { + return hashObject({ + id: row.id, + name: row.name, + slug: row.slug, + description: row.description, + type_id: row.type_id, + status: row.status, + is_deleted: row.is_deleted, + }); +} + +function hashGeometryRow(row) { + return hashObject({ + id: row.id, + type: row.type, + is_deleted: row.is_deleted, + draw_geometry: row.draw_geometry, + binding: row.binding, + time_start: row.time_start, + time_end: row.time_end, + bbox_min_lng: row.bbox_min_lng, + bbox_min_lat: row.bbox_min_lat, + bbox_max_lng: row.bbox_max_lng, + bbox_max_lat: row.bbox_max_lat, + }); +} + +function hashObject(value) { + return hashString(JSON.stringify(value)); +} + +function hashString(value) { + return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +} + +function normalizeActor(value) { + return normalizeId(value) || "anonymous"; +} + +function normalizeId(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 normalizeOptionalInteger(value) { + if (value === undefined || value === null || value === "") return null; + const num = Number(value); + if (!Number.isFinite(num)) throw createHttpError("Expected a number", 400); + return Math.trunc(num); +} + +function normalizeInteger(value, fallback) { + if (value === undefined || value === null || value === "") return fallback; + const num = Number(value); + return Number.isFinite(num) ? Math.trunc(num) : fallback; +} + +function normalizeTypeId(value) { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : "country"; +} + +function isSqliteConstraint(err) { + const code = err?.code || ""; + return code === "SQLITE_CONSTRAINT" || String(code).startsWith("SQLITE_CONSTRAINT_"); +} + +function createHttpError(message, status = 400) { + const err = new Error(message); + err.status = status; + return err; +} + +function createConflictError(message) { + const err = createHttpError(message, 409); + err.isConflict = true; + return err; +} + +function handleRouteError(res, err, fallbackMessage) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + if (isSqliteConstraint(err)) { + return res.status(409).json({ error: "Database constraint failed" }); + } + console.error(fallbackMessage, err); + return res.status(500).json({ error: fallbackMessage }); +} + +module.exports = router; diff --git a/swagger.js b/swagger.js index 5ecfd3d..8603e10 100644 --- a/swagger.js +++ b/swagger.js @@ -3,7 +3,7 @@ const openApiSpec = { info: { title: "Ultimate History Map API", version: "1.0.0", - description: "API docs for tiles, geometries, and entities.", + description: "Read APIs plus section review workflow. Direct geometry/entity mutations are intentionally not exposed.", }, servers: [ { @@ -12,10 +12,12 @@ const openApiSpec = { }, ], tags: [ - { name: "System", description: "Health and meta endpoints" }, + { name: "System", description: "Health and OpenAPI endpoints" }, { name: "Tiles", description: "Vector and raster tile endpoints" }, - { name: "Geometries", description: "Geometry CRUD and batch save" }, - { name: "Entities", description: "Entity CRUD" }, + { 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: { @@ -26,125 +28,26 @@ const openApiSpec = { }, 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 }, + geometry_count: { type: "number" }, + created_at: { type: "string", nullable: true }, + updated_at: { type: "string", 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" }, - }, - }, - EntityBatchCreateChange: { - type: "object", - properties: { - action: { type: "string", enum: ["create"] }, - entity: { $ref: "#/components/schemas/EntityCreateInput" }, - }, - required: ["action"], - }, - EntityBatchUpdateChange: { - type: "object", - properties: { - action: { type: "string", enum: ["update"] }, - id: { type: "string" }, - entity: { $ref: "#/components/schemas/EntityUpdateInput" }, - name: { type: "string" }, - slug: { type: "string", nullable: true }, - description: { type: "string", nullable: true }, - type_id: { type: "string" }, - status: { type: "number" }, - }, - required: ["action", "id"], - }, - EntityBatchDeleteChange: { - type: "object", - properties: { - action: { type: "string", enum: ["delete"] }, - id: { type: "string" }, - }, - required: ["action", "id"], - }, - EntityBatchPayload: { - type: "object", - properties: { - changes: { - type: "array", - items: { - oneOf: [ - { $ref: "#/components/schemas/EntityBatchCreateChange" }, - { $ref: "#/components/schemas/EntityBatchUpdateChange" }, - { $ref: "#/components/schemas/EntityBatchDeleteChange" }, - ], - }, - }, - }, - required: ["changes"], - }, - EntityBatchResponse: { - type: "object", - properties: { - success: { type: "boolean" }, - applied: { type: "number" }, - created_entity_ids: { - type: "array", - items: { type: "string" }, - }, - }, - required: ["success", "applied", "created_entity_ids"], + required: ["id", "name", "geometry_count"], }, GeoJSONGeometry: { type: "object", properties: { - type: { - type: "string", - enum: [ - "Point", - "MultiPoint", - "LineString", - "MultiLineString", - "Polygon", - "MultiPolygon", - ], - }, - coordinates: { - type: "array", - items: {}, - }, + type: { type: "string" }, + coordinates: { type: "array", items: {} }, }, required: ["type", "coordinates"], }, @@ -152,31 +55,7 @@ const openApiSpec = { 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"], - }, + properties: { type: "object" }, geometry: { $ref: "#/components/schemas/GeoJSONGeometry" }, }, required: ["type", "properties", "geometry"], @@ -192,143 +71,39 @@ const openApiSpec = { }, 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: { + 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"], + required: ["id", "title", "state"], }, - BatchCreateChange: { + SectionCommit: { 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" }, - }, + section_id: { type: "string" }, + commit_no: { type: "number" }, + kind: { type: "string" }, + snapshot: { type: "object", nullable: true }, }, - required: ["action", "id", "geometry"], + required: ["id", "section_id", "commit_no", "kind"], }, - BatchDeleteChange: { + SectionSubmission: { type: "object", properties: { - action: { type: "string", enum: ["delete"] }, id: { type: "string" }, + section_id: { type: "string" }, + commit_id: { type: "string" }, + status: { type: "string" }, + snapshot: { type: "object", nullable: true }, }, - 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"], - }, - CombinedBatchPayload: { - type: "object", - properties: { - entity_changes: { - type: "array", - items: { - oneOf: [ - { $ref: "#/components/schemas/EntityBatchCreateChange" }, - { $ref: "#/components/schemas/EntityBatchUpdateChange" }, - { $ref: "#/components/schemas/EntityBatchDeleteChange" }, - ], - }, - }, - geometry_changes: { - type: "array", - items: { - oneOf: [ - { $ref: "#/components/schemas/BatchCreateChange" }, - { $ref: "#/components/schemas/BatchUpdateChange" }, - { $ref: "#/components/schemas/BatchDeleteChange" }, - ], - }, - }, - }, - }, - CombinedBatchResponse: { - type: "object", - properties: { - success: { type: "boolean" }, - applied: { type: "number" }, - entity_applied: { type: "number" }, - geometry_applied: { type: "number" }, - created_entity_ids: { - type: "array", - items: { type: "string" }, - }, - }, - required: [ - "success", - "applied", - "entity_applied", - "geometry_applied", - "created_entity_ids", - ], - }, - MetadataResponse: { - type: "object", - additionalProperties: { - oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }], - }, + required: ["id", "section_id", "commit_id", "status"], }, }, }, @@ -337,374 +112,65 @@ const openApiSpec = { get: { tags: ["System"], summary: "Health check", - responses: { - 200: { - description: "Server status text", - content: { - "text/plain": { - schema: { type: "string" }, - }, - }, - }, - }, + 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: "Get vector tiles metadata", - responses: { - 200: { - description: "MBTiles metadata", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/MetadataResponse" }, - }, - }, - }, - }, + summary: "Vector tile metadata", + responses: { 200: { description: "MBTiles metadata" } }, }, }, "/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" } }, - ], + summary: "Vector tile", + parameters: tileParameters(), 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", - }, + 200: { description: "Tile 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" }, - }, - }, - }, - }, + summary: "Raster tile metadata", + responses: { 200: { description: "MBTiles metadata" } }, }, }, "/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" } }, - ], + summary: "Raster tile", + parameters: tileParameters(), 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/batch": { - post: { - tags: ["Entities"], - summary: "Apply batch create/update/delete entities", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/EntityBatchPayload" }, - }, - }, - }, - responses: { - 200: { - description: "Batch applied", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/EntityBatchResponse" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 409: { - description: "Unique conflict or cannot delete due to orphaned geometries", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - }, - }, - }, - "/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" }, - }, - }, - }, + 200: { description: "Tile binary" }, + 400: { description: "Invalid tile coordinates" }, + 404: { description: "Tile not found" }, }, }, }, "/geometries": { get: { tags: ["Geometries"], - summary: "Query geometries by bbox, time, and entity", + summary: "Query published 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" } }, + 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: { @@ -715,198 +181,266 @@ const openApiSpec = { }, }, }, - 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" }, - }, - }, - }, + 400: responseRef("Invalid query"), }, }, }, - "/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" }, - }, - }, - }, + "/entities": { + get: { + tags: ["Entities"], + summary: "List published entities", + parameters: [queryParam("q", "string", false)], 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" }, - }, - }, - }, + 200: arrayResponse("Entity list", "#/components/schemas/Entity"), }, }, - delete: { - tags: ["Geometries"], - summary: "Soft-delete geometry", + }, + "/entities/search": { + get: { + tags: ["Entities"], + summary: "Search published entities by name", parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string" } }, + queryParam("name", "string", true), + queryParam("limit", "integer", false), ], 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" }, - }, - }, - }, + 200: arrayResponse("Matched entities", "#/components/schemas/Entity"), }, }, }, - "/geometries/batch": { - post: { - tags: ["Geometries"], - summary: "Apply batch create/update/delete geometries", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/GeometryBatchPayload" }, - }, - }, - }, + "/entities/{id}": { + get: { + tags: ["Entities"], + summary: "Get published entity by id", + parameters: [pathParam("id")], 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" }, - }, - }, - }, + 200: objectResponse("Entity", "#/components/schemas/Entity"), + 404: responseRef("Entity not found"), }, }, }, - "/geometries/batch/combined": { + "/sections": { + get: { + tags: ["Sections"], + summary: "List sections", + responses: { 200: arrayResponse("Sections", "#/components/schemas/Section") }, + }, post: { - tags: ["Geometries", "Entities"], - summary: "Apply entity batch and geometry batch in one transaction", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/CombinedBatchPayload" }, - }, + 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: { - 200: { - description: "Combined batch applied", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/CombinedBatchResponse" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 409: { - description: "Conflict in entity changes (unique or orphan guard)", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, + 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: { $ref: ref }, + }, + }, + }; +} + +function arrayResponse(description, itemRef) { + return { + description, + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: itemRef }, + }, + }, + }, + }; +} + +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, }; From 7804ef842bfc537cfdafb517ce431b85ad705b28 Mon Sep 17 00:00:00 2001 From: taDuc Date: Sun, 19 Apr 2026 23:43:31 +0700 Subject: [PATCH 4/6] demo 20-4-2026 --- index.js | 13 +++++---- routes/rasterTiles.js | 4 +-- routes/sections.js | 4 +-- routes/tiles.js | 4 +-- swagger.js | 32 +++++++++++++++++---- utils/apiEnvelope.js | 65 +++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 105 insertions(+), 17 deletions(-) create mode 100644 utils/apiEnvelope.js diff --git a/index.js b/index.js index a1a61e8..d655cb1 100644 --- a/index.js +++ b/index.js @@ -8,6 +8,7 @@ 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(); @@ -15,12 +16,12 @@ 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("/sections", sectionRoutes); -app.use("/submissions", (req, res, next) => { +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); }); diff --git a/routes/rasterTiles.js b/routes/rasterTiles.js index 4f8a629..4f0637a 100644 --- a/routes/rasterTiles.js +++ b/routes/rasterTiles.js @@ -33,7 +33,7 @@ router.get("/:z/:x/:y", (req, res) => { const y = Number(req.params.y); if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) { - return res.status(400).send("Invalid tile coordinates"); + return res.status(400).json({ error: "Invalid tile coordinates" }); } const tmsY = (1 << z) - 1 - y; @@ -47,7 +47,7 @@ router.get("/:z/:x/:y", (req, res) => { `).get(z, x, tmsY); if (!tile) { - return res.status(404).send("Tile not found"); + return res.status(404).json({ error: "Tile not found" }); } res.setHeader("Content-Type", contentType); diff --git a/routes/sections.js b/routes/sections.js index 1f3b30e..5486670 100644 --- a/routes/sections.js +++ b/routes/sections.js @@ -289,9 +289,9 @@ router.post("/:sectionId/submit", (req, res) => { throw createHttpError("Section is not editable", 409); } - const commitId = normalizeId(req.body?.commit_id) || state.head_commit_id; + const commitId = state.head_commit_id; if (!commitId) { - throw createHttpError("Section has no commit to submit", 400); + throw createHttpError("Section has no head commit to submit", 400); } const commit = getCommitForSection(section.id, commitId); diff --git a/routes/tiles.js b/routes/tiles.js index 07b3546..d77f5ea 100644 --- a/routes/tiles.js +++ b/routes/tiles.js @@ -47,7 +47,7 @@ router.get("/:z/:x/:y", (req, res) => { const y = Number(req.params.y); if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) { - return res.status(400).send("Invalid tile coordinates"); + return res.status(400).json({ error: "Invalid tile coordinates" }); } // convert XYZ → TMS @@ -64,7 +64,7 @@ router.get("/:z/:x/:y", (req, res) => { const tile = stmt.get(z, x, tmsY); if (!tile) { - return res.status(404).send("Tile not found"); + return res.status(404).json({ error: "Tile not found" }); } res.setHeader("Content-Type", contentType); diff --git a/swagger.js b/swagger.js index 8603e10..c9649d5 100644 --- a/swagger.js +++ b/swagger.js @@ -24,9 +24,15 @@ const openApiSpec = { ErrorResponse: { type: "object", properties: { - error: { type: "string" }, + status: { type: "string", enum: ["error"] }, + data: { nullable: true }, + message: { type: "string" }, + errors: { + type: "array", + items: {}, + }, }, - required: ["error"], + required: ["status", "data", "message", "errors"], }, Entity: { type: "object", @@ -366,7 +372,7 @@ function objectResponse(description, ref) { description, content: { "application/json": { - schema: { $ref: ref }, + schema: successEnvelopeSchema({ $ref: ref }), }, }, }; @@ -377,15 +383,31 @@ function arrayResponse(description, itemRef) { description, content: { "application/json": { - schema: { + 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, diff --git a/utils/apiEnvelope.js b/utils/apiEnvelope.js new file mode 100644 index 0000000..ab3247c --- /dev/null +++ b/utils/apiEnvelope.js @@ -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, +}; From 6105a4c8da3e5dafeb2588f19cd6c07143936b13 Mon Sep 17 00:00:00 2001 From: taDuc Date: Mon, 20 Apr 2026 23:27:38 +0700 Subject: [PATCH 5/6] refactor --- routes/entities.js | 21 +--- routes/geometries.js | 9 +- routes/sections.js | 78 ++++---------- types/contracts.js | 246 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 78 deletions(-) create mode 100644 types/contracts.js diff --git a/routes/entities.js b/routes/entities.js index 1c7cf56..495dda0 100644 --- a/routes/entities.js +++ b/routes/entities.js @@ -1,5 +1,6 @@ const express = require("express"); const db = require("../db/polygons"); +const { normalizeEntityContract } = require("../types/contracts"); const router = express.Router(); @@ -29,7 +30,7 @@ router.get("/", (req, res) => { `; const rows = db.prepare(sql).all(...params); - res.json(rows.map(normalizeEntityRow)); + res.json(rows.map(normalizeEntityContract)); }); router.get("/search", (req, res) => { @@ -55,7 +56,7 @@ router.get("/search", (req, res) => { LIMIT ? `).all(pattern, limit); - res.json(rows.map(normalizeEntityRow)); + res.json(rows.map(normalizeEntityContract)); }); router.get("/:id", (req, res) => { @@ -75,25 +76,11 @@ router.get("/:id", (req, res) => { return res.status(404).json({ error: "Entity not found" }); } - res.json(normalizeEntityRow(row)); + res.json(normalizeEntityContract(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; diff --git a/routes/geometries.js b/routes/geometries.js index 2bbc6b7..2cfe615 100644 --- a/routes/geometries.js +++ b/routes/geometries.js @@ -1,5 +1,6 @@ const express = require("express"); const db = require("../db/polygons"); +const { normalizeFeatureCollectionContract, normalizeFeatureContract } = require("../types/contracts"); const router = express.Router(); @@ -70,13 +71,13 @@ router.get("/", (req, res) => { const geometryIds = rows.map((row) => String(row.id)); const linksByGeometryId = loadGeometryLinksByGeometryId(geometryIds); - res.json({ + res.json(normalizeFeatureCollectionContract({ type: "FeatureCollection", features: rows.map((row) => { const linkedEntities = linksByGeometryId.get(String(row.id)) || []; return buildFeatureFromRow(row, linkedEntities); }), - }); + })); }); module.exports = router; @@ -95,7 +96,7 @@ function buildFeatureFromRow(row, linkedEntities = []) { ? storedGeometryType : normalizeGeometryType(primaryEntity?.entity_type_id); - return { + return normalizeFeatureContract({ type: "Feature", properties: { id: row.id, @@ -112,7 +113,7 @@ function buildFeatureFromRow(row, linkedEntities = []) { entity_type_id: primaryEntity?.entity_type_id || null, }, geometry: JSON.parse(row.draw_geometry), - }; + }); } function normalizeGeometryType(value) { diff --git a/routes/sections.js b/routes/sections.js index 5486670..a1d5da6 100644 --- a/routes/sections.js +++ b/routes/sections.js @@ -2,6 +2,14 @@ const express = require("express"); const crypto = require("crypto"); const db = require("../db/polygons"); const { getBBox } = require("../utils/bbox"); +const { + assertEditorSnapshotContract, + normalizeEditorSnapshotContract, + normalizeSectionCommitContract, + normalizeSectionContract, + normalizeSectionStateContract, + normalizeSectionSubmissionContract, +} = require("../types/contracts"); const router = express.Router(); const LOCK_TTL_MS = 15 * 60 * 1000; @@ -673,6 +681,8 @@ function applyLinkScopeSnapshot(scope, now) { } function validateSnapshot(snapshot) { + assertEditorSnapshotContract(snapshot); + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { throw createHttpError("snapshot must be an object", 400); } @@ -847,71 +857,25 @@ function updateSubmissionReview(submissionId, status, actor, now, reviewNote) { } function normalizeSectionRow(row) { - return { - id: row.id, - title: row.title, - description: row.description, - user_id: row.user_id || null, - created_by: row.created_by, - created_at: row.created_at, - updated_at: row.updated_at, - state: { - status: row.status || "editing", - head_commit_id: row.head_commit_id || null, - version: Number(row.version || 0), - locked_by: row.locked_by || null, - locked_at: row.locked_at || null, - lock_expires_at: row.lock_expires_at || null, - }, - }; + return normalizeSectionContract(row); } function normalizeStateRow(row) { - return { - section_id: row.section_id, - status: row.status, - head_commit_id: row.head_commit_id || null, - version: Number(row.version || 0), - locked_by: row.locked_by || null, - locked_at: row.locked_at || null, - lock_expires_at: row.lock_expires_at || null, - updated_at: row.updated_at, - }; + return normalizeSectionStateContract(row); } function normalizeCommitRow(row, includeSnapshot) { - const out = { - id: row.id, - section_id: row.section_id, - parent_commit_id: row.parent_commit_id || null, - commit_no: Number(row.commit_no), - kind: row.kind, - restored_from_commit_id: row.restored_from_commit_id || null, - created_by: row.created_by, - created_at: row.created_at, - title: row.title, - note: row.note, - snapshot_hash: row.snapshot_hash, - }; - if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json); - return out; + if (includeSnapshot) { + return normalizeSectionCommitContract(row, parseSnapshotJson(row.snapshot_json)); + } + return normalizeSectionCommitContract(row); } function normalizeSubmissionRow(row, includeSnapshot) { - const out = { - id: row.id, - section_id: row.section_id, - commit_id: row.commit_id, - submitted_by: row.submitted_by, - submitted_at: row.submitted_at, - status: row.status, - reviewed_by: row.reviewed_by || null, - reviewed_at: row.reviewed_at || null, - review_note: row.review_note || null, - snapshot_hash: row.snapshot_hash, - }; - if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json); - return out; + if (includeSnapshot) { + return normalizeSectionSubmissionContract(row, parseSnapshotJson(row.snapshot_json)); + } + return normalizeSectionSubmissionContract(row); } function buildEmptySnapshot(section) { @@ -944,7 +908,7 @@ function normalizeSnapshotInput(input) { function parseSnapshotJson(value) { try { - return JSON.parse(value); + return normalizeEditorSnapshotContract(JSON.parse(value)); } catch (_err) { return null; } diff --git a/types/contracts.js b/types/contracts.js new file mode 100644 index 0000000..f64543f --- /dev/null +++ b/types/contracts.js @@ -0,0 +1,246 @@ +"use strict"; + +const SECTION_STATUSES = Object.freeze(["editing", "submitted", "approved", "rejected"]); +const SECTION_COMMIT_KINDS = Object.freeze(["manual", "restore"]); +const SECTION_SUBMISSION_STATUSES = Object.freeze(["pending", "approved", "rejected", "conflicted"]); +const SNAPSHOT_OPERATIONS = Object.freeze(["create", "update", "delete", "reference", "replace"]); + +/** + * @typedef {"editing"|"submitted"|"approved"|"rejected"} SectionStatus + * @typedef {"manual"|"restore"} SectionCommitKind + * @typedef {"pending"|"approved"|"rejected"|"conflicted"} SectionSubmissionStatus + * @typedef {"create"|"update"|"delete"|"reference"|"replace"} SnapshotOperation + */ + +function normalizeNullableString(value) { + if (value === undefined || value === null) return null; + const normalized = String(value); + return normalized.length ? normalized : null; +} + +function normalizeRequiredString(value, fallback = "") { + if (value === undefined || value === null) return fallback; + return String(value); +} + +function normalizeInteger(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; +} + +function normalizeStatus(value, allowed, fallback) { + const normalized = String(value || "").trim().toLowerCase(); + return allowed.includes(normalized) ? normalized : fallback; +} + +function normalizeIdArray(value) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const ids = []; + for (const item of value) { + if (typeof item !== "string" && typeof item !== "number") continue; + const id = String(item).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +function normalizeEntityContract(row) { + return { + id: normalizeRequiredString(row.id), + name: normalizeRequiredString(row.name), + slug: normalizeNullableString(row.slug), + description: normalizeNullableString(row.description), + type_id: normalizeNullableString(row.type_id), + status: row.status === undefined || row.status === null ? null : normalizeInteger(row.status, 1), + created_at: normalizeNullableString(row.created_at), + updated_at: normalizeNullableString(row.updated_at), + geometry_count: normalizeInteger(row.geometry_count, 0), + }; +} + +function normalizeFeaturePropertiesContract(properties) { + return { + id: properties.id, + type: normalizeNullableString(properties.type), + time_start: properties.time_start === undefined || properties.time_start === null + ? null + : normalizeInteger(properties.time_start, 0), + time_end: properties.time_end === undefined || properties.time_end === null + ? null + : normalizeInteger(properties.time_end, 0), + binding: normalizeIdArray(properties.binding), + entity_id: normalizeNullableString(properties.entity_id), + entity_ids: normalizeIdArray(properties.entity_ids), + entity_name: normalizeNullableString(properties.entity_name), + entity_names: Array.isArray(properties.entity_names) + ? properties.entity_names + .filter((name) => typeof name === "string" && name.length > 0) + : [], + entity_type_id: normalizeNullableString(properties.entity_type_id), + }; +} + +function normalizeFeatureContract(feature) { + return { + type: "Feature", + properties: normalizeFeaturePropertiesContract(feature.properties || {}), + geometry: feature.geometry, + }; +} + +function normalizeFeatureCollectionContract(collection) { + return { + type: "FeatureCollection", + features: Array.isArray(collection?.features) + ? collection.features.map(normalizeFeatureContract) + : [], + }; +} + +function normalizeSectionStateContract(row) { + return { + section_id: normalizeRequiredString(row.section_id), + status: normalizeStatus(row.status, SECTION_STATUSES, "editing"), + head_commit_id: normalizeNullableString(row.head_commit_id), + version: normalizeInteger(row.version, 0), + locked_by: normalizeNullableString(row.locked_by), + locked_at: normalizeNullableString(row.locked_at), + lock_expires_at: normalizeNullableString(row.lock_expires_at), + updated_at: normalizeRequiredString(row.updated_at), + }; +} + +function normalizeSectionContract(row) { + return { + id: normalizeRequiredString(row.id), + title: normalizeRequiredString(row.title), + description: normalizeNullableString(row.description), + user_id: normalizeNullableString(row.user_id), + created_by: normalizeNullableString(row.created_by), + created_at: normalizeRequiredString(row.created_at), + updated_at: normalizeRequiredString(row.updated_at), + state: { + status: normalizeStatus(row.status, SECTION_STATUSES, "editing"), + head_commit_id: normalizeNullableString(row.head_commit_id), + version: normalizeInteger(row.version, 0), + locked_by: normalizeNullableString(row.locked_by), + locked_at: normalizeNullableString(row.locked_at), + lock_expires_at: normalizeNullableString(row.lock_expires_at), + }, + }; +} + +function normalizeSectionCommitContract(row, snapshot) { + const out = { + id: normalizeRequiredString(row.id), + section_id: normalizeRequiredString(row.section_id), + parent_commit_id: normalizeNullableString(row.parent_commit_id), + commit_no: normalizeInteger(row.commit_no, 0), + kind: normalizeStatus(row.kind, SECTION_COMMIT_KINDS, "manual"), + restored_from_commit_id: normalizeNullableString(row.restored_from_commit_id), + created_by: normalizeRequiredString(row.created_by), + created_at: normalizeRequiredString(row.created_at), + title: normalizeNullableString(row.title), + note: normalizeNullableString(row.note), + snapshot_hash: normalizeNullableString(row.snapshot_hash), + }; + if (arguments.length >= 2) out.snapshot = normalizeEditorSnapshotContract(snapshot); + return out; +} + +function normalizeSectionSubmissionContract(row, snapshot) { + const out = { + id: normalizeRequiredString(row.id), + section_id: normalizeRequiredString(row.section_id), + commit_id: normalizeRequiredString(row.commit_id), + submitted_by: normalizeRequiredString(row.submitted_by), + submitted_at: normalizeRequiredString(row.submitted_at), + status: normalizeStatus(row.status, SECTION_SUBMISSION_STATUSES, "pending"), + reviewed_by: normalizeNullableString(row.reviewed_by), + reviewed_at: normalizeNullableString(row.reviewed_at), + review_note: normalizeNullableString(row.review_note), + snapshot_hash: normalizeNullableString(row.snapshot_hash), + }; + if (arguments.length >= 2) out.snapshot = normalizeEditorSnapshotContract(snapshot); + return out; +} + +function normalizeSnapshotOperation(value) { + return normalizeStatus(value, SNAPSHOT_OPERATIONS, ""); +} + +function normalizeEditorSnapshotContract(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; + return { + schema_version: normalizeInteger(snapshot.schema_version, 1), + section: { + id: normalizeRequiredString(snapshot.section?.id), + title: normalizeRequiredString(snapshot.section?.title), + }, + editor_feature_collection: snapshot.editor_feature_collection + ? normalizeFeatureCollectionContract(snapshot.editor_feature_collection) + : undefined, + entities: Array.isArray(snapshot.entities) ? snapshot.entities : [], + geometries: Array.isArray(snapshot.geometries) ? snapshot.geometries : [], + link_scopes: Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : [], + }; +} + +function assertEditorSnapshotContract(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throwContractError("snapshot must be an object"); + } + if (!snapshot.section || typeof snapshot.section !== "object" || Array.isArray(snapshot.section)) { + throwContractError("snapshot.section must be an object"); + } + if (!normalizeRequiredString(snapshot.section.id)) { + throwContractError("snapshot.section.id is required"); + } + if (!normalizeRequiredString(snapshot.section.title)) { + throwContractError("snapshot.section.title is required"); + } + + for (const entity of Array.isArray(snapshot.entities) ? snapshot.entities : []) { + const operation = normalizeSnapshotOperation(entity?.operation); + if (!operation) throwContractError("Invalid entity operation"); + if (!normalizeRequiredString(entity?.id)) throwContractError("Entity id is required"); + } + + for (const geometry of Array.isArray(snapshot.geometries) ? snapshot.geometries : []) { + const operation = normalizeSnapshotOperation(geometry?.operation); + if (!operation) throwContractError("Invalid geometry operation"); + if (!normalizeRequiredString(geometry?.id)) throwContractError("Geometry id is required"); + } + + for (const scope of Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []) { + if (!normalizeRequiredString(scope?.geometry_id)) throwContractError("link_scope geometry_id is required"); + if (!Array.isArray(scope?.entity_ids)) throwContractError("link_scope entity_ids must be an array"); + } +} + +function throwContractError(message) { + const err = new Error(message); + err.status = 400; + throw err; +} + +module.exports = { + SECTION_STATUSES, + SECTION_COMMIT_KINDS, + SECTION_SUBMISSION_STATUSES, + SNAPSHOT_OPERATIONS, + assertEditorSnapshotContract, + normalizeEditorSnapshotContract, + normalizeEntityContract, + normalizeFeatureCollectionContract, + normalizeFeatureContract, + normalizeIdArray, + normalizeSectionCommitContract, + normalizeSectionContract, + normalizeSectionStateContract, + normalizeSectionSubmissionContract, + normalizeSnapshotOperation, +}; From f064b099be97d3c3a4cc20587d673235ecb48cc8 Mon Sep 17 00:00:00 2001 From: taDuc Date: Tue, 21 Apr 2026 16:07:17 +0700 Subject: [PATCH 6/6] finish refactor pre merge --- DB.md | 604 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ api.md | 573 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 1177 insertions(+) create mode 100644 DB.md create mode 100644 api.md diff --git a/DB.md b/DB.md new file mode 100644 index 0000000..080968e --- /dev/null +++ b/DB.md @@ -0,0 +1,604 @@ +# Database Design - Ultimate History Map + +Tài liệu này mô tả database hiện tại của project theo code đang chạy trong +`BackEnd/db/polygons.js` và schema thực tế đã kiểm tra bằng `PRAGMA`. + +- Engine: SQLite +- Driver: `better-sqlite3` +- DB file runtime: `BackEnd/data/polygons.db` +- Schema init/migration runtime: `BackEnd/db/polygons.js` +- Domain read API: `BackEnd/routes/entities.js`, `BackEnd/routes/geometries.js` +- Section workflow API: `BackEnd/routes/sections.js` +- Cập nhật: `2026-04-19` + +## 1. Tổng Quan + +Database hiện chia thành 2 lớp dữ liệu: + +1. Published data + - Dữ liệu đã được duyệt. + - Map, search và entity browser đọc trực tiếp từ các bảng này. + - Gồm `entities`, `geometries`, `entity_geometries`. + +2. Section review workflow + - Dữ liệu editor/version control/review. + - Commit và submission lưu full JSON snapshot, không lưu diff. + - Gồm `sections`, `section_states`, `section_commits`, `section_submissions`. + +Quy tắc hiện tại: + +- `entities` và `geometries` chỉ được ghi qua workflow section review. +- API domain hiện chỉ expose read cho entity/geometry. +- Editor tạo snapshot, commit snapshot vào `section_commits`, submit sang `section_submissions`. +- Chỉ khi reviewer approve thì snapshot mới được apply vào published tables. +- Reject không đổi published data. +- Conflict protection hiện dựa trên `base_updated_at`, `base_hash`, `base_links_hash` nếu snapshot có gửi. + +## 2. ERD + +```mermaid +erDiagram + entities { + TEXT id PK + TEXT name + TEXT slug UK + TEXT description + TEXT type_id + INTEGER status + INTEGER is_deleted + TEXT created_at + TEXT updated_at + } + + geometries { + TEXT id PK + TEXT type + INTEGER is_deleted + TEXT draw_geometry + TEXT binding + INTEGER time_start + INTEGER time_end + REAL bbox_min_lng + REAL bbox_min_lat + REAL bbox_max_lng + REAL bbox_max_lat + TEXT created_at + TEXT updated_at + } + + entity_geometries { + TEXT entity_id PK, FK + TEXT geometry_id PK, FK + TEXT created_at + } + + sections { + TEXT id PK + TEXT title + TEXT description + TEXT user_id + TEXT created_by + TEXT created_at + TEXT updated_at + } + + section_states { + TEXT section_id PK, FK + TEXT status + TEXT head_commit_id + INTEGER version + TEXT locked_by + TEXT locked_at + TEXT lock_expires_at + TEXT updated_at + } + + section_commits { + TEXT id PK + TEXT section_id FK + TEXT parent_commit_id FK + INTEGER commit_no + TEXT kind + TEXT restored_from_commit_id FK + TEXT created_by + TEXT created_at + TEXT title + TEXT note + TEXT snapshot_json + TEXT snapshot_hash + } + + section_submissions { + TEXT id PK + TEXT section_id FK + TEXT commit_id FK + TEXT submitted_by + TEXT submitted_at + TEXT status + TEXT reviewed_by + TEXT reviewed_at + TEXT review_note + TEXT snapshot_json + TEXT snapshot_hash + } + + entities ||--o{ entity_geometries : links + geometries ||--o{ entity_geometries : links + sections ||--|| section_states : state + sections ||--o{ section_commits : commits + section_commits ||--o{ section_commits : parent + section_commits ||--o{ section_submissions : submitted + sections ||--o{ section_submissions : submissions +``` + +Ghi chú: + +- `section_states.head_commit_id` là liên kết logic tới `section_commits.id`, nhưng DB hiện không đặt foreign key cho cột này. +- `section_commits.parent_commit_id` và `restored_from_commit_id` có foreign key tới chính `section_commits.id`. +- `section_submissions.commit_id` có foreign key tới `section_commits.id`. + +## 3. Published Data + +### 3.1 `entities` + +Lưu entity đã được duyệt. API đọc từ bảng này: + +- `GET /entities` +- `GET /entities/search` +- `GET /entities/:id` + +| Cột | Kiểu | Ràng buộc/default | Ý nghĩa | +| --- | --- | --- | --- | +| `id` | `TEXT` | `PRIMARY KEY` | ID entity | +| `name` | `TEXT` | `NOT NULL` | Tên entity | +| `slug` | `TEXT` | `UNIQUE`, nullable | Slug tìm kiếm/URL | +| `description` | `TEXT` | nullable | Mô tả | +| `type_id` | `TEXT` | `NOT NULL DEFAULT 'country'` | Semantic type của entity | +| `status` | `INTEGER` | `DEFAULT 1` | Trạng thái nghiệp vụ | +| `is_deleted` | `INTEGER` | `NOT NULL DEFAULT 0` | Soft delete | +| `created_at` | `TEXT` | nullable | ISO datetime | +| `updated_at` | `TEXT` | nullable | ISO datetime | + +Index: + +| Index | Cột | Loại | +| --- | --- | --- | +| `idx_entities_slug` | `slug` | unique | +| `idx_entities_name` | `name` | normal | + +Read behavior: + +- Entity read API luôn lọc `is_deleted = 0`. +- `GET /entities` search optional qua `q`, match `name` hoặc `slug` bằng `LIKE`. +- `GET /entities/search` search qua `name`, có `limit`, max 100. +- Response có `geometry_count` từ `entity_geometries`. + +Apply snapshot behavior: + +- `operation = reference`: không ghi DB. +- `operation = create`: insert entity mới. +- `operation = update` hoặc `replace`: update entity hiện có. +- `operation = delete`: set `is_deleted = 1`, đồng thời xóa link trong `entity_geometries`. +- Nếu snapshot có `base_updated_at`, backend so với `entities.updated_at`. +- Nếu snapshot có `base_hash`, backend tính hash row hiện tại để bắt conflict. + +### 3.2 `geometries` + +Lưu geometry đã được duyệt. API đọc chính: + +- `GET /geometries?minLng=&minLat=&maxLng=&maxLat=&time=&entity_id=` + +| Cột | Kiểu | Ràng buộc/default | Ý nghĩa | +| --- | --- | --- | --- | +| `id` | `TEXT` | `PRIMARY KEY` | ID geometry | +| `type` | `TEXT` | nullable | Semantic type dùng render FE | +| `is_deleted` | `INTEGER` | `NOT NULL DEFAULT 0` | Soft delete | +| `draw_geometry` | `TEXT` | `NOT NULL` | GeoJSON geometry serialize JSON | +| `binding` | `TEXT` | nullable | JSON array geometry id được bind | +| `time_start` | `INTEGER` | nullable | Năm bắt đầu | +| `time_end` | `INTEGER` | nullable | Năm kết thúc | +| `bbox_min_lng` | `REAL` | nullable | BBox min longitude | +| `bbox_min_lat` | `REAL` | nullable | BBox min latitude | +| `bbox_max_lng` | `REAL` | nullable | BBox max longitude | +| `bbox_max_lat` | `REAL` | nullable | BBox max latitude | +| `created_at` | `TEXT` | nullable | ISO datetime | +| `updated_at` | `TEXT` | nullable | ISO datetime | + +Read behavior: + +- BBox query bắt buộc có đủ `minLng`, `minLat`, `maxLng`, `maxLat`. +- Backend lọc `g.is_deleted = 0`. +- Time filter optional: + - Pass nếu `time_start IS NULL OR time_start <= time`. + - Pass nếu `time_end IS NULL OR time_end >= time`. +- Entity filter optional qua `entity_id`, check link active trong `entity_geometries` và `entities`. +- Response là GeoJSON `FeatureCollection`. +- `properties.type` ưu tiên `geometries.type`. +- Nếu `geometries.type` là legacy token `line` hoặc `path`, backend fallback sang `entity.type_id` của primary entity. +- `properties.binding` parse từ JSON string trong `geometries.binding`. + +Apply snapshot behavior: + +- `operation = reference`: không ghi DB. +- `operation = create`: insert geometry mới. +- `operation = update` hoặc `replace`: update geometry hiện có. +- `operation = delete`: set `is_deleted = 1`, xóa link trong `entity_geometries`, và gỡ geometry id đó khỏi `binding` của các geometry khác. +- `draw_geometry` nhận từ `snapshot.draw_geometry` hoặc fallback `snapshot.geometry`. +- Nếu snapshot không gửi `bbox`, backend tự tính bằng `getBBox`. +- `binding` được normalize thành array string, bỏ rỗng, dedupe, không cho chứa chính id của geometry. +- Nếu snapshot có `base_updated_at`, backend so với `geometries.updated_at`. +- Nếu snapshot có `base_hash`, backend tính hash row hiện tại để bắt conflict. + +### 3.3 `entity_geometries` + +Bảng many-to-many giữa entity và geometry. + +| Cột | Kiểu | Ràng buộc/default | Ý nghĩa | +| --- | --- | --- | --- | +| `entity_id` | `TEXT` | `NOT NULL`, `PRIMARY KEY(entity_id, geometry_id)`, `FK -> entities(id) ON DELETE CASCADE` | Entity được gắn | +| `geometry_id` | `TEXT` | `NOT NULL`, `PRIMARY KEY(entity_id, geometry_id)`, `FK -> geometries(id) ON DELETE CASCADE` | Geometry được gắn | +| `created_at` | `TEXT` | nullable | ISO datetime | + +Index: + +| Index | Cột | Loại | +| --- | --- | --- | +| `idx_entity_geometries_geometry_id` | `geometry_id` | normal | +| `idx_entity_geometries_entity_id` | `entity_id` | normal | + +Apply `link_scopes` behavior: + +- Mỗi scope cần `geometry_id`. +- Geometry phải tồn tại và `is_deleted = 0`. +- `entity_ids` không được rỗng. +- Tất cả entity trong `entity_ids` phải tồn tại và `is_deleted = 0`. +- Nếu scope có `base_links_hash`, backend hash links hiện tại để bắt conflict. +- Apply bằng cách xóa toàn bộ links cũ theo `geometry_id`, sau đó insert lại danh sách `entity_ids`. + +## 4. Section Review Workflow + +### 4.1 `sections` + +Đại diện cho một workspace/section editor. + +| Cột | Kiểu | Ràng buộc/default | Ý nghĩa | +| --- | --- | --- | --- | +| `id` | `TEXT` | `PRIMARY KEY` | ID section | +| `title` | `TEXT` | `NOT NULL` | Tên section | +| `description` | `TEXT` | nullable | Mô tả | +| `user_id` | `TEXT` | nullable | Owner/user liên quan | +| `created_by` | `TEXT` | nullable | Actor tạo section | +| `created_at` | `TEXT` | `NOT NULL` | ISO datetime | +| `updated_at` | `TEXT` | `NOT NULL` | ISO datetime | + +Behavior: + +- `POST /sections` tạo section. +- Sau khi tạo section, backend tạo kèm row trong `section_states` với status `editing`. +- `GET /sections` trả section kèm `state`. + +### 4.2 `section_states` + +Lưu trạng thái hiện tại của section. + +| Cột | Kiểu | Ràng buộc/default | Ý nghĩa | +| --- | --- | --- | --- | +| `section_id` | `TEXT` | `PRIMARY KEY`, `FK -> sections(id) ON DELETE CASCADE` | Section | +| `status` | `TEXT` | `NOT NULL DEFAULT 'editing'` | Trạng thái section | +| `head_commit_id` | `TEXT` | nullable | Commit head hiện tại, link logic tới `section_commits.id` | +| `version` | `INTEGER` | `NOT NULL DEFAULT 0` | Optimistic version | +| `locked_by` | `TEXT` | nullable | Actor đang giữ lock | +| `locked_at` | `TEXT` | nullable | ISO datetime lock | +| `lock_expires_at` | `TEXT` | nullable | ISO datetime hết hạn lock | +| `updated_at` | `TEXT` | `NOT NULL` | ISO datetime | + +Index: + +| Index | Cột | Loại | +| --- | --- | --- | +| `idx_section_states_status` | `status`, `updated_at` | normal | + +Status hiện dùng: + +- `editing` +- `submitted` +- `approved` +- `rejected` + +Ý nghĩa `status`: + +| Status | Ý nghĩa | +| --- | --- | +| `editing` | Section đang ở trạng thái chỉnh sửa. Editor có thể mở section, acquire lock, tạo commit mới, restore commit và submit commit hiện tại để review. Đây là trạng thái mặc định khi tạo section mới. | +| `submitted` | Section đã được submit để review từ `head_commit_id` hiện tại. Trong trạng thái này không tạo commit/restore mới cho section cho tới khi submission được xử lý. Submit cũng release lock editor. | +| `approved` | Submission mới nhất đã được reviewer approve và snapshot đã được apply vào published tables (`entities`, `geometries`, `entity_geometries`). Published data đã thay đổi theo snapshot được duyệt. | +| `rejected` | Submission mới nhất bị reviewer reject. Published data không đổi. Section có thể quay lại chỉnh sửa bằng commit/restore mới; commit mới sẽ set status về `editing`. | + +Lock behavior: + +- Lock TTL: 15 phút. +- `GET /sections/:sectionId/editor?user_id=...` sẽ acquire lock nếu gửi actor. +- Lock hợp lệ nếu trống, cùng actor, hoặc đã hết hạn. +- `POST /sections/:sectionId/lock` acquire lock explicit. +- `POST /sections/:sectionId/unlock` release lock nếu không bị user khác giữ. + +State transition: + +| Action | Điều kiện chính | State sau action | +| --- | --- | --- | +| create section | title hợp lệ | `editing` | +| commit | state `editing` hoặc `rejected`, lock hợp lệ | `editing` | +| restore | state `editing` hoặc `rejected`, lock hợp lệ | `editing` | +| submit | state `editing`, có commit | `submitted` | +| approve | submission `pending`, apply snapshot thành công | `approved` | +| reject | submission `pending` | `rejected` | + +### 4.3 `section_commits` + +Lưu version history của section. Mỗi commit là một full snapshot. + +| Cột | Kiểu | Ràng buộc/default | Ý nghĩa | +| --- | --- | --- | --- | +| `id` | `TEXT` | `PRIMARY KEY` | ID commit | +| `section_id` | `TEXT` | `NOT NULL`, `FK -> sections(id) ON DELETE CASCADE` | Section | +| `parent_commit_id` | `TEXT` | nullable, `FK -> section_commits(id)` | Commit head trước đó | +| `commit_no` | `INTEGER` | `NOT NULL` | Số thứ tự commit trong section | +| `kind` | `TEXT` | `NOT NULL DEFAULT 'manual'` | `manual` hoặc `restore` | +| `restored_from_commit_id` | `TEXT` | nullable, `FK -> section_commits(id)` | Commit nguồn khi restore | +| `created_by` | `TEXT` | `NOT NULL` | Actor tạo commit | +| `created_at` | `TEXT` | `NOT NULL` | ISO datetime | +| `title` | `TEXT` | nullable | Title commit | +| `note` | `TEXT` | nullable | Ghi chú | +| `snapshot_json` | `TEXT` | `NOT NULL` | Full snapshot JSON string | +| `snapshot_hash` | `TEXT` | nullable | `sha256:` | + +Index: + +| Index | Cột | Loại | +| --- | --- | --- | +| `idx_section_commits_no` | `section_id`, `commit_no` | unique | +| `idx_section_commits_section_time` | `section_id`, `created_at` | normal | + +Behavior: + +- `POST /sections/:sectionId/commits` tạo commit `manual`. +- `POST /sections/:sectionId/restore` tạo commit `restore`, copy snapshot từ commit nguồn. +- Commit không apply vào published data. +- `commit_no` tăng theo từng section. +- Commit mới set `section_states.head_commit_id`, tăng `section_states.version`, set status về `editing`. +- `expected_version` và `expected_head_commit_id` là optional optimistic checks. + +### 4.4 `section_submissions` + +Lưu submission được gửi đi review. Submission copy snapshot từ commit tại thời điểm submit. + +| Cột | Kiểu | Ràng buộc/default | Ý nghĩa | +| --- | --- | --- | --- | +| `id` | `TEXT` | `PRIMARY KEY` | ID submission | +| `section_id` | `TEXT` | `NOT NULL`, `FK -> sections(id) ON DELETE CASCADE` | Section | +| `commit_id` | `TEXT` | `NOT NULL`, `FK -> section_commits(id)` | Commit được submit | +| `submitted_by` | `TEXT` | `NOT NULL` | Actor submit | +| `submitted_at` | `TEXT` | `NOT NULL` | ISO datetime | +| `status` | `TEXT` | `NOT NULL DEFAULT 'pending'` | Trạng thái review | +| `reviewed_by` | `TEXT` | nullable | Actor review | +| `reviewed_at` | `TEXT` | nullable | ISO datetime review | +| `review_note` | `TEXT` | nullable | Ghi chú review hoặc conflict message | +| `snapshot_json` | `TEXT` | `NOT NULL` | Snapshot copy từ commit | +| `snapshot_hash` | `TEXT` | nullable | `sha256:` | + +Index: + +| Index | Cột | Loại | +| --- | --- | --- | +| `idx_section_submissions_section_status` | `section_id`, `status`, `submitted_at` | normal | + +Status hiện dùng: + +- `pending` +- `approved` +- `rejected` +- `conflicted` + +Behavior: + +- `POST /sections/:sectionId/submit` tạo submission `pending`. +- Submit yêu cầu section đang `editing` và có commit. +- Submit set section state sang `submitted` và release lock. +- `GET /sections/:sectionId/submissions` trả submission history của section. +- `POST /submissions/:submissionId/approve` chỉ nhận submission `pending`. +- `POST /submissions/:submissionId/reject` chỉ nhận submission `pending`. +- Approve verify `snapshot_hash`, parse snapshot, apply vào published data trong transaction. +- Reject chỉ cập nhật submission và section state. +- Nếu approve gặp conflict trong quá trình apply, backend set submission thành `conflicted`. + +## 5. Snapshot Format + +Backend lưu snapshot trong `section_commits.snapshot_json` và +`section_submissions.snapshot_json`. + +FE hiện gửi thêm `editor_feature_collection` để editor/reviewer render lại draft đúng trạng thái. +Backend không apply trực tiếp trường này vào published tables, nhưng reviewer UI cần nó để preview. + +Shape đang dùng: + +```json +{ + "schema_version": 1, + "section": { + "id": "section-id", + "title": "Section title" + }, + "editor_feature_collection": { + "type": "FeatureCollection", + "features": [] + }, + "entities": [ + { + "operation": "create", + "id": "entity-id", + "name": "Entity name", + "slug": "entity-slug", + "description": null, + "type_id": "country", + "status": 1, + "is_deleted": 0, + "base_updated_at": "optional ISO datetime", + "base_hash": "optional sha256 hash" + } + ], + "geometries": [ + { + "operation": "create", + "id": "geometry-id", + "type": "country", + "draw_geometry": { + "type": "Polygon", + "coordinates": [] + }, + "binding": ["other-geometry-id"], + "time_start": null, + "time_end": null, + "bbox": { + "min_lng": 0, + "min_lat": 0, + "max_lng": 1, + "max_lat": 1 + }, + "is_deleted": 0, + "base_updated_at": "optional ISO datetime", + "base_hash": "optional sha256 hash" + } + ], + "link_scopes": [ + { + "operation": "replace", + "geometry_id": "geometry-id", + "entity_ids": ["entity-id"], + "base_links_hash": "optional sha256 hash" + } + ] +} +``` + +Operation hợp lệ: + +- `create` +- `update` +- `delete` +- `reference` +- `replace`, backend normalize thành `update` trong entity/geometry apply, còn link scope dùng như replace semantics. + +Validation hiện tại: + +- Snapshot phải là object. +- Entity operation phải hợp lệ. +- Entity `create/update/replace` cần `name`. +- Slug trong cùng snapshot không được trùng theo lowercase. +- Geometry operation phải hợp lệ. +- Geometry `delete/reference` không cần `draw_geometry`. +- Geometry `create/update/replace` cần `draw_geometry` hoặc `geometry`. +- `time_start <= time_end` nếu cả hai cùng có giá trị. +- `bbox` nếu có thì phải gồm number hợp lệ và min <= max. +- `binding` nếu có phải là mảng id, không được chứa chính geometry id. +- Mỗi `link_scope` cần `entity_ids` không rỗng. + +## 6. Luồng Dữ Liệu Chính + +### 6.1 Đọc map + +1. FE gọi `GET /geometries` với bbox bắt buộc. +2. Backend query `geometries` active theo bbox, optional time và optional entity. +3. Backend load links từ `entity_geometries` và `entities`. +4. Backend trả GeoJSON `FeatureCollection`. + +### 6.2 Tạo hoặc sửa dữ liệu trong editor + +1. FE mở section bằng `GET /sections/:sectionId/editor`. +2. Backend trả section, state, head commit và snapshot. +3. FE chỉnh draft local. +4. FE build full snapshot. +5. FE gọi `POST /sections/:sectionId/commits`. +6. Backend validate snapshot và insert `section_commits`. +7. Published data chưa thay đổi. + +### 6.3 Submit section + +1. FE gọi `POST /sections/:sectionId/submit`. +2. Backend lấy commit id từ body hoặc `section_states.head_commit_id`. +3. Backend copy snapshot từ commit sang `section_submissions`. +4. Backend set section state thành `submitted`. +5. Backend release lock. + +### 6.4 Duyệt section + +1. Reviewer UI `/submited` load `GET /sections`, rồi load submissions theo từng section. +2. Reviewer xem snapshot preview từ `editor_feature_collection`. +3. Reviewer approve hoặc reject. +4. Approve apply snapshot vào `entities`, `geometries`, `entity_geometries`. +5. Reject không apply published data. + +## 7. Runtime Migration + +`BackEnd/db/polygons.js` vừa init schema mới, vừa tự xử lý một số database cũ. + +Runtime migration hiện có: + +- `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")` +- Drop/rebuild `entities` nếu còn cột deprecated `kind`. +- Drop/rebuild `geometries` nếu còn cột deprecated `kind` hoặc `line_mode`. +- Migrate legacy `geometries.type` nếu rỗng hoặc là `line/path`, bằng cách lấy `type_id` từ active entity đầu tiên đang link. + +Lưu ý: + +- Project chưa có bảng migrations riêng. +- Migration chạy khi backend require `BackEnd/db/polygons.js`. +- Rebuild table tạm tắt foreign keys rồi bật lại sau transaction. + +## 8. Index Tổng Hợp + +| Table | Index | Columns | Unique | +| --- | --- | --- | --- | +| `entities` | `idx_entities_slug` | `slug` | yes | +| `entities` | `idx_entities_name` | `name` | no | +| `entity_geometries` | `idx_entity_geometries_geometry_id` | `geometry_id` | no | +| `entity_geometries` | `idx_entity_geometries_entity_id` | `entity_id` | no | +| `section_states` | `idx_section_states_status` | `status`, `updated_at` | no | +| `section_commits` | `idx_section_commits_no` | `section_id`, `commit_no` | yes | +| `section_commits` | `idx_section_commits_section_time` | `section_id`, `created_at` | no | +| `section_submissions` | `idx_section_submissions_section_status` | `section_id`, `status`, `submitted_at` | no | + +## 9. Không Có Trong DB Hiện Tại + +Các phần sau chưa tồn tại trong schema hiện tại: + +- `users` table. +- Auth/JWT/session table. +- Role/permission table. +- Migration version table. +- Audit log riêng ngoài `section_commits` và `section_submissions`. +- Direct draft table. Draft editor nằm trong snapshot JSON. + +## 10. Checklist Khi Sửa Schema + +Khi thêm hoặc đổi field published: + +1. Update table init trong `BackEnd/db/polygons.js`. +2. Thêm migration runtime nếu DB cũ cần nâng cấp. +3. Update apply logic trong `BackEnd/routes/sections.js`. +4. Update hash conflict logic nếu field ảnh hưởng conflict. +5. Update read mapper trong `BackEnd/routes/entities.js` hoặc `BackEnd/routes/geometries.js`. +6. Update FE snapshot builder nếu field đến từ editor. +7. Update reviewer preview nếu field cần hiển thị ở `/submited`. +8. Update `DB.md`, `api.md`, `api_use.md` nếu API/schema thay đổi. + +Khi đổi workflow section: + +1. Update `section_states` status transition. +2. Update create/commit/restore/submit/review routes. +3. Update FE editor và reviewer UI. +4. Giữ invariant: commit không apply published data, approve mới apply. diff --git a/api.md b/api.md new file mode 100644 index 0000000..ddebc6f --- /dev/null +++ b/api.md @@ -0,0 +1,573 @@ +# API - Ultimate History Map (Backend) + +Tài liệu này mô tả backend API theo đúng hành vi hiện tại của code trong `BackEnd/index.js` và `BackEnd/routes/*`. + +- Backend: Express +- Base URL local (hardcode): `http://localhost:3000` +- DB nghiệp vụ: SQLite `BackEnd/data/polygons.db` +- Tile data: MBTiles `BackEnd/data/map.mbtiles` (vector) và `BackEnd/data/raster.mbtiles` (raster) +- Auth/permission: chưa có +- CORS: mở toàn bộ (`cors()`) +- Update: 2026-04-21 + +## 1. Envelope Response (Rất Quan Trọng) + +Mọi endpoint JSON được mount qua `envelopeResponses` sẽ trả về envelope: + +```json +{ + "status": "success", + "data": {}, + "message": "OK", + "errors": [] +} +``` + +Khi lỗi (`HTTP >= 400`), envelope: + +```json +{ + "status": "error", + "data": null, + "message": "Some message", + "errors": ["Some message"] +} +``` + +Quy tắc bọc envelope hiện tại: + +- Nếu handler `res.json({ error: "..." })` thì `message/errors` sẽ lấy từ `error`. +- Nếu handler `res.json({ message: "..." })` thì `message/errors` sẽ lấy từ `message`. +- Nếu handler trả `res.json([...])` hoặc `res.json({ ...domain... })` thì payload nằm trong `data`. + +Ngoại lệ KHÔNG envelope: + +- `GET /` trả plain text. +- `GET /docs` trả Swagger UI (HTML). +- `GET /docs.json` trả raw OpenAPI JSON (không bọc envelope). +- Tile binary `GET /tiles/:z/:x/:y`, `GET /raster-tiles/:z/:x/:y` trả binary khi success (vì dùng `res.send`). Các lỗi tile (400/404) vẫn là JSON nên vẫn bị bọc envelope. + +## 2. Actor (user_id) Và “anonymous” + +Backend có cơ chế normalize actor: + +- Đọc actor từ body/query/header tùy endpoint (`user_id`, `user`, `created_by`, `submitted_by`, `reviewed_by`, header `x-user-id`). +- Hiện tại `normalizeActor()` luôn fallback sang chuỗi `"anonymous"` nếu thiếu. + +Hệ quả: + +- Thực tế các field actor **không bắt buộc** (dù code có vài chỗ check `"… is required"` nhưng check đó gần như không bao giờ nổ do luôn fallback `"anonymous"`). +- Lock/ownership khi thiếu actor sẽ bị “dính” vào cùng một user `"anonymous"` (dễ gây va chạm). + +Khuyến nghị cho FE/clients: luôn gửi một actor id ổn định (ví dụ user id) để lock và audit có ý nghĩa. + +## 3. System API + +### `GET /` + +Health check. + +- `200` (text): `GIS server running` + +### `GET /docs` + +Swagger UI. + +### `GET /docs.json` + +OpenAPI JSON (raw, không envelope). + +## 4. Tile API (MBTiles) + +### `GET /tiles/metadata/info` + +Vector MBTiles metadata từ `map.mbtiles` (table `metadata`). + +- `200` JSON envelope, trong `data` là object key/value metadata. + +### `GET /tiles/:z/:x/:y` + +Vector tile binary (XYZ -> TMS). + +Luồng xử lý: + +1. Parse `z/x/y` thành integer; sai -> `400`. +2. Convert `y` từ XYZ sang TMS: `tmsY = (1 << z) - 1 - y`. +3. Query `tiles` table theo `(zoom_level, tile_column, tile_row)`. +4. Không có row -> `404`. +5. Set header: + - `Content-Type` dựa trên `metadata.format` (`pbf` -> `application/x-protobuf`). + - Nếu format `pbf` thì set `Content-Encoding: gzip` (tile data trong MBTiles thường đã gzip sẵn). +6. `res.send(tile_data)` (binary). + +### `GET /raster-tiles/metadata/info` + +Raster MBTiles metadata từ `raster.mbtiles`. + +- `200` JSON envelope, `data` là metadata object. + +### `GET /raster-tiles/:z/:x/:y` + +Raster tile binary (XYZ -> TMS). + +Luồng xử lý tương tự `/tiles/:z/:x/:y`, khác ở `Content-Type`: + +- `png` -> `image/png` +- `jpg|jpeg` -> `image/jpeg` +- `webp` -> `image/webp` +- khác -> `application/octet-stream` + +## 5. Published Entity API (Read-only) + +Published entities lấy từ table `entities` với filter `is_deleted = 0`. + +### Entity Object (trong envelope `data`) + +```json +{ + "id": "entity-id", + "name": "Vietnam", + "slug": "vietnam", + "description": null, + "type_id": "country", + "status": 1, + "created_at": "2026-04-17T10:00:00.000Z", + "updated_at": "2026-04-17T10:00:00.000Z", + "geometry_count": 3 +} +``` + +### `GET /entities?q=...` + +List entities, optional search theo `name` hoặc `slug` bằng `LIKE`. + +Luồng xử lý: + +1. `q` được trim; nếu rỗng thì list toàn bộ. +2. Query `entities` + LEFT JOIN `entity_geometries` để tính `geometry_count`. +3. Sort theo `name COLLATE NOCASE ASC`. + +### `GET /entities/search?name=...&limit=...` + +Search entity theo `name` bằng `LIKE`. + +Luồng xử lý: + +1. `name` rỗng -> trả `[]`. +2. `limit` default `25`, max `100`. +3. Query `entities` + LEFT JOIN `entity_geometries` để tính `geometry_count`. + +### `GET /entities/:id` + +Luồng xử lý: + +1. Query entity theo `id` và `is_deleted = 0`. +2. Không tồn tại -> `404` (`error: "Entity not found"`). + +## 6. Published Geometry API (Read-only) + +Published geometries lấy từ table `geometries` với filter `is_deleted = 0`. + +### `GET /geometries` + +Query geometries theo bbox/time/entity. + +Query params: + +| Field | Bắt buộc | Type | Ghi chú | +| --- | ---: | --- | --- | +| `minLng` | Có | number | bbox min longitude | +| `minLat` | Có | number | bbox min latitude | +| `maxLng` | Có | number | bbox max longitude | +| `maxLat` | Có | number | bbox max latitude | +| `time` | Không | number | parse int; filter temporal range | +| `entity_id` | Không | string | lọc geometry có link entity này | + +Luồng xử lý: + +1. Validate bbox là số hữu hạn; fail -> `400` (`Missing/invalid bbox`). +2. Nếu có `time` (không rỗng) thì parse số; fail -> `400` (`Invalid time`). +3. Query geometries theo: + - bbox intersects + - nếu có `time` thì pass khi: + - `time_start` null hoặc `time_start <= time` + - `time_end` null hoặc `time_end >= time` + - nếu có `entity_id` thì dùng `EXISTS` trên `entity_geometries` + `entities.is_deleted = 0` +4. Load links entity cho các geometry id trả về, giữ thứ tự theo `entity_geometries.rowid ASC`. +5. Build GeoJSON FeatureCollection: + - `feature.geometry` parse từ `geometries.draw_geometry` (JSON string). + - `properties.binding` được parse/normalize từ `geometries.binding` (JSON string hoặc fallback). + - `properties.entity_ids/entity_names/...` dựa trên link table. + - `properties.type`: + - ưu tiên `geometries.type` nếu khác null và không phải legacy `line|path` + - nếu legacy `line|path` thì fallback sang `entities.type_id` của primary entity. + +### Geometry Feature (trong envelope `data`) + +```json +{ + "type": "Feature", + "properties": { + "id": "geometry-id", + "type": "country", + "time_start": 1802, + "time_end": 1884, + "binding": [], + "entity_id": "entity-id", + "entity_ids": ["entity-id"], + "entity_name": "Vietnam", + "entity_names": ["Vietnam"], + "entity_type_id": "country" + }, + "geometry": { "type": "Polygon", "coordinates": [] } +} +``` + +## 7. Section Workflow API (Write path chính) + +Không có API mutate trực tiếp published `entities/geometries`. Mọi thay đổi đi qua section workflow: + +1. Editor mở section (`GET /sections/:id/editor`) để lấy snapshot và lock. +2. Editor commit snapshot (`POST /sections/:id/commits` hoặc `/restore`). +3. Editor submit head (`POST /sections/:id/submit`) -> tạo submission pending. +4. Reviewer approve/reject (`POST /submissions/:id/...`). +5. Chỉ approve mới apply snapshot vào published tables. + +### 7.1. Data Model (khái niệm) + +- `sections`: metadata của draft space. +- `section_states`: trạng thái và lock, version, head commit. +- `section_commits`: lịch sử snapshot (không apply). +- `section_submissions`: snapshot pending/review. + +Status: + +- `section_states.status`: `editing | submitted | approved | rejected` +- `section_submissions.status`: `pending | approved | rejected | conflicted` + +Lock: + +- TTL 15 phút (`LOCK_TTL_MS = 15 * 60 * 1000`). +- Lock được check theo `locked_by` và `lock_expires_at`. + +Optimistic concurrency: + +- Client có thể gửi `expected_version` và/hoặc `expected_head_commit_id` khi commit/restore. +- Sai -> `409` (`Section version changed` / `Section head commit changed`). + +### 7.2. `GET /sections` + +List sections + state (LEFT JOIN). Sort `sections.updated_at DESC`. + +### 7.3. `POST /sections` + +Tạo section và tạo luôn state (status `editing`, version `0`). + +Luồng xử lý: + +1. Validate `title` là string non-empty (trim). Sai -> `400` (`title is required`). +2. `id` nếu thiếu thì server tự sinh UUID. +3. Insert vào `sections`. +4. `ensureSectionState(section_id)` (insert `section_states` nếu chưa có). +5. SQLite constraint (trùng id) -> `409` (`Section id already exists`). + +### 7.4. `GET /sections/:sectionId/editor` + +Mở editor và (có side-effect) acquire lock. + +Actor lấy từ query/header: + +- `?user_id=...` hoặc `?user=...` +- header `x-user-id` + +Luồng xử lý (transaction): + +1. Load section; không có -> `404` (`Section not found`). +2. `ensureSectionState`. +3. `assertLockAvailable(state, actor, now)`: + - OK nếu chưa lock, hoặc lock bởi chính actor, hoặc lock đã expire. + - Fail -> `409` (`Section is locked by another user`). +4. Acquire/refresh lock cho actor (set `locked_by/locked_at/lock_expires_at`). +5. Nếu có `head_commit_id` thì load commit đó. +6. Response: + - `section`: object normalized + - `state`: object normalized + - `commit`: normalized commit (không include snapshot) + - `snapshot`: + - nếu có head commit: parse `snapshot_json` + - nếu chưa có commit: trả empty snapshot `{ schema_version, section, entities:[], geometries:[], link_scopes:[] }` + +Ghi chú: vì actor fallback `"anonymous"`, gọi endpoint này mà không gửi user id vẫn tạo lock (locked_by = `"anonymous"`). + +### 7.5. `POST /sections/:sectionId/lock` + +Acquire/refresh lock (tách riêng, không trả snapshot). + +Luồng xử lý: + +1. Load section; không có -> `404`. +2. `ensureSectionState`. +3. `assertLockAvailable`. +4. Acquire lock và trả `state`. + +### 7.6. `POST /sections/:sectionId/unlock` + +Release lock. + +Luồng xử lý: + +1. Load section; không có -> `404`. +2. `ensureSectionState`. +3. Nếu `state.locked_by` tồn tại và khác actor hiện tại -> `409` (`Section is locked by another user`). +4. Clear lock fields. + +### 7.7. `GET /sections/:sectionId/commits?include_snapshot=1` + +List commit history (sort `commit_no DESC`). + +- `include_snapshot=1|true` -> mỗi commit có thêm `snapshot` (parsed JSON). + +### 7.8. `POST /sections/:sectionId/commits` + +Tạo commit mới từ snapshot editor. + +Body: + +- `snapshot` (object) hoặc `snapshot_json` (string JSON) là bắt buộc. +- Actor thường gửi qua `created_by` hoặc `user_id` hoặc header `x-user-id`. +- Optional: + - `expected_version` + - `expected_head_commit_id` + - `title`, `note` + +Luồng xử lý (transaction): + +1. Load section; không có -> `404`. +2. Normalize snapshot input: + - `snapshot_json` không parse được -> `400` (`snapshot_json must be valid JSON`) + - thiếu snapshot -> `400` (`snapshot is required`) +3. `ensureSectionState`. +4. `assertCanEdit`: + - status phải là `editing` hoặc `rejected`, không thì `409` (`Section is not editable`) + - lock phải available, không thì `409` (`Section is locked by another user`) +5. `assertExpectedState` nếu client có gửi expected fields. +6. `validateSnapshot`: + - contract level (bắt buộc có `snapshot.section.id/title`, entity/geometry ids, link_scope shape) + - rule bổ sung: name required (trừ delete/reference), slug không trùng (case-insensitive), time range hợp lệ, bbox nếu có phải hợp lệ, binding không chứa self id, link_scope entity_ids không rỗng. +7. Insert `section_commits`: + - `parent_commit_id = state.head_commit_id` + - `commit_no` auto tăng + - `snapshot_hash = sha256(JSON)` +8. Update `section_states`: + - `status = 'editing'` + - `head_commit_id = newCommitId` + - `version = version + 1` +9. Update `sections.updated_at`. + +Response `201`: `{ commit, state }` (commit có `snapshot`). + +### 7.9. `POST /sections/:sectionId/restore` + +Restore một commit cũ bằng cách tạo commit mới `kind = "restore"`. + +Luồng xử lý giống `/commits` nhưng snapshot lấy từ commit nguồn: + +1. Validate `commit_id`/`restore_commit_id`; thiếu -> `400` (`commit_id is required`). +2. Check commit nguồn thuộc section; không có -> `404` (`Commit not found`). +3. Insert commit mới với `restored_from_commit_id = source.id`. +4. Update head commit + bump version. + +### 7.10. `POST /sections/:sectionId/submit` + +Submit head commit hiện tại -> tạo submission pending, set state `submitted`, release lock. + +Luồng xử lý (transaction): + +1. Load section; không có -> `404`. +2. `ensureSectionState`. +3. `assertLockAvailable` (chỉ check không bị user khác lock). +4. status phải là `editing`, không thì `409` (`Section is not editable`). +5. `head_commit_id` phải có, không thì `400` (`Section has no head commit to submit`). +6. Load commit head; không có -> `404` (`Commit not found`). +7. Insert `section_submissions` (status `pending`) copy snapshot từ commit head. +8. Update `section_states`: + - `status = 'submitted'` + - clear lock fields. + +Response `201`: `SectionSubmission` (có `snapshot`). + +### 7.11. `GET /sections/:sectionId/submissions?include_snapshot=1` + +List submission history của section (sort `submitted_at DESC`). + +## 8. Review API (`/submissions`) + +Review endpoints được mount “public” qua `/submissions` nhưng implementation nằm trong `BackEnd/routes/sections.js`: + +- `POST /submissions/:submissionId/approve` +- `POST /submissions/:submissionId/reject` + +### 8.1. `POST /submissions/:submissionId/reject` + +Luồng xử lý (transaction): + +1. Load submission; không có -> `404` (`Submission not found`). +2. Submission phải `pending`, không thì `409` (`Submission is not pending`). +3. Update submission: + - `status = rejected` + - set `reviewed_by/reviewed_at/review_note` +4. Update `section_states.status = rejected`. +5. Không apply published tables. + +Response `200`: `SectionSubmission` (có `snapshot`). + +### 8.2. `POST /submissions/:submissionId/approve` + +Luồng xử lý (transaction): + +1. Load submission; không có -> `404`. +2. Submission phải `pending`, không thì `409`. +3. Verify snapshot hash: + - nếu `submission.snapshot_hash` tồn tại và khác `sha256(submission.snapshot_json)` -> `409` (`Submission snapshot hash mismatch`). +4. Parse snapshot JSON. +5. `applySnapshotToPublished(snapshot, now)` theo thứ tự: + - apply `entities` + - apply `geometries` + - apply `link_scopes` (replace links) +6. Update submission: + - `status = approved` + - set `reviewed_by/reviewed_at/review_note` +7. Update `section_states.status = approved`. + +Conflict handling đặc biệt: + +- Nếu trong lúc apply snapshot phát hiện conflict (409) thì backend: + - (best-effort) update `section_submissions.status = conflicted` và set `review_note = ` nếu submission vẫn còn `pending`. + - trả `409` cho client. + +## 9. Snapshot Format Và Apply Semantics + +Commit/submission snapshot là full JSON object. Backend quan tâm các mảng: + +- `entities[]` +- `geometries[]` +- `link_scopes[]` + +Ngoài ra FE có thể kèm `editor_feature_collection` để preview (backend chỉ normalize, không apply vào DB). + +### 9.1. Snapshot Shape (rút gọn) + +```json +{ + "schema_version": 1, + "section": { "id": "section-id", "title": "Section title" }, + "editor_feature_collection": { "type": "FeatureCollection", "features": [] }, + "entities": [], + "geometries": [], + "link_scopes": [] +} +``` + +Operations (case-insensitive): + +- `create | update | delete | reference | replace` +- `replace` được normalize thành `update` cho entity/geometry. + +### 9.2. Validate Snapshot (400) + +Backend validate ở `/commits` và trước khi approve: + +- `snapshot.section.id` và `snapshot.section.title` bắt buộc. +- Mỗi entity/geometry trong snapshot bắt buộc có `id` và `operation` hợp lệ. +- Entity: + - `name` bắt buộc nếu không phải `delete|reference` + - `slug` (nếu có) không được trùng trong cùng snapshot (case-insensitive) +- Geometry: + - nếu `create|update|replace` thì bắt buộc có `draw_geometry` hoặc `geometry` + - nếu có cả `time_start/time_end` thì `time_start <= time_end` + - `bbox` nếu gửi thì phải hợp lệ; nếu không gửi, approve sẽ tự tính bbox từ geometry + - `binding` không được chứa chính `geometry.id` +- Link scope: + - `geometry_id` bắt buộc, `entity_ids` phải là array + - `entity_ids` sau normalize không được rỗng + +### 9.3. Apply Snapshot (Approve) + +#### Entities + +- `create`: INSERT `entities` (type_id default `"country"` nếu thiếu), `created_at/updated_at = now`. +- `update|replace`: UPDATE toàn bộ fields; có thể check conflict bằng `base_updated_at` và/hoặc `base_hash` (nếu snapshot item gửi). +- `delete`: set `entities.is_deleted = 1`, xóa mọi link trong `entity_geometries`. +- `reference`: bỏ qua. + +#### Geometries + +- `create`: INSERT `geometries`: + - `draw_geometry` được stringify JSON + - `bbox`: nếu snapshot có `bbox` hợp lệ thì dùng, nếu không thì compute bằng `getBBox(draw_geometry)` + - `binding`: normalize array id và tự loại self id +- `update|replace`: UPDATE toàn bộ fields tương tự create (có check conflict bằng base fields). +- `delete`: + - set `geometries.is_deleted = 1` + - delete links trong `entity_geometries` theo `geometry_id` + - remove mọi reference tới geometry đó trong `binding` của các geometry khác. +- `reference`: bỏ qua. + +#### Link scopes (replace entity links theo geometry) + +- Geometry phải tồn tại và `is_deleted = 0`, nếu không -> conflict (409). +- `entity_ids` không rỗng, và mọi entity id phải tồn tại + `is_deleted = 0`, nếu không -> `400`. +- Nếu snapshot gửi `base_links_hash`: + - backend hash current links (sorted by entity_id) và so sánh; mismatch -> conflict (409). +- Apply bằng cách: + - `DELETE FROM entity_geometries WHERE geometry_id = ?` + - rồi `INSERT` lại toàn bộ link theo thứ tự `entity_ids` gửi lên. + +### 9.4. Conflict Semantics (409) + +Nhóm lỗi conflict (status 409, `err.isConflict = true`) xảy ra khi approve apply: + +- Update/delete entity/geometry nhưng record không tồn tại. +- `base_updated_at` mismatch hoặc `base_hash` mismatch. +- Link scope geometry không tồn tại/đã deleted. +- `base_links_hash` mismatch. + +## 10. Status Code Summary + +| Status | Khi nào | +| --- | --- | +| `200` | OK (read/review) | +| `201` | Created (section/commit/restore/submit) | +| `400` | Query/body/snapshot không hợp lệ | +| `404` | Resource không tồn tại | +| `409` | Lock conflict, stale version/head, state không editable, conflict khi apply, hoặc DB constraint | +| `500` | Lỗi server | + +## 11. Luồng Tham Chiếu (End-to-End) + +### 11.1. Editor Flow + +1. List section: `GET /sections`. +2. Open editor (và lock): `GET /sections/:sectionId/editor?user_id=`. +3. User edit local -> tạo snapshot. +4. Commit: `POST /sections/:sectionId/commits` (khuyến nghị gửi `expected_version` + `expected_head_commit_id` từ state). +5. (Optional) Restore: `POST /sections/:sectionId/restore`. +6. Submit: `POST /sections/:sectionId/submit`. + +### 11.2. Reviewer Flow + +1. List sections: `GET /sections`. +2. Với từng section: `GET /sections/:sectionId/submissions?include_snapshot=1`. +3. Approve/reject: + - `POST /submissions/:submissionId/approve` + - `POST /submissions/:submissionId/reject` + +## 12. Giới Hạn / “Gotchas” Hiện Tại + +- Không có auth/permission. +- Actor fallback `"anonymous"` làm “required actor” không thực sự enforced; lock và audit dễ sai nếu client không gửi user id. +- `GET /sections/:id/editor` luôn acquire lock (side-effect). Nếu bạn chỉ muốn đọc snapshot mà không lock, hiện chưa có endpoint riêng. +- Không có pagination cho list sections/commits/submissions. +- Reviewer UI nếu cần list submissions toàn cục: hiện chưa có endpoint `GET /submissions` (phải gom theo section). +- Conflict protection (`base_hash/base_updated_at/base_links_hash`) chỉ hiệu quả nếu FE gửi các field này. +