Files
temp-history-api/routes/entities.js
2026-04-17 20:55:33 +07:00

371 lines
11 KiB
JavaScript

const express = require("express");
const crypto = require("crypto");
const db = require("../db/polygons");
const { applyEntityBatchChanges } = require("../lib/entityBatch");
const router = express.Router();
router.get("/", (req, res) => {
const search = typeof req.query.q === "string" ? req.query.q.trim() : "";
let sql = `
SELECT
e.*,
COUNT(eg.geometry_id) AS geometry_count
FROM entities e
LEFT JOIN entity_geometries eg
ON eg.entity_id = e.id
WHERE e.is_deleted = 0
`;
const params = [];
if (search) {
sql += " AND (e.name LIKE ? OR e.slug LIKE ?)";
const pattern = `%${search}%`;
params.push(pattern, pattern);
}
sql += `
GROUP BY e.id
ORDER BY e.name COLLATE NOCASE ASC
`;
const rows = db.prepare(sql).all(...params);
res.json(rows.map(normalizeEntityRow));
});
router.get("/search", (req, res) => {
const name = typeof req.query.name === "string" ? req.query.name.trim() : "";
const limit = normalizeLimit(req.query.limit, 25, 100);
if (!name) {
return res.json([]);
}
const pattern = `%${name}%`;
const rows = db.prepare(`
SELECT
e.*,
COUNT(eg.geometry_id) AS geometry_count
FROM entities e
LEFT JOIN entity_geometries eg
ON eg.entity_id = e.id
WHERE e.is_deleted = 0
AND e.name LIKE ?
GROUP BY e.id
ORDER BY e.name COLLATE NOCASE ASC
LIMIT ?
`).all(pattern, limit);
res.json(rows.map(normalizeEntityRow));
});
router.get("/:id", (req, res) => {
const row = db.prepare(`
SELECT
e.*,
COUNT(eg.geometry_id) AS geometry_count
FROM entities e
LEFT JOIN entity_geometries eg
ON eg.entity_id = e.id
WHERE e.id = ?
AND e.is_deleted = 0
GROUP BY e.id
`).get(req.params.id);
if (!row) {
return res.status(404).json({ error: "Entity not found" });
}
res.json(normalizeEntityRow(row));
});
router.post("/", (req, res) => {
const name = normalizeRequiredString(req.body?.name);
if (!name) {
return res.status(400).json({ error: "name is required" });
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
const slug = normalizeOptionalString(req.body?.slug) || toSlug(name);
const description = normalizeOptionalString(req.body?.description);
const typeId = normalizeTypeId(req.body?.type_id);
const status = normalizeOptionalNumber(req.body?.status) ?? 1;
try {
db.prepare(`
INSERT INTO entities (
id, name, slug, description, type_id, status, is_deleted, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, 0, ?, ?)
`).run(
id,
name,
slug,
description,
typeId,
status,
now,
now
);
} catch (err) {
if (isSqliteConstraint(err)) {
return res.status(409).json({ error: "Entity name/slug must be unique" });
}
console.error("Create entity failed", err);
return res.status(500).json({ error: "Create entity failed" });
}
const created = db.prepare(`
SELECT
e.*,
COUNT(eg.geometry_id) AS geometry_count
FROM entities e
LEFT JOIN entity_geometries eg
ON eg.entity_id = e.id
WHERE e.id = ?
GROUP BY e.id
`).get(id);
res.status(201).json(normalizeEntityRow(created));
});
router.put("/:id", (req, res) => {
const existing = db.prepare(`
SELECT *
FROM entities
WHERE id = ?
AND is_deleted = 0
`).get(req.params.id);
if (!existing) {
return res.status(404).json({ error: "Entity not found" });
}
const hasName = Object.prototype.hasOwnProperty.call(req.body || {}, "name");
const hasSlug = Object.prototype.hasOwnProperty.call(req.body || {}, "slug");
const hasDescription = Object.prototype.hasOwnProperty.call(req.body || {}, "description");
const hasTypeId = Object.prototype.hasOwnProperty.call(req.body || {}, "type_id");
const hasStatus = Object.prototype.hasOwnProperty.call(req.body || {}, "status");
if (!hasName && !hasSlug && !hasDescription && !hasTypeId && !hasStatus) {
return res.status(400).json({ error: "No updatable field provided" });
}
const name = hasName ? normalizeRequiredString(req.body?.name) : existing.name;
if (!name) {
return res.status(400).json({ error: "name cannot be empty" });
}
const slug = hasSlug ? normalizeOptionalString(req.body?.slug) : existing.slug;
const description = hasDescription ? normalizeOptionalString(req.body?.description) : existing.description;
const typeId = hasTypeId ? normalizeTypeId(req.body?.type_id) : existing.type_id;
const status = hasStatus
? (normalizeOptionalNumber(req.body?.status) ?? existing.status)
: existing.status;
const now = new Date().toISOString();
try {
db.prepare(`
UPDATE entities
SET name = ?,
slug = ?,
description = ?,
type_id = ?,
status = ?,
updated_at = ?
WHERE id = ?
`).run(
name,
slug,
description,
typeId,
status,
now,
req.params.id
);
} catch (err) {
if (isSqliteConstraint(err)) {
return res.status(409).json({ error: "Entity name/slug must be unique" });
}
console.error("Update entity failed", err);
return res.status(500).json({ error: "Update entity failed" });
}
const updated = db.prepare(`
SELECT
e.*,
COUNT(eg.geometry_id) AS geometry_count
FROM entities e
LEFT JOIN entity_geometries eg
ON eg.entity_id = e.id
WHERE e.id = ?
AND e.is_deleted = 0
GROUP BY e.id
`).get(req.params.id);
res.json(normalizeEntityRow(updated));
});
router.delete("/:id", (req, res) => {
const entityId = req.params.id;
const existing = db.prepare(`
SELECT id
FROM entities
WHERE id = ?
AND is_deleted = 0
`).get(entityId);
if (!existing) {
return res.status(404).json({ error: "Entity not found" });
}
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) {
const previewIds = orphanedGeometryRows.slice(0, 10).map((row) => row.geometry_id);
const suffix = orphanedGeometryRows.length > 10 ? ", ..." : "";
return res.status(409).json({
error: `Cannot delete entity. Reassign ${orphanedGeometryRows.length} linked geometries first: ${previewIds.join(", ")}${suffix}`,
});
}
const now = new Date().toISOString();
const deleted = db.transaction((id, timestamp) => {
const result = db.prepare(`
UPDATE entities
SET is_deleted = 1,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(timestamp, id);
if (!result.changes) {
return false;
}
db.prepare(`
DELETE FROM entity_geometries
WHERE entity_id = ?
`).run(id);
return true;
})(entityId, now);
if (!deleted) {
return res.status(404).json({ error: "Entity not found" });
}
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) {
return {
id: row.id,
name: row.name,
slug: row.slug,
description: row.description,
type_id: row.type_id,
status: row.status,
created_at: row.created_at,
updated_at: row.updated_at,
geometry_count: Number(row.geometry_count || 0),
};
}
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 normalizeLimit(value, fallback = 25, max = 100) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
const intValue = Math.trunc(num);
if (intValue <= 0) return fallback;
return Math.min(intValue, max);
}
function toSlug(value) {
return value
.normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 120) || null;
}
function isSqliteConstraint(err) {
const message = typeof err?.message === "string" ? err.message : "";
return message.includes("UNIQUE constraint failed");
}