const express = require("express"); const crypto = require("crypto"); const db = require("../db/polygons"); const { applyEntityBatchChanges } = require("../lib/entityBatch"); const { getBBox } = require("../utils/bbox"); const router = express.Router(); // ======================= // Create geometry // ======================= router.post("/", (req, res) => { const { geometry } = req.body || {}; if (!geometry) { return res.status(400).json({ error: "Missing geometry" }); } let temporalRange; let entityPayload; let bindingPayload; try { temporalRange = normalizeTemporalRange(req.body?.time_start, req.body?.time_end); entityPayload = extractEntityIdsFromPayload(req.body || {}); bindingPayload = extractBindingFromPayload(req.body || {}); ensureGeometryHasEntities(entityPayload.entityIds); validateEntityIdsExist(entityPayload.entityIds); } catch (err) { return res.status(err.status || 400).json({ error: err.message }); } const id = crypto.randomUUID(); const now = new Date().toISOString(); const bbox = getBBox(geometry); const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, id); const geometryType = resolveGeometryType(req.body?.type, entityPayload.entityIds); try { const tx = db.transaction(() => { db.prepare(` INSERT INTO geometries ( id, type, is_deleted, draw_geometry, binding, time_start, time_end, bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat, created_at, updated_at ) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( id, geometryType, JSON.stringify(geometry), serializeBindingIds(bindingIds), temporalRange.timeStart, temporalRange.timeEnd, bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat, now, now ); syncGeometryEntityLinks(id, entityPayload.entityIds, now); }); tx(); } catch (err) { if (err.status) { return res.status(err.status).json({ error: err.message }); } console.error("Create geometry failed:", err); return res.status(500).json({ error: "Create geometry failed" }); } res.json({ id }); }); // ======================= // Query by bbox/time(/entity) // ======================= 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) => { applyGeometryBatchChanges(items, now); }); tx(changes); res.json({ success: true, applied: changes.length }); } catch (err) { if (err.status) { return res.status(err.status).json({ error: err.message }); } console.error("Batch apply error:", err); res.status(500).json({ error: "Batch apply failed" }); } }); // ======================= // Apply entities + geometries in one transaction // ======================= router.post("/batch/combined", (req, res) => { const entityChangesRaw = req.body?.entity_changes; const geometryChangesRaw = req.body?.geometry_changes; if (entityChangesRaw !== undefined && !Array.isArray(entityChangesRaw)) { return res.status(400).json({ error: "entity_changes must be an array" }); } if (geometryChangesRaw !== undefined && !Array.isArray(geometryChangesRaw)) { return res.status(400).json({ error: "geometry_changes must be an array" }); } const entityChanges = entityChangesRaw || []; const geometryChanges = geometryChangesRaw || []; const now = new Date().toISOString(); try { const tx = db.transaction((entities, geometries) => { const entityResult = applyEntityBatchChanges(entities, { now }); applyGeometryBatchChanges(geometries, now); return entityResult; }); const entityResult = tx(entityChanges, geometryChanges); res.json({ success: true, applied: entityChanges.length + geometryChanges.length, entity_applied: entityChanges.length, geometry_applied: geometryChanges.length, created_entity_ids: entityResult.createdEntityIds, }); } catch (err) { if (err.status) { return res.status(err.status).json({ error: err.message }); } console.error("Combined batch apply error:", err); res.status(500).json({ error: "Combined batch apply failed" }); } }); function applyGeometryBatchChanges(items, now) { for (const change of items) { const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action"); const action = normalizeBatchAction(hasAction ? change.action : change?.type); if (!change || !action) { throw createValidationError("Invalid change entry"); } if (action === "create") { const feature = change.feature; if (!feature || !feature.properties || !feature.properties.id || !feature.geometry) { throw createValidationError("Invalid create payload"); } const temporalRange = normalizeTemporalRange( feature.properties.time_start, feature.properties.time_end ); const entityPayload = extractEntityIdsFromPayload(feature.properties || {}); const entityIds = entityPayload.entityIds; ensureGeometryHasEntities(entityIds); validateEntityIdsExist(entityIds); const bbox = getBBox(feature.geometry); const geometryId = String(feature.properties.id); const bindingPayload = extractBindingFromPayload(feature.properties || {}); const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, geometryId); const geometryType = resolveGeometryType(feature.properties?.type, entityIds); db.prepare(` INSERT OR REPLACE INTO geometries ( id, type, is_deleted, draw_geometry, binding, time_start, time_end, bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat, created_at, updated_at ) VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(( SELECT created_at FROM geometries WHERE id = ? ), ?), ?) `).run( geometryId, geometryType, JSON.stringify(feature.geometry), serializeBindingIds(bindingIds), temporalRange.timeStart, temporalRange.timeEnd, bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat, geometryId, now, now ); syncGeometryEntityLinks(geometryId, entityIds, now); continue; } if (action === "update") { const { id, geometry } = change; if (!id || !geometry) { throw createValidationError("Invalid update payload"); } const existing = db.prepare(` SELECT id, type, time_start, time_end, binding FROM geometries WHERE id = ? AND is_deleted = 0 `).get(id); if (!existing) { continue; } const hasTimeStart = Object.prototype.hasOwnProperty.call(change, "time_start"); const hasTimeEnd = Object.prototype.hasOwnProperty.call(change, "time_end"); const hasType = hasAction && Object.prototype.hasOwnProperty.call(change, "type"); const temporalRange = normalizeTemporalRange( hasTimeStart ? change.time_start : existing.time_start, hasTimeEnd ? change.time_end : existing.time_end ); const bindingPayload = extractBindingFromPayload(change); const nextBindingIds = sanitizeBindingIdsForGeometry( bindingPayload.provided ? bindingPayload.bindingIds : parseBindingIds(existing.binding), id ); const entityPayload = extractEntityIdsFromPayload(change); const nextEntityIds = entityPayload.provided ? entityPayload.entityIds : getLinkedEntityIdsByGeometryId(id); ensureGeometryHasEntities(nextEntityIds); validateEntityIdsExist(nextEntityIds); const nextType = hasType ? resolveGeometryType(change.type, nextEntityIds, existing.type) : resolveGeometryType(null, nextEntityIds, existing.type); const bbox = getBBox(geometry); db.prepare(` UPDATE geometries SET type = ?, draw_geometry = ?, binding = ?, time_start = ?, time_end = ?, bbox_min_lng = ?, bbox_min_lat = ?, bbox_max_lng = ?, bbox_max_lat = ?, updated_at = ? WHERE id = ? AND is_deleted = 0 `).run( nextType, JSON.stringify(geometry), serializeBindingIds(nextBindingIds), temporalRange.timeStart, temporalRange.timeEnd, bbox.minLng, bbox.minLat, bbox.maxLng, bbox.maxLat, now, id ); if (entityPayload.provided) { syncGeometryEntityLinks(id, nextEntityIds, now); } continue; } if (action === "delete") { if (!change.id) { throw createValidationError("Invalid delete payload"); } const result = db.prepare(` UPDATE geometries SET is_deleted = 1, updated_at = ? WHERE id = ? AND is_deleted = 0 `).run(now, change.id); if (result.changes) { db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(change.id); removeBindingReferenceFromAll(change.id, now); } continue; } throw createValidationError(`Unknown change type: ${String(action)}`); } } module.exports = router; function buildFeatureFromRow(row, linkedEntities = []) { 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; }