diff --git a/configure/package.json b/configure/package.json index 1acd2a862..ab4e96872 100644 --- a/configure/package.json +++ b/configure/package.json @@ -1,6 +1,6 @@ { "name": "configure", - "version": "5.2.16-20260708", + "version": "5.2.17-20260709", "homepage": "./configure/build", "private": true, "dependencies": { diff --git a/docs/mmgis-openapi.json b/docs/mmgis-openapi.json index 9540d4602..98f29af19 100644 --- a/docs/mmgis-openapi.json +++ b/docs/mmgis-openapi.json @@ -159,13 +159,22 @@ "type": "object", "properties": { "mission": { - "type": "string" + "type": "string", + "description": "Name of the new mission. Must be filesystem-safe: cannot start with a number and cannot contain path traversal sequences ('../').", + "example": "ExampleMission" }, "config": { - "type": "object" + "type": "object", + "description": "Optional partial configuration object that is deep-merged onto the default mission template." }, "makedir": { - "type": "string" + "type": "string", + "description": "'true' to also create the ./Missions/ directory on disk.", + "example": "true" + }, + "setupReferenceMission": { + "description": "Optional. Boolean true, or a variant key string, to instead scaffold a Reference Mission variant rather than a blank mission.", + "oneOf": [{ "type": "boolean" }, { "type": "string" }] } }, "required": ["mission"] @@ -791,6 +800,50 @@ } } }, + "/api/configure/reference-mission/save-to-base": { + "post": { + "summary": "Save a Reference Mission's current working config back to its blueprint template file. Development mode only.", + "tags": ["Configure"], + "description": "Only available when NODE_ENV is 'development' and only for missions that are Reference Mission variants; requires mission-level admin permission. Writes the latest config for the mission to its blueprints/Missions//.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "mission": { + "type": "string", + "description": "The Reference Mission variant name whose current config should be written back to its blueprint template.", + "example": "ReferenceMission" + } + }, + "required": ["mission"] + } + } + } + }, + "responses": { + "200": { + "description": "Save result.", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": { "status": "success", "message": "Config saved to ./blueprints/Missions/Earth/config.json" } + }, + "failure": { + "summary": "Failure", + "value": { "status": "failure", "message": "This endpoint is only available for Reference Mission variants." } + } + } + } + } + } + } + } + }, "/api/configure/getGeneralOptions": { "get": { "summary": "Get the system-wide configured options", @@ -823,7 +876,13 @@ "application/json": { "schema": { "type": "object", - "properties": {} + "properties": { + "options": { + "description": "The general options object (or a JSON-encoded string of it). This FULLY REPLACES the existing system-wide general options.", + "oneOf": [{ "type": "object" }, { "type": "string" }] + } + }, + "required": ["options"] } } } @@ -1655,6 +1714,101 @@ } } }, + "/api/draw/aggregations": { + "post": { + "summary": "Return property value histograms/aggregations for a random sample of features in Draw file(s).", + "tags": ["Drawing"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "id": { + "description": "A single Draw file ID or an array of file IDs to aggregate over. May be sent as a JSON array or a JSON-encoded string.", + "oneOf": [ + { "type": "integer" }, + { "type": "array", "items": { "type": "integer" } }, + { "type": "string" } + ], + "example": [12] + }, + "test": { + "type": "string", + "description": "'true' to operate on the test tables (user_features_tests). Default 'false'.", + "example": "false" + }, + "limit": { + "type": "integer", + "description": "Number of features to randomly sample when building the aggregations. Default 500.", + "example": 500 + }, + "time": { + "type": "integer", + "description": "Optional epoch-ms point-in-time; aggregates the feature history as it existed at or before this time. Defaults to now." + }, + "minx": { + "type": "number", + "description": "Optional spatial-bounds filter: minimum longitude. Supply all of minx/miny/maxx/maxy together." + }, + "miny": { + "type": "number", + "description": "Optional spatial-bounds filter: minimum latitude." + }, + "maxx": { + "type": "number", + "description": "Optional spatial-bounds filter: maximum longitude." + }, + "maxy": { + "type": "number", + "description": "Optional spatial-bounds filter: maximum latitude." + }, + "startTime": { + "type": "string", + "description": "Optional temporal filter start; used with endTime and timeProp." + }, + "endTime": { + "type": "string", + "description": "Optional temporal filter end; used with startTime and timeProp." + }, + "timeProp": { + "type": "string", + "description": "Name of the feature property holding the time value for temporal filtering. Default 'time'." + } + }, + "required": ["id"] + } + } + } + }, + "responses": { + "200": { + "description": "Aggregation counts per property value (plus a 'geometry.type' aggregation).", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": { + "status": "success", + "aggregations": { + "geometry.type": { "type": "string", "aggs": { "Point": 3 } }, + "status": { "type": "string", "aggs": { "open": 2, "closed": 1 } } + } + } + }, + "failure": { + "summary": "Failure", + "value": { "status": "failure", "message": "No file IDs provided." } + } + } + } + } + } + } + } + }, "/api/draw/split": { "post": { "summary": "Split features", @@ -2615,6 +2769,46 @@ "type": "integer", "minimum": 0 } + }, + { + "name": "group_id", + "in": "query", + "description": "In the case of type=geojson, return only features whose group_id equals this value. Requires the GeoDataset to have been created with a group id field.", + "example": "track_042", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "query", + "description": "In the case of type=geojson, return only the single feature with this internal row id. Ignored if 'group_id' is also provided.", + "example": 12, + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "spatialFilter", + "in": "query", + "description": "In the case of type=geojson, return only features intersecting a circle. Comma-separated 'lat,lng,radius' where radius is in meters.", + "example": "29.7,-95.4,50000", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "In the case of a temporal query, the PostgreSQL date format string used to interpret 'starttime'/'endtime'. Defaults to 'YYYY-MM-DDTHH:MI:SSZ'.", + "example": "YYYY-MM-DDTHH:MI:SSZ", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -2869,6 +3063,46 @@ "type": "integer", "minimum": 0 } + }, + { + "name": "group_id", + "in": "query", + "description": "In the case of type=geojson, return only features whose group_id equals this value. Requires the GeoDataset to have been created with a group id field.", + "example": "track_042", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "id", + "in": "query", + "description": "In the case of type=geojson, return only the single feature with this internal row id. Ignored if 'group_id' is also provided.", + "example": 12, + "required": false, + "schema": { + "type": "integer" + } + }, + { + "name": "spatialFilter", + "in": "query", + "description": "In the case of type=geojson, return only features intersecting a circle. Comma-separated 'lat,lng,radius' where radius is in meters.", + "example": "29.7,-95.4,50000", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "In the case of a temporal query, the PostgreSQL date format string used to interpret 'starttime'/'endtime'. Defaults to 'YYYY-MM-DDTHH:MI:SSZ'.", + "example": "YYYY-MM-DDTHH:MI:SSZ", + "required": false, + "schema": { + "type": "string" + } } ], "responses": { @@ -3027,6 +3261,20 @@ }, "maxy": { "type": "number" + }, + "operator": { + "type": "string", + "description": "Comparison operator applied to 'key'/'value'. Defaults to '='. Anything invalid falls back to '='.", + "enum": ["=", "!=", "<", ">", "<=", ">=", "contains", "beginswith", "endswith", "in", ",", "isnull", "isnotnull", "regex"] + }, + "type": { + "type": "string", + "description": "When set to 'number', numeric operators (<, >, <=, >=) cast the field and value to NUMERIC for comparison.", + "enum": ["string", "number"] + }, + "last": { + "type": "boolean", + "description": "If true, ignore key/value matching and return only the most recent feature (highest id), honoring any bounding box / geometry-type filters." } }, "required": ["layer"] @@ -3089,6 +3337,104 @@ } } }, + "/api/geodatasets/intersect": { + "post": { + "summary": "Return the features of a GeoDataset that spatially intersect a supplied geometry.", + "tags": ["GeoDatasets"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "layer": { + "type": "string", + "description": "The name of the GeoDataset to query." + }, + "intersect": { + "description": "The GeoJSON geometry (object or stringified) to intersect features against.", + "oneOf": [{ "type": "object" }, { "type": "string" }] + }, + "noDuplicates": { + "type": "boolean", + "description": "Returns only DISTINCT features. Uses the group_id field, if set, for distinctness else uses the geometry field." + }, + "starttime": { + "type": "string", + "description": "For a temporal query, the start time of the window. If unset but 'endtime' is set, defaults to '1970-01-01T00:00:00Z'." + }, + "endtime": { + "type": "string", + "description": "For a temporal query, the end time of the window." + }, + "startProp": { + "type": "string", + "description": "For a temporal query, the start time column defined at the GeoDataset's creation. Defaults to 'start_time'." + }, + "endProp": { + "type": "string", + "description": "For a temporal query, the end time column defined at the GeoDataset's creation. Defaults to 'end_time'." + }, + "format": { + "type": "string", + "description": "For a temporal query, the PostgreSQL date format string used to interpret 'starttime'/'endtime'. Defaults to 'YYYY-MM-DDTHH:MI:SSZ'." + } + }, + "required": ["layer", "intersect"] + } + } + } + }, + "responses": { + "200": { + "description": "Intersecting features", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": { + "status": "success", + "body": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [102.0, 0.5] + }, + "properties": { + "name": "example_feature", + "_": { "idx": 1 } + } + } + ] + } + } + }, + "failure": { + "summary": "Failure", + "value": { + "status": "failure", + "message": "Failed to query Geodataset." + } + }, + "not_found": { + "summary": "Not Found", + "value": { + "status": "failure", + "message": "Not Found" + } + } + } + } + } + } + } + } + }, "/api/geodatasets/aggregations": { "get": { "summary": "Return the histograms/aggregations of values for the feature properties within a GeoDataset.", @@ -3173,20 +3519,50 @@ "schema": { "type": "string" } - } - ], - "responses": { - "200": { - "description": "GeoDataset aggregations", - "content": { - "application/json": { - "examples": { - "success": { - "summary": "Success", - "value": { - "status": "success", - "aggregations": [ - { + }, + { + "name": "startProp", + "in": "query", + "description": "For a temporal query, the start time column defined at the GeoDataset's creation. Defaults to 'start_time'.", + "example": "start_time", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endProp", + "in": "query", + "description": "For a temporal query, the end time column defined at the GeoDataset's creation. Defaults to 'end_time'.", + "example": "end_time", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "format", + "in": "query", + "description": "For a temporal query, the PostgreSQL date format string used to interpret 'starttime'/'endtime'. Defaults to 'YYYY-MM-DDTHH:MI:SSZ'.", + "example": "YYYY-MM-DDTHH:MI:SSZ", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "GeoDataset aggregations", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": { + "status": "success", + "aggregations": [ + { "passDirection": { "type": "string", "aggs": { "Descending": 252, "Ascending": 248 } @@ -3278,6 +3654,106 @@ } } }, + "/api/geodatasets/bulk_aggregations": { + "get": { + "summary": "Return aggregated feature-property values across multiple GeoDataset layers in a single call.", + "tags": ["GeoDatasets"], + "parameters": [ + { + "name": "layers", + "in": "query", + "description": "Comma-separated list of GeoDataset layer names to aggregate (capped at 100).", + "example": "reference_mission_basic,reference_mission_time_series", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "in": "query", + "description": "Sample size per layer used to build the aggregations. Default 500, clamped to 1-1000.", + "example": 500, + "required": false, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 1000 + } + }, + { + "name": "starttime", + "in": "query", + "description": "If set together with 'endtime', restricts the sampled features to those whose time columns fall within the window.", + "example": "2024-08-01T00:00:00.000Z", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endtime", + "in": "query", + "description": "If set together with 'starttime', restricts the sampled features to those whose time columns fall within the window.", + "example": "2025-04-03T21:55:06.013Z", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "startProp", + "in": "query", + "description": "Start time column name used for time filtering. Defaults to 'start_time'.", + "example": "start_time", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "endProp", + "in": "query", + "description": "End time column name used for time filtering. Defaults to 'end_time'.", + "example": "end_time", + "required": false, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Combined aggregations across the requested layers.", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": { + "status": "success", + "aggregations": { + "passDirection": { + "type": "string", + "aggs": { "Descending": 252, "Ascending": 248 } + } + } + } + }, + "failure": { + "summary": "Failure", + "value": { + "status": "failure", + "message": "Failure querying bulk geodataset aggregations." + } + } + } + } + } + } + } + } + }, "/api/geodatasets/append/{name}": { "post": { "summary": "Append to a geodataset", @@ -3294,6 +3770,8 @@ { "name": "start_prop", "in": "query", + "description": "Name of the property inside each feature's 'properties' object to use as its start time. Append does NOT auto-reuse the field configured at creation — supply it (or the body-level 'startProp') on every append or the appended features get a NULL start_time and won't match temporal queries.", + "example": "start_time", "required": false, "schema": { "type": "string" @@ -3302,6 +3780,28 @@ { "name": "end_prop", "in": "query", + "description": "Name of the property inside each feature's 'properties' object to use as its end time. Append does NOT auto-reuse the field configured at creation — supply it (or the body-level 'endProp') on every append or the appended features get a NULL end_time and won't match temporal queries.", + "example": "end_time", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "group_id_prop", + "in": "query", + "description": "Name of the property inside each feature's 'properties' object to use as its group id. Comma-separated to merge multiple properties (e.g. 'track,frame'). Append does NOT auto-reuse the field configured at creation — supply it (or the body-level 'groupIdProp') on every append or the appended features get a NULL group_id.", + "example": "track", + "required": false, + "schema": { + "type": "string" + } + }, + { + "name": "feature_id_prop", + "in": "query", + "description": "Name of the property inside each feature's 'properties' object to use as its feature id. Comma-separated to merge multiple properties (e.g. 'track,id'). Append does NOT auto-reuse the field configured at creation — supply it (or the body-level 'featureIdProp') on every append or the appended features get a NULL feature_id.", + "example": "id", "required": false, "schema": { "type": "string" @@ -3310,6 +3810,8 @@ { "name": "filename", "in": "query", + "description": "Optional source filename recorded on the GeoDataset entry.", + "example": "features_to_append.geojson", "required": false, "schema": { "type": "string" @@ -3318,16 +3820,59 @@ ], "requestBody": { "required": true, + "description": "The entire request body is the GeoJSON FeatureCollection to append. The field names can alternatively be supplied here as top-level GeoJSON members ('startProp', 'endProp', 'groupIdProp', 'featureIdProp'), which take precedence over the equivalent query parameters. If none are supplied, the appended features get NULL start_time/end_time/group_id/feature_id and will not match temporal or group_id queries even if the GeoDataset was created with those fields.", "content": { "application/json": { "schema": { "type": "object", "properties": { - "geojson": { - "type": "object" + "type": { + "type": "string", + "example": "FeatureCollection" + }, + "features": { + "type": "array", + "items": { + "type": "object" + } + }, + "startProp": { + "type": "string", + "description": "Optional. Property key to use as each feature's start time. Takes precedence over the 'start_prop' query parameter." + }, + "endProp": { + "type": "string", + "description": "Optional. Property key to use as each feature's end time. Takes precedence over the 'end_prop' query parameter." + }, + "groupIdProp": { + "type": "string", + "description": "Optional. Property key (comma-separated to merge) to use as each feature's group id. Takes precedence over the 'group_id_prop' query parameter." + }, + "featureIdProp": { + "type": "string", + "description": "Optional. Property key (comma-separated to merge) to use as each feature's feature id. Takes precedence over the 'feature_id_prop' query parameter." } }, - "required": ["geojson"] + "required": ["features"] + }, + "example": { + "type": "FeatureCollection", + "startProp": "start_time", + "endProp": "end_time", + "groupIdProp": "track", + "featureIdProp": "id", + "features": [ + { + "type": "Feature", + "geometry": { "type": "Point", "coordinates": [-95.4, 29.7] }, + "properties": { + "start_time": "2024-03-13T21:26:22.090Z", + "end_time": "2024-03-13T21:30:00.000Z", + "track": "track_042", + "id": "feat-0001" + } + } + ] } } } @@ -3377,6 +3922,8 @@ { "name": "start_end_prop", "in": "path", + "description": "Comma-separated 'startProp,endProp' — the property keys to use as each feature's start and end time. To set group id / feature id on this route, include body-level 'groupIdProp'/'featureIdProp' members instead.", + "example": "start_time,end_time", "required": true, "schema": { "type": "string" @@ -3385,16 +3932,24 @@ ], "requestBody": { "required": true, + "description": "The entire request body is the GeoJSON FeatureCollection to append. Top-level GeoJSON members 'startProp', 'endProp', 'groupIdProp', 'featureIdProp' are also honored and take precedence over the path parameter.", "content": { "application/json": { "schema": { "type": "object", "properties": { - "geojson": { - "type": "object" + "type": { + "type": "string", + "example": "FeatureCollection" + }, + "features": { + "type": "array", + "items": { + "type": "object" + } } }, - "required": ["geojson"] + "required": ["features"] } } } @@ -3443,16 +3998,31 @@ "type": "string" }, "startProp": { - "type": "string" + "type": "string", + "description": "Property key to use as each feature's start time." }, "endProp": { - "type": "string" + "type": "string", + "description": "Property key to use as each feature's end time." + }, + "groupIdProp": { + "type": "string", + "description": "Property key (comma-separated to merge) to use as each feature's group id." + }, + "featureIdProp": { + "type": "string", + "description": "Property key (comma-separated to merge) to use as each feature's feature id." + }, + "filename": { + "type": "string", + "description": "Optional source filename recorded on the GeoDataset entry." }, "geojson": { "type": "object" }, "action": { "type": "string", + "description": "'recreate' (default) truncates and replaces all features; 'append' adds to the existing features. On 'append', the *_field configuration is preserved but the appended features only get start_time/end_time/group_id/feature_id populated if the corresponding prop is supplied.", "enum": ["append", "recreate"] } }, @@ -3756,10 +4326,14 @@ "type": "object", "properties": { "name": { - "type": "string" + "type": "string", + "description": "Optional label for the token. Non-alphanumeric characters are stripped and it is lowercased, then prefixed to the generated token (e.g. name 'My CI' produces a token like 'myci-').", + "example": "ci-pipeline" }, "period": { - "type": "string" + "type": "string", + "description": "Token lifetime, measured in MILLISECONDS from creation, or the literal string 'never' for no expiry. IMPORTANT: unit strings like '1d' are NOT parsed \u2014 they resolve to parseInt('1d')=1ms and the token is treated as immediately expired. Use e.g. '86400000' for one day, or 'never'.", + "example": "never" } }, "required": ["period"] @@ -3819,7 +4393,9 @@ "type": "object", "properties": { "id": { - "type": "integer" + "type": "integer", + "description": "The numeric id of the long term token to delete (as returned by GET /api/longtermtoken/get).", + "example": 1 } }, "required": ["id"] @@ -3876,10 +4452,13 @@ "type": "object", "properties": { "url": { - "type": "string" + "type": "string", + "description": "The full URL to shorten. It is URL-encoded and stored; a short random code is returned.", + "example": "https://example.com/?mission=ExampleMission&s=long-state-string" }, "text": { - "type": "string" + "type": "string", + "description": "Optional; not currently persisted by the server." } }, "required": ["url"] @@ -3938,7 +4517,9 @@ "type": "object", "properties": { "short": { - "type": "string" + "type": "string", + "description": "The short code previously returned by /api/shortener/shorten.", + "example": "a1b2c" } }, "required": ["short"] @@ -4035,10 +4616,14 @@ "type": "object", "properties": { "username": { - "type": "string" + "type": "string", + "description": "Username for the first (SuperAdmin) account. Only succeeds when there are zero existing users.", + "example": "admin" }, "password": { - "type": "string" + "type": "string", + "description": "Password. Must be at least 8 characters and contain at least: 1 uppercase, 1 lowercase, 1 number and 1 symbol.", + "example": "ChangeMe1!" } }, "required": ["username", "password"] @@ -4099,16 +4684,22 @@ "type": "object", "properties": { "username": { - "type": "string" + "type": "string", + "description": "Username for the new account.", + "example": "jane" }, "email": { - "type": "string" + "type": "string", + "description": "Optional email address; an empty string is stored as null." }, "password": { - "type": "string" + "type": "string", + "description": "Password. Must be at least 8 characters and contain at least: 1 uppercase, 1 lowercase, 1 number and 1 symbol.", + "example": "ChangeMe1!" }, "skipLogin": { - "type": "boolean" + "type": "boolean", + "description": "If true, create the account without logging the new user in on this session. Requires an admin session unless AUTH_LOCAL_ALLOW_SIGNUP is enabled." } }, "required": ["username", "password"] @@ -4178,13 +4769,17 @@ "type": "object", "properties": { "username": { - "type": "string" + "type": "string", + "description": "Account username.", + "example": "admin" }, "password": { - "type": "string" + "type": "string", + "description": "Account password. Required for a normal login (omit only when useToken is true)." }, "useToken": { - "type": "boolean" + "type": "boolean", + "description": "If true, re-authenticate using the token from the MMGISUser cookie instead of a password (used on page reload). Session regeneration and token rotation are intentionally skipped for multi-tab compatibility." } }, "required": ["username"] @@ -4338,13 +4933,16 @@ "type": "object", "properties": { "username": { - "type": "string" + "type": "string", + "description": "Username of the account whose password is being reset." }, "password": { - "type": "string" + "type": "string", + "description": "The new password. Must be at least 8 characters and contain at least: 1 uppercase, 1 lowercase, 1 number and 1 symbol." }, "resetToken": { - "type": "boolean" + "type": "string", + "description": "The reset token string (from the reset link). Must match the stored token and not be expired." } }, "required": ["username", "password", "resetToken"] @@ -4784,25 +5382,34 @@ "type": "object", "properties": { "path": { - "type": "string" + "type": "string", + "description": "Server-side path to the raster (e.g. a DEM GeoTIFF) to sample.", + "example": "Missions/ExampleMission/Layers/dem.tif" }, "lat1": { - "type": "string" + "type": "string", + "description": "Latitude of the profile's start point." }, "lon1": { - "type": "string" + "type": "string", + "description": "Longitude of the profile's start point." }, "lat2": { - "type": "string" + "type": "string", + "description": "Latitude of the profile's end point." }, "lon2": { - "type": "string" + "type": "string", + "description": "Longitude of the profile's end point." }, "steps": { - "type": "string" + "type": "string", + "description": "Number of samples to take along the line between the two points.", + "example": "100" }, "axes": { - "type": "string" + "type": "string", + "description": "Which axes to include in the returned profile." } }, "required": [ @@ -4862,19 +5469,27 @@ "type": "object", "properties": { "path": { - "type": "string" + "type": "string", + "description": "Server-side path to the raster to sample.", + "example": "Missions/ExampleMission/Layers/image.tif" }, "x": { - "type": "string" + "type": "string", + "description": "X coordinate: pixel column when xyorll='xy', or longitude when xyorll='ll'." }, "y": { - "type": "string" + "type": "string", + "description": "Y coordinate: pixel row when xyorll='xy', or latitude when xyorll='ll'." }, "xyorll": { - "type": "string" + "type": "string", + "description": "'xy' to interpret x/y as pixel coordinates, or 'll' to interpret them as longitude/latitude.", + "example": "ll" }, "bands": { - "type": "string" + "type": "string", + "description": "Comma-separated 1-based band index/indices to sample.", + "example": "1" } }, "required": ["path", "x", "y", "xyorll", "bands"] @@ -4905,6 +5520,50 @@ } } }, + "/api/utils/getminmax": { + "post": { + "summary": "Get the minimum and maximum pixel values of a raster's band(s)", + "tags": ["Utilities"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "path": { + "type": "string", + "description": "Server-side path to the raster (e.g. a GeoTIFF) to inspect with gdalinfo.", + "example": "Missions/ExampleMission/Layers/dem.tif" + }, + "bands": { + "type": "string", + "description": "Comma-separated 1-based band index/indices to compute the min/max for.", + "example": "1" + } + }, + "required": ["path", "bands"] + } + } + } + }, + "responses": { + "200": { + "description": "Per-band minimum and maximum values (raw gdalinfo-derived output).", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": [[1, -4229.0, 21229.0]] + } + } + } + } + } + } + } + }, "/api/utils/ll2aerll": { "post": { "summary": "Convert Lat/Lon to aeronautical Lat/Lon", @@ -4995,6 +5654,113 @@ } } }, + "/api/utils/ll2aerll_bulk": { + "post": { + "summary": "Convert Lat/Lon to Azimuth/Elevation/Range for many times in a single request (SPICE kernels are loaded once).", + "tags": ["Utilities"], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "lng": { + "type": "number", + "description": "Observer longitude in degrees.", + "example": 137.4 + }, + "lat": { + "type": "number", + "description": "Observer latitude in degrees.", + "example": -4.6 + }, + "height": { + "type": "number", + "description": "Observer height above the surface in meters.", + "example": 0 + }, + "target": { + "type": "string", + "description": "SPICE target body/object to point at (alphanumeric, underscore or hyphen only).", + "example": "SUN" + }, + "times": { + "type": "array", + "items": { "type": "string" }, + "description": "Non-empty array of time strings to evaluate (maximum 1000 entries).", + "example": ["2024-06-01T00:00:00", "2024-06-01T01:00:00"] + }, + "obsRefFrame": { + "type": "string", + "description": "Observer reference frame (alphanumeric, underscore or hyphen only). Default 'IAU_MARS'.", + "example": "IAU_MARS" + }, + "obsBody": { + "type": "string", + "description": "Observer body (alphanumeric, underscore or hyphen only). Default 'MARS'.", + "example": "MARS" + }, + "includeSunEarth": { + "type": "string", + "description": "'true' to also include Sun and Earth azimuth/elevation in the ancillary output. Default 'false'.", + "example": "false" + }, + "isCustom": { + "type": "string", + "description": "'true' to use the supplied customAz/customEl/customRange instead of computing from the target. Default 'false'.", + "example": "false" + }, + "customAz": { + "type": "number", + "description": "Custom azimuth (used only when isCustom is 'true')." + }, + "customEl": { + "type": "number", + "description": "Custom elevation (used only when isCustom is 'true')." + }, + "customRange": { + "type": "number", + "description": "Custom range (used only when isCustom is 'true')." + } + }, + "required": ["lng", "lat", "height", "target", "times"] + } + } + } + }, + "responses": { + "200": { + "description": "An array of Az/El/Range results, one per requested time.", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": [ + { "azimuth": 191.18, "elevation": -45.49, "range": 5166.63 } + ] + } + } + } + } + }, + "400": { + "description": "Invalid request (e.g. 'times' missing/empty/too large, or target/obsRefFrame/obsBody contain disallowed characters).", + "content": { + "application/json": { + "examples": { + "failure": { + "summary": "Failure", + "value": { "error": true, "message": "times must be a non-empty array" } + } + } + } + } + } + } + } + }, "/api/utils/chronice": { "post": { "summary": "SPICE time converter", @@ -5086,6 +5852,98 @@ } } }, + "/api/stac/collections": { + "get": { + "summary": "List STAC collections, each annotated with the MMGIS missions/layers that reference it.", + "tags": ["STAC"], + "description": "Proxies the adjacent STAC server's GET /collections (any supplied query string is forwarded) and augments each collection with an 'occurrences' map of the mission layers (from the latest configs) whose layer url is 'stac-collection:'.", + "responses": { + "200": { + "description": "The STAC collections plus per-mission occurrences.", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": { + "status": "success", + "body": { + "collections": [ + { + "id": "example_collection", + "occurrences": { + "ExampleMission": [ + { "name": "Example Layer", "uuid": "abc-123", "path": "Group/Example Layer" } + ] + } + } + ], + "links": [] + } + } + }, + "failure": { + "summary": "Failure", + "value": { "status": "failure", "message": "Failed to find missions for STAC Collections." } + } + } + } + } + } + } + } + }, + "/api/stac/collections/{collection}/export": { + "get": { + "summary": "Export a single STAC collection together with all of its items.", + "tags": ["STAC"], + "description": "Fetches the collection metadata and pages through all of its items from the adjacent STAC server, returning them combined.", + "parameters": [ + { + "name": "collection", + "in": "path", + "required": true, + "description": "The STAC collection id to export.", + "schema": { "type": "string" }, + "example": "example_collection" + } + ], + "responses": { + "200": { + "description": "The collection metadata and all of its items.", + "content": { + "application/json": { + "examples": { + "success": { + "summary": "Success", + "value": { + "status": "success", + "body": { + "collection": { "id": "example_collection", "type": "Collection" }, + "items": [{ "type": "Feature", "id": "item-1" }] + } + } + } + } + } + } + }, + "500": { + "description": "Collection not found or items could not be fetched.", + "content": { + "application/json": { + "examples": { + "failure": { + "summary": "Failure", + "value": { "status": "failure", "message": "Collection not found" } + } + } + } + } + } + } + } + }, "/api/webhooks/save": { "post": { "summary": "Save webhooks config", diff --git a/docs/pages/APIs/GeoDatasets/GeoDatasets_API.md b/docs/pages/APIs/GeoDatasets/GeoDatasets_API.md index 17c2fa99e..e8a2b6547 100644 --- a/docs/pages/APIs/GeoDatasets/GeoDatasets_API.md +++ b/docs/pages/APIs/GeoDatasets/GeoDatasets_API.md @@ -17,6 +17,10 @@ Enables programmatic control over GeoDataset layers. GeoDatasets are GeoJSON fil - [Endpoints](#endpoints) - [GET /get](#get-get) - [GET /get/:layer](#get-getlayer) + - [POST /intersect](#post-intersect) + - [GET /aggregations](#get-aggregations) + - [GET /bulk_aggregations](#get-bulk_aggregations) + - [GET /schema](#get-schema) - [POST /entries](#post-entries) - [POST /search](#post-search) - [POST /append/:name](#post-appendname) @@ -58,6 +62,10 @@ Queries and geodataset and returns geojson or vectortiles. | **starttime** | _time_ | false | N/A | Start time of time window to query | | **endProp** | _string_ | false | N/A | Name of key of feature's end time property | | **endtime** | _time_ | false | N/A | End time of time window to query | +| **format** | _string_ | false | `YYYY-MM-DDTHH:MI:SSZ` | PostgreSQL date format used to parse starttime/endtime | +| **group_id** | _string_ | false | N/A | Return only features with this group_id value | +| **id** | _integer_ | false | N/A | Return only the single feature with this internal row id | +|**spatialFilter**| _string_| false | N/A | Return features intersecting a circle: `lat,lng,radius` (radius in meters) | | **x** | _integer_ | false | N/A | If type=mvt, x of tile to get | | **y** | _integer_ | false | N/A | If type=mvt, y of tile to get | | **z** | _integer_ | false | N/A | If type=mvt, z of tile to get | @@ -78,6 +86,81 @@ See [GET /get](#get-get). `layer` parameter can be passed in through URL instead --- +### POST /intersect + +Returns the features of a geodataset that spatially intersect a supplied GeoJSON geometry. Parameters are sent in the JSON body. + +| Parameter | Type | Required | Default | Description | +| :-----------: | :-------------: | :------: | :-----: | :-------------------------------------------------: | +| **layer** | _string_ | true | N/A | Geodataset layer name | +| **intersect** | _object/string_ | true | N/A | GeoJSON geometry (object or stringified) to intersect against | +|**noDuplicates**| _boolean_ | false | false | Return only DISTINCT features (by group_id/geom) | +| **starttime** | _time_ | false | N/A | Start time of time window to query | +| **endtime** | _time_ | false | N/A | End time of time window to query | +| **startProp** | _string_ | false |`start_time`| Name of feature's start time column | +| **endProp** | _string_ | false | `end_time` | Name of feature's end time column | +| **format** | _string_ | false |`YYYY-MM-DDTHH:MI:SSZ`| PostgreSQL date format for time window | + +#### Example + +`curl -X POST -H "Authorization:Bearer " -H "Content-Type: application/json" -d '{"layer":"my_geodataset","intersect":{"type":"Polygon","coordinates":[[[-1,-1],[-1,1],[1,1],[1,-1],[-1,-1]]]}}' http://localhost:8889/api/geodatasets/intersect` + +--- + +### GET /aggregations + +Returns histograms/aggregations of feature `properties` values for a single geodataset (built from a random sample). + +| Parameter | Type | Required | Default | Description | +| :-----------: | :------: | :------: | :-----: | :------------------------------------------------: | +| **layer** | _string_ | true | N/A | Geodataset layer name | +| **limit** | _integer_| false | 500 | Random sample size used to build aggregations | +| **minx/miny/maxx/maxy** | _number_ | false | N/A | Optional bounding-box extent | +| **starttime** | _time_ | false | N/A | Start time of time window | +| **endtime** | _time_ | false | N/A | End time of time window | +| **startProp** | _string_ | false |`start_time`| Name of feature's start time column | +| **endProp** | _string_ | false | `end_time` | Name of feature's end time column | +| **format** | _string_ | false |`YYYY-MM-DDTHH:MI:SSZ`| PostgreSQL date format for time window | + +#### Example + +`curl -X GET -H "Authorization:Bearer " "http://localhost:8889/api/geodatasets/aggregations?layer=my_geodataset&limit=500"` + +--- + +### GET /bulk_aggregations + +Like [GET /aggregations](#get-aggregations) but aggregates across multiple layers in one call. + +| Parameter | Type | Required | Default | Description | +| :-----------: | :------: | :------: | :-----: | :------------------------------------------------: | +| **layers** | _string_ | true | N/A | Comma-separated list of layer names (max 100) | +| **limit** | _integer_| false | 500 | Sample size per layer (clamped to 1–1000) | +| **starttime** | _time_ | false | N/A | Start time of time window (used with `endtime`) | +| **endtime** | _time_ | false | N/A | End time of time window (used with `starttime`) | +| **startProp** | _string_ | false |`start_time`| Start time column used for time filtering | +| **endProp** | _string_ | false | `end_time` | End time column used for time filtering | + +#### Example + +`curl -X GET -H "Authorization:Bearer " "http://localhost:8889/api/geodatasets/bulk_aggregations?layers=layer_a,layer_b&limit=500"` + +--- + +### GET /schema + +Returns field names, types, and source layers for one or more geodataset layers in bulk. + +| Parameter | Type | Required | Default | Description | +| :-------: | :------: | :------: | :-----: | :------------------------------------------: | +|**layers** | _string_ | true | N/A | Comma-separated list of layer names (max 100)| + +#### Example + +`curl -X GET -H "Authorization:Bearer " "http://localhost:8889/api/geodatasets/schema?layers=layer_a,layer_b"` + +--- + ### POST /entries Lists out available geodatasets and their last updated dates @@ -126,12 +209,19 @@ Returns all features that match a geojson `properties` property key's value. Append geojson features to an existing geodataset. -| Parameter | Type | Required | Default | Description | -| :-------: | :------: | :------: | :-----: | :-----------------------------------------: | -| **:name** | _string_ | true | N/A | Geodataset layer name - included in the url | -| **body** | _object_ | false | N/A | Entire body is a geojson file | +| Parameter | Type | In | Required | Default | Description | +| :--------------: | :------: | :---: | :------: | :-----: | :-----------------------------------------: | +| **:name** | _string_ | url | true | N/A | Geodataset layer name - included in the url | +| **start_prop** | _string_ | query | false | N/A | Property key to use as each feature's start time | +| **end_prop** | _string_ | query | false | N/A | Property key to use as each feature's end time | +| **group_id_prop**| _string_ | query | false | N/A | Property key to use as each feature's group id (comma-separate to merge, e.g. `track,frame`) | +|**feature_id_prop**| _string_| query | false | N/A | Property key to use as each feature's feature id (comma-separate to merge) | +| **filename** | _string_ | query | false | N/A | Optional source filename recorded on the entry | +| **body** | _object_ | body | true | N/A | Entire body is a geojson file | + +_Note:_ The geojson body can also include the top-level foreign geojson members `startProp`, `endProp`, `groupIdProp`, and `featureIdProp` to specify which feature properties fields to use. Body-level members take precedence over the equivalent query parameters. -_Note:_ The geojson body can include the top-level foreign geojson members `startProp` and `endProp` to specific which feature properties fields to use as the start and end times. +> **Important:** Append does **not** automatically reuse the `start_time_field` / `end_time_field` / `group_id_field` / `feature_id_field` that were configured when the geodataset was created. If you do not supply the corresponding prop on the append request (via query param or body-level member), the appended features are stored with `NULL` `start_time`/`end_time`/`group_id`/`feature_id` and therefore will **not** match temporal or `group_id` queries. Pass the same field names on every append. ```json { @@ -162,7 +252,7 @@ _Note:_ The geojson body can include the top-level foreign geojson members `star ### POST /append/:name/:start_end_prop -See [POST /append/:name](#post-append-name). `startProp` and `endProp` parameters can be passed in through URL instead. `startProp` and `endProp` are comma-separated. +See [POST /append/:name](#post-appendname). `startProp` and `endProp` parameters can be passed in through the URL instead as a comma-separated `startProp,endProp` pair. To set group id / feature id on this route, include the body-level `groupIdProp`/`featureIdProp` geojson members. #### Example @@ -174,12 +264,16 @@ See [POST /append/:name](#post-append-name). `startProp` and `endProp` parameter Creates or replaces an existing geodataset with a new geojson. -| Parameter | Type | Required | Default | Description | -| :-----------: | :------: | :------: | :-----: | :------------------------------------------: | -| **name** | _string_ | true | N/A | Geodataset layer name | -| **geojson** | _object_ | true | N/A | The geojson object to create | -| **startProp** | _object_ | false | N/A | Name of key of feature's start time property | -| **endProp** | _object_ | false | N/A | Name of key of feature's end time property | +| Parameter | Type | Required | Default | Description | +| :---------------: | :------: | :------: | :-----: | :------------------------------------------: | +| **name** | _string_ | true | N/A | Geodataset layer name | +| **geojson** | _object_ | true | N/A | The geojson object to create | +| **startProp** | _string_ | false | N/A | Name of key of feature's start time property | +| **endProp** | _string_ | false | N/A | Name of key of feature's end time property | +| **groupIdProp** | _string_ | false | N/A | Name of key of feature's group id property (comma-separate to merge) | +| **featureIdProp** | _string_ | false | N/A | Name of key of feature's feature id property (comma-separate to merge) | +| **filename** | _string_ | false | N/A | Optional source filename recorded on the entry | +| **action** | _string_ | false |`recreate`| `recreate` truncates & replaces; `append` adds to existing features | #### Example diff --git a/package.json b/package.json index b15bea79a..285c2dc6e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "mmgis", - "version": "5.2.16-20260708", + "version": "5.2.17-20260709", "description": "A web-based mapping and localization solution for science operation on planetary missions.", "homepage": "build", "repository": { diff --git a/tests/e2e/api/geodatasets-append-fields.spec.js b/tests/e2e/api/geodatasets-append-fields.spec.js new file mode 100644 index 000000000..f530e3485 --- /dev/null +++ b/tests/e2e/api/geodatasets-append-fields.spec.js @@ -0,0 +1,231 @@ +import { test, expect, request as apiRequest } from '@playwright/test'; + +/** + * E2E test: appending to a geodataset with all four field params set. + * + * Backend route: plugins/core/backend/Geodatasets/routes/geodatasets.js + * POST /api/geodatasets/append/:name + * ?start_prop=&end_prop=&group_id_prop=&feature_id_prop= + * + * Append does NOT automatically reuse the start_time_field / end_time_field / + * group_id_field / feature_id_field configured when the geodataset was created — + * the property names must be re-supplied on every append request. This test + * exercises that contract end to end: + * + * 1. Create a geodataset configured with all four field mappings. + * 2. Append features WITH the four *_prop params set and verify the appended + * features come back on a temporal (starttime/endtime) query and on a + * group_id query, with feature_id populated. + * 3. Append a feature WITHOUT the *_prop params and verify it gets NULL + * group_id (it is excluded from the group_id query) — the documented + * gotcha that motivated this test. + * + * Creating / appending / removing a geodataset requires an admin session + * (ensureAdmin middleware). global-setup creates `test_admin` when AUTH=local; + * when admin access is unavailable (e.g. AUTH=off) the suite skips gracefully. + */ + +test.describe.serial('Geodatasets API — append honors field params', () => { + const baseURL = process.env.TEST_BASE_URL || 'http://localhost:18888'; + const layerName = `test_append_fields_${Date.now()}`; + + // Property keys inside each feature's `properties` object. + const START_PROP = 'start_time'; + const END_PROP = 'end_time'; + const GROUP_ID_PROP = 'track'; + const FEATURE_ID_PROP = 'feat_id'; + + // Values for the feature appended WITH all params supplied. + const APPENDED_GROUP = 'TRACK_APPEND'; + const APPENDED_FEATURE_ID = 'feat-append-001'; + const APPENDED_START = '2024-06-01T00:00:00Z'; + const APPENDED_END = '2024-06-01T01:00:00Z'; + + // Value for the feature appended WITHOUT any params (should get NULL group_id). + const ORPHAN_GROUP = 'TRACK_ORPHAN'; + + /** Shared authenticated request context (cookies persist across tests). */ + let api; + /** True only when the geodataset was successfully created (admin available). */ + let adminReady = false; + + test.beforeAll(async () => { + api = await apiRequest.newContext({ baseURL }); + + // Admin login — succeeds under AUTH=local (test_admin created by global-setup). + await api + .post('/api/users/login', { + data: { username: 'test_admin', password: 'TestAdmin1!' }, // pragma: allowlist secret + }) + .catch(() => {}); + + // Create the geodataset configured with all four field mappings so the + // start_time / end_time / group_id / feature_id columns exist on the table. + const createRes = await api.post('/api/geodatasets/recreate', { + data: { + name: layerName, + startProp: START_PROP, + endProp: END_PROP, + groupIdProp: GROUP_ID_PROP, + featureIdProp: FEATURE_ID_PROP, + geojson: JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.42, 37.78] }, + properties: { + name: 'Seed feature', + [START_PROP]: '2024-01-01T00:00:00Z', + [END_PROP]: '2024-01-01T01:00:00Z', + [GROUP_ID_PROP]: 'TRACK_SEED', + [FEATURE_ID_PROP]: 'feat-seed-001', + }, + }, + ], + }), + }, + }); + const createData = await createRes.json().catch(() => null); + adminReady = !!createData && createData.status === 'success'; + }); + + test.afterAll(async () => { + if (adminReady) { + await api.delete(`/api/geodatasets/remove/${layerName}`).catch(() => {}); + } + if (api) await api.dispose(); + }); + + test('append WITH all *_prop params populates start/end/group_id/feature_id', async () => { + if (!adminReady) { + test.skip(true, 'SKIP: admin session unavailable — cannot create/append geodataset'); + return; + } + + const appendRes = await api.post( + `/api/geodatasets/append/${layerName}` + + `?start_prop=${START_PROP}` + + `&end_prop=${END_PROP}` + + `&group_id_prop=${GROUP_ID_PROP}` + + `&feature_id_prop=${FEATURE_ID_PROP}`, + { + data: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.44, 37.8] }, + properties: { + name: 'Appended with params', + [START_PROP]: APPENDED_START, + [END_PROP]: APPENDED_END, + [GROUP_ID_PROP]: APPENDED_GROUP, + [FEATURE_ID_PROP]: APPENDED_FEATURE_ID, + }, + }, + ], + }, + } + ); + expect(appendRes.status()).toBeLessThan(500); + const appendData = await appendRes.json(); + expect(appendData.status).toBe('success'); + }); + + test('appended feature is returned by a temporal (starttime/endtime) query', async () => { + if (!adminReady) { + test.skip(true, 'SKIP: admin session unavailable'); + return; + } + + const res = await api.get(`/api/geodatasets/get`, { + params: { + layer: layerName, + // Window fully contains the appended feature's [start_time, end_time]. + starttime: '2024-05-01T00:00:00Z', + endtime: '2024-07-01T00:00:00Z', + }, + }); + expect(res.status()).toBeLessThan(500); + const data = await res.json(); + expect(data.type).toBe('FeatureCollection'); + + // Only the feature appended with a start/end inside the window matches; + // the seed feature (Jan 2024) falls outside it. + const names = data.features.map((f) => f.properties?.name); + expect(names).toContain('Appended with params'); + expect(names).not.toContain('Seed feature'); + + // The temporal columns round-trip on the appended feature. start_time / + // end_time are BIGINT columns, serialized as strings, so coerce to Number. + const appended = data.features.find( + (f) => f.properties?.name === 'Appended with params' + ); + expect(Number(appended.properties._.start_time)).toBe(new Date(APPENDED_START).getTime()); + expect(Number(appended.properties._.end_time)).toBe(new Date(APPENDED_END).getTime()); + }); + + test('appended feature is returned by a group_id query with feature_id set', async () => { + if (!adminReady) { + test.skip(true, 'SKIP: admin session unavailable'); + return; + } + + const res = await api.get(`/api/geodatasets/get`, { + params: { + layer: layerName, + group_id: APPENDED_GROUP, + _source: `name,group_id,feature_id`, + }, + }); + expect(res.status()).toBeLessThan(500); + const data = await res.json(); + expect(data.type).toBe('FeatureCollection'); + expect(data.features.length).toBe(1); + + const feature = data.features[0]; + expect(feature.properties.group_id).toBe(APPENDED_GROUP); + expect(feature.properties.feature_id).toBe(APPENDED_FEATURE_ID); + }); + + test('append WITHOUT params leaves group_id NULL (documented gotcha)', async () => { + if (!adminReady) { + test.skip(true, 'SKIP: admin session unavailable'); + return; + } + + // Append a feature carrying the same property keys but WITHOUT re-supplying + // any *_prop query params — group_id/feature_id/start/end are NOT computed. + const appendRes = await api.post(`/api/geodatasets/append/${layerName}`, { + data: { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { type: 'Point', coordinates: [-122.46, 37.82] }, + properties: { + name: 'Appended without params', + [START_PROP]: APPENDED_START, + [END_PROP]: APPENDED_END, + [GROUP_ID_PROP]: ORPHAN_GROUP, + [FEATURE_ID_PROP]: 'feat-orphan-001', + }, + }, + ], + }, + }); + expect(appendRes.status()).toBeLessThan(500); + expect((await appendRes.json()).status).toBe('success'); + + // Because group_id_prop was not supplied, the feature's group_id is NULL — + // so a group_id query for its track value returns nothing. + const res = await api.get(`/api/geodatasets/get`, { + params: { layer: layerName, group_id: ORPHAN_GROUP }, + }); + expect(res.status()).toBeLessThan(500); + const data = await res.json(); + expect(data.type).toBe('FeatureCollection'); + expect(data.features.length).toBe(0); + }); +});