From 5397bf9808d6f0a8ea46166d221d4b2e7037eae8 Mon Sep 17 00:00:00 2001 From: taDuc Date: Fri, 17 Apr 2026 20:55:33 +0700 Subject: [PATCH] pre updating version control --- db/polygons.js | 87 ++++++++++- lib/entityBatch.js | 302 +++++++++++++++++++++++++++++++++++ routes/entities.js | 35 ++++- routes/geometries.js | 365 ++++++++++++++++++++++++------------------- swagger.js | 183 ++++++++++++++++++++++ 5 files changed, 806 insertions(+), 166 deletions(-) create mode 100644 lib/entityBatch.js diff --git a/db/polygons.js b/db/polygons.js index fb4085d..3d7cbec 100644 --- a/db/polygons.js +++ b/db/polygons.js @@ -30,7 +30,6 @@ db.prepare(` slug TEXT UNIQUE, description TEXT, type_id TEXT NOT NULL DEFAULT 'country', - kind TEXT, status INTEGER DEFAULT 1, is_deleted INTEGER NOT NULL DEFAULT 0, created_at TEXT, @@ -54,6 +53,7 @@ ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'"); ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("geometries", "binding", "TEXT"); +dropEntityDeprecatedColumnsIfExists(); dropGeometryDeprecatedColumnsIfExists(); migrateLegacyGeometryTypeTokens(); @@ -98,6 +98,91 @@ function dropGeometryDeprecatedColumnsIfExists() { rebuildGeometriesTableToCurrentSchema(columns); } +function dropEntityDeprecatedColumnsIfExists() { + const columns = db.prepare(`PRAGMA table_info(entities)`).all(); + const hasKind = columns.some((column) => column.name === "kind"); + if (!hasKind) return; + rebuildEntitiesTableToCurrentSchema(columns); +} + +function rebuildEntitiesTableToCurrentSchema(existingColumns = null) { + const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1; + const columns = existingColumns || db.prepare(`PRAGMA table_info(entities)`).all(); + const hasSlug = columns.some((column) => column.name === "slug"); + const hasDescription = columns.some((column) => column.name === "description"); + const hasTypeId = columns.some((column) => column.name === "type_id"); + const hasStatus = columns.some((column) => column.name === "status"); + const hasIsDeleted = columns.some((column) => column.name === "is_deleted"); + const hasCreatedAt = columns.some((column) => column.name === "created_at"); + const hasUpdatedAt = columns.some((column) => column.name === "updated_at"); + + const slugSelect = hasSlug ? "slug" : "NULL"; + const descriptionSelect = hasDescription ? "description" : "NULL"; + const typeIdSelect = hasTypeId ? "type_id" : "'country'"; + const statusSelect = hasStatus ? "status" : "1"; + const isDeletedSelect = hasIsDeleted ? "is_deleted" : "0"; + const createdAtSelect = hasCreatedAt ? "created_at" : "NULL"; + const updatedAtSelect = hasUpdatedAt ? "updated_at" : "NULL"; + + if (foreignKeysEnabled) { + db.pragma("foreign_keys = OFF"); + } + + try { + const tx = db.transaction(() => { + db.prepare(`DROP TABLE IF EXISTS entities_new`).run(); + + db.prepare(` + CREATE TABLE entities_new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + slug TEXT UNIQUE, + description TEXT, + type_id TEXT NOT NULL DEFAULT 'country', + status INTEGER DEFAULT 1, + is_deleted INTEGER NOT NULL DEFAULT 0, + created_at TEXT, + updated_at TEXT + ) + `).run(); + + db.prepare(` + INSERT INTO entities_new ( + id, + name, + slug, + description, + type_id, + status, + is_deleted, + created_at, + updated_at + ) + SELECT + id, + name, + ${slugSelect}, + ${descriptionSelect}, + ${typeIdSelect}, + ${statusSelect}, + ${isDeletedSelect}, + ${createdAtSelect}, + ${updatedAtSelect} + FROM entities + `).run(); + + db.prepare(`DROP TABLE entities`).run(); + db.prepare(`ALTER TABLE entities_new RENAME TO entities`).run(); + }); + + tx(); + } finally { + if (foreignKeysEnabled) { + db.pragma("foreign_keys = ON"); + } + } +} + function rebuildGeometriesTableToCurrentSchema(existingColumns = null) { const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1; const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all(); diff --git a/lib/entityBatch.js b/lib/entityBatch.js new file mode 100644 index 0000000..36c0812 --- /dev/null +++ b/lib/entityBatch.js @@ -0,0 +1,302 @@ +const crypto = require("crypto"); +const db = require("../db/polygons"); + +function applyEntityBatchChanges(changes, options = {}) { + const now = options.now || new Date().toISOString(); + const createdEntityIds = []; + + for (const change of changes) { + const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action"); + const action = normalizeBatchAction(hasAction ? change.action : change?.type); + + if (!change || !action) { + throw createValidationError("Invalid entity change entry"); + } + + if (action === "create") { + const payload = normalizeEntityCreatePayload(change); + const id = payload.id || crypto.randomUUID(); + + try { + db.prepare(` + INSERT INTO entities ( + id, name, slug, description, type_id, status, is_deleted, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) + `).run( + id, + payload.name, + payload.slug, + payload.description, + payload.typeId, + payload.status, + now, + now + ); + } catch (err) { + if (isSqliteConstraint(err)) { + throw createValidationError("Entity name/slug must be unique", 409); + } + throw err; + } + + createdEntityIds.push(id); + continue; + } + + if (action === "update") { + const payload = normalizeEntityUpdatePayload(change); + const existing = db.prepare(` + SELECT * + FROM entities + WHERE id = ? + AND is_deleted = 0 + `).get(payload.id); + + if (!existing) { + continue; + } + + const hasName = Object.prototype.hasOwnProperty.call(payload.fields, "name"); + const hasSlug = Object.prototype.hasOwnProperty.call(payload.fields, "slug"); + const hasDescription = Object.prototype.hasOwnProperty.call(payload.fields, "description"); + const hasTypeId = Object.prototype.hasOwnProperty.call(payload.fields, "type_id"); + const hasStatus = Object.prototype.hasOwnProperty.call(payload.fields, "status"); + + if (!hasName && !hasSlug && !hasDescription && !hasTypeId && !hasStatus) { + throw createValidationError("Invalid entity update payload"); + } + + const name = hasName ? normalizeRequiredString(payload.fields.name) : existing.name; + if (!name) { + throw createValidationError("name cannot be empty"); + } + + const slug = hasSlug ? normalizeOptionalString(payload.fields.slug) : existing.slug; + const description = hasDescription + ? normalizeOptionalString(payload.fields.description) + : existing.description; + const typeId = hasTypeId ? normalizeTypeId(payload.fields.type_id) : existing.type_id; + const status = hasStatus + ? (normalizeOptionalNumber(payload.fields.status) ?? existing.status) + : existing.status; + + try { + db.prepare(` + UPDATE entities + SET name = ?, + slug = ?, + description = ?, + type_id = ?, + status = ?, + updated_at = ? + WHERE id = ? + AND is_deleted = 0 + `).run( + name, + slug, + description, + typeId, + status, + now, + payload.id + ); + } catch (err) { + if (isSqliteConstraint(err)) { + throw createValidationError("Entity name/slug must be unique", 409); + } + throw err; + } + continue; + } + + if (action === "delete") { + const id = normalizeDeleteEntityId(change); + if (!id) { + throw createValidationError("Invalid entity delete payload"); + } + + const existing = db.prepare(` + SELECT id + FROM entities + WHERE id = ? + AND is_deleted = 0 + `).get(id); + + if (!existing) { + continue; + } + + assertEntityCanBeDeleted(id); + + db.prepare(` + UPDATE entities + SET is_deleted = 1, + updated_at = ? + WHERE id = ? + AND is_deleted = 0 + `).run(now, id); + + db.prepare(` + DELETE FROM entity_geometries + WHERE entity_id = ? + `).run(id); + continue; + } + + throw createValidationError(`Unknown entity change type: ${String(action)}`); + } + + return { + applied: changes.length, + createdEntityIds, + }; +} + +module.exports = { + applyEntityBatchChanges, +}; + +function normalizeEntityCreatePayload(change) { + const source = isPlainObject(change.entity) ? change.entity : change; + const name = normalizeRequiredString(source?.name); + if (!name) { + throw createValidationError("Invalid entity create payload: name is required"); + } + + const id = normalizeEntityId(source?.id); + const slug = Object.prototype.hasOwnProperty.call(source || {}, "slug") + ? normalizeOptionalString(source.slug) + : toSlug(name); + const description = normalizeOptionalString(source?.description); + const typeId = normalizeTypeId(source?.type_id); + const status = normalizeOptionalNumber(source?.status) ?? 1; + + return { + id, + name, + slug, + description, + typeId, + status, + }; +} + +function normalizeEntityUpdatePayload(change) { + const source = isPlainObject(change.entity) ? change.entity : change; + const id = normalizeEntityId(source?.id ?? change?.id); + if (!id) { + throw createValidationError("Invalid entity update payload: id is required"); + } + + return { + id, + fields: source, + }; +} + +function normalizeDeleteEntityId(change) { + const source = isPlainObject(change.entity) ? change.entity : null; + return normalizeEntityId(change?.id ?? source?.id); +} + +function assertEntityCanBeDeleted(entityId) { + const orphanedGeometryRows = db.prepare(` + SELECT eg_target.geometry_id + FROM entity_geometries eg_target + JOIN geometries g_target + ON g_target.id = eg_target.geometry_id + AND g_target.is_deleted = 0 + LEFT JOIN entity_geometries eg_other + ON eg_other.geometry_id = eg_target.geometry_id + AND eg_other.entity_id <> eg_target.entity_id + LEFT JOIN entities e_other + ON e_other.id = eg_other.entity_id + AND e_other.is_deleted = 0 + WHERE eg_target.entity_id = ? + GROUP BY eg_target.geometry_id + HAVING COUNT(e_other.id) = 0 + `).all(entityId); + + if (!orphanedGeometryRows.length) return; + + const previewIds = orphanedGeometryRows.slice(0, 10).map((row) => row.geometry_id); + const suffix = orphanedGeometryRows.length > 10 ? ", ..." : ""; + throw createValidationError( + `Cannot delete entity. Reassign ${orphanedGeometryRows.length} linked geometries first: ${previewIds.join(", ")}${suffix}`, + 409 + ); +} + +function normalizeBatchAction(value) { + if (value === undefined || value === null) return null; + const normalized = String(value).trim().toLowerCase(); + if (normalized === "create" || normalized === "update" || normalized === "delete") { + return normalized; + } + return null; +} + +function normalizeEntityId(value) { + if (value === undefined || value === null || value === "") return null; + const normalized = String(value).trim(); + return normalized || null; +} + +function normalizeRequiredString(value) { + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function normalizeOptionalString(value) { + if (value === undefined || value === null) return null; + if (typeof value !== "string") return null; + const trimmed = value.trim(); + return trimmed.length ? trimmed : null; +} + +function normalizeOptionalNumber(value) { + if (value === undefined || value === null || value === "") return null; + const num = Number(value); + if (!Number.isFinite(num)) return null; + return num; +} + +function normalizeTypeId(value) { + if (value === undefined || value === null || value === "") { + return "country"; + } + const trimmed = String(value).trim().toLowerCase(); + return trimmed || "country"; +} + +function toSlug(value) { + return value + .normalize("NFKD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 120) || null; +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function isSqliteConstraint(error) { + if (!error || typeof error !== "object") return false; + const code = error.code || ""; + if (code === "SQLITE_CONSTRAINT" || String(code).startsWith("SQLITE_CONSTRAINT_")) { + return true; + } + const message = typeof error.message === "string" ? error.message : ""; + return message.includes("UNIQUE constraint failed"); +} + +function createValidationError(message, status = 400) { + const err = new Error(message); + err.status = status; + return err; +} diff --git a/routes/entities.js b/routes/entities.js index 2f1426c..967561f 100644 --- a/routes/entities.js +++ b/routes/entities.js @@ -1,6 +1,7 @@ const express = require("express"); const crypto = require("crypto"); const db = require("../db/polygons"); +const { applyEntityBatchChanges } = require("../lib/entityBatch"); const router = express.Router(); @@ -95,16 +96,15 @@ router.post("/", (req, res) => { try { db.prepare(` INSERT INTO entities ( - id, name, slug, description, type_id, kind, status, is_deleted, created_at, updated_at + id, name, slug, description, type_id, status, is_deleted, created_at, updated_at ) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?) `).run( id, name, slug, description, typeId, - null, status, now, now @@ -173,7 +173,6 @@ router.put("/:id", (req, res) => { slug = ?, description = ?, type_id = ?, - kind = ?, status = ?, updated_at = ? WHERE id = ? @@ -182,7 +181,6 @@ router.put("/:id", (req, res) => { slug, description, typeId, - null, status, now, req.params.id @@ -277,6 +275,33 @@ router.delete("/:id", (req, res) => { res.json({ success: true }); }); +router.post("/batch", (req, res) => { + const { changes } = req.body || {}; + if (!Array.isArray(changes)) { + return res.status(400).json({ error: "changes must be an array" }); + } + + const now = new Date().toISOString(); + try { + const tx = db.transaction((items) => applyEntityBatchChanges(items, { now })); + const result = tx(changes); + res.json({ + success: true, + applied: result.applied, + created_entity_ids: result.createdEntityIds, + }); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + if (isSqliteConstraint(err)) { + return res.status(409).json({ error: "Entity name/slug must be unique" }); + } + console.error("Batch entity apply failed", err); + res.status(500).json({ error: "Batch entity apply failed" }); + } +}); + module.exports = router; function normalizeEntityRow(row) { diff --git a/routes/geometries.js b/routes/geometries.js index e347506..abe8111 100644 --- a/routes/geometries.js +++ b/routes/geometries.js @@ -1,6 +1,7 @@ const express = require("express"); const crypto = require("crypto"); const db = require("../db/polygons"); +const { applyEntityBatchChanges } = require("../lib/entityBatch"); const { getBBox } = require("../utils/bbox"); const router = express.Router(); @@ -304,169 +305,10 @@ router.post("/batch", (req, res) => { } const now = new Date().toISOString(); - try { const tx = db.transaction((items) => { - for (const change of items) { - const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action"); - const action = normalizeBatchAction(hasAction ? change.action : change?.type); - - if (!change || !action) { - throw createValidationError("Invalid change entry"); - } - - if (action === "create") { - const feature = change.feature; - if (!feature || !feature.properties || !feature.properties.id || !feature.geometry) { - throw createValidationError("Invalid create payload"); - } - - const temporalRange = normalizeTemporalRange( - feature.properties.time_start, - feature.properties.time_end - ); - const entityPayload = extractEntityIdsFromPayload(feature.properties || {}); - const entityIds = entityPayload.entityIds; - ensureGeometryHasEntities(entityIds); - validateEntityIdsExist(entityIds); - - const bbox = getBBox(feature.geometry); - const geometryId = String(feature.properties.id); - const bindingPayload = extractBindingFromPayload(feature.properties || {}); - const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, geometryId); - const geometryType = resolveGeometryType(feature.properties?.type, entityIds); - - db.prepare(` - INSERT OR REPLACE INTO geometries ( - id, type, is_deleted, draw_geometry, binding, time_start, time_end, - bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat, - created_at, updated_at - ) - VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(( - SELECT created_at FROM geometries WHERE id = ? - ), ?), ?) - `).run( - geometryId, - geometryType, - JSON.stringify(feature.geometry), - serializeBindingIds(bindingIds), - temporalRange.timeStart, - temporalRange.timeEnd, - bbox.minLng, - bbox.minLat, - bbox.maxLng, - bbox.maxLat, - geometryId, - now, - now - ); - - syncGeometryEntityLinks(geometryId, entityIds, now); - continue; - } - - if (action === "update") { - const { id, geometry } = change; - if (!id || !geometry) { - throw createValidationError("Invalid update payload"); - } - - const existing = db.prepare(` - SELECT id, type, time_start, time_end, binding - FROM geometries - WHERE id = ? - AND is_deleted = 0 - `).get(id); - - if (!existing) { - continue; - } - - const hasTimeStart = Object.prototype.hasOwnProperty.call(change, "time_start"); - const hasTimeEnd = Object.prototype.hasOwnProperty.call(change, "time_end"); - const hasType = hasAction && Object.prototype.hasOwnProperty.call(change, "type"); - const temporalRange = normalizeTemporalRange( - hasTimeStart ? change.time_start : existing.time_start, - hasTimeEnd ? change.time_end : existing.time_end - ); - const bindingPayload = extractBindingFromPayload(change); - const nextBindingIds = sanitizeBindingIdsForGeometry( - bindingPayload.provided - ? bindingPayload.bindingIds - : parseBindingIds(existing.binding), - id - ); - - const entityPayload = extractEntityIdsFromPayload(change); - const nextEntityIds = entityPayload.provided - ? entityPayload.entityIds - : getLinkedEntityIdsByGeometryId(id); - ensureGeometryHasEntities(nextEntityIds); - validateEntityIdsExist(nextEntityIds); - const nextType = hasType - ? resolveGeometryType(change.type, nextEntityIds, existing.type) - : resolveGeometryType(null, nextEntityIds, existing.type); - - const bbox = getBBox(geometry); - - db.prepare(` - UPDATE geometries - SET type = ?, - draw_geometry = ?, - binding = ?, - time_start = ?, - time_end = ?, - bbox_min_lng = ?, - bbox_min_lat = ?, - bbox_max_lng = ?, - bbox_max_lat = ?, - updated_at = ? - WHERE id = ? - AND is_deleted = 0 - `).run( - nextType, - JSON.stringify(geometry), - serializeBindingIds(nextBindingIds), - temporalRange.timeStart, - temporalRange.timeEnd, - bbox.minLng, - bbox.minLat, - bbox.maxLng, - bbox.maxLat, - now, - id - ); - - if (entityPayload.provided) { - syncGeometryEntityLinks(id, nextEntityIds, now); - } - continue; - } - - if (action === "delete") { - if (!change.id) { - throw createValidationError("Invalid delete payload"); - } - - const result = db.prepare(` - UPDATE geometries - SET is_deleted = 1, - updated_at = ? - WHERE id = ? - AND is_deleted = 0 - `).run(now, change.id); - - if (result.changes) { - db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(change.id); - removeBindingReferenceFromAll(change.id, now); - } - continue; - } - - throw createValidationError(`Unknown change type: ${String(action)}`); - } + applyGeometryBatchChanges(items, now); }); - tx(changes); res.json({ success: true, applied: changes.length }); } catch (err) { @@ -478,6 +320,209 @@ router.post("/batch", (req, res) => { } }); +// ======================= +// Apply entities + geometries in one transaction +// ======================= +router.post("/batch/combined", (req, res) => { + const entityChangesRaw = req.body?.entity_changes; + const geometryChangesRaw = req.body?.geometry_changes; + + if (entityChangesRaw !== undefined && !Array.isArray(entityChangesRaw)) { + return res.status(400).json({ error: "entity_changes must be an array" }); + } + if (geometryChangesRaw !== undefined && !Array.isArray(geometryChangesRaw)) { + return res.status(400).json({ error: "geometry_changes must be an array" }); + } + + const entityChanges = entityChangesRaw || []; + const geometryChanges = geometryChangesRaw || []; + const now = new Date().toISOString(); + + try { + const tx = db.transaction((entities, geometries) => { + const entityResult = applyEntityBatchChanges(entities, { now }); + applyGeometryBatchChanges(geometries, now); + return entityResult; + }); + + const entityResult = tx(entityChanges, geometryChanges); + res.json({ + success: true, + applied: entityChanges.length + geometryChanges.length, + entity_applied: entityChanges.length, + geometry_applied: geometryChanges.length, + created_entity_ids: entityResult.createdEntityIds, + }); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + console.error("Combined batch apply error:", err); + res.status(500).json({ error: "Combined batch apply failed" }); + } +}); + +function applyGeometryBatchChanges(items, now) { + for (const change of items) { + const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action"); + const action = normalizeBatchAction(hasAction ? change.action : change?.type); + + if (!change || !action) { + throw createValidationError("Invalid change entry"); + } + + if (action === "create") { + const feature = change.feature; + if (!feature || !feature.properties || !feature.properties.id || !feature.geometry) { + throw createValidationError("Invalid create payload"); + } + + const temporalRange = normalizeTemporalRange( + feature.properties.time_start, + feature.properties.time_end + ); + const entityPayload = extractEntityIdsFromPayload(feature.properties || {}); + const entityIds = entityPayload.entityIds; + ensureGeometryHasEntities(entityIds); + validateEntityIdsExist(entityIds); + + const bbox = getBBox(feature.geometry); + const geometryId = String(feature.properties.id); + const bindingPayload = extractBindingFromPayload(feature.properties || {}); + const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, geometryId); + const geometryType = resolveGeometryType(feature.properties?.type, entityIds); + + db.prepare(` + INSERT OR REPLACE INTO geometries ( + id, type, is_deleted, draw_geometry, binding, time_start, time_end, + bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat, + created_at, updated_at + ) + VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE(( + SELECT created_at FROM geometries WHERE id = ? + ), ?), ?) + `).run( + geometryId, + geometryType, + JSON.stringify(feature.geometry), + serializeBindingIds(bindingIds), + temporalRange.timeStart, + temporalRange.timeEnd, + bbox.minLng, + bbox.minLat, + bbox.maxLng, + bbox.maxLat, + geometryId, + now, + now + ); + + syncGeometryEntityLinks(geometryId, entityIds, now); + continue; + } + + if (action === "update") { + const { id, geometry } = change; + if (!id || !geometry) { + throw createValidationError("Invalid update payload"); + } + + const existing = db.prepare(` + SELECT id, type, time_start, time_end, binding + FROM geometries + WHERE id = ? + AND is_deleted = 0 + `).get(id); + + if (!existing) { + continue; + } + + const hasTimeStart = Object.prototype.hasOwnProperty.call(change, "time_start"); + const hasTimeEnd = Object.prototype.hasOwnProperty.call(change, "time_end"); + const hasType = hasAction && Object.prototype.hasOwnProperty.call(change, "type"); + const temporalRange = normalizeTemporalRange( + hasTimeStart ? change.time_start : existing.time_start, + hasTimeEnd ? change.time_end : existing.time_end + ); + const bindingPayload = extractBindingFromPayload(change); + const nextBindingIds = sanitizeBindingIdsForGeometry( + bindingPayload.provided + ? bindingPayload.bindingIds + : parseBindingIds(existing.binding), + id + ); + + const entityPayload = extractEntityIdsFromPayload(change); + const nextEntityIds = entityPayload.provided + ? entityPayload.entityIds + : getLinkedEntityIdsByGeometryId(id); + ensureGeometryHasEntities(nextEntityIds); + validateEntityIdsExist(nextEntityIds); + const nextType = hasType + ? resolveGeometryType(change.type, nextEntityIds, existing.type) + : resolveGeometryType(null, nextEntityIds, existing.type); + + const bbox = getBBox(geometry); + + db.prepare(` + UPDATE geometries + SET type = ?, + draw_geometry = ?, + binding = ?, + time_start = ?, + time_end = ?, + bbox_min_lng = ?, + bbox_min_lat = ?, + bbox_max_lng = ?, + bbox_max_lat = ?, + updated_at = ? + WHERE id = ? + AND is_deleted = 0 + `).run( + nextType, + JSON.stringify(geometry), + serializeBindingIds(nextBindingIds), + temporalRange.timeStart, + temporalRange.timeEnd, + bbox.minLng, + bbox.minLat, + bbox.maxLng, + bbox.maxLat, + now, + id + ); + + if (entityPayload.provided) { + syncGeometryEntityLinks(id, nextEntityIds, now); + } + continue; + } + + if (action === "delete") { + if (!change.id) { + throw createValidationError("Invalid delete payload"); + } + + const result = db.prepare(` + UPDATE geometries + SET is_deleted = 1, + updated_at = ? + WHERE id = ? + AND is_deleted = 0 + `).run(now, change.id); + + if (result.changes) { + db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(change.id); + removeBindingReferenceFromAll(change.id, now); + } + continue; + } + + throw createValidationError(`Unknown change type: ${String(action)}`); + } +} + module.exports = router; function buildFeatureFromRow(row, linkedEntities = []) { diff --git a/swagger.js b/swagger.js index 69acd3c..5ecfd3d 100644 --- a/swagger.js +++ b/swagger.js @@ -69,6 +69,64 @@ const openApiSpec = { status: { type: "number" }, }, }, + EntityBatchCreateChange: { + type: "object", + properties: { + action: { type: "string", enum: ["create"] }, + entity: { $ref: "#/components/schemas/EntityCreateInput" }, + }, + required: ["action"], + }, + EntityBatchUpdateChange: { + type: "object", + properties: { + action: { type: "string", enum: ["update"] }, + id: { type: "string" }, + entity: { $ref: "#/components/schemas/EntityUpdateInput" }, + name: { type: "string" }, + slug: { type: "string", nullable: true }, + description: { type: "string", nullable: true }, + type_id: { type: "string" }, + status: { type: "number" }, + }, + required: ["action", "id"], + }, + EntityBatchDeleteChange: { + type: "object", + properties: { + action: { type: "string", enum: ["delete"] }, + id: { type: "string" }, + }, + required: ["action", "id"], + }, + EntityBatchPayload: { + type: "object", + properties: { + changes: { + type: "array", + items: { + oneOf: [ + { $ref: "#/components/schemas/EntityBatchCreateChange" }, + { $ref: "#/components/schemas/EntityBatchUpdateChange" }, + { $ref: "#/components/schemas/EntityBatchDeleteChange" }, + ], + }, + }, + }, + required: ["changes"], + }, + EntityBatchResponse: { + type: "object", + properties: { + success: { type: "boolean" }, + applied: { type: "number" }, + created_entity_ids: { + type: "array", + items: { type: "string" }, + }, + }, + required: ["success", "applied", "created_entity_ids"], + }, GeoJSONGeometry: { type: "object", properties: { @@ -221,6 +279,51 @@ const openApiSpec = { }, required: ["success", "applied"], }, + CombinedBatchPayload: { + type: "object", + properties: { + entity_changes: { + type: "array", + items: { + oneOf: [ + { $ref: "#/components/schemas/EntityBatchCreateChange" }, + { $ref: "#/components/schemas/EntityBatchUpdateChange" }, + { $ref: "#/components/schemas/EntityBatchDeleteChange" }, + ], + }, + }, + geometry_changes: { + type: "array", + items: { + oneOf: [ + { $ref: "#/components/schemas/BatchCreateChange" }, + { $ref: "#/components/schemas/BatchUpdateChange" }, + { $ref: "#/components/schemas/BatchDeleteChange" }, + ], + }, + }, + }, + }, + CombinedBatchResponse: { + type: "object", + properties: { + success: { type: "boolean" }, + applied: { type: "number" }, + entity_applied: { type: "number" }, + geometry_applied: { type: "number" }, + created_entity_ids: { + type: "array", + items: { type: "string" }, + }, + }, + required: [ + "success", + "applied", + "entity_applied", + "geometry_applied", + "created_entity_ids", + ], + }, MetadataResponse: { type: "object", additionalProperties: { @@ -442,6 +545,46 @@ const openApiSpec = { }, }, }, + "/entities/batch": { + post: { + tags: ["Entities"], + summary: "Apply batch create/update/delete entities", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EntityBatchPayload" }, + }, + }, + }, + responses: { + 200: { + description: "Batch applied", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/EntityBatchResponse" }, + }, + }, + }, + 400: { + description: "Invalid payload", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + 409: { + description: "Unique conflict or cannot delete due to orphaned geometries", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, "/entities/{id}": { get: { tags: ["Entities"], @@ -721,6 +864,46 @@ const openApiSpec = { }, }, }, + "/geometries/batch/combined": { + post: { + tags: ["Geometries", "Entities"], + summary: "Apply entity batch and geometry batch in one transaction", + requestBody: { + required: true, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CombinedBatchPayload" }, + }, + }, + }, + responses: { + 200: { + description: "Combined batch applied", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/CombinedBatchResponse" }, + }, + }, + }, + 400: { + description: "Invalid payload", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + 409: { + description: "Conflict in entity changes (unique or orphan guard)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }, + }, + }, + }, }, };