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; }