Files
temp-history-api/routes/sections.js
2026-04-19 00:13:22 +07:00

1143 lines
37 KiB
JavaScript

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;