chore: init backend and ignore local artifacts

This commit is contained in:
taDuc
2026-04-13 21:33:12 +07:00
commit 6f7e819aca
11 changed files with 3703 additions and 0 deletions

345
routes/entities.js Normal file
View File

@@ -0,0 +1,345 @@
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");
}

829
routes/geometries.js Normal file
View File

@@ -0,0 +1,829 @@
const express = require("express");
const crypto = require("crypto");
const db = require("../db/polygons");
const { getBBox } = require("../utils/bbox");
const router = express.Router();
// =======================
// Create geometry
// =======================
router.post("/", (req, res) => {
const { geometry } = req.body || {};
if (!geometry) {
return res.status(400).json({ error: "Missing geometry" });
}
let temporalRange;
let entityPayload;
let bindingPayload;
try {
temporalRange = normalizeTemporalRange(req.body?.time_start, req.body?.time_end);
entityPayload = extractEntityIdsFromPayload(req.body || {});
bindingPayload = extractBindingFromPayload(req.body || {});
ensureGeometryHasEntities(entityPayload.entityIds);
validateEntityIdsExist(entityPayload.entityIds);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
const id = crypto.randomUUID();
const now = new Date().toISOString();
const bbox = getBBox(geometry);
const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, id);
const geometryType = resolveGeometryType(req.body?.type, entityPayload.entityIds);
try {
const tx = db.transaction(() => {
db.prepare(`
INSERT INTO geometries (
id, type, is_deleted, draw_geometry, binding, time_start, time_end,
bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat,
created_at, updated_at
)
VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
id,
geometryType,
JSON.stringify(geometry),
serializeBindingIds(bindingIds),
temporalRange.timeStart,
temporalRange.timeEnd,
bbox.minLng,
bbox.minLat,
bbox.maxLng,
bbox.maxLat,
now,
now
);
syncGeometryEntityLinks(id, entityPayload.entityIds, now);
});
tx();
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
console.error("Create geometry failed:", err);
return res.status(500).json({ error: "Create geometry failed" });
}
res.json({ id });
});
// =======================
// Query by bbox/time(/entity)
// =======================
router.get("/", (req, res) => {
const minLng = Number(req.query.minLng);
const minLat = Number(req.query.minLat);
const maxLng = Number(req.query.maxLng);
const maxLat = Number(req.query.maxLat);
if (
!Number.isFinite(minLng) ||
!Number.isFinite(minLat) ||
!Number.isFinite(maxLng) ||
!Number.isFinite(maxLat)
) {
return res.status(400).json({ error: "Missing/invalid bbox" });
}
let t = null;
if (req.query.time !== undefined && req.query.time !== "") {
const parsed = Number(req.query.time);
if (!Number.isFinite(parsed)) {
return res.status(400).json({ error: "Invalid time" });
}
t = Math.trunc(parsed);
}
let entityId = null;
try {
entityId = normalizeEntityId(req.query.entity_id);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
const rows = db.prepare(`
SELECT
g.*
FROM geometries g
WHERE g.is_deleted = 0
AND g.bbox_max_lng >= ?
AND g.bbox_min_lng <= ?
AND g.bbox_max_lat >= ?
AND g.bbox_min_lat <= ?
AND (
? IS NULL
OR (
(g.time_start IS NULL OR g.time_start <= ?)
AND (g.time_end IS NULL OR g.time_end >= ?)
)
)
AND (
? IS NULL
OR EXISTS (
SELECT 1
FROM entity_geometries eg
JOIN entities e
ON e.id = eg.entity_id
AND e.is_deleted = 0
WHERE eg.geometry_id = g.id
AND eg.entity_id = ?
)
)
`).all(minLng, maxLng, minLat, maxLat, t, t, t, entityId, entityId);
const geometryIds = rows.map((row) => String(row.id));
const linksByGeometryId = loadGeometryLinksByGeometryId(geometryIds);
res.json({
type: "FeatureCollection",
features: rows.map((row) => {
const linkedEntities = linksByGeometryId.get(String(row.id)) || [];
return buildFeatureFromRow(row, linkedEntities);
}),
});
});
// =======================
// Update geometry
// =======================
router.put("/:id", (req, res) => {
const { geometry } = req.body || {};
if (!geometry) {
return res.status(400).json({ error: "Missing geometry" });
}
const existing = db.prepare(`
SELECT id, type, time_start, time_end, binding
FROM geometries
WHERE id = ?
AND is_deleted = 0
`).get(req.params.id);
if (!existing) {
return res.status(404).json({ error: "Not found" });
}
const hasTimeStart = Object.prototype.hasOwnProperty.call(req.body || {}, "time_start");
const hasTimeEnd = Object.prototype.hasOwnProperty.call(req.body || {}, "time_end");
const hasType = Object.prototype.hasOwnProperty.call(req.body || {}, "type");
let temporalRange;
let entityPayload;
let nextEntityIds;
let bindingPayload;
let nextBindingIds;
let nextType;
try {
temporalRange = normalizeTemporalRange(
hasTimeStart ? req.body?.time_start : existing.time_start,
hasTimeEnd ? req.body?.time_end : existing.time_end
);
entityPayload = extractEntityIdsFromPayload(req.body || {});
nextEntityIds = entityPayload.provided
? entityPayload.entityIds
: getLinkedEntityIdsByGeometryId(req.params.id);
nextType = hasType
? resolveGeometryType(req.body?.type, nextEntityIds, existing.type)
: resolveGeometryType(null, nextEntityIds, existing.type);
bindingPayload = extractBindingFromPayload(req.body || {});
nextBindingIds = bindingPayload.provided
? bindingPayload.bindingIds
: parseBindingIds(existing.binding);
nextBindingIds = sanitizeBindingIdsForGeometry(nextBindingIds, req.params.id);
ensureGeometryHasEntities(nextEntityIds);
validateEntityIdsExist(nextEntityIds);
} catch (err) {
return res.status(err.status || 400).json({ error: err.message });
}
const bbox = getBBox(geometry);
const now = new Date().toISOString();
try {
const tx = db.transaction(() => {
const result = db.prepare(`
UPDATE geometries
SET type = ?,
draw_geometry = ?,
binding = ?,
time_start = ?,
time_end = ?,
bbox_min_lng = ?,
bbox_min_lat = ?,
bbox_max_lng = ?,
bbox_max_lat = ?,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(
nextType,
JSON.stringify(geometry),
serializeBindingIds(nextBindingIds),
temporalRange.timeStart,
temporalRange.timeEnd,
bbox.minLng,
bbox.minLat,
bbox.maxLng,
bbox.maxLat,
now,
req.params.id
);
if (!result.changes) {
throw createValidationError("Not found", 404);
}
if (entityPayload.provided) {
syncGeometryEntityLinks(req.params.id, nextEntityIds, now);
}
});
tx();
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
console.error("Update geometry failed:", err);
return res.status(500).json({ error: "Update geometry failed" });
}
res.json({ success: true });
});
// =======================
// Soft-delete geometry
// =======================
router.delete("/:id", (req, res) => {
const now = new Date().toISOString();
const tx = db.transaction(() => {
const result = db.prepare(`
UPDATE geometries
SET is_deleted = 1,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(now, req.params.id);
if (!result.changes) {
throw createValidationError("Not found", 404);
}
db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(req.params.id);
removeBindingReferenceFromAll(req.params.id, now);
});
try {
tx();
} catch (err) {
if (err.status) {
return res.status(err.status).json({ error: err.message });
}
console.error("Delete geometry failed:", err);
return res.status(500).json({ error: "Delete geometry failed" });
}
res.json({ success: true });
});
// =======================
// Apply batch of create/update/delete (used by FE Save)
// =======================
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) => {
for (const change of items) {
const hasAction = Object.prototype.hasOwnProperty.call(change || {}, "action");
const action = normalizeBatchAction(hasAction ? change.action : change?.type);
if (!change || !action) {
throw createValidationError("Invalid change entry");
}
if (action === "create") {
const feature = change.feature;
if (!feature || !feature.properties || !feature.properties.id || !feature.geometry) {
throw createValidationError("Invalid create payload");
}
const temporalRange = normalizeTemporalRange(
feature.properties.time_start,
feature.properties.time_end
);
const entityPayload = extractEntityIdsFromPayload(feature.properties || {});
const entityIds = entityPayload.entityIds;
ensureGeometryHasEntities(entityIds);
validateEntityIdsExist(entityIds);
const bbox = getBBox(feature.geometry);
const geometryId = String(feature.properties.id);
const bindingPayload = extractBindingFromPayload(feature.properties || {});
const bindingIds = sanitizeBindingIdsForGeometry(bindingPayload.bindingIds, geometryId);
const geometryType = resolveGeometryType(feature.properties?.type, entityIds);
db.prepare(`
INSERT OR REPLACE INTO geometries (
id, type, is_deleted, draw_geometry, binding, time_start, time_end,
bbox_min_lng, bbox_min_lat, bbox_max_lng, bbox_max_lat,
created_at, updated_at
)
VALUES (?, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((
SELECT created_at FROM geometries WHERE id = ?
), ?), ?)
`).run(
geometryId,
geometryType,
JSON.stringify(feature.geometry),
serializeBindingIds(bindingIds),
temporalRange.timeStart,
temporalRange.timeEnd,
bbox.minLng,
bbox.minLat,
bbox.maxLng,
bbox.maxLat,
geometryId,
now,
now
);
syncGeometryEntityLinks(geometryId, entityIds, now);
continue;
}
if (action === "update") {
const { id, geometry } = change;
if (!id || !geometry) {
throw createValidationError("Invalid update payload");
}
const existing = db.prepare(`
SELECT id, type, time_start, time_end, binding
FROM geometries
WHERE id = ?
AND is_deleted = 0
`).get(id);
if (!existing) {
continue;
}
const hasTimeStart = Object.prototype.hasOwnProperty.call(change, "time_start");
const hasTimeEnd = Object.prototype.hasOwnProperty.call(change, "time_end");
const hasType = hasAction && Object.prototype.hasOwnProperty.call(change, "type");
const temporalRange = normalizeTemporalRange(
hasTimeStart ? change.time_start : existing.time_start,
hasTimeEnd ? change.time_end : existing.time_end
);
const bindingPayload = extractBindingFromPayload(change);
const nextBindingIds = sanitizeBindingIdsForGeometry(
bindingPayload.provided
? bindingPayload.bindingIds
: parseBindingIds(existing.binding),
id
);
const entityPayload = extractEntityIdsFromPayload(change);
const nextEntityIds = entityPayload.provided
? entityPayload.entityIds
: getLinkedEntityIdsByGeometryId(id);
ensureGeometryHasEntities(nextEntityIds);
validateEntityIdsExist(nextEntityIds);
const nextType = hasType
? resolveGeometryType(change.type, nextEntityIds, existing.type)
: resolveGeometryType(null, nextEntityIds, existing.type);
const bbox = getBBox(geometry);
db.prepare(`
UPDATE geometries
SET type = ?,
draw_geometry = ?,
binding = ?,
time_start = ?,
time_end = ?,
bbox_min_lng = ?,
bbox_min_lat = ?,
bbox_max_lng = ?,
bbox_max_lat = ?,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(
nextType,
JSON.stringify(geometry),
serializeBindingIds(nextBindingIds),
temporalRange.timeStart,
temporalRange.timeEnd,
bbox.minLng,
bbox.minLat,
bbox.maxLng,
bbox.maxLat,
now,
id
);
if (entityPayload.provided) {
syncGeometryEntityLinks(id, nextEntityIds, now);
}
continue;
}
if (action === "delete") {
if (!change.id) {
throw createValidationError("Invalid delete payload");
}
const result = db.prepare(`
UPDATE geometries
SET is_deleted = 1,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(now, change.id);
if (result.changes) {
db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(change.id);
removeBindingReferenceFromAll(change.id, now);
}
continue;
}
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;
function buildFeatureFromRow(row, linkedEntities = []) {
const entityIds = linkedEntities.map((entity) => entity.entity_id);
const primaryEntityId = entityIds[0] || null;
const orderedEntityIds = entityIds;
const orderedLinkedEntities = linkedEntities;
const primaryEntity =
orderedLinkedEntities.find((entity) => entity.entity_id === primaryEntityId) ||
orderedLinkedEntities[0] ||
null;
const storedGeometryType = normalizeGeometryType(row.type);
const semanticType = storedGeometryType && !isLegacyLineModeToken(storedGeometryType)
? storedGeometryType
: normalizeGeometryType(primaryEntity?.entity_type_id);
return {
type: "Feature",
properties: {
id: row.id,
type: semanticType || null,
time_start: row.time_start,
time_end: row.time_end,
binding: parseBindingIds(row.binding),
entity_id: primaryEntityId,
entity_ids: orderedEntityIds,
entity_name: primaryEntity?.entity_name || null,
entity_names: orderedLinkedEntities
.map((entity) => entity.entity_name)
.filter((name) => typeof name === "string" && name.length > 0),
entity_type_id: primaryEntity?.entity_type_id || null,
},
geometry: JSON.parse(row.draw_geometry),
};
}
function normalizeOptionalYear(value) {
if (value === undefined || value === null || value === "") return null;
const num = Number(value);
if (!Number.isFinite(num)) {
throw createValidationError("time_start/time_end must be numbers");
}
return Math.trunc(num);
}
function normalizeGeometryType(value) {
if (value === undefined || value === null || value === "") return null;
const normalized = String(value).trim().toLowerCase();
return normalized.length ? normalized : null;
}
function resolveGeometryType(requestedType, entityIds, fallbackType = null) {
const normalizedRequestedType = normalizeGeometryType(requestedType);
if (normalizedRequestedType && !isLegacyLineModeToken(normalizedRequestedType)) {
return normalizedRequestedType;
}
const derivedType = deriveGeometryTypeFromEntityIds(entityIds);
if (derivedType) return derivedType;
const normalizedFallbackType = normalizeGeometryType(fallbackType);
if (normalizedFallbackType && !isLegacyLineModeToken(normalizedFallbackType)) {
return normalizedFallbackType;
}
return null;
}
function deriveGeometryTypeFromEntityIds(entityIds) {
if (!Array.isArray(entityIds) || !entityIds.length) return null;
const primaryEntityId = normalizeEntityId(entityIds[0]);
if (!primaryEntityId) return null;
const row = db.prepare(`
SELECT type_id
FROM entities
WHERE id = ?
AND is_deleted = 0
LIMIT 1
`).get(primaryEntityId);
return normalizeGeometryType(row?.type_id);
}
function isLegacyLineModeToken(value) {
return value === "line" || value === "path";
}
function normalizeTemporalRange(timeStartValue, timeEndValue) {
const timeStart = normalizeOptionalYear(timeStartValue);
const timeEnd = normalizeOptionalYear(timeEndValue);
if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) {
throw createValidationError("time_start must be <= time_end");
}
return { timeStart, timeEnd };
}
function normalizeEntityId(value) {
if (value === undefined || value === null || value === "") return null;
const normalized = String(value).trim();
return normalized || null;
}
function normalizeEntityIds(rawEntityIds) {
if (rawEntityIds === undefined) return [];
if (rawEntityIds === null) return [];
if (!Array.isArray(rawEntityIds)) {
throw createValidationError("entity_ids must be an array");
}
const deduped = [];
const seen = new Set();
for (const rawId of rawEntityIds) {
const entityId = normalizeEntityId(rawId);
if (!entityId || seen.has(entityId)) continue;
seen.add(entityId);
deduped.push(entityId);
}
return deduped;
}
function normalizeBindingIds(rawBinding) {
if (rawBinding === undefined || rawBinding === null || rawBinding === "") {
return [];
}
if (!Array.isArray(rawBinding)) {
throw createValidationError("binding must be an array");
}
const deduped = [];
const seen = new Set();
for (const rawId of rawBinding) {
if (typeof rawId !== "string" && typeof rawId !== "number") {
throw createValidationError("binding must contain geometry ids as string/number");
}
const normalized = String(rawId).trim();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
deduped.push(normalized);
}
return deduped;
}
function parseBindingIds(rawBinding) {
if (rawBinding === undefined || rawBinding === null || rawBinding === "") {
return [];
}
if (Array.isArray(rawBinding)) {
try {
return normalizeBindingIds(rawBinding);
} catch (_err) {
return [];
}
}
if (typeof rawBinding === "string") {
try {
const parsed = JSON.parse(rawBinding);
return normalizeBindingIds(parsed);
} catch (_err) {
const plain = rawBinding.trim();
return plain.length ? [plain] : [];
}
}
return [];
}
function extractBindingFromPayload(payload) {
const hasBinding = Object.prototype.hasOwnProperty.call(payload || {}, "binding");
if (!hasBinding) {
return {
provided: false,
bindingIds: [],
};
}
return {
provided: true,
bindingIds: normalizeBindingIds(payload.binding),
};
}
function sanitizeBindingIdsForGeometry(bindingIds, geometryId) {
const selfId = String(geometryId);
return bindingIds.filter((bindingId) => bindingId !== selfId);
}
function serializeBindingIds(bindingIds) {
return bindingIds.length ? JSON.stringify(bindingIds) : null;
}
function extractEntityIdsFromPayload(payload) {
const hasEntityIds = Object.prototype.hasOwnProperty.call(payload || {}, "entity_ids");
const hasEntityId = Object.prototype.hasOwnProperty.call(payload || {}, "entity_id");
if (!hasEntityIds && !hasEntityId) {
return {
provided: false,
entityIds: [],
};
}
const fromArray = hasEntityIds ? normalizeEntityIds(payload.entity_ids) : [];
const singleEntityId = hasEntityId ? normalizeEntityId(payload.entity_id) : null;
const entityIds = [...fromArray];
if (singleEntityId && !entityIds.includes(singleEntityId)) {
entityIds.unshift(singleEntityId);
}
return {
provided: true,
entityIds,
};
}
function ensureGeometryHasEntities(entityIds) {
if (!Array.isArray(entityIds) || !entityIds.length) {
throw createValidationError("geometry must be linked to at least one entity");
}
}
function validateEntityIdsExist(entityIds) {
if (!entityIds.length) {
throw createValidationError("geometry must be linked to at least one entity");
}
const placeholders = entityIds.map(() => "?").join(",");
const rows = db.prepare(`
SELECT id
FROM entities
WHERE is_deleted = 0
AND id IN (${placeholders})
`).all(...entityIds);
const found = new Set(rows.map((row) => row.id));
const missing = entityIds.filter((entityId) => !found.has(entityId));
if (missing.length) {
throw createValidationError(`Entity not found: ${missing.join(", ")}`);
}
}
function getLinkedEntityIdsByGeometryId(geometryId) {
const rows = db.prepare(`
SELECT eg.entity_id
FROM entity_geometries eg
JOIN entities e
ON e.id = eg.entity_id
AND e.is_deleted = 0
WHERE eg.geometry_id = ?
ORDER BY eg.rowid ASC
`).all(String(geometryId));
return rows.map((row) => row.entity_id);
}
function loadGeometryLinksByGeometryId(geometryIds) {
const map = new Map();
if (!geometryIds.length) return map;
const placeholders = geometryIds.map(() => "?").join(",");
const rows = db.prepare(`
SELECT
eg.geometry_id,
eg.entity_id,
e.name AS entity_name,
e.type_id AS entity_type_id
FROM entity_geometries eg
JOIN entities e
ON e.id = eg.entity_id
AND e.is_deleted = 0
WHERE eg.geometry_id IN (${placeholders})
ORDER BY eg.rowid ASC
`).all(...geometryIds);
for (const row of rows) {
const geometryId = String(row.geometry_id);
const current = map.get(geometryId) || [];
current.push({
entity_id: row.entity_id,
entity_name: row.entity_name || null,
entity_type_id: row.entity_type_id || null,
});
map.set(geometryId, current);
}
return map;
}
function syncGeometryEntityLinks(geometryId, entityIds, now) {
db.prepare(`
DELETE FROM entity_geometries
WHERE geometry_id = ?
`).run(String(geometryId));
for (const entityId of entityIds) {
db.prepare(`
INSERT INTO entity_geometries (entity_id, geometry_id, created_at)
VALUES (?, ?, ?)
`).run(entityId, String(geometryId), now);
}
}
function removeBindingReferenceFromAll(removedGeometryId, now) {
const removedId = String(removedGeometryId);
const rows = db.prepare(`
SELECT id, binding
FROM geometries
WHERE is_deleted = 0
AND binding IS NOT NULL
AND binding != ''
`).all();
for (const row of rows) {
const currentBindingIds = parseBindingIds(row.binding);
if (!currentBindingIds.length) continue;
const nextBindingIds = currentBindingIds.filter((bindingId) => bindingId !== removedId);
if (nextBindingIds.length === currentBindingIds.length) continue;
db.prepare(`
UPDATE geometries
SET binding = ?,
updated_at = ?
WHERE id = ?
AND is_deleted = 0
`).run(serializeBindingIds(nextBindingIds), now, row.id);
}
}
function createValidationError(message, status = 400) {
const err = new Error(message);
err.status = status;
return err;
}
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;
}

57
routes/rasterTiles.js Normal file
View File

@@ -0,0 +1,57 @@
const express = require("express");
const Database = require("better-sqlite3");
const path = require("path");
const router = express.Router();
const mbtilesPath = path.join(__dirname, "..", "data", "raster.mbtiles");
const tileDb = new Database(mbtilesPath, { readonly: true });
const metadataRows = tileDb.prepare("SELECT name, value FROM metadata").all();
const metadata = {};
for (const row of metadataRows) {
metadata[row.name] = row.value;
}
let contentType = "application/octet-stream";
if (metadata.format === "png") {
contentType = "image/png";
} else if (metadata.format === "jpg" || metadata.format === "jpeg") {
contentType = "image/jpeg";
} else if (metadata.format === "webp") {
contentType = "image/webp";
}
router.get("/metadata/info", (req, res) => {
res.json(metadata);
});
router.get("/:z/:x/:y", (req, res) => {
const z = Number(req.params.z);
const x = Number(req.params.x);
const y = Number(req.params.y);
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) {
return res.status(400).send("Invalid tile coordinates");
}
const tmsY = (1 << z) - 1 - y;
const tile = tileDb.prepare(`
SELECT tile_data
FROM tiles
WHERE zoom_level = ?
AND tile_column = ?
AND tile_row = ?
`).get(z, x, tmsY);
if (!tile) {
return res.status(404).send("Tile not found");
}
res.setHeader("Content-Type", contentType);
res.send(tile.tile_data);
});
module.exports = router;

79
routes/tiles.js Normal file
View File

@@ -0,0 +1,79 @@
const express = require("express");
const Database = require("better-sqlite3");
const path = require("path");
const router = express.Router();
// =======================
// MBTiles DB (READONLY)
// =======================
const mbtilesPath = path.join(__dirname, "..", "data", "map.mbtiles");
const tileDb = new Database(mbtilesPath, { readonly: true });
// =======================
// 📊 METADATA
// =======================
const metadataRows = tileDb.prepare("SELECT name, value FROM metadata").all();
const metadata = {};
for (const row of metadataRows) {
metadata[row.name] = row.value;
}
// decide content-type
let contentType = "application/octet-stream";
if (metadata.format === "pbf") {
contentType = "application/x-protobuf";
} else if (metadata.format === "png") {
contentType = "image/png";
} else if (metadata.format === "jpg" || metadata.format === "jpeg") {
contentType = "image/jpeg";
}
// =======================
// METADATA API
// =======================
router.get("/metadata/info", (req, res) => {
res.json(metadata);
});
// =======================
// TILE API (XYZ → TMS)
// =======================
router.get("/:z/:x/:y", (req, res) => {
const z = Number(req.params.z);
const x = Number(req.params.x);
const y = Number(req.params.y);
if (!Number.isInteger(z) || !Number.isInteger(x) || !Number.isInteger(y)) {
return res.status(400).send("Invalid tile coordinates");
}
// convert XYZ → TMS
const tmsY = (1 << z) - 1 - y;
const stmt = tileDb.prepare(`
SELECT tile_data
FROM tiles
WHERE zoom_level = ?
AND tile_column = ?
AND tile_row = ?
`);
const tile = stmt.get(z, x, tmsY);
if (!tile) {
return res.status(404).send("Tile not found");
}
res.setHeader("Content-Type", contentType);
if (metadata.format === "pbf") {
res.setHeader("Content-Encoding", "gzip");
}
res.send(tile.tile_data);
});
module.exports = router;