"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, };