1107 lines
36 KiB
JavaScript
1107 lines
36 KiB
JavaScript
const express = require("express");
|
|
const crypto = require("crypto");
|
|
const db = require("../db/polygons");
|
|
const { getBBox } = require("../utils/bbox");
|
|
const {
|
|
assertEditorSnapshotContract,
|
|
normalizeEditorSnapshotContract,
|
|
normalizeSectionCommitContract,
|
|
normalizeSectionContract,
|
|
normalizeSectionStateContract,
|
|
normalizeSectionSubmissionContract,
|
|
} = require("../types/contracts");
|
|
|
|
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 = state.head_commit_id;
|
|
if (!commitId) {
|
|
throw createHttpError("Section has no head 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) {
|
|
assertEditorSnapshotContract(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 normalizeSectionContract(row);
|
|
}
|
|
|
|
function normalizeStateRow(row) {
|
|
return normalizeSectionStateContract(row);
|
|
}
|
|
|
|
function normalizeCommitRow(row, includeSnapshot) {
|
|
if (includeSnapshot) {
|
|
return normalizeSectionCommitContract(row, parseSnapshotJson(row.snapshot_json));
|
|
}
|
|
return normalizeSectionCommitContract(row);
|
|
}
|
|
|
|
function normalizeSubmissionRow(row, includeSnapshot) {
|
|
if (includeSnapshot) {
|
|
return normalizeSectionSubmissionContract(row, parseSnapshotJson(row.snapshot_json));
|
|
}
|
|
return normalizeSectionSubmissionContract(row);
|
|
}
|
|
|
|
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 normalizeEditorSnapshotContract(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;
|