303 lines
9.7 KiB
JavaScript
303 lines
9.7 KiB
JavaScript
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;
|
|
}
|