reduce api | version control
This commit is contained in:
@@ -48,11 +48,76 @@ db.prepare(`
|
|||||||
)
|
)
|
||||||
`).run();
|
`).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", "status", "INTEGER DEFAULT 1");
|
||||||
ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
||||||
ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'");
|
ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'");
|
||||||
ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0");
|
||||||
ensureColumn("geometries", "binding", "TEXT");
|
ensureColumn("geometries", "binding", "TEXT");
|
||||||
|
ensureColumn("sections", "user_id", "TEXT");
|
||||||
dropEntityDeprecatedColumnsIfExists();
|
dropEntityDeprecatedColumnsIfExists();
|
||||||
dropGeometryDeprecatedColumnsIfExists();
|
dropGeometryDeprecatedColumnsIfExists();
|
||||||
migrateLegacyGeometryTypeTokens();
|
migrateLegacyGeometryTypeTokens();
|
||||||
@@ -79,6 +144,26 @@ db.prepare(`
|
|||||||
ON entity_geometries(entity_id)
|
ON entity_geometries(entity_id)
|
||||||
`).run();
|
`).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;
|
module.exports = db;
|
||||||
|
|
||||||
function ensureColumn(tableName, columnName, columnDefinition) {
|
function ensureColumn(tableName, columnName, columnDefinition) {
|
||||||
|
|||||||
6
index.js
6
index.js
@@ -6,6 +6,7 @@ const tileRoutes = require("./routes/tiles");
|
|||||||
const rasterTileRoutes = require("./routes/rasterTiles");
|
const rasterTileRoutes = require("./routes/rasterTiles");
|
||||||
const geoRoutes = require("./routes/geometries");
|
const geoRoutes = require("./routes/geometries");
|
||||||
const entityRoutes = require("./routes/entities");
|
const entityRoutes = require("./routes/entities");
|
||||||
|
const sectionRoutes = require("./routes/sections");
|
||||||
const { openApiSpec } = require("./swagger");
|
const { openApiSpec } = require("./swagger");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -18,6 +19,11 @@ app.use("/tiles", tileRoutes);
|
|||||||
app.use("/raster-tiles", rasterTileRoutes);
|
app.use("/raster-tiles", rasterTileRoutes);
|
||||||
app.use("/geometries", geoRoutes);
|
app.use("/geometries", geoRoutes);
|
||||||
app.use("/entities", entityRoutes);
|
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, {
|
app.use("/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec, {
|
||||||
explorer: true,
|
explorer: true,
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const crypto = require("crypto");
|
|
||||||
const db = require("../db/polygons");
|
const db = require("../db/polygons");
|
||||||
const { applyEntityBatchChanges } = require("../lib/entityBatch");
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -80,228 +78,6 @@ router.get("/:id", (req, res) => {
|
|||||||
res.json(normalizeEntityRow(row));
|
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;
|
module.exports = router;
|
||||||
|
|
||||||
function normalizeEntityRow(row) {
|
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) {
|
function normalizeLimit(value, fallback = 25, max = 100) {
|
||||||
const num = Number(value);
|
const num = Number(value);
|
||||||
if (!Number.isFinite(num)) return fallback;
|
if (!Number.isFinite(num)) return fallback;
|
||||||
@@ -353,18 +101,3 @@ function normalizeLimit(value, fallback = 25, max = 100) {
|
|||||||
if (intValue <= 0) return fallback;
|
if (intValue <= 0) return fallback;
|
||||||
return Math.min(intValue, max);
|
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");
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,78 +1,8 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const crypto = require("crypto");
|
|
||||||
const db = require("../db/polygons");
|
const db = require("../db/polygons");
|
||||||
const { applyEntityBatchChanges } = require("../lib/entityBatch");
|
|
||||||
const { getBBox } = require("../utils/bbox");
|
|
||||||
|
|
||||||
const router = express.Router();
|
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)
|
// 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;
|
module.exports = router;
|
||||||
|
|
||||||
function buildFeatureFromRow(row, linkedEntities = []) {
|
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) {
|
function normalizeGeometryType(value) {
|
||||||
if (value === undefined || value === null || value === "") return null;
|
if (value === undefined || value === null || value === "") return null;
|
||||||
const normalized = String(value).trim().toLowerCase();
|
const normalized = String(value).trim().toLowerCase();
|
||||||
return normalized.length ? normalized : null;
|
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) {
|
function isLegacyLineModeToken(value) {
|
||||||
return value === "line" || value === "path";
|
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) {
|
function normalizeEntityId(value) {
|
||||||
if (value === undefined || value === null || value === "") return null;
|
if (value === undefined || value === null || value === "") return null;
|
||||||
const normalized = String(value).trim();
|
const normalized = String(value).trim();
|
||||||
return normalized || null;
|
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) {
|
function normalizeBindingIds(rawBinding) {
|
||||||
if (rawBinding === undefined || rawBinding === null || rawBinding === "") {
|
if (rawBinding === undefined || rawBinding === null || rawBinding === "") {
|
||||||
return [];
|
return [];
|
||||||
@@ -695,94 +183,6 @@ function parseBindingIds(rawBinding) {
|
|||||||
return [];
|
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) {
|
function loadGeometryLinksByGeometryId(geometryIds) {
|
||||||
const map = new Map();
|
const map = new Map();
|
||||||
if (!geometryIds.length) return map;
|
if (!geometryIds.length) return map;
|
||||||
@@ -816,59 +216,8 @@ function loadGeometryLinksByGeometryId(geometryIds) {
|
|||||||
return map;
|
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) {
|
function createValidationError(message, status = 400) {
|
||||||
const err = new Error(message);
|
const err = new Error(message);
|
||||||
err.status = status;
|
err.status = status;
|
||||||
return err;
|
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;
|
|
||||||
}
|
|
||||||
|
|||||||
1142
routes/sections.js
Normal file
1142
routes/sections.js
Normal file
File diff suppressed because it is too large
Load Diff
1032
swagger.js
1032
swagger.js
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user