const express = require("express"); const crypto = require("crypto"); const db = require("../db/polygons"); 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, kind, status, is_deleted, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?, ?) `).run( id, name, slug, description, typeId, null, 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 = ?, kind = ?, status = ?, updated_at = ? WHERE id = ? `).run( name, slug, description, typeId, null, 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 }); }); 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"); }