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,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
File diff suppressed because it is too large
Load Diff
+296
-762
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user