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;