diff --git a/db/polygons.js b/db/polygons.js index 3d7cbec..0d3ac0d 100644 --- a/db/polygons.js +++ b/db/polygons.js @@ -48,11 +48,76 @@ db.prepare(` ) `).run(); +db.prepare(` + CREATE TABLE IF NOT EXISTS sections ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT, + user_id TEXT, + created_by TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL + ) +`).run(); + +db.prepare(` + CREATE TABLE IF NOT EXISTS section_states ( + section_id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'editing', + head_commit_id TEXT, + version INTEGER NOT NULL DEFAULT 0, + locked_by TEXT, + locked_at TEXT, + lock_expires_at TEXT, + updated_at TEXT NOT NULL, + FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE + ) +`).run(); + +db.prepare(` + CREATE TABLE IF NOT EXISTS section_commits ( + id TEXT PRIMARY KEY, + section_id TEXT NOT NULL, + parent_commit_id TEXT, + commit_no INTEGER NOT NULL, + kind TEXT NOT NULL DEFAULT 'manual', + restored_from_commit_id TEXT, + created_by TEXT NOT NULL, + created_at TEXT NOT NULL, + title TEXT, + note TEXT, + snapshot_json TEXT NOT NULL, + snapshot_hash TEXT, + FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE, + FOREIGN KEY (parent_commit_id) REFERENCES section_commits(id), + FOREIGN KEY (restored_from_commit_id) REFERENCES section_commits(id) + ) +`).run(); + +db.prepare(` + CREATE TABLE IF NOT EXISTS section_submissions ( + id TEXT PRIMARY KEY, + section_id TEXT NOT NULL, + commit_id TEXT NOT NULL, + submitted_by TEXT NOT NULL, + submitted_at TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', + reviewed_by TEXT, + reviewed_at TEXT, + review_note TEXT, + snapshot_json TEXT NOT NULL, + snapshot_hash TEXT, + FOREIGN KEY (section_id) REFERENCES sections(id) ON DELETE CASCADE, + FOREIGN KEY (commit_id) REFERENCES section_commits(id) + ) +`).run(); + ensureColumn("entities", "status", "INTEGER DEFAULT 1"); ensureColumn("entities", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("entities", "type_id", "TEXT NOT NULL DEFAULT 'country'"); ensureColumn("geometries", "is_deleted", "INTEGER NOT NULL DEFAULT 0"); ensureColumn("geometries", "binding", "TEXT"); +ensureColumn("sections", "user_id", "TEXT"); dropEntityDeprecatedColumnsIfExists(); dropGeometryDeprecatedColumnsIfExists(); migrateLegacyGeometryTypeTokens(); @@ -79,6 +144,26 @@ db.prepare(` ON entity_geometries(entity_id) `).run(); +db.prepare(` + CREATE INDEX IF NOT EXISTS idx_section_states_status + ON section_states(status, updated_at) +`).run(); + +db.prepare(` + CREATE UNIQUE INDEX IF NOT EXISTS idx_section_commits_no + ON section_commits(section_id, commit_no) +`).run(); + +db.prepare(` + CREATE INDEX IF NOT EXISTS idx_section_commits_section_time + ON section_commits(section_id, created_at) +`).run(); + +db.prepare(` + CREATE INDEX IF NOT EXISTS idx_section_submissions_section_status + ON section_submissions(section_id, status, submitted_at) +`).run(); + module.exports = db; function ensureColumn(tableName, columnName, columnDefinition) { diff --git a/index.js b/index.js index 065b045..a1a61e8 100644 --- a/index.js +++ b/index.js @@ -6,6 +6,7 @@ const tileRoutes = require("./routes/tiles"); const rasterTileRoutes = require("./routes/rasterTiles"); const geoRoutes = require("./routes/geometries"); const entityRoutes = require("./routes/entities"); +const sectionRoutes = require("./routes/sections"); const { openApiSpec } = require("./swagger"); const app = express(); @@ -18,6 +19,11 @@ app.use("/tiles", tileRoutes); app.use("/raster-tiles", rasterTileRoutes); app.use("/geometries", geoRoutes); app.use("/entities", entityRoutes); +app.use("/sections", sectionRoutes); +app.use("/submissions", (req, res, next) => { + req.url = `/submissions${req.url}`; + sectionRoutes(req, res, next); +}); app.use("/docs", swaggerUi.serve, swaggerUi.setup(openApiSpec, { explorer: true, })); diff --git a/routes/entities.js b/routes/entities.js index 967561f..1c7cf56 100644 --- a/routes/entities.js +++ b/routes/entities.js @@ -1,7 +1,5 @@ const express = require("express"); -const crypto = require("crypto"); const db = require("../db/polygons"); -const { applyEntityBatchChanges } = require("../lib/entityBatch"); const router = express.Router(); @@ -80,228 +78,6 @@ router.get("/:id", (req, res) => { 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) { @@ -318,34 +94,6 @@ function normalizeEntityRow(row) { }; } -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; @@ -353,18 +101,3 @@ function normalizeLimit(value, fallback = 25, max = 100) { 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"); -} diff --git a/routes/geometries.js b/routes/geometries.js index abe8111..2bbc6b7 100644 --- a/routes/geometries.js +++ b/routes/geometries.js @@ -1,78 +1,8 @@ const express = require("express"); -const crypto = require("crypto"); const db = require("../db/polygons"); -const { applyEntityBatchChanges } = require("../lib/entityBatch"); -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) // ======================= @@ -149,380 +79,6 @@ router.get("/", (req, res) => { }); }); -// ======================= -// 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) => { - applyGeometryBatchChanges(items, now); - }); - 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" }); - } -}); - -// ======================= -// Apply entities + geometries in one transaction -// ======================= -router.post("/batch/combined", (req, res) => { - const entityChangesRaw = req.body?.entity_changes; - const geometryChangesRaw = req.body?.geometry_changes; - - if (entityChangesRaw !== undefined && !Array.isArray(entityChangesRaw)) { - return res.status(400).json({ error: "entity_changes must be an array" }); - } - if (geometryChangesRaw !== undefined && !Array.isArray(geometryChangesRaw)) { - return res.status(400).json({ error: "geometry_changes must be an array" }); - } - - const entityChanges = entityChangesRaw || []; - const geometryChanges = geometryChangesRaw || []; - const now = new Date().toISOString(); - - try { - const tx = db.transaction((entities, geometries) => { - const entityResult = applyEntityBatchChanges(entities, { now }); - applyGeometryBatchChanges(geometries, now); - return entityResult; - }); - - const entityResult = tx(entityChanges, geometryChanges); - res.json({ - success: true, - applied: entityChanges.length + geometryChanges.length, - entity_applied: entityChanges.length, - geometry_applied: geometryChanges.length, - created_entity_ids: entityResult.createdEntityIds, - }); - } catch (err) { - if (err.status) { - return res.status(err.status).json({ error: err.message }); - } - console.error("Combined batch apply error:", err); - res.status(500).json({ error: "Combined batch apply failed" }); - } -}); - -function applyGeometryBatchChanges(items, now) { - 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)}`); - } -} - module.exports = router; function buildFeatureFromRow(row, linkedEntities = []) { @@ -559,90 +115,22 @@ function buildFeatureFromRow(row, linkedEntities = []) { }; } -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 []; @@ -695,94 +183,6 @@ function parseBindingIds(rawBinding) { 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; @@ -816,59 +216,8 @@ function loadGeometryLinksByGeometryId(geometryIds) { 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; -} diff --git a/routes/sections.js b/routes/sections.js new file mode 100644 index 0000000..1f3b30e --- /dev/null +++ b/routes/sections.js @@ -0,0 +1,1142 @@ +const express = require("express"); +const crypto = require("crypto"); +const db = require("../db/polygons"); +const { getBBox } = require("../utils/bbox"); + +const router = express.Router(); +const LOCK_TTL_MS = 15 * 60 * 1000; + +router.get("/", (_req, res) => { + const rows = db.prepare(` + SELECT + s.*, + st.status, + st.head_commit_id, + st.version, + st.locked_by, + st.locked_at, + st.lock_expires_at + FROM sections s + LEFT JOIN section_states st + ON st.section_id = s.id + ORDER BY s.updated_at DESC + `).all(); + + res.json(rows.map(normalizeSectionRow)); +}); + +router.post("/", (req, res) => { + const title = normalizeRequiredString(req.body?.title); + if (!title) { + return res.status(400).json({ error: "title is required" }); + } + + const id = normalizeId(req.body?.id) || crypto.randomUUID(); + const now = new Date().toISOString(); + const description = normalizeOptionalString(req.body?.description); + const userId = normalizeActor(req.body?.user_id || req.body?.created_by); + const createdBy = normalizeActor(req.body?.created_by || req.body?.user_id); + + try { + const tx = db.transaction(() => { + db.prepare(` + INSERT INTO sections (id, title, description, user_id, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `).run(id, title, description, userId, createdBy, now, now); + + ensureSectionState(id, now); + }); + tx(); + } catch (err) { + if (isSqliteConstraint(err)) { + return res.status(409).json({ error: "Section id already exists" }); + } + console.error("Create section failed", err); + return res.status(500).json({ error: "Create section failed" }); + } + + res.status(201).json(getSectionById(id)); +}); + +router.get("/:sectionId/editor", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.query.user_id || req.query.user || req.get("x-user-id")); + const now = new Date().toISOString(); + let state; + let commit = null; + + try { + const tx = db.transaction(() => { + state = ensureSectionState(section.id, now); + assertLockAvailable(state, actor, now); + if (actor) { + state = acquireLock(section.id, actor, now); + } + if (state.head_commit_id) { + commit = getCommitForSection(section.id, state.head_commit_id); + } + }); + tx(); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + console.error("Open editor failed", err); + return res.status(500).json({ error: "Open editor failed" }); + } + + res.json({ + section, + state: normalizeStateRow(state), + commit: commit ? normalizeCommitRow(commit, false) : null, + snapshot: commit ? parseSnapshotJson(commit.snapshot_json) : buildEmptySnapshot(section), + }); +}); + +router.post("/:sectionId/lock", (req, res) => { + const actor = normalizeActor(req.body?.user_id || req.body?.user || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "user_id is required" }); + } + + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const now = new Date().toISOString(); + try { + const state = db.transaction(() => { + const current = ensureSectionState(section.id, now); + assertLockAvailable(current, actor, now); + return acquireLock(section.id, actor, now); + })(); + res.json({ state: normalizeStateRow(state) }); + } catch (err) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + console.error("Lock section failed", err); + res.status(500).json({ error: "Lock section failed" }); + } +}); + +router.post("/:sectionId/unlock", (req, res) => { + const actor = normalizeActor(req.body?.user_id || req.body?.user || req.get("x-user-id")); + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const state = ensureSectionState(section.id, new Date().toISOString()); + if (state.locked_by && actor && state.locked_by !== actor) { + return res.status(409).json({ error: "Section is locked by another user" }); + } + + db.prepare(` + UPDATE section_states + SET locked_by = NULL, + locked_at = NULL, + lock_expires_at = NULL, + updated_at = ? + WHERE section_id = ? + `).run(new Date().toISOString(), section.id); + + res.json({ success: true }); +}); + +router.get("/:sectionId/commits", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const includeSnapshot = req.query.include_snapshot === "1" || req.query.include_snapshot === "true"; + const rows = db.prepare(` + SELECT * + FROM section_commits + WHERE section_id = ? + ORDER BY commit_no DESC + `).all(section.id); + + res.json(rows.map((row) => normalizeCommitRow(row, includeSnapshot))); +}); + +router.post("/:sectionId/commits", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.body?.created_by || req.body?.user_id || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "created_by is required" }); + } + + const snapshotResult = normalizeSnapshotInput(req.body?.snapshot_json ?? req.body?.snapshot); + if (snapshotResult.error) { + return res.status(400).json({ error: snapshotResult.error }); + } + + try { + const result = db.transaction(() => { + const now = new Date().toISOString(); + const state = ensureSectionState(section.id, now); + assertCanEdit(state, actor, now); + assertExpectedState(state, req.body); + validateSnapshot(snapshotResult.snapshot); + + const commit = insertCommit({ + section, + state, + snapshotJson: snapshotResult.snapshotJson, + snapshotHash: hashString(snapshotResult.snapshotJson), + kind: "manual", + restoredFromCommitId: null, + actor, + title: normalizeOptionalString(req.body?.title), + note: normalizeOptionalString(req.body?.note), + now, + }); + + const nextState = setHeadCommit(section.id, commit.id, now); + return { commit, state: nextState }; + })(); + + res.status(201).json({ + commit: normalizeCommitRow(result.commit, true), + state: normalizeStateRow(result.state), + }); + } catch (err) { + handleRouteError(res, err, "Create commit failed"); + } +}); + +router.post("/:sectionId/restore", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.body?.created_by || req.body?.user_id || req.get("x-user-id")); + const restoreCommitId = normalizeId(req.body?.commit_id || req.body?.restore_commit_id); + if (!actor) { + return res.status(400).json({ error: "created_by is required" }); + } + if (!restoreCommitId) { + return res.status(400).json({ error: "commit_id is required" }); + } + + try { + const result = db.transaction(() => { + const now = new Date().toISOString(); + const state = ensureSectionState(section.id, now); + assertCanEdit(state, actor, now); + assertExpectedState(state, req.body); + + const sourceCommit = getCommitForSection(section.id, restoreCommitId); + if (!sourceCommit) { + throw createHttpError("Commit not found", 404); + } + + const commit = insertCommit({ + section, + state, + snapshotJson: sourceCommit.snapshot_json, + snapshotHash: sourceCommit.snapshot_hash || hashString(sourceCommit.snapshot_json), + kind: "restore", + restoredFromCommitId: sourceCommit.id, + actor, + title: normalizeOptionalString(req.body?.title) || `Restore #${sourceCommit.commit_no}`, + note: normalizeOptionalString(req.body?.note), + now, + }); + + const nextState = setHeadCommit(section.id, commit.id, now); + return { commit, state: nextState }; + })(); + + res.status(201).json({ + commit: normalizeCommitRow(result.commit, true), + state: normalizeStateRow(result.state), + }); + } catch (err) { + handleRouteError(res, err, "Restore section failed"); + } +}); + +router.post("/:sectionId/submit", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const actor = normalizeActor(req.body?.submitted_by || req.body?.user_id || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "submitted_by is required" }); + } + + try { + const submission = db.transaction(() => { + const now = new Date().toISOString(); + const state = ensureSectionState(section.id, now); + assertLockAvailable(state, actor, now); + if (state.status !== "editing") { + throw createHttpError("Section is not editable", 409); + } + + const commitId = normalizeId(req.body?.commit_id) || state.head_commit_id; + if (!commitId) { + throw createHttpError("Section has no commit to submit", 400); + } + + const commit = getCommitForSection(section.id, commitId); + if (!commit) { + throw createHttpError("Commit not found", 404); + } + + const id = crypto.randomUUID(); + db.prepare(` + INSERT INTO section_submissions ( + id, section_id, commit_id, submitted_by, submitted_at, status, + snapshot_json, snapshot_hash + ) + VALUES (?, ?, ?, ?, ?, 'pending', ?, ?) + `).run( + id, + section.id, + commit.id, + actor, + now, + commit.snapshot_json, + commit.snapshot_hash || hashString(commit.snapshot_json) + ); + + db.prepare(` + UPDATE section_states + SET status = 'submitted', + locked_by = NULL, + locked_at = NULL, + lock_expires_at = NULL, + updated_at = ? + WHERE section_id = ? + `).run(now, section.id); + + return getSubmissionById(id); + })(); + + res.status(201).json(normalizeSubmissionRow(submission, true)); + } catch (err) { + handleRouteError(res, err, "Submit section failed"); + } +}); + +router.get("/:sectionId/submissions", (req, res) => { + const section = getSectionById(req.params.sectionId); + if (!section) { + return res.status(404).json({ error: "Section not found" }); + } + + const includeSnapshot = req.query.include_snapshot === "1" || req.query.include_snapshot === "true"; + const rows = db.prepare(` + SELECT * + FROM section_submissions + WHERE section_id = ? + ORDER BY submitted_at DESC + `).all(section.id); + + res.json(rows.map((row) => normalizeSubmissionRow(row, includeSnapshot))); +}); + +router.post("/submissions/:submissionId/approve", (req, res) => { + reviewSubmission(req, res, "approve"); +}); + +router.post("/submissions/:submissionId/reject", (req, res) => { + reviewSubmission(req, res, "reject"); +}); + +function reviewSubmission(req, res, action) { + const actor = normalizeActor(req.body?.reviewed_by || req.body?.user_id || req.get("x-user-id")); + if (!actor) { + return res.status(400).json({ error: "reviewed_by is required" }); + } + + try { + const result = db.transaction(() => { + const submission = getSubmissionById(req.params.submissionId); + if (!submission) { + throw createHttpError("Submission not found", 404); + } + if (submission.status !== "pending") { + throw createHttpError("Submission is not pending", 409); + } + + const now = new Date().toISOString(); + const reviewNote = normalizeOptionalString(req.body?.review_note); + + if (action === "reject") { + updateSubmissionReview(submission.id, "rejected", actor, now, reviewNote); + db.prepare(` + UPDATE section_states + SET status = 'rejected', + updated_at = ? + WHERE section_id = ? + `).run(now, submission.section_id); + return { submission: getSubmissionById(submission.id) }; + } + + const expectedHash = submission.snapshot_hash; + if (expectedHash && expectedHash !== hashString(submission.snapshot_json)) { + throw createHttpError("Submission snapshot hash mismatch", 409); + } + + const snapshot = parseSnapshotJson(submission.snapshot_json); + applySnapshotToPublished(snapshot, now); + updateSubmissionReview(submission.id, "approved", actor, now, reviewNote); + db.prepare(` + UPDATE section_states + SET status = 'approved', + updated_at = ? + WHERE section_id = ? + `).run(now, submission.section_id); + + return { submission: getSubmissionById(submission.id) }; + })(); + + res.json(normalizeSubmissionRow(result.submission, true)); + } catch (err) { + if (err && err.isConflict && req.params.submissionId) { + const now = new Date().toISOString(); + db.prepare(` + UPDATE section_submissions + SET status = 'conflicted', + reviewed_by = ?, + reviewed_at = ?, + review_note = ? + WHERE id = ? + AND status = 'pending' + `).run(actor, now, err.message, req.params.submissionId); + } + handleRouteError(res, err, "Review submission failed"); + } +} + +function insertCommit(options) { + const nextNo = getNextCommitNo(options.section.id); + const id = crypto.randomUUID(); + db.prepare(` + INSERT INTO section_commits ( + id, section_id, parent_commit_id, commit_no, kind, restored_from_commit_id, + created_by, created_at, title, note, snapshot_json, snapshot_hash + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + options.section.id, + options.state.head_commit_id || null, + nextNo, + options.kind, + options.restoredFromCommitId, + options.actor, + options.now, + options.title, + options.note, + options.snapshotJson, + options.snapshotHash + ); + + return getCommitForSection(options.section.id, id); +} + +function applySnapshotToPublished(snapshot, now) { + validateSnapshot(snapshot); + const entities = Array.isArray(snapshot.entities) ? snapshot.entities : []; + const geometries = Array.isArray(snapshot.geometries) ? snapshot.geometries : []; + const linkScopes = Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []; + + for (const entity of entities) { + applyEntitySnapshot(entity, now); + } + + for (const geometry of geometries) { + applyGeometrySnapshot(geometry, now); + } + + for (const scope of linkScopes) { + applyLinkScopeSnapshot(scope, now); + } +} + +function applyEntitySnapshot(entity, now) { + const operation = normalizeOperation(entity?.operation); + if (operation === "reference") return; + + const id = normalizeId(entity?.id); + if (!id) throw createHttpError("Entity id is required in snapshot", 400); + + if (operation === "create") { + db.prepare(` + INSERT INTO entities ( + id, name, slug, description, type_id, status, is_deleted, created_at, updated_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + normalizeRequiredString(entity.name), + normalizeOptionalString(entity.slug), + normalizeOptionalString(entity.description), + normalizeTypeId(entity.type_id), + normalizeInteger(entity.status, 1), + normalizeInteger(entity.is_deleted, 0), + now, + now + ); + return; + } + + const existing = db.prepare(`SELECT * FROM entities WHERE id = ?`).get(id); + assertBaseMatches("entity", id, existing, entity); + + if (operation === "delete") { + db.prepare(` + UPDATE entities + SET is_deleted = 1, + updated_at = ? + WHERE id = ? + `).run(now, id); + db.prepare(`DELETE FROM entity_geometries WHERE entity_id = ?`).run(id); + return; + } + + db.prepare(` + UPDATE entities + SET name = ?, + slug = ?, + description = ?, + type_id = ?, + status = ?, + is_deleted = ?, + updated_at = ? + WHERE id = ? + `).run( + normalizeRequiredString(entity.name), + normalizeOptionalString(entity.slug), + normalizeOptionalString(entity.description), + normalizeTypeId(entity.type_id), + normalizeInteger(entity.status, 1), + normalizeInteger(entity.is_deleted, 0), + now, + id + ); +} + +function applyGeometrySnapshot(geometry, now) { + const operation = normalizeOperation(geometry?.operation); + if (operation === "reference") return; + + const id = normalizeId(geometry?.id); + if (!id) throw createHttpError("Geometry id is required in snapshot", 400); + + if (operation === "delete") { + const existing = db.prepare(`SELECT * FROM geometries WHERE id = ?`).get(id); + assertBaseMatches("geometry", id, existing, geometry); + db.prepare(` + UPDATE geometries + SET is_deleted = 1, + updated_at = ? + WHERE id = ? + `).run(now, id); + db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(id); + removeBindingReferenceFromAll(id, now); + return; + } + + const drawGeometry = geometry.draw_geometry || geometry.geometry; + if (!drawGeometry) { + throw createHttpError("Geometry draw_geometry is required in snapshot", 400); + } + const temporalRange = normalizeTemporalRange(geometry.time_start, geometry.time_end); + const bbox = normalizeBBox(geometry.bbox) || getBBox(drawGeometry); + const binding = normalizeIdArray(geometry.binding).filter((bindingId) => bindingId !== id); + + if (operation === "create") { + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run( + id, + normalizeOptionalString(geometry.type), + normalizeInteger(geometry.is_deleted, 0), + JSON.stringify(drawGeometry), + serializeIdArray(binding), + temporalRange.timeStart, + temporalRange.timeEnd, + bbox.minLng, + bbox.minLat, + bbox.maxLng, + bbox.maxLat, + now, + now + ); + return; + } + + const existing = db.prepare(`SELECT * FROM geometries WHERE id = ?`).get(id); + assertBaseMatches("geometry", id, existing, geometry); + + db.prepare(` + UPDATE geometries + SET type = ?, + is_deleted = ?, + draw_geometry = ?, + binding = ?, + time_start = ?, + time_end = ?, + bbox_min_lng = ?, + bbox_min_lat = ?, + bbox_max_lng = ?, + bbox_max_lat = ?, + updated_at = ? + WHERE id = ? + `).run( + normalizeOptionalString(geometry.type), + normalizeInteger(geometry.is_deleted, 0), + JSON.stringify(drawGeometry), + serializeIdArray(binding), + temporalRange.timeStart, + temporalRange.timeEnd, + bbox.minLng, + bbox.minLat, + bbox.maxLng, + bbox.maxLat, + now, + id + ); +} + +function applyLinkScopeSnapshot(scope, now) { + if (normalizeOperation(scope?.operation) === "reference") return; + + const geometryId = normalizeId(scope?.geometry_id); + if (!geometryId) throw createHttpError("link_scope geometry_id is required", 400); + + const existing = db.prepare(` + SELECT id + FROM geometries + WHERE id = ? + AND is_deleted = 0 + `).get(geometryId); + if (!existing) { + throw createConflictError(`Geometry not found for link scope: ${geometryId}`); + } + + const entityIds = normalizeIdArray(scope.entity_ids); + if (!entityIds.length) { + throw createHttpError("link_scope entity_ids must not be empty", 400); + } + + if (scope.base_links_hash) { + const currentHash = hashEntityLinks(geometryId); + if (scope.base_links_hash !== currentHash) { + throw createConflictError(`Links changed for geometry: ${geometryId}`); + } + } + + 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 createHttpError(`Entity not found: ${missing.join(", ")}`, 400); + } + + db.prepare(`DELETE FROM entity_geometries WHERE geometry_id = ?`).run(geometryId); + for (const entityId of entityIds) { + db.prepare(` + INSERT INTO entity_geometries (entity_id, geometry_id, created_at) + VALUES (?, ?, ?) + `).run(entityId, geometryId, now); + } +} + +function validateSnapshot(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throw createHttpError("snapshot must be an object", 400); + } + + const slugSet = new Set(); + for (const entity of Array.isArray(snapshot.entities) ? snapshot.entities : []) { + const operation = normalizeOperation(entity?.operation); + if (!operation) throw createHttpError("Invalid entity operation", 400); + if (operation !== "delete" && operation !== "reference" && !normalizeRequiredString(entity?.name)) { + throw createHttpError("Entity name is required", 400); + } + const slug = normalizeOptionalString(entity?.slug); + if (slug) { + const key = slug.toLowerCase(); + if (slugSet.has(key)) throw createHttpError(`Duplicate slug in snapshot: ${slug}`, 400); + slugSet.add(key); + } + } + + for (const geometry of Array.isArray(snapshot.geometries) ? snapshot.geometries : []) { + const operation = normalizeOperation(geometry?.operation); + if (!operation) throw createHttpError("Invalid geometry operation", 400); + if (operation === "delete" || operation === "reference") continue; + + normalizeTemporalRange(geometry?.time_start, geometry?.time_end); + const bbox = normalizeBBox(geometry?.bbox); + if (geometry?.bbox && !bbox) throw createHttpError("Invalid geometry bbox", 400); + const binding = normalizeIdArray(geometry?.binding); + if (binding.includes(normalizeId(geometry?.id))) { + throw createHttpError("binding cannot contain self id", 400); + } + } + + for (const scope of Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []) { + const entityIds = normalizeIdArray(scope?.entity_ids); + if (!entityIds.length) { + throw createHttpError("link_scope entity_ids must not be empty", 400); + } + } +} + +function assertBaseMatches(kind, id, existing, snapshotItem) { + if (!existing) { + throw createConflictError(`${kind} not found: ${id}`); + } + if (snapshotItem.base_updated_at && existing.updated_at !== snapshotItem.base_updated_at) { + throw createConflictError(`${kind} changed: ${id}`); + } + if (snapshotItem.base_hash) { + const currentHash = kind === "entity" ? hashEntityRow(existing) : hashGeometryRow(existing); + if (snapshotItem.base_hash !== currentHash) { + throw createConflictError(`${kind} changed: ${id}`); + } + } +} + +function getSectionById(sectionId) { + const row = db.prepare(` + SELECT + s.*, + st.status, + st.head_commit_id, + st.version, + st.locked_by, + st.locked_at, + st.lock_expires_at + FROM sections s + LEFT JOIN section_states st + ON st.section_id = s.id + WHERE s.id = ? + `).get(sectionId); + + return row ? normalizeSectionRow(row) : null; +} + +function ensureSectionState(sectionId, now) { + let row = db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); + if (row) return row; + + db.prepare(` + INSERT INTO section_states (section_id, status, version, updated_at) + VALUES (?, 'editing', 0, ?) + `).run(sectionId, now); + + return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); +} + +function acquireLock(sectionId, actor, now) { + db.prepare(` + UPDATE section_states + SET locked_by = ?, + locked_at = ?, + lock_expires_at = ?, + updated_at = ? + WHERE section_id = ? + `).run(actor, now, new Date(Date.parse(now) + LOCK_TTL_MS).toISOString(), now, sectionId); + + return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); +} + +function assertCanEdit(state, actor, now) { + if (state.status !== "editing" && state.status !== "rejected") { + throw createHttpError("Section is not editable", 409); + } + assertLockAvailable(state, actor, now); +} + +function assertLockAvailable(state, actor, now) { + if (!state.locked_by) return; + if (actor && state.locked_by === actor) return; + if (state.lock_expires_at && Date.parse(state.lock_expires_at) <= Date.parse(now)) return; + throw createHttpError("Section is locked by another user", 409); +} + +function assertExpectedState(state, body) { + if (body?.expected_version !== undefined && Number(body.expected_version) !== Number(state.version)) { + throw createHttpError("Section version changed", 409); + } + if ( + body?.expected_head_commit_id !== undefined && + normalizeId(body.expected_head_commit_id) !== (state.head_commit_id || null) + ) { + throw createHttpError("Section head commit changed", 409); + } +} + +function setHeadCommit(sectionId, commitId, now) { + db.prepare(` + UPDATE section_states + SET status = 'editing', + head_commit_id = ?, + version = version + 1, + updated_at = ? + WHERE section_id = ? + `).run(commitId, now, sectionId); + + db.prepare(`UPDATE sections SET updated_at = ? WHERE id = ?`).run(now, sectionId); + return db.prepare(`SELECT * FROM section_states WHERE section_id = ?`).get(sectionId); +} + +function getNextCommitNo(sectionId) { + const row = db.prepare(` + SELECT COALESCE(MAX(commit_no), 0) + 1 AS next_no + FROM section_commits + WHERE section_id = ? + `).get(sectionId); + return Number(row?.next_no || 1); +} + +function getCommitForSection(sectionId, commitId) { + return db.prepare(` + SELECT * + FROM section_commits + WHERE section_id = ? + AND id = ? + `).get(sectionId, commitId); +} + +function getSubmissionById(submissionId) { + return db.prepare(`SELECT * FROM section_submissions WHERE id = ?`).get(submissionId); +} + +function updateSubmissionReview(submissionId, status, actor, now, reviewNote) { + db.prepare(` + UPDATE section_submissions + SET status = ?, + reviewed_by = ?, + reviewed_at = ?, + review_note = ? + WHERE id = ? + `).run(status, actor, now, reviewNote, submissionId); +} + +function normalizeSectionRow(row) { + return { + id: row.id, + title: row.title, + description: row.description, + user_id: row.user_id || null, + created_by: row.created_by, + created_at: row.created_at, + updated_at: row.updated_at, + state: { + status: row.status || "editing", + head_commit_id: row.head_commit_id || null, + version: Number(row.version || 0), + locked_by: row.locked_by || null, + locked_at: row.locked_at || null, + lock_expires_at: row.lock_expires_at || null, + }, + }; +} + +function normalizeStateRow(row) { + return { + section_id: row.section_id, + status: row.status, + head_commit_id: row.head_commit_id || null, + version: Number(row.version || 0), + locked_by: row.locked_by || null, + locked_at: row.locked_at || null, + lock_expires_at: row.lock_expires_at || null, + updated_at: row.updated_at, + }; +} + +function normalizeCommitRow(row, includeSnapshot) { + const out = { + id: row.id, + section_id: row.section_id, + parent_commit_id: row.parent_commit_id || null, + commit_no: Number(row.commit_no), + kind: row.kind, + restored_from_commit_id: row.restored_from_commit_id || null, + created_by: row.created_by, + created_at: row.created_at, + title: row.title, + note: row.note, + snapshot_hash: row.snapshot_hash, + }; + if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json); + return out; +} + +function normalizeSubmissionRow(row, includeSnapshot) { + const out = { + id: row.id, + section_id: row.section_id, + commit_id: row.commit_id, + submitted_by: row.submitted_by, + submitted_at: row.submitted_at, + status: row.status, + reviewed_by: row.reviewed_by || null, + reviewed_at: row.reviewed_at || null, + review_note: row.review_note || null, + snapshot_hash: row.snapshot_hash, + }; + if (includeSnapshot) out.snapshot = parseSnapshotJson(row.snapshot_json); + return out; +} + +function buildEmptySnapshot(section) { + return { + schema_version: 1, + section: { + id: section.id, + title: section.title, + }, + entities: [], + geometries: [], + link_scopes: [], + }; +} + +function normalizeSnapshotInput(input) { + if (typeof input === "string") { + try { + const snapshot = JSON.parse(input); + return { snapshot, snapshotJson: JSON.stringify(snapshot) }; + } catch (_err) { + return { error: "snapshot_json must be valid JSON" }; + } + } + if (input && typeof input === "object" && !Array.isArray(input)) { + return { snapshot: input, snapshotJson: JSON.stringify(input) }; + } + return { error: "snapshot is required" }; +} + +function parseSnapshotJson(value) { + try { + return JSON.parse(value); + } catch (_err) { + return null; + } +} + +function normalizeOperation(value) { + const normalized = String(value || "").trim().toLowerCase(); + if (["create", "update", "delete", "reference", "replace"].includes(normalized)) { + return normalized === "replace" ? "update" : normalized; + } + return null; +} + +function normalizeTemporalRange(timeStartValue, timeEndValue) { + const timeStart = normalizeOptionalInteger(timeStartValue); + const timeEnd = normalizeOptionalInteger(timeEndValue); + if (timeStart !== null && timeEnd !== null && timeStart > timeEnd) { + throw createHttpError("time_start must be <= time_end", 400); + } + return { timeStart, timeEnd }; +} + +function normalizeBBox(value) { + if (!value || typeof value !== "object") return null; + const minLng = Number(value.min_lng ?? value.minLng); + const minLat = Number(value.min_lat ?? value.minLat); + const maxLng = Number(value.max_lng ?? value.maxLng); + const maxLat = Number(value.max_lat ?? value.maxLat); + if (![minLng, minLat, maxLng, maxLat].every(Number.isFinite)) return null; + if (minLng > maxLng || minLat > maxLat) return null; + return { minLng, minLat, maxLng, maxLat }; +} + +function normalizeIdArray(value) { + if (value === undefined || value === null || value === "") return []; + if (!Array.isArray(value)) throw createHttpError("Expected an array of ids", 400); + const seen = new Set(); + const ids = []; + for (const item of value) { + const id = normalizeId(item); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +function serializeIdArray(value) { + return value.length ? JSON.stringify(value) : null; +} + +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) { + let binding; + try { + binding = JSON.parse(row.binding); + } catch (_err) { + binding = []; + } + const next = normalizeIdArray(binding).filter((id) => id !== removedId); + if (next.length === binding.length) continue; + db.prepare(` + UPDATE geometries + SET binding = ?, + updated_at = ? + WHERE id = ? + `).run(serializeIdArray(next), now, row.id); + } +} + +function hashEntityLinks(geometryId) { + const rows = db.prepare(` + SELECT entity_id + FROM entity_geometries + WHERE geometry_id = ? + ORDER BY entity_id ASC + `).all(geometryId); + return hashObject(rows.map((row) => row.entity_id)); +} + +function hashEntityRow(row) { + return hashObject({ + id: row.id, + name: row.name, + slug: row.slug, + description: row.description, + type_id: row.type_id, + status: row.status, + is_deleted: row.is_deleted, + }); +} + +function hashGeometryRow(row) { + return hashObject({ + id: row.id, + type: row.type, + is_deleted: row.is_deleted, + draw_geometry: row.draw_geometry, + binding: row.binding, + time_start: row.time_start, + time_end: row.time_end, + bbox_min_lng: row.bbox_min_lng, + bbox_min_lat: row.bbox_min_lat, + bbox_max_lng: row.bbox_max_lng, + bbox_max_lat: row.bbox_max_lat, + }); +} + +function hashObject(value) { + return hashString(JSON.stringify(value)); +} + +function hashString(value) { + return `sha256:${crypto.createHash("sha256").update(value).digest("hex")}`; +} + +function normalizeActor(value) { + return normalizeId(value) || "anonymous"; +} + +function normalizeId(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 normalizeOptionalInteger(value) { + if (value === undefined || value === null || value === "") return null; + const num = Number(value); + if (!Number.isFinite(num)) throw createHttpError("Expected a number", 400); + return Math.trunc(num); +} + +function normalizeInteger(value, fallback) { + if (value === undefined || value === null || value === "") return fallback; + const num = Number(value); + return Number.isFinite(num) ? Math.trunc(num) : fallback; +} + +function normalizeTypeId(value) { + const normalized = normalizeOptionalString(value); + return normalized ? normalized.toLowerCase() : "country"; +} + +function isSqliteConstraint(err) { + const code = err?.code || ""; + return code === "SQLITE_CONSTRAINT" || String(code).startsWith("SQLITE_CONSTRAINT_"); +} + +function createHttpError(message, status = 400) { + const err = new Error(message); + err.status = status; + return err; +} + +function createConflictError(message) { + const err = createHttpError(message, 409); + err.isConflict = true; + return err; +} + +function handleRouteError(res, err, fallbackMessage) { + if (err.status) { + return res.status(err.status).json({ error: err.message }); + } + if (isSqliteConstraint(err)) { + return res.status(409).json({ error: "Database constraint failed" }); + } + console.error(fallbackMessage, err); + return res.status(500).json({ error: fallbackMessage }); +} + +module.exports = router; diff --git a/swagger.js b/swagger.js index 5ecfd3d..8603e10 100644 --- a/swagger.js +++ b/swagger.js @@ -3,7 +3,7 @@ const openApiSpec = { info: { title: "Ultimate History Map API", version: "1.0.0", - description: "API docs for tiles, geometries, and entities.", + description: "Read APIs plus section review workflow. Direct geometry/entity mutations are intentionally not exposed.", }, servers: [ { @@ -12,10 +12,12 @@ const openApiSpec = { }, ], tags: [ - { name: "System", description: "Health and meta endpoints" }, + { name: "System", description: "Health and OpenAPI endpoints" }, { name: "Tiles", description: "Vector and raster tile endpoints" }, - { name: "Geometries", description: "Geometry CRUD and batch save" }, - { name: "Entities", description: "Entity CRUD" }, + { name: "Geometries", description: "Published geometry read endpoints" }, + { name: "Entities", description: "Published entity read endpoints" }, + { name: "Sections", description: "Draft, commit, and submit workflow" }, + { name: "Submissions", description: "Submission review workflow" }, ], components: { schemas: { @@ -26,125 +28,26 @@ const openApiSpec = { }, required: ["error"], }, - SuccessResponse: { - type: "object", - properties: { - success: { type: "boolean" }, - }, - required: ["success"], - }, Entity: { type: "object", properties: { id: { type: "string" }, - name: { type: "string" }, - slug: { type: "string", nullable: true }, - description: { type: "string", nullable: true }, - type_id: { type: "string" }, - status: { type: "number" }, - created_at: { type: "string", format: "date-time", nullable: true }, - updated_at: { type: "string", format: "date-time", nullable: true }, - geometry_count: { type: "number" }, - }, - required: ["id", "name", "type_id", "geometry_count"], - }, - EntityCreateInput: { - type: "object", - properties: { name: { type: "string" }, slug: { type: "string", nullable: true }, description: { type: "string", nullable: true }, type_id: { type: "string", nullable: true }, status: { type: "number", nullable: true }, + geometry_count: { type: "number" }, + created_at: { type: "string", nullable: true }, + updated_at: { type: "string", nullable: true }, }, - required: ["name"], - }, - EntityUpdateInput: { - type: "object", - properties: { - name: { type: "string" }, - slug: { type: "string", nullable: true }, - description: { type: "string", nullable: true }, - type_id: { type: "string" }, - status: { type: "number" }, - }, - }, - EntityBatchCreateChange: { - type: "object", - properties: { - action: { type: "string", enum: ["create"] }, - entity: { $ref: "#/components/schemas/EntityCreateInput" }, - }, - required: ["action"], - }, - EntityBatchUpdateChange: { - type: "object", - properties: { - action: { type: "string", enum: ["update"] }, - id: { type: "string" }, - entity: { $ref: "#/components/schemas/EntityUpdateInput" }, - name: { type: "string" }, - slug: { type: "string", nullable: true }, - description: { type: "string", nullable: true }, - type_id: { type: "string" }, - status: { type: "number" }, - }, - required: ["action", "id"], - }, - EntityBatchDeleteChange: { - type: "object", - properties: { - action: { type: "string", enum: ["delete"] }, - id: { type: "string" }, - }, - required: ["action", "id"], - }, - EntityBatchPayload: { - type: "object", - properties: { - changes: { - type: "array", - items: { - oneOf: [ - { $ref: "#/components/schemas/EntityBatchCreateChange" }, - { $ref: "#/components/schemas/EntityBatchUpdateChange" }, - { $ref: "#/components/schemas/EntityBatchDeleteChange" }, - ], - }, - }, - }, - required: ["changes"], - }, - EntityBatchResponse: { - type: "object", - properties: { - success: { type: "boolean" }, - applied: { type: "number" }, - created_entity_ids: { - type: "array", - items: { type: "string" }, - }, - }, - required: ["success", "applied", "created_entity_ids"], + required: ["id", "name", "geometry_count"], }, GeoJSONGeometry: { type: "object", properties: { - type: { - type: "string", - enum: [ - "Point", - "MultiPoint", - "LineString", - "MultiLineString", - "Polygon", - "MultiPolygon", - ], - }, - coordinates: { - type: "array", - items: {}, - }, + type: { type: "string" }, + coordinates: { type: "array", items: {} }, }, required: ["type", "coordinates"], }, @@ -152,31 +55,7 @@ const openApiSpec = { type: "object", properties: { type: { type: "string", enum: ["Feature"] }, - properties: { - type: "object", - properties: { - id: { type: "string" }, - type: { type: "string", nullable: true }, - time_start: { type: "number", nullable: true }, - time_end: { type: "number", nullable: true }, - binding: { - type: "array", - items: { type: "string" }, - }, - entity_id: { type: "string", nullable: true }, - entity_ids: { - type: "array", - items: { type: "string" }, - }, - entity_name: { type: "string", nullable: true }, - entity_names: { - type: "array", - items: { type: "string" }, - }, - entity_type_id: { type: "string", nullable: true }, - }, - required: ["id"], - }, + properties: { type: "object" }, geometry: { $ref: "#/components/schemas/GeoJSONGeometry" }, }, required: ["type", "properties", "geometry"], @@ -192,143 +71,39 @@ const openApiSpec = { }, required: ["type", "features"], }, - GeometryUpsertInput: { - type: "object", - properties: { - geometry: { $ref: "#/components/schemas/GeoJSONGeometry" }, - type: { type: "string", nullable: true }, - time_start: { type: "number", nullable: true }, - time_end: { type: "number", nullable: true }, - binding: { - type: "array", - items: { type: "string" }, - }, - entity_id: { type: "string", nullable: true }, - entity_ids: { - type: "array", - items: { type: "string" }, - }, - }, - required: ["geometry"], - }, - GeometryCreateResponse: { + Section: { type: "object", properties: { id: { type: "string" }, + title: { type: "string" }, + description: { type: "string", nullable: true }, + user_id: { type: "string", nullable: true }, + created_by: { type: "string", nullable: true }, + state: { type: "object" }, }, - required: ["id"], + required: ["id", "title", "state"], }, - BatchCreateChange: { + SectionCommit: { type: "object", properties: { - action: { type: "string", enum: ["create"] }, - feature: { $ref: "#/components/schemas/GeometryFeature" }, - }, - required: ["action", "feature"], - }, - BatchUpdateChange: { - type: "object", - properties: { - action: { type: "string", enum: ["update"] }, id: { type: "string" }, - geometry: { $ref: "#/components/schemas/GeoJSONGeometry" }, - type: { type: "string", nullable: true }, - time_start: { type: "number", nullable: true }, - time_end: { type: "number", nullable: true }, - binding: { - type: "array", - items: { type: "string" }, - }, - entity_id: { type: "string", nullable: true }, - entity_ids: { - type: "array", - items: { type: "string" }, - }, + section_id: { type: "string" }, + commit_no: { type: "number" }, + kind: { type: "string" }, + snapshot: { type: "object", nullable: true }, }, - required: ["action", "id", "geometry"], + required: ["id", "section_id", "commit_no", "kind"], }, - BatchDeleteChange: { + SectionSubmission: { type: "object", properties: { - action: { type: "string", enum: ["delete"] }, id: { type: "string" }, + section_id: { type: "string" }, + commit_id: { type: "string" }, + status: { type: "string" }, + snapshot: { type: "object", nullable: true }, }, - required: ["action", "id"], - }, - GeometryBatchPayload: { - type: "object", - properties: { - changes: { - type: "array", - items: { - oneOf: [ - { $ref: "#/components/schemas/BatchCreateChange" }, - { $ref: "#/components/schemas/BatchUpdateChange" }, - { $ref: "#/components/schemas/BatchDeleteChange" }, - ], - }, - }, - }, - required: ["changes"], - }, - GeometryBatchResponse: { - type: "object", - properties: { - success: { type: "boolean" }, - applied: { type: "number" }, - }, - required: ["success", "applied"], - }, - CombinedBatchPayload: { - type: "object", - properties: { - entity_changes: { - type: "array", - items: { - oneOf: [ - { $ref: "#/components/schemas/EntityBatchCreateChange" }, - { $ref: "#/components/schemas/EntityBatchUpdateChange" }, - { $ref: "#/components/schemas/EntityBatchDeleteChange" }, - ], - }, - }, - geometry_changes: { - type: "array", - items: { - oneOf: [ - { $ref: "#/components/schemas/BatchCreateChange" }, - { $ref: "#/components/schemas/BatchUpdateChange" }, - { $ref: "#/components/schemas/BatchDeleteChange" }, - ], - }, - }, - }, - }, - CombinedBatchResponse: { - type: "object", - properties: { - success: { type: "boolean" }, - applied: { type: "number" }, - entity_applied: { type: "number" }, - geometry_applied: { type: "number" }, - created_entity_ids: { - type: "array", - items: { type: "string" }, - }, - }, - required: [ - "success", - "applied", - "entity_applied", - "geometry_applied", - "created_entity_ids", - ], - }, - MetadataResponse: { - type: "object", - additionalProperties: { - oneOf: [{ type: "string" }, { type: "number" }, { type: "boolean" }], - }, + required: ["id", "section_id", "commit_id", "status"], }, }, }, @@ -337,374 +112,65 @@ const openApiSpec = { get: { tags: ["System"], summary: "Health check", - responses: { - 200: { - description: "Server status text", - content: { - "text/plain": { - schema: { type: "string" }, - }, - }, - }, - }, + responses: { 200: { description: "Server is running" } }, + }, + }, + "/docs.json": { + get: { + tags: ["System"], + summary: "OpenAPI JSON", + responses: { 200: { description: "OpenAPI document" } }, }, }, "/tiles/metadata/info": { get: { tags: ["Tiles"], - summary: "Get vector tiles metadata", - responses: { - 200: { - description: "MBTiles metadata", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/MetadataResponse" }, - }, - }, - }, - }, + summary: "Vector tile metadata", + responses: { 200: { description: "MBTiles metadata" } }, }, }, "/tiles/{z}/{x}/{y}": { get: { tags: ["Tiles"], - summary: "Get vector tile by XYZ", - parameters: [ - { name: "z", in: "path", required: true, schema: { type: "integer" } }, - { name: "x", in: "path", required: true, schema: { type: "integer" } }, - { name: "y", in: "path", required: true, schema: { type: "integer" } }, - ], + summary: "Vector tile", + parameters: tileParameters(), responses: { - 200: { - description: "Tile binary", - content: { - "application/x-protobuf": { - schema: { type: "string", format: "binary" }, - }, - "image/png": { - schema: { type: "string", format: "binary" }, - }, - "image/jpeg": { - schema: { type: "string", format: "binary" }, - }, - "application/octet-stream": { - schema: { type: "string", format: "binary" }, - }, - }, - }, - 400: { - description: "Invalid tile coordinates", - }, - 404: { - description: "Tile not found", - }, + 200: { description: "Tile binary" }, + 400: { description: "Invalid tile coordinates" }, + 404: { description: "Tile not found" }, }, }, }, "/raster-tiles/metadata/info": { get: { tags: ["Tiles"], - summary: "Get raster tiles metadata", - responses: { - 200: { - description: "MBTiles metadata", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/MetadataResponse" }, - }, - }, - }, - }, + summary: "Raster tile metadata", + responses: { 200: { description: "MBTiles metadata" } }, }, }, "/raster-tiles/{z}/{x}/{y}": { get: { tags: ["Tiles"], - summary: "Get raster tile by XYZ", - parameters: [ - { name: "z", in: "path", required: true, schema: { type: "integer" } }, - { name: "x", in: "path", required: true, schema: { type: "integer" } }, - { name: "y", in: "path", required: true, schema: { type: "integer" } }, - ], + summary: "Raster tile", + parameters: tileParameters(), responses: { - 200: { - description: "Tile binary", - content: { - "image/png": { schema: { type: "string", format: "binary" } }, - "image/jpeg": { schema: { type: "string", format: "binary" } }, - "image/webp": { schema: { type: "string", format: "binary" } }, - "application/octet-stream": { schema: { type: "string", format: "binary" } }, - }, - }, - 400: { - description: "Invalid tile coordinates", - }, - 404: { - description: "Tile not found", - }, - }, - }, - }, - "/entities": { - get: { - tags: ["Entities"], - summary: "List entities", - parameters: [ - { - name: "q", - in: "query", - required: false, - schema: { type: "string" }, - description: "Search by name or slug", - }, - ], - responses: { - 200: { - description: "Entity list", - content: { - "application/json": { - schema: { - type: "array", - items: { $ref: "#/components/schemas/Entity" }, - }, - }, - }, - }, - }, - }, - post: { - tags: ["Entities"], - summary: "Create entity", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/EntityCreateInput" }, - }, - }, - }, - responses: { - 201: { - description: "Entity created", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Entity" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 409: { - description: "Unique conflict", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - }, - }, - }, - "/entities/search": { - get: { - tags: ["Entities"], - summary: "Search entities by name", - parameters: [ - { - name: "name", - in: "query", - required: true, - schema: { type: "string" }, - description: "Entity name keyword", - }, - { - name: "limit", - in: "query", - required: false, - schema: { type: "integer", minimum: 1, maximum: 100 }, - }, - ], - responses: { - 200: { - description: "Matched entity list", - content: { - "application/json": { - schema: { - type: "array", - items: { $ref: "#/components/schemas/Entity" }, - }, - }, - }, - }, - }, - }, - }, - "/entities/batch": { - post: { - tags: ["Entities"], - summary: "Apply batch create/update/delete entities", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/EntityBatchPayload" }, - }, - }, - }, - responses: { - 200: { - description: "Batch applied", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/EntityBatchResponse" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 409: { - description: "Unique conflict or cannot delete due to orphaned geometries", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - }, - }, - }, - "/entities/{id}": { - get: { - tags: ["Entities"], - summary: "Get entity by id", - parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string" } }, - ], - responses: { - 200: { - description: "Entity", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Entity" }, - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - }, - }, - put: { - tags: ["Entities"], - summary: "Update entity", - parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string" } }, - ], - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/EntityUpdateInput" }, - }, - }, - }, - responses: { - 200: { - description: "Entity updated", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Entity" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 409: { - description: "Unique conflict", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - }, - }, - delete: { - tags: ["Entities"], - summary: "Soft-delete entity", - parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string" } }, - ], - responses: { - 200: { - description: "Soft-delete success", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/SuccessResponse" }, - }, - }, - }, - 409: { - description: "Entity is still the last active link of one or more geometries", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, + 200: { description: "Tile binary" }, + 400: { description: "Invalid tile coordinates" }, + 404: { description: "Tile not found" }, }, }, }, "/geometries": { get: { tags: ["Geometries"], - summary: "Query geometries by bbox, time, and entity", + summary: "Query published geometries by bbox, time, and entity", parameters: [ - { name: "minLng", in: "query", required: true, schema: { type: "number" } }, - { name: "minLat", in: "query", required: true, schema: { type: "number" } }, - { name: "maxLng", in: "query", required: true, schema: { type: "number" } }, - { name: "maxLat", in: "query", required: true, schema: { type: "number" } }, - { name: "time", in: "query", required: false, schema: { type: "integer" } }, - { name: "entity_id", in: "query", required: false, schema: { type: "string" } }, + queryParam("minLng", "number", true), + queryParam("minLat", "number", true), + queryParam("maxLng", "number", true), + queryParam("maxLat", "number", true), + queryParam("time", "integer", false), + queryParam("entity_id", "string", false), ], responses: { 200: { @@ -715,198 +181,266 @@ const openApiSpec = { }, }, }, - 400: { - description: "Invalid query", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - }, - }, - post: { - tags: ["Geometries"], - summary: "Create geometry", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/GeometryUpsertInput" }, - }, - }, - }, - responses: { - 200: { - description: "Created geometry id", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/GeometryCreateResponse" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 404: { - description: "Entity not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, + 400: responseRef("Invalid query"), }, }, }, - "/geometries/{id}": { - put: { - tags: ["Geometries"], - summary: "Update geometry", - parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string" } }, - ], - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/GeometryUpsertInput" }, - }, - }, - }, + "/entities": { + get: { + tags: ["Entities"], + summary: "List published entities", + parameters: [queryParam("q", "string", false)], responses: { - 200: { - description: "Updated", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/SuccessResponse" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, + 200: arrayResponse("Entity list", "#/components/schemas/Entity"), }, }, - delete: { - tags: ["Geometries"], - summary: "Soft-delete geometry", + }, + "/entities/search": { + get: { + tags: ["Entities"], + summary: "Search published entities by name", parameters: [ - { name: "id", in: "path", required: true, schema: { type: "string" } }, + queryParam("name", "string", true), + queryParam("limit", "integer", false), ], responses: { - 200: { - description: "Soft-delete success", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/SuccessResponse" }, - }, - }, - }, - 404: { - description: "Not found", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, + 200: arrayResponse("Matched entities", "#/components/schemas/Entity"), }, }, }, - "/geometries/batch": { - post: { - tags: ["Geometries"], - summary: "Apply batch create/update/delete geometries", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/GeometryBatchPayload" }, - }, - }, - }, + "/entities/{id}": { + get: { + tags: ["Entities"], + summary: "Get published entity by id", + parameters: [pathParam("id")], responses: { - 200: { - description: "Batch applied", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/GeometryBatchResponse" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, + 200: objectResponse("Entity", "#/components/schemas/Entity"), + 404: responseRef("Entity not found"), }, }, }, - "/geometries/batch/combined": { + "/sections": { + get: { + tags: ["Sections"], + summary: "List sections", + responses: { 200: arrayResponse("Sections", "#/components/schemas/Section") }, + }, post: { - tags: ["Geometries", "Entities"], - summary: "Apply entity batch and geometry batch in one transaction", - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/CombinedBatchPayload" }, - }, + tags: ["Sections"], + summary: "Create section", + requestBody: jsonBody({ + type: "object", + required: ["title"], + properties: { + id: { type: "string" }, + title: { type: "string" }, + description: { type: "string", nullable: true }, + user_id: { type: "string" }, + created_by: { type: "string" }, }, - }, + }), responses: { - 200: { - description: "Combined batch applied", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/CombinedBatchResponse" }, - }, - }, - }, - 400: { - description: "Invalid payload", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, - 409: { - description: "Conflict in entity changes (unique or orphan guard)", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ErrorResponse" }, - }, - }, - }, + 201: objectResponse("Created section", "#/components/schemas/Section"), + 400: responseRef("Invalid payload"), + 409: responseRef("Section id already exists"), }, }, }, + "/sections/{sectionId}/editor": { + get: { + tags: ["Sections"], + summary: "Open section editor and acquire lock", + parameters: [ + pathParam("sectionId"), + queryParam("user_id", "string", false), + ], + responses: { + 200: { description: "Editor payload" }, + 404: responseRef("Section not found"), + 409: responseRef("Section locked by another user"), + }, + }, + }, + "/sections/{sectionId}/lock": sectionActorPost("Acquire section lock"), + "/sections/{sectionId}/unlock": sectionActorPost("Release section lock"), + "/sections/{sectionId}/commits": { + get: { + tags: ["Sections"], + summary: "List section commits", + parameters: [ + pathParam("sectionId"), + queryParam("include_snapshot", "string", false), + ], + responses: { 200: arrayResponse("Commits", "#/components/schemas/SectionCommit") }, + }, + post: { + tags: ["Sections"], + summary: "Create reviewed workflow commit", + description: "Direct geometry/entity mutations are not exposed. Commit snapshots are the mutation path for geometry/entity changes.", + parameters: [pathParam("sectionId")], + requestBody: jsonBody({ + type: "object", + required: ["snapshot", "created_by"], + properties: { + snapshot: { type: "object" }, + created_by: { type: "string" }, + expected_version: { type: "number" }, + expected_head_commit_id: { type: "string", nullable: true }, + title: { type: "string", nullable: true }, + note: { type: "string", nullable: true }, + }, + }), + responses: { + 201: { description: "Created commit" }, + 400: responseRef("Invalid snapshot"), + 409: responseRef("Section conflict"), + }, + }, + }, + "/sections/{sectionId}/restore": { + post: { + tags: ["Sections"], + summary: "Restore a previous commit", + parameters: [pathParam("sectionId")], + responses: { + 201: { description: "Restore commit created" }, + 400: responseRef("Invalid payload"), + 404: responseRef("Commit not found"), + 409: responseRef("Section conflict"), + }, + }, + }, + "/sections/{sectionId}/submit": { + post: { + tags: ["Sections"], + summary: "Submit head commit for review", + parameters: [pathParam("sectionId")], + responses: { + 201: objectResponse("Submission", "#/components/schemas/SectionSubmission"), + 400: responseRef("Invalid payload"), + 409: responseRef("Section conflict"), + }, + }, + }, + "/sections/{sectionId}/submissions": { + get: { + tags: ["Sections"], + summary: "List section submissions", + parameters: [ + pathParam("sectionId"), + queryParam("include_snapshot", "string", false), + ], + responses: { 200: arrayResponse("Submissions", "#/components/schemas/SectionSubmission") }, + }, + }, + "/submissions/{submissionId}/approve": submissionReviewPost("Approve submission"), + "/submissions/{submissionId}/reject": submissionReviewPost("Reject submission"), }, }; +function pathParam(name) { + return { name, in: "path", required: true, schema: { type: "string" } }; +} + +function queryParam(name, type, required) { + return { name, in: "query", required, schema: { type } }; +} + +function tileParameters() { + return [pathParam("z"), pathParam("x"), pathParam("y")]; +} + +function responseRef(description) { + return { + description, + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ErrorResponse" }, + }, + }, + }; +} + +function objectResponse(description, ref) { + return { + description, + content: { + "application/json": { + schema: { $ref: ref }, + }, + }, + }; +} + +function arrayResponse(description, itemRef) { + return { + description, + content: { + "application/json": { + schema: { + type: "array", + items: { $ref: itemRef }, + }, + }, + }, + }; +} + +function jsonBody(schema) { + return { + required: true, + content: { + "application/json": { schema }, + }, + }; +} + +function sectionActorPost(summary) { + return { + post: { + tags: ["Sections"], + summary, + parameters: [pathParam("sectionId")], + requestBody: jsonBody({ + type: "object", + properties: { + user_id: { type: "string" }, + user: { type: "string" }, + }, + }), + responses: { + 200: { description: "Updated section state" }, + 400: responseRef("user_id is required"), + 404: responseRef("Section not found"), + 409: responseRef("Section conflict"), + }, + }, + }; +} + +function submissionReviewPost(summary) { + return { + post: { + tags: ["Submissions"], + summary, + parameters: [pathParam("submissionId")], + requestBody: jsonBody({ + type: "object", + properties: { + reviewed_by: { type: "string" }, + user_id: { type: "string" }, + review_note: { type: "string", nullable: true }, + }, + }), + responses: { + 200: objectResponse("Updated submission", "#/components/schemas/SectionSubmission"), + 404: responseRef("Submission not found"), + 409: responseRef("Submission conflict"), + }, + }, + }; +} + module.exports = { openApiSpec, };