diff --git a/routes/entities.js b/routes/entities.js index 1c7cf56..495dda0 100644 --- a/routes/entities.js +++ b/routes/entities.js @@ -1,5 +1,6 @@ const express = require("express"); const db = require("../db/polygons"); +const { normalizeEntityContract } = require("../types/contracts"); const router = express.Router(); @@ -29,7 +30,7 @@ router.get("/", (req, res) => { `; const rows = db.prepare(sql).all(...params); - res.json(rows.map(normalizeEntityRow)); + res.json(rows.map(normalizeEntityContract)); }); router.get("/search", (req, res) => { @@ -55,7 +56,7 @@ router.get("/search", (req, res) => { LIMIT ? `).all(pattern, limit); - res.json(rows.map(normalizeEntityRow)); + res.json(rows.map(normalizeEntityContract)); }); router.get("/:id", (req, res) => { @@ -75,25 +76,11 @@ router.get("/:id", (req, res) => { return res.status(404).json({ error: "Entity not found" }); } - res.json(normalizeEntityRow(row)); + res.json(normalizeEntityContract(row)); }); module.exports = router; -function normalizeEntityRow(row) { - return { - id: row.id, - name: row.name, - slug: row.slug, - description: row.description, - type_id: row.type_id, - status: row.status, - created_at: row.created_at, - updated_at: row.updated_at, - geometry_count: Number(row.geometry_count || 0), - }; -} - function normalizeLimit(value, fallback = 25, max = 100) { const num = Number(value); if (!Number.isFinite(num)) return fallback; diff --git a/routes/geometries.js b/routes/geometries.js index 2bbc6b7..2cfe615 100644 --- a/routes/geometries.js +++ b/routes/geometries.js @@ -1,5 +1,6 @@ const express = require("express"); const db = require("../db/polygons"); +const { normalizeFeatureCollectionContract, normalizeFeatureContract } = require("../types/contracts"); const router = express.Router(); @@ -70,13 +71,13 @@ router.get("/", (req, res) => { const geometryIds = rows.map((row) => String(row.id)); const linksByGeometryId = loadGeometryLinksByGeometryId(geometryIds); - res.json({ + res.json(normalizeFeatureCollectionContract({ type: "FeatureCollection", features: rows.map((row) => { const linkedEntities = linksByGeometryId.get(String(row.id)) || []; return buildFeatureFromRow(row, linkedEntities); }), - }); + })); }); module.exports = router; @@ -95,7 +96,7 @@ function buildFeatureFromRow(row, linkedEntities = []) { ? storedGeometryType : normalizeGeometryType(primaryEntity?.entity_type_id); - return { + return normalizeFeatureContract({ type: "Feature", properties: { id: row.id, @@ -112,7 +113,7 @@ function buildFeatureFromRow(row, linkedEntities = []) { entity_type_id: primaryEntity?.entity_type_id || null, }, geometry: JSON.parse(row.draw_geometry), - }; + }); } function normalizeGeometryType(value) { diff --git a/routes/sections.js b/routes/sections.js index 5486670..a1d5da6 100644 --- a/routes/sections.js +++ b/routes/sections.js @@ -2,6 +2,14 @@ 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; @@ -673,6 +681,8 @@ function applyLinkScopeSnapshot(scope, now) { } function validateSnapshot(snapshot) { + assertEditorSnapshotContract(snapshot); + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { throw createHttpError("snapshot must be an object", 400); } @@ -847,71 +857,25 @@ function updateSubmissionReview(submissionId, status, actor, now, reviewNote) { } 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, - }, - }; + return normalizeSectionContract(row); } 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, - }; + return normalizeSectionStateContract(row); } 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; + if (includeSnapshot) { + return normalizeSectionCommitContract(row, parseSnapshotJson(row.snapshot_json)); + } + return normalizeSectionCommitContract(row); } 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; + if (includeSnapshot) { + return normalizeSectionSubmissionContract(row, parseSnapshotJson(row.snapshot_json)); + } + return normalizeSectionSubmissionContract(row); } function buildEmptySnapshot(section) { @@ -944,7 +908,7 @@ function normalizeSnapshotInput(input) { function parseSnapshotJson(value) { try { - return JSON.parse(value); + return normalizeEditorSnapshotContract(JSON.parse(value)); } catch (_err) { return null; } diff --git a/types/contracts.js b/types/contracts.js new file mode 100644 index 0000000..f64543f --- /dev/null +++ b/types/contracts.js @@ -0,0 +1,246 @@ +"use strict"; + +const SECTION_STATUSES = Object.freeze(["editing", "submitted", "approved", "rejected"]); +const SECTION_COMMIT_KINDS = Object.freeze(["manual", "restore"]); +const SECTION_SUBMISSION_STATUSES = Object.freeze(["pending", "approved", "rejected", "conflicted"]); +const SNAPSHOT_OPERATIONS = Object.freeze(["create", "update", "delete", "reference", "replace"]); + +/** + * @typedef {"editing"|"submitted"|"approved"|"rejected"} SectionStatus + * @typedef {"manual"|"restore"} SectionCommitKind + * @typedef {"pending"|"approved"|"rejected"|"conflicted"} SectionSubmissionStatus + * @typedef {"create"|"update"|"delete"|"reference"|"replace"} SnapshotOperation + */ + +function normalizeNullableString(value) { + if (value === undefined || value === null) return null; + const normalized = String(value); + return normalized.length ? normalized : null; +} + +function normalizeRequiredString(value, fallback = "") { + if (value === undefined || value === null) return fallback; + return String(value); +} + +function normalizeInteger(value, fallback = 0) { + const parsed = Number(value); + return Number.isFinite(parsed) ? Math.trunc(parsed) : fallback; +} + +function normalizeStatus(value, allowed, fallback) { + const normalized = String(value || "").trim().toLowerCase(); + return allowed.includes(normalized) ? normalized : fallback; +} + +function normalizeIdArray(value) { + if (!Array.isArray(value)) return []; + const seen = new Set(); + const ids = []; + for (const item of value) { + if (typeof item !== "string" && typeof item !== "number") continue; + const id = String(item).trim(); + if (!id || seen.has(id)) continue; + seen.add(id); + ids.push(id); + } + return ids; +} + +function normalizeEntityContract(row) { + return { + id: normalizeRequiredString(row.id), + name: normalizeRequiredString(row.name), + slug: normalizeNullableString(row.slug), + description: normalizeNullableString(row.description), + type_id: normalizeNullableString(row.type_id), + status: row.status === undefined || row.status === null ? null : normalizeInteger(row.status, 1), + created_at: normalizeNullableString(row.created_at), + updated_at: normalizeNullableString(row.updated_at), + geometry_count: normalizeInteger(row.geometry_count, 0), + }; +} + +function normalizeFeaturePropertiesContract(properties) { + return { + id: properties.id, + type: normalizeNullableString(properties.type), + time_start: properties.time_start === undefined || properties.time_start === null + ? null + : normalizeInteger(properties.time_start, 0), + time_end: properties.time_end === undefined || properties.time_end === null + ? null + : normalizeInteger(properties.time_end, 0), + binding: normalizeIdArray(properties.binding), + entity_id: normalizeNullableString(properties.entity_id), + entity_ids: normalizeIdArray(properties.entity_ids), + entity_name: normalizeNullableString(properties.entity_name), + entity_names: Array.isArray(properties.entity_names) + ? properties.entity_names + .filter((name) => typeof name === "string" && name.length > 0) + : [], + entity_type_id: normalizeNullableString(properties.entity_type_id), + }; +} + +function normalizeFeatureContract(feature) { + return { + type: "Feature", + properties: normalizeFeaturePropertiesContract(feature.properties || {}), + geometry: feature.geometry, + }; +} + +function normalizeFeatureCollectionContract(collection) { + return { + type: "FeatureCollection", + features: Array.isArray(collection?.features) + ? collection.features.map(normalizeFeatureContract) + : [], + }; +} + +function normalizeSectionStateContract(row) { + return { + section_id: normalizeRequiredString(row.section_id), + status: normalizeStatus(row.status, SECTION_STATUSES, "editing"), + head_commit_id: normalizeNullableString(row.head_commit_id), + version: normalizeInteger(row.version, 0), + locked_by: normalizeNullableString(row.locked_by), + locked_at: normalizeNullableString(row.locked_at), + lock_expires_at: normalizeNullableString(row.lock_expires_at), + updated_at: normalizeRequiredString(row.updated_at), + }; +} + +function normalizeSectionContract(row) { + return { + id: normalizeRequiredString(row.id), + title: normalizeRequiredString(row.title), + description: normalizeNullableString(row.description), + user_id: normalizeNullableString(row.user_id), + created_by: normalizeNullableString(row.created_by), + created_at: normalizeRequiredString(row.created_at), + updated_at: normalizeRequiredString(row.updated_at), + state: { + status: normalizeStatus(row.status, SECTION_STATUSES, "editing"), + head_commit_id: normalizeNullableString(row.head_commit_id), + version: normalizeInteger(row.version, 0), + locked_by: normalizeNullableString(row.locked_by), + locked_at: normalizeNullableString(row.locked_at), + lock_expires_at: normalizeNullableString(row.lock_expires_at), + }, + }; +} + +function normalizeSectionCommitContract(row, snapshot) { + const out = { + id: normalizeRequiredString(row.id), + section_id: normalizeRequiredString(row.section_id), + parent_commit_id: normalizeNullableString(row.parent_commit_id), + commit_no: normalizeInteger(row.commit_no, 0), + kind: normalizeStatus(row.kind, SECTION_COMMIT_KINDS, "manual"), + restored_from_commit_id: normalizeNullableString(row.restored_from_commit_id), + created_by: normalizeRequiredString(row.created_by), + created_at: normalizeRequiredString(row.created_at), + title: normalizeNullableString(row.title), + note: normalizeNullableString(row.note), + snapshot_hash: normalizeNullableString(row.snapshot_hash), + }; + if (arguments.length >= 2) out.snapshot = normalizeEditorSnapshotContract(snapshot); + return out; +} + +function normalizeSectionSubmissionContract(row, snapshot) { + const out = { + id: normalizeRequiredString(row.id), + section_id: normalizeRequiredString(row.section_id), + commit_id: normalizeRequiredString(row.commit_id), + submitted_by: normalizeRequiredString(row.submitted_by), + submitted_at: normalizeRequiredString(row.submitted_at), + status: normalizeStatus(row.status, SECTION_SUBMISSION_STATUSES, "pending"), + reviewed_by: normalizeNullableString(row.reviewed_by), + reviewed_at: normalizeNullableString(row.reviewed_at), + review_note: normalizeNullableString(row.review_note), + snapshot_hash: normalizeNullableString(row.snapshot_hash), + }; + if (arguments.length >= 2) out.snapshot = normalizeEditorSnapshotContract(snapshot); + return out; +} + +function normalizeSnapshotOperation(value) { + return normalizeStatus(value, SNAPSHOT_OPERATIONS, ""); +} + +function normalizeEditorSnapshotContract(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) return null; + return { + schema_version: normalizeInteger(snapshot.schema_version, 1), + section: { + id: normalizeRequiredString(snapshot.section?.id), + title: normalizeRequiredString(snapshot.section?.title), + }, + editor_feature_collection: snapshot.editor_feature_collection + ? normalizeFeatureCollectionContract(snapshot.editor_feature_collection) + : undefined, + entities: Array.isArray(snapshot.entities) ? snapshot.entities : [], + geometries: Array.isArray(snapshot.geometries) ? snapshot.geometries : [], + link_scopes: Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : [], + }; +} + +function assertEditorSnapshotContract(snapshot) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + throwContractError("snapshot must be an object"); + } + if (!snapshot.section || typeof snapshot.section !== "object" || Array.isArray(snapshot.section)) { + throwContractError("snapshot.section must be an object"); + } + if (!normalizeRequiredString(snapshot.section.id)) { + throwContractError("snapshot.section.id is required"); + } + if (!normalizeRequiredString(snapshot.section.title)) { + throwContractError("snapshot.section.title is required"); + } + + for (const entity of Array.isArray(snapshot.entities) ? snapshot.entities : []) { + const operation = normalizeSnapshotOperation(entity?.operation); + if (!operation) throwContractError("Invalid entity operation"); + if (!normalizeRequiredString(entity?.id)) throwContractError("Entity id is required"); + } + + for (const geometry of Array.isArray(snapshot.geometries) ? snapshot.geometries : []) { + const operation = normalizeSnapshotOperation(geometry?.operation); + if (!operation) throwContractError("Invalid geometry operation"); + if (!normalizeRequiredString(geometry?.id)) throwContractError("Geometry id is required"); + } + + for (const scope of Array.isArray(snapshot.link_scopes) ? snapshot.link_scopes : []) { + if (!normalizeRequiredString(scope?.geometry_id)) throwContractError("link_scope geometry_id is required"); + if (!Array.isArray(scope?.entity_ids)) throwContractError("link_scope entity_ids must be an array"); + } +} + +function throwContractError(message) { + const err = new Error(message); + err.status = 400; + throw err; +} + +module.exports = { + SECTION_STATUSES, + SECTION_COMMIT_KINDS, + SECTION_SUBMISSION_STATUSES, + SNAPSHOT_OPERATIONS, + assertEditorSnapshotContract, + normalizeEditorSnapshotContract, + normalizeEntityContract, + normalizeFeatureCollectionContract, + normalizeFeatureContract, + normalizeIdArray, + normalizeSectionCommitContract, + normalizeSectionContract, + normalizeSectionStateContract, + normalizeSectionSubmissionContract, + normalizeSnapshotOperation, +};