pre updating version control
This commit is contained in:
@@ -30,7 +30,6 @@ db.prepare(`
|
|||||||
slug TEXT UNIQUE,
|
slug TEXT UNIQUE,
|
||||||
description TEXT,
|
description TEXT,
|
||||||
type_id TEXT NOT NULL DEFAULT 'country',
|
type_id TEXT NOT NULL DEFAULT 'country',
|
||||||
kind TEXT,
|
|
||||||
status INTEGER DEFAULT 1,
|
status INTEGER DEFAULT 1,
|
||||||
is_deleted INTEGER NOT NULL DEFAULT 0,
|
is_deleted INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at TEXT,
|
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("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");
|
||||||
|
dropEntityDeprecatedColumnsIfExists();
|
||||||
dropGeometryDeprecatedColumnsIfExists();
|
dropGeometryDeprecatedColumnsIfExists();
|
||||||
migrateLegacyGeometryTypeTokens();
|
migrateLegacyGeometryTypeTokens();
|
||||||
|
|
||||||
@@ -98,6 +98,91 @@ function dropGeometryDeprecatedColumnsIfExists() {
|
|||||||
rebuildGeometriesTableToCurrentSchema(columns);
|
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) {
|
function rebuildGeometriesTableToCurrentSchema(existingColumns = null) {
|
||||||
const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1;
|
const foreignKeysEnabled = Number(db.pragma("foreign_keys", { simple: true })) === 1;
|
||||||
const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all();
|
const columns = existingColumns || db.prepare(`PRAGMA table_info(geometries)`).all();
|
||||||
|
|||||||
302
lib/entityBatch.js
Normal file
302
lib/entityBatch.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const crypto = require("crypto");
|
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();
|
||||||
|
|
||||||
@@ -95,16 +96,15 @@ router.post("/", (req, res) => {
|
|||||||
try {
|
try {
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
INSERT INTO entities (
|
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(
|
`).run(
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
description,
|
description,
|
||||||
typeId,
|
typeId,
|
||||||
null,
|
|
||||||
status,
|
status,
|
||||||
now,
|
now,
|
||||||
now
|
now
|
||||||
@@ -173,7 +173,6 @@ router.put("/:id", (req, res) => {
|
|||||||
slug = ?,
|
slug = ?,
|
||||||
description = ?,
|
description = ?,
|
||||||
type_id = ?,
|
type_id = ?,
|
||||||
kind = ?,
|
|
||||||
status = ?,
|
status = ?,
|
||||||
updated_at = ?
|
updated_at = ?
|
||||||
WHERE id = ?
|
WHERE id = ?
|
||||||
@@ -182,7 +181,6 @@ router.put("/:id", (req, res) => {
|
|||||||
slug,
|
slug,
|
||||||
description,
|
description,
|
||||||
typeId,
|
typeId,
|
||||||
null,
|
|
||||||
status,
|
status,
|
||||||
now,
|
now,
|
||||||
req.params.id
|
req.params.id
|
||||||
@@ -277,6 +275,33 @@ router.delete("/:id", (req, res) => {
|
|||||||
res.json({ success: true });
|
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) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const crypto = require("crypto");
|
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 { getBBox } = require("../utils/bbox");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -304,9 +305,64 @@ router.post("/batch", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const tx = db.transaction((items) => {
|
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) {
|
for (const change of items) {
|
||||||
const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action");
|
const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action");
|
||||||
const action = normalizeBatchAction(hasAction ? change.action : change?.type);
|
const action = normalizeBatchAction(hasAction ? change.action : change?.type);
|
||||||
@@ -465,18 +521,7 @@ router.post("/batch", (req, res) => {
|
|||||||
|
|
||||||
throw createValidationError(`Unknown change type: ${String(action)}`);
|
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;
|
module.exports = router;
|
||||||
|
|
||||||
|
|||||||
183
swagger.js
183
swagger.js
@@ -69,6 +69,64 @@ const openApiSpec = {
|
|||||||
status: { type: "number" },
|
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: {
|
GeoJSONGeometry: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
@@ -221,6 +279,51 @@ const openApiSpec = {
|
|||||||
},
|
},
|
||||||
required: ["success", "applied"],
|
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: {
|
MetadataResponse: {
|
||||||
type: "object",
|
type: "object",
|
||||||
additionalProperties: {
|
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}": {
|
"/entities/{id}": {
|
||||||
get: {
|
get: {
|
||||||
tags: ["Entities"],
|
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" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user