830 lines
28 KiB
JavaScript
830 lines
28 KiB
JavaScript
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;
|
|
}
|