From 39c37683e01afc5f0db98a724b37860089ae8514 Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 31 Jul 2025 14:01:03 -0500 Subject: [PATCH 01/10] add Qgis watershed bounds util --- utils/buildWatershedGeomFromQgisLayer.py | 152 +++++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100755 utils/buildWatershedGeomFromQgisLayer.py diff --git a/utils/buildWatershedGeomFromQgisLayer.py b/utils/buildWatershedGeomFromQgisLayer.py new file mode 100755 index 00000000..6c4a6124 --- /dev/null +++ b/utils/buildWatershedGeomFromQgisLayer.py @@ -0,0 +1,152 @@ +from qgis.core import * +from qgis.utils import iface +import processing +import math + +### run in Qgis console with watershed selected as active layer + + +def get_buffered_rectangular_extents(): + active_layer = iface.activeLayer() + + if not active_layer: + print("No active layer selected!") + return None + + print(f"Processing layer: {active_layer.name()}") + + buffer_distance_meters = 2 * 1609.344 + layer_crs = active_layer.crs() + + # Define target CRS for final output (EPSG:5070) + output_crs = QgsCoordinateReferenceSystem("EPSG:5070") + + # For buffering, use a suitable projected CRS + if layer_crs.isGeographic(): + buffer_crs = QgsCoordinateReferenceSystem( + "EPSG:3857" + ) # Web Mercator for buffering + else: + buffer_crs = layer_crs + + # Set buffer distance (always in meters for this workflow) + buffer_distance = buffer_distance_meters + + try: + # Step 1: Fix invalid geometries first + print("Fixing invalid geometries...") + fixed_result = processing.run( + "native:fixgeometries", + {"INPUT": active_layer, "OUTPUT": "TEMPORARY_OUTPUT"}, + ) + fixed_layer = fixed_result["OUTPUT"] + print("Geometries fixed") + + # Step 2: Get outline using the fixed layer + outline_result = processing.run( + "native:convexhull", {"INPUT": fixed_layer, "OUTPUT": "TEMPORARY_OUTPUT"} + ) + outline_layer = outline_result["OUTPUT"] + print("Created convex hull outline") + + # Step 3: Reproject to buffering CRS if needed + if layer_crs.isGeographic(): + reprojected_result = processing.run( + "native:reprojectlayer", + { + "INPUT": outline_layer, + "TARGET_CRS": buffer_crs, + "OUTPUT": "TEMPORARY_OUTPUT", + }, + ) + outline_layer = reprojected_result["OUTPUT"] + print(f"Reprojected to {buffer_crs.authid()} for buffering") + + # Step 4: Buffer + buffer_result = processing.run( + "native:buffer", + { + "INPUT": outline_layer, + "DISTANCE": buffer_distance, + "SEGMENTS": 10, + "END_CAP_STYLE": 0, + "JOIN_STYLE": 0, + "MITER_LIMIT": 2, + "DISSOLVE": True, + "OUTPUT": "TEMPORARY_OUTPUT", + }, + ) + buffered_layer = buffer_result["OUTPUT"] + print(f"Applied 2-mile buffer") + + # Step 5: Get bounding box + bbox_result = processing.run( + "native:boundingboxes", + {"INPUT": buffered_layer, "OUTPUT": "TEMPORARY_OUTPUT"}, + ) + bbox_layer = bbox_result["OUTPUT"] + print("Created bounding box") + + # Step 6: Reproject to EPSG:5070 for final output + final_result = processing.run( + "native:reprojectlayer", + { + "INPUT": bbox_layer, + "TARGET_CRS": output_crs, + "OUTPUT": "TEMPORARY_OUTPUT", + }, + ) + bbox_layer_5070 = final_result["OUTPUT"] + print(f"Reprojected to {output_crs.authid()}") + + extent = bbox_layer_5070.extent() + + # Round the extents appropriately + # Round down western and southern extents, round up eastern and northern + west = int(math.floor(extent.xMinimum())) + east = int(math.ceil(extent.xMaximum())) + south = int(math.floor(extent.yMinimum())) + north = int(math.ceil(extent.yMaximum())) + + # Format as ST_GeomFromText with proper coordinate order + st_geom_text = f"""ST_GeomFromText('Polygon (( + {west} {north}, + {east} {north}, + {east} {south}, + {west} {south}, + {west} {north}))', + 5070),""" + + print("\n=== RESULTS ===") + print(st_geom_text) + + # Also print individual coordinates for reference + print(f"\nCoordinates (EPSG:5070):") + print(f"West: {west}") + print(f"East: {east}") + print(f"South: {south}") + print(f"North: {north}") + print(f"Width: {east - west} meters") + print(f"Height: {north - south} meters") + + # Add to map (optional) + bbox_layer_5070.setName(f"{active_layer.name()}_2mile_extent_5070") + QgsProject.instance().addMapLayer(bbox_layer_5070) + print(f"\nAdded result to map: {bbox_layer_5070.name()}") + + return { + "st_geom_text": st_geom_text, + "west": west, + "east": east, + "south": south, + "north": north, + "extent": extent, + } + + except Exception as e: + print(f"Error: {str(e)}") + return None + + +# Run the function +result = get_buffered_rectangular_extents() From 1a8778468160a34fb1084c35915bed386b78a7b3 Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 31 Jul 2025 14:02:29 -0500 Subject: [PATCH 02/10] add MVP red river watershed --- sql/common/V2.53.2__add_MVP_watersheds.sql | 88 ++++++++++++++++++++++ 1 file changed, 88 insertions(+) create mode 100755 sql/common/V2.53.2__add_MVP_watersheds.sql diff --git a/sql/common/V2.53.2__add_MVP_watersheds.sql b/sql/common/V2.53.2__add_MVP_watersheds.sql new file mode 100755 index 00000000..05a05fe4 --- /dev/null +++ b/sql/common/V2.53.2__add_MVP_watersheds.sql @@ -0,0 +1,88 @@ +--(xmin ymax, xmax ymax, xmax ymin, xmin ymin, xmin ymax) + +-- add new watershed MVP Bois de Sioux River +INSERT INTO watershed (id, + slug, + "name", + geometry, + office_id, + output_srid) +VALUES + ('374cf5e0-c935-40c6-8b96-c33a1c8e579f', + 'bois-de-sioux', + 'Bois de Sioux River', + ST_GeomFromText('Polygon (( + -88720 2698951, + 55404 2698951, + 55404 2494540, + -88720 2494540, + -88720 2698951))', + 5070), + '33f03e9a-711b-41e7-9bdd-66152b69128d', + 5070); + + +-- add new watershed MVP Park River +INSERT INTO watershed (id, + slug, + "name", + geometry, + office_id, + output_srid) +VALUES + ('4bff8a03-93d2-4579-aa0c-a65afc2066b6', + 'park-mvp', + 'Park River', + ST_GeomFromText('Polygon (( + -176350 2863898, + -83643 2863898, + -83643 2815127, + -176350 2815127, + -176350 2863898))', + 5070), + '33f03e9a-711b-41e7-9bdd-66152b69128d', + 5070); + + +-- add new watershed MVP Red Lake River +INSERT INTO watershed (id, + slug, + "name", + geometry, + office_id, + output_srid) +VALUES + ('157a95a9-665f-46d6-b0eb-cc3497da141f', + 'red-lake', + 'Red lake River', + ST_GeomFromText('Polygon (( + -77926 2841800, + 146064 2841800, + 146064 2714875, + -77926 2714875, + -77926 2841800))', + 5070), + '33f03e9a-711b-41e7-9bdd-66152b69128d', + 5070); + + +-- add new watershed MVP Sheyenne River +INSERT INTO watershed (id, + slug, + "name", + geometry, + office_id, + output_srid) +VALUES + ('b9feb168-a692-4c04-8a36-5e9cd5868e7a', + 'sheyenne-river', + 'Sheyenne River', + ST_GeomFromText('Polygon (( + -352090 2815523, + -77217 2815523, + -77217 2583070, + -352090 2583070, + -352090 2815523))', + 5070), + '33f03e9a-711b-41e7-9bdd-66152b69128d', + 5070); \ No newline at end of file From c1c16f676933853f647e6e6d877e6997979e0336 Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 31 Jul 2025 14:31:53 -0500 Subject: [PATCH 03/10] remove qgis util --- utils/buildWatershedGeomFromQgisLayer.py | 152 ----------------------- 1 file changed, 152 deletions(-) delete mode 100755 utils/buildWatershedGeomFromQgisLayer.py diff --git a/utils/buildWatershedGeomFromQgisLayer.py b/utils/buildWatershedGeomFromQgisLayer.py deleted file mode 100755 index 6c4a6124..00000000 --- a/utils/buildWatershedGeomFromQgisLayer.py +++ /dev/null @@ -1,152 +0,0 @@ -from qgis.core import * -from qgis.utils import iface -import processing -import math - -### run in Qgis console with watershed selected as active layer - - -def get_buffered_rectangular_extents(): - active_layer = iface.activeLayer() - - if not active_layer: - print("No active layer selected!") - return None - - print(f"Processing layer: {active_layer.name()}") - - buffer_distance_meters = 2 * 1609.344 - layer_crs = active_layer.crs() - - # Define target CRS for final output (EPSG:5070) - output_crs = QgsCoordinateReferenceSystem("EPSG:5070") - - # For buffering, use a suitable projected CRS - if layer_crs.isGeographic(): - buffer_crs = QgsCoordinateReferenceSystem( - "EPSG:3857" - ) # Web Mercator for buffering - else: - buffer_crs = layer_crs - - # Set buffer distance (always in meters for this workflow) - buffer_distance = buffer_distance_meters - - try: - # Step 1: Fix invalid geometries first - print("Fixing invalid geometries...") - fixed_result = processing.run( - "native:fixgeometries", - {"INPUT": active_layer, "OUTPUT": "TEMPORARY_OUTPUT"}, - ) - fixed_layer = fixed_result["OUTPUT"] - print("Geometries fixed") - - # Step 2: Get outline using the fixed layer - outline_result = processing.run( - "native:convexhull", {"INPUT": fixed_layer, "OUTPUT": "TEMPORARY_OUTPUT"} - ) - outline_layer = outline_result["OUTPUT"] - print("Created convex hull outline") - - # Step 3: Reproject to buffering CRS if needed - if layer_crs.isGeographic(): - reprojected_result = processing.run( - "native:reprojectlayer", - { - "INPUT": outline_layer, - "TARGET_CRS": buffer_crs, - "OUTPUT": "TEMPORARY_OUTPUT", - }, - ) - outline_layer = reprojected_result["OUTPUT"] - print(f"Reprojected to {buffer_crs.authid()} for buffering") - - # Step 4: Buffer - buffer_result = processing.run( - "native:buffer", - { - "INPUT": outline_layer, - "DISTANCE": buffer_distance, - "SEGMENTS": 10, - "END_CAP_STYLE": 0, - "JOIN_STYLE": 0, - "MITER_LIMIT": 2, - "DISSOLVE": True, - "OUTPUT": "TEMPORARY_OUTPUT", - }, - ) - buffered_layer = buffer_result["OUTPUT"] - print(f"Applied 2-mile buffer") - - # Step 5: Get bounding box - bbox_result = processing.run( - "native:boundingboxes", - {"INPUT": buffered_layer, "OUTPUT": "TEMPORARY_OUTPUT"}, - ) - bbox_layer = bbox_result["OUTPUT"] - print("Created bounding box") - - # Step 6: Reproject to EPSG:5070 for final output - final_result = processing.run( - "native:reprojectlayer", - { - "INPUT": bbox_layer, - "TARGET_CRS": output_crs, - "OUTPUT": "TEMPORARY_OUTPUT", - }, - ) - bbox_layer_5070 = final_result["OUTPUT"] - print(f"Reprojected to {output_crs.authid()}") - - extent = bbox_layer_5070.extent() - - # Round the extents appropriately - # Round down western and southern extents, round up eastern and northern - west = int(math.floor(extent.xMinimum())) - east = int(math.ceil(extent.xMaximum())) - south = int(math.floor(extent.yMinimum())) - north = int(math.ceil(extent.yMaximum())) - - # Format as ST_GeomFromText with proper coordinate order - st_geom_text = f"""ST_GeomFromText('Polygon (( - {west} {north}, - {east} {north}, - {east} {south}, - {west} {south}, - {west} {north}))', - 5070),""" - - print("\n=== RESULTS ===") - print(st_geom_text) - - # Also print individual coordinates for reference - print(f"\nCoordinates (EPSG:5070):") - print(f"West: {west}") - print(f"East: {east}") - print(f"South: {south}") - print(f"North: {north}") - print(f"Width: {east - west} meters") - print(f"Height: {north - south} meters") - - # Add to map (optional) - bbox_layer_5070.setName(f"{active_layer.name()}_2mile_extent_5070") - QgsProject.instance().addMapLayer(bbox_layer_5070) - print(f"\nAdded result to map: {bbox_layer_5070.name()}") - - return { - "st_geom_text": st_geom_text, - "west": west, - "east": east, - "south": south, - "north": north, - "extent": extent, - } - - except Exception as e: - print(f"Error: {str(e)}") - return None - - -# Run the function -result = get_buffered_rectangular_extents() From 316cb981fe2055ebef83321819e904b74358e673 Mon Sep 17 00:00:00 2001 From: Oskar Hurst Date: Wed, 10 Sep 2025 02:19:03 +0000 Subject: [PATCH 04/10] Create new user regions --- api/handlers/download.go | 119 ++++++++++- api/handlers/user_regions.go | 191 ++++++++++++++++++ api/main.go | 9 + api/models/download.go | 63 +++--- api/models/user_region.go | 186 +++++++++++++++++ sql/common/R__05_views_downloads.sql | 31 ++- sql/common/R__07_grants_user_regions.sql | 18 ++ .../V2.61.0__add_custom_region_downloads.sql | 12 ++ sql/common/V2.62.0__user_saved_regions.sql | 103 ++++++++++ 9 files changed, 694 insertions(+), 38 deletions(-) create mode 100644 api/handlers/user_regions.go create mode 100644 api/models/user_region.go create mode 100644 sql/common/R__07_grants_user_regions.sql create mode 100644 sql/common/V2.61.0__add_custom_region_downloads.sql create mode 100644 sql/common/V2.62.0__user_saved_regions.sql diff --git a/api/handlers/download.go b/api/handlers/download.go index d72a5fc9..afe17bb9 100644 --- a/api/handlers/download.go +++ b/api/handlers/download.go @@ -1,7 +1,9 @@ package handlers import ( + "encoding/json" "net/http" + "time" "github.com/USACE/cumulus-api/api/config" "github.com/USACE/cumulus-api/api/messages" @@ -74,12 +76,111 @@ func ListMyDownloads(db *pgxpool.Pool) echo.HandlerFunc { // CreateDownload creates record of a new download func CreateDownload(db *pgxpool.Pool, cfg *config.Config) echo.HandlerFunc { return func(c echo.Context) error { - //need to check if products provided are valid uuids for existing products - //sanity check on dates in time windows and geometry?? - var dr models.DownloadRequest - if err := c.Bind(&dr); err != nil { + // Parse the raw request to handle GeoJSON + var rawRequest map[string]interface{} + if err := c.Bind(&rawRequest); err != nil { return c.String(http.StatusBadRequest, err.Error()) } + + // Build the download request + var dr models.DownloadRequest + + // Parse datetime fields + if dtStart, ok := rawRequest["datetime_start"].(string); ok { + if t, err := time.Parse(time.RFC3339, dtStart); err == nil { + dr.DatetimeStart = t + } else { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid datetime_start format")) + } + } + + if dtEnd, ok := rawRequest["datetime_end"].(string); ok { + if t, err := time.Parse(time.RFC3339, dtEnd); err == nil { + dr.DatetimeEnd = t + } else { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid datetime_end format")) + } + } + + // Parse watershed_id if provided + if wid, ok := rawRequest["watershed_id"].(string); ok { + if id, err := uuid.Parse(wid); err == nil { + dr.WatershedID = &id + } + } + + // Parse user_region_id if provided - load the region's GeoJSON + if regionID, ok := rawRequest["user_region_id"].(string); ok { + if rid, err := uuid.Parse(regionID); err == nil { + // Get the user's sub + sub, err := GetSub(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, models.DefaultMessageUnauthorized) + } + + // Fetch the user region + region, err := models.GetUserRegion(db, &rid, sub) + if err != nil { + return c.JSON(http.StatusBadRequest, messages.NewMessage("User region not found or not accessible")) + } + + // Use the region's GeoJSON and name + geojsonStr := string(region.GeoJSON) + dr.ClipGeoJSON = &geojsonStr + dr.ClipRegionName = ®ion.Name + } + } + + // Parse product_id array + if products, ok := rawRequest["product_id"].([]interface{}); ok { + dr.ProductID = make([]uuid.UUID, 0) + for _, p := range products { + if pid, ok := p.(string); ok { + if id, err := uuid.Parse(pid); err == nil { + dr.ProductID = append(dr.ProductID, id) + } + } + } + } + + // Parse format + if format, ok := rawRequest["format"].(string); ok { + dr.Format = &format + } + + // Parse clip_region_name (if not set from user_region) + if dr.ClipRegionName == nil { + if name, ok := rawRequest["clip_region_name"].(string); ok { + dr.ClipRegionName = &name + } + } + + // Parse and convert clip_geojson to string (if not set from user_region) + if dr.ClipGeoJSON == nil { + if geojson, ok := rawRequest["clip_geojson"]; ok && geojson != nil { + geojsonBytes, err := json.Marshal(geojson) + if err != nil { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid GeoJSON format")) + } + geojsonStr := string(geojsonBytes) + dr.ClipGeoJSON = &geojsonStr + } + } + + // Validate that either watershed_id, clip_geojson, or user_region_id was provided + if dr.WatershedID == nil && dr.ClipGeoJSON == nil { + return c.JSON( + http.StatusBadRequest, + messages.NewMessage("Either watershed_id, user_region_id, or clip_geojson must be provided"), + ) + } + + // If both are provided, prefer custom clip region + if dr.WatershedID != nil && dr.ClipGeoJSON != nil { + // Log or handle as needed - using custom region over watershed + dr.WatershedID = nil // Clear watershed ID to use custom region + } + // If output format unspecified, use default format if dr.Format == nil { dr.Format = &cfg.DownloadDefaultFormat @@ -109,13 +210,11 @@ func CreateDownload(db *pgxpool.Pool, cfg *config.Config) echo.HandlerFunc { } } -// // GetDownload gets a single download +// GetDownload gets a single download func GetDownload(db *pgxpool.Pool) echo.HandlerFunc { return func(c echo.Context) error { - downloadID, err := uuid.Parse(c.Param("download_id")) if err != nil { - //return c.String(http.StatusBadRequest, "Malformed ID") return c.String(http.StatusBadRequest, err.Error()) } dl, err := models.GetDownload(db, &downloadID) @@ -153,10 +252,9 @@ func GetDownloadPackagerRequest(db *pgxpool.Pool) echo.HandlerFunc { } } -//UpdateDownload updates the status, progress and datetime_end from the lambda function +// UpdateDownload updates the status, progress and datetime_end from the lambda function func UpdateDownload(db *pgxpool.Pool) echo.HandlerFunc { return func(c echo.Context) error { - var u models.PackagerInfo if err := c.Bind(&u); err != nil { return c.String(http.StatusBadRequest, err.Error()) @@ -177,10 +275,9 @@ func UpdateDownload(db *pgxpool.Pool) echo.HandlerFunc { } } -// // GetDownloadMetrics returns metrics +// GetDownloadMetrics returns metrics func GetDownloadMetrics(db *pgxpool.Pool) echo.HandlerFunc { return func(c echo.Context) error { - dm, err := models.GetDownloadMetrics(db) if err != nil { return c.String(http.StatusInternalServerError, err.Error()) diff --git a/api/handlers/user_regions.go b/api/handlers/user_regions.go new file mode 100644 index 00000000..3b75e11e --- /dev/null +++ b/api/handlers/user_regions.go @@ -0,0 +1,191 @@ +package handlers + +import ( + "net/http" + + "github.com/USACE/cumulus-api/api/messages" + "github.com/USACE/cumulus-api/api/models" + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" + "github.com/labstack/echo/v4" +) + +// ListMyRegions returns all regions for the authenticated user +func ListMyRegions(db *pgxpool.Pool) echo.HandlerFunc { + return func(c echo.Context) error { + sub, err := GetSub(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, models.DefaultMessageUnauthorized) + } + + regions, err := models.ListUserRegions(db, sub) + if err != nil { + return c.JSON(http.StatusInternalServerError, models.DefaultMessageInternalServerError) + } + + return c.JSON(http.StatusOK, regions) + } +} + +// ListPublicRegions returns all public regions +func ListPublicRegions(db *pgxpool.Pool) echo.HandlerFunc { + return func(c echo.Context) error { + regions, err := models.ListPublicUserRegions(db) + if err != nil { + return c.JSON(http.StatusInternalServerError, models.DefaultMessageInternalServerError) + } + + return c.JSON(http.StatusOK, regions) + } +} + +// GetUserRegion returns a single user region +func GetUserRegion(db *pgxpool.Pool) echo.HandlerFunc { + return func(c echo.Context) error { + sub, err := GetSub(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, models.DefaultMessageUnauthorized) + } + + regionID, err := uuid.Parse(c.Param("region_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid region ID")) + } + + region, err := models.GetUserRegion(db, ®ionID, sub) + if err != nil { + if err == pgx.ErrNoRows { + return c.JSON(http.StatusNotFound, messages.NewMessage("Region not found")) + } + return c.JSON(http.StatusInternalServerError, models.DefaultMessageInternalServerError) + } + + return c.JSON(http.StatusOK, region) + } +} + +// CreateUserRegion creates a new user region +func CreateUserRegion(db *pgxpool.Pool) echo.HandlerFunc { + return func(c echo.Context) error { + sub, err := GetSub(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, models.DefaultMessageUnauthorized) + } + + var input models.UserRegionInput + if err := c.Bind(&input); err != nil { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid request body")) + } + + // Validate the input + if input.Name == "" { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Region name is required")) + } + + if len(input.GeoJSON) == 0 { + return c.JSON(http.StatusBadRequest, messages.NewMessage("GeoJSON is required")) + } + + region, err := models.CreateUserRegion(db, sub, &input) + if err != nil { + // Check for unique constraint violation + if err.Error() == "ERROR: duplicate key value violates unique constraint \"unique_user_region_name\" (SQLSTATE 23505)" { + return c.JSON(http.StatusConflict, messages.NewMessage("A region with this name already exists")) + } + return c.JSON(http.StatusInternalServerError, models.DefaultMessageInternalServerError) + } + + return c.JSON(http.StatusCreated, region) + } +} + +// UpdateUserRegion updates an existing user region +func UpdateUserRegion(db *pgxpool.Pool) echo.HandlerFunc { + return func(c echo.Context) error { + sub, err := GetSub(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, models.DefaultMessageUnauthorized) + } + + regionID, err := uuid.Parse(c.Param("region_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid region ID")) + } + + var input models.UserRegionInput + if err := c.Bind(&input); err != nil { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid request body")) + } + + // Validate the input + if input.Name == "" { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Region name is required")) + } + + if len(input.GeoJSON) == 0 { + return c.JSON(http.StatusBadRequest, messages.NewMessage("GeoJSON is required")) + } + + region, err := models.UpdateUserRegion(db, ®ionID, sub, &input) + if err != nil { + if err == pgx.ErrNoRows { + return c.JSON(http.StatusNotFound, messages.NewMessage("Region not found or you don't have permission to update it")) + } + // Check for unique constraint violation + if err.Error() == "ERROR: duplicate key value violates unique constraint \"unique_user_region_name\" (SQLSTATE 23505)" { + return c.JSON(http.StatusConflict, messages.NewMessage("A region with this name already exists")) + } + return c.JSON(http.StatusInternalServerError, models.DefaultMessageInternalServerError) + } + + return c.JSON(http.StatusOK, region) + } +} + +// DeleteUserRegion deletes a user region +func DeleteUserRegion(db *pgxpool.Pool) echo.HandlerFunc { + return func(c echo.Context) error { + sub, err := GetSub(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, models.DefaultMessageUnauthorized) + } + + regionID, err := uuid.Parse(c.Param("region_id")) + if err != nil { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Invalid region ID")) + } + + err = models.DeleteUserRegion(db, ®ionID, sub) + if err != nil { + if err == pgx.ErrNoRows { + return c.JSON(http.StatusNotFound, messages.NewMessage("Region not found or you don't have permission to delete it")) + } + return c.JSON(http.StatusInternalServerError, models.DefaultMessageInternalServerError) + } + + return c.JSON(http.StatusNoContent, nil) + } +} + +// SearchUserRegions searches for regions by name or tags +func SearchUserRegions(db *pgxpool.Pool) echo.HandlerFunc { + return func(c echo.Context) error { + sub, err := GetSub(c) + if err != nil { + return c.JSON(http.StatusUnauthorized, models.DefaultMessageUnauthorized) + } + + query := c.QueryParam("q") + if query == "" { + return c.JSON(http.StatusBadRequest, messages.NewMessage("Search query is required")) + } + + regions, err := models.SearchUserRegions(db, sub, query) + if err != nil { + return c.JSON(http.StatusInternalServerError, models.DefaultMessageInternalServerError) + } + + return c.JSON(http.StatusOK, regions) + } +} \ No newline at end of file diff --git a/api/main.go b/api/main.go index a05c76d7..ca913362 100644 --- a/api/main.go +++ b/api/main.go @@ -241,6 +241,15 @@ func main() { // -- Download Metrics public.GET("/metrics/downloads", handlers.GetDownloadMetrics(db)) + // User Regions + private.GET("/user-regions", handlers.ListMyRegions(db)) + private.GET("/user-regions/public", handlers.ListPublicRegions(db)) + private.GET("/user-regions/search", handlers.SearchUserRegions(db)) + private.GET("/user-regions/:region_id", handlers.GetUserRegion(db)) + private.POST("/user-regions", handlers.CreateUserRegion(db)) + private.PUT("/user-regions/:region_id", handlers.UpdateUserRegion(db)) + private.DELETE("/user-regions/:region_id", handlers.DeleteUserRegion(db)) + // // Watersheds public.GET("/watersheds", handlers.ListWatersheds(db)) public.GET("/watersheds/:watershed_id", handlers.GetWatershed(db)) diff --git a/api/models/download.go b/api/models/download.go index 6a92a219..6c24e16f 100644 --- a/api/models/download.go +++ b/api/models/download.go @@ -26,17 +26,16 @@ type DownloadStatus struct { } // DownloadRequest holds all information from a download request coming from a user -// TODO; Update DownloadRequest to accept a bbox instead of an explicit WatershedID -// Could choose to leave WatershedID as optional field for metrics tracking by Watershed -// TODO; Sub has been changed to a NOT NULL field in the database. Investigate changing *uuid.UUID -// to uuid.UUID +// Supports both watershed-based and custom GeoJSON region-based downloads type DownloadRequest struct { - Sub *uuid.UUID `json:"sub" db:"sub"` - DatetimeStart time.Time `json:"datetime_start" db:"datetime_start"` - DatetimeEnd time.Time `json:"datetime_end" db:"datetime_end"` - WatershedID uuid.UUID `json:"watershed_id" db:"watershed_id"` - ProductID []uuid.UUID `json:"product_id" db:"product_id"` - Format *string `json:"format" db:"format"` + Sub *uuid.UUID `json:"sub" db:"sub"` + DatetimeStart time.Time `json:"datetime_start" db:"datetime_start"` + DatetimeEnd time.Time `json:"datetime_end" db:"datetime_end"` + WatershedID *uuid.UUID `json:"watershed_id,omitempty" db:"watershed_id"` + ProductID []uuid.UUID `json:"product_id" db:"product_id"` + Format *string `json:"format" db:"format"` + ClipGeoJSON *string `json:"clip_geojson,omitempty" db:"clip_geojson"` // GeoJSON string from database + ClipRegionName *string `json:"clip_region_name,omitempty" db:"clip_region_name"` } // Download holds all information about a download @@ -46,8 +45,10 @@ type Download struct { DownloadStatus PackagerInfo // Include Watershed Name and Watershed Slug for Convenience - WatershedSlug string `json:"watershed_slug" db:"watershed_slug"` - WatershedName string `json:"watershed_name" db:"watershed_name"` + WatershedSlug *string `json:"watershed_slug,omitempty" db:"watershed_slug"` + WatershedName *string `json:"watershed_name,omitempty" db:"watershed_name"` + ClipBbox []float64 `json:"clip_bbox,omitempty" db:"clip_bbox"` + ClipName string `json:"clip_name,omitempty" db:"clip_name"` } // PackagerInfo holds all information Packager provides after a download starts @@ -91,7 +92,8 @@ type PackagerContentItem struct { var listDownloadsSQL = fmt.Sprintf( `SELECT id, sub, datetime_start, datetime_end, progress, ('%s' || '/' || file) as file, - processing_start, processing_end, status_id, watershed_id, watershed_slug, watershed_name, status, product_id, format, manifest + processing_start, processing_end, status_id, watershed_id, watershed_slug, watershed_name, + status, product_id, format, manifest, clip_geojson, clip_region_name, clip_bbox, clip_name FROM v_download `, cfg.StaticHost, ) @@ -180,14 +182,25 @@ func GetDownloadPackagerRequest(db *pgxpool.Pool, downloadID *uuid.UUID) (*Packa ) SELECT d.id AS download_id, json_build_object( - 'name', w.name, - 'bbox', ARRAY[ - ST_XMin(ST_Transform(w.geometry,w.output_srid)), - ST_YMin(ST_Transform(w.geometry,w.output_srid)), - ST_XMax(ST_Transform(w.geometry,w.output_srid)), - ST_YMax(ST_Transform(w.geometry,w.output_srid)) - ], - 'srid', w.output_srid + 'name', CASE + WHEN d.clip_geojson IS NOT NULL THEN COALESCE(d.clip_region_name, 'Custom Region') + ELSE w.name + END, + 'bbox', CASE + WHEN d.clip_geojson IS NOT NULL THEN ARRAY[ + ST_XMin(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), COALESCE(w.output_srid, 5070))), + ST_YMin(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), COALESCE(w.output_srid, 5070))), + ST_XMax(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), COALESCE(w.output_srid, 5070))), + ST_YMax(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), COALESCE(w.output_srid, 5070))) + ] + ELSE ARRAY[ + ST_XMin(ST_Transform(w.geometry,w.output_srid)), + ST_YMin(ST_Transform(w.geometry,w.output_srid)), + ST_XMax(ST_Transform(w.geometry,w.output_srid)), + ST_YMax(ST_Transform(w.geometry,w.output_srid)) + ] + END, + 'srid', COALESCE(w.output_srid, 5070) ) AS extent, CONCAT( 'cumulus/download/', f.abbreviation, @@ -197,7 +210,7 @@ func GetDownloadPackagerRequest(db *pgxpool.Pool, downloadID *uuid.UUID) (*Packa COALESCE(c.contents, '[]'::jsonb) AS contents FROM download d INNER JOIN download_format f ON f.id = d.download_format_id - INNER JOIN watershed w ON w.id = d.watershed_id + LEFT JOIN watershed w ON w.id = d.watershed_id LEFT JOIN ( SELECT download_id, jsonb_agg( @@ -237,12 +250,12 @@ func CreateDownload(db *pgxpool.Pool, dr *DownloadRequest) (*Download, error) { // Insert Record of Download rows, err := tx.Query( context.Background(), - `INSERT INTO download (download_format_id, datetime_start, datetime_end, status_id, watershed_id, sub) + `INSERT INTO download (download_format_id, datetime_start, datetime_end, status_id, watershed_id, sub, clip_geojson, clip_region_name) VALUES ( (SELECT id FROM download_format WHERE UPPER(abbreviation) = UPPER($1)), $2, $3, - (SELECT id FROM download_status WHERE UPPER(name) = 'INITIATED'), $4, $5 + (SELECT id FROM download_status WHERE UPPER(name) = 'INITIATED'), $4, $5, $6, $7 ) - RETURNING id`, dr.Format, dr.DatetimeStart, dr.DatetimeEnd, dr.WatershedID, dr.Sub, + RETURNING id`, dr.Format, dr.DatetimeStart, dr.DatetimeEnd, dr.WatershedID, dr.Sub, dr.ClipGeoJSON, dr.ClipRegionName, ) if err != nil { tx.Rollback(context.Background()) diff --git a/api/models/user_region.go b/api/models/user_region.go new file mode 100644 index 00000000..9fa85e30 --- /dev/null +++ b/api/models/user_region.go @@ -0,0 +1,186 @@ +package models + +import ( + "context" + "encoding/json" + "time" + + "github.com/georgysavva/scany/pgxscan" + "github.com/google/uuid" + "github.com/jackc/pgx/v4" + "github.com/jackc/pgx/v4/pgxpool" +) + +// UserRegion represents a user-defined geographic region +type UserRegion struct { + ID uuid.UUID `json:"id" db:"id"` + Sub uuid.UUID `json:"sub" db:"sub"` + Name string `json:"name" db:"name"` + Description *string `json:"description,omitempty" db:"description"` + GeoJSON json.RawMessage `json:"geojson" db:"geojson"` + Bbox []float64 `json:"bbox" db:"bbox"` + AreaSqKm float64 `json:"area_sqkm" db:"area_sqkm"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + IsPublic bool `json:"is_public" db:"is_public"` + Tags []string `json:"tags,omitempty" db:"tags"` + UsageCount int `json:"usage_count" db:"usage_count"` +} + +// UserRegionInput represents the input for creating/updating a user region +type UserRegionInput struct { + Name string `json:"name" validate:"required,min=1,max=255"` + Description *string `json:"description,omitempty"` + GeoJSON json.RawMessage `json:"geojson" validate:"required"` + IsPublic bool `json:"is_public"` + Tags []string `json:"tags,omitempty"` +} + +// ListUserRegions returns all regions for a given user +func ListUserRegions(db *pgxpool.Pool, sub *uuid.UUID) ([]UserRegion, error) { + regions := make([]UserRegion, 0) + sql := ` + SELECT id, sub, name, description, geojson, bbox, area_sqkm, + created_at, updated_at, is_public, tags, usage_count + FROM v_user_region + WHERE sub = $1 + ORDER BY updated_at DESC + ` + if err := pgxscan.Select(context.Background(), db, ®ions, sql, sub); err != nil { + return nil, err + } + return regions, nil +} + +// ListPublicUserRegions returns all public regions +func ListPublicUserRegions(db *pgxpool.Pool) ([]UserRegion, error) { + regions := make([]UserRegion, 0) + sql := ` + SELECT id, sub, name, description, geojson, bbox, area_sqkm, + created_at, updated_at, is_public, tags, usage_count + FROM v_user_region + WHERE is_public = true + ORDER BY usage_count DESC, updated_at DESC + LIMIT 100 + ` + if err := pgxscan.Select(context.Background(), db, ®ions, sql); err != nil { + return nil, err + } + return regions, nil +} + +// GetUserRegion returns a single user region by ID +func GetUserRegion(db *pgxpool.Pool, id *uuid.UUID, sub *uuid.UUID) (*UserRegion, error) { + var region UserRegion + sql := ` + SELECT id, sub, name, description, geojson, bbox, area_sqkm, + created_at, updated_at, is_public, tags, usage_count + FROM v_user_region + WHERE id = $1 AND (sub = $2 OR is_public = true) + ` + if err := pgxscan.Get(context.Background(), db, ®ion, sql, id, sub); err != nil { + return nil, err + } + return ®ion, nil +} + +// CreateUserRegion creates a new user region +func CreateUserRegion(db *pgxpool.Pool, sub *uuid.UUID, input *UserRegionInput) (*UserRegion, error) { + var regionID uuid.UUID + + sql := ` + INSERT INTO user_region (sub, name, description, geojson, is_public, tags) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + ` + + err := db.QueryRow( + context.Background(), + sql, + sub, + input.Name, + input.Description, + string(input.GeoJSON), + input.IsPublic, + input.Tags, + ).Scan(®ionID) + + if err != nil { + return nil, err + } + + return GetUserRegion(db, ®ionID, sub) +} + +// UpdateUserRegion updates an existing user region +func UpdateUserRegion(db *pgxpool.Pool, id *uuid.UUID, sub *uuid.UUID, input *UserRegionInput) (*UserRegion, error) { + sql := ` + UPDATE user_region + SET name = $3, + description = $4, + geojson = $5, + is_public = $6, + tags = $7, + updated_at = NOW() + WHERE id = $1 AND sub = $2 + ` + + _, err := db.Exec( + context.Background(), + sql, + id, + sub, + input.Name, + input.Description, + string(input.GeoJSON), + input.IsPublic, + input.Tags, + ) + + if err != nil { + return nil, err + } + + return GetUserRegion(db, id, sub) +} + +// DeleteUserRegion deletes a user region +func DeleteUserRegion(db *pgxpool.Pool, id *uuid.UUID, sub *uuid.UUID) error { + sql := `DELETE FROM user_region WHERE id = $1 AND sub = $2` + + result, err := db.Exec(context.Background(), sql, id, sub) + if err != nil { + return err + } + + if result.RowsAffected() == 0 { + return pgx.ErrNoRows + } + + return nil +} + +// SearchUserRegions searches for regions by name or tags +func SearchUserRegions(db *pgxpool.Pool, sub *uuid.UUID, query string) ([]UserRegion, error) { + regions := make([]UserRegion, 0) + sql := ` + SELECT id, sub, name, description, geojson, bbox, area_sqkm, + created_at, updated_at, is_public, tags, usage_count + FROM v_user_region + WHERE (sub = $1 OR is_public = true) + AND ( + name ILIKE '%' || $2 || '%' + OR description ILIKE '%' || $2 || '%' + OR $2 = ANY(tags) + ) + ORDER BY + CASE WHEN sub = $1 THEN 0 ELSE 1 END, + usage_count DESC, + updated_at DESC + LIMIT 50 + ` + if err := pgxscan.Select(context.Background(), db, ®ions, sql, sub, query); err != nil { + return nil, err + } + return regions, nil +} \ No newline at end of file diff --git a/sql/common/R__05_views_downloads.sql b/sql/common/R__05_views_downloads.sql index 9bb7083e..b9d84cf8 100644 --- a/sql/common/R__05_views_downloads.sql +++ b/sql/common/R__05_views_downloads.sql @@ -15,11 +15,38 @@ CREATE OR REPLACE VIEW v_download AS ( s.name AS status, dp.product_id AS product_id, f.abbreviation AS format, - d.manifest AS manifest + d.manifest AS manifest, + d.clip_geojson AS clip_geojson, + d.clip_region_name AS clip_region_name, + -- Return either custom clip region bbox or watershed bbox + CASE + WHEN d.clip_geojson IS NOT NULL THEN + ARRAY[ + ST_XMin(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), 5070))::FLOAT, + ST_YMin(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), 5070))::FLOAT, + ST_XMax(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), 5070))::FLOAT, + ST_YMax(ST_Transform(ST_GeomFromGeoJSON(d.clip_geojson), 5070))::FLOAT + ] + WHEN w.geometry IS NOT NULL THEN + ARRAY[ + ST_XMin(w.geometry)::FLOAT, + ST_YMin(w.geometry)::FLOAT, + ST_XMax(w.geometry)::FLOAT, + ST_YMax(w.geometry)::FLOAT + ] + ELSE NULL + END AS clip_bbox, + -- Return the clip region name + CASE + WHEN d.clip_geojson IS NOT NULL THEN + COALESCE(d.clip_region_name, 'Custom Region') + ELSE + w.name + END AS clip_name FROM download d INNER JOIN download_format f ON f.id = d.download_format_id INNER JOIN download_status s ON d.status_id = s.id - INNER JOIN watershed w on w.id = d.watershed_id + LEFT JOIN watershed w on w.id = d.watershed_id INNER JOIN ( SELECT array_agg(product_id) as product_id, download_id diff --git a/sql/common/R__07_grants_user_regions.sql b/sql/common/R__07_grants_user_regions.sql new file mode 100644 index 00000000..03d98480 --- /dev/null +++ b/sql/common/R__07_grants_user_regions.sql @@ -0,0 +1,18 @@ +-- Repeatable migration to ensure user_region permissions are always correct +-- This runs every time migrations are applied to ensure permissions are set + +-- Grant permissions to cumulus_user (the API user) +GRANT USAGE ON SCHEMA cumulus TO cumulus_user; +GRANT SELECT, INSERT, UPDATE, DELETE ON cumulus.user_region TO cumulus_user; +GRANT SELECT ON cumulus.v_user_region TO cumulus_user; +GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA cumulus TO cumulus_user; + +-- Also ensure cumulus_user can access other required tables for the view +GRANT SELECT ON cumulus.download TO cumulus_user; + +-- Log what we did +DO $$ +BEGIN + RAISE NOTICE 'Granted user_region permissions to cumulus_user'; +END +$$; \ No newline at end of file diff --git a/sql/common/V2.61.0__add_custom_region_downloads.sql b/sql/common/V2.61.0__add_custom_region_downloads.sql new file mode 100644 index 00000000..c7db0b8f --- /dev/null +++ b/sql/common/V2.61.0__add_custom_region_downloads.sql @@ -0,0 +1,12 @@ +-- Add support for custom GeoJSON regions in downloads +-- This allows users to download data clipped to their exact area of interest +-- instead of being limited to predefined watershed boundaries + +-- Add column to store custom clip region as GeoJSON +ALTER TABLE download ADD COLUMN IF NOT EXISTS clip_geojson TEXT; + +-- Add column to store the clip region name (optional) +ALTER TABLE download ADD COLUMN IF NOT EXISTS clip_region_name VARCHAR(255); + +-- Note: The v_download view is updated in the repeatable migration R__05_views_downloads.sql +-- to include the new clip_geojson and clip_region_name columns \ No newline at end of file diff --git a/sql/common/V2.62.0__user_saved_regions.sql b/sql/common/V2.62.0__user_saved_regions.sql new file mode 100644 index 00000000..34147756 --- /dev/null +++ b/sql/common/V2.62.0__user_saved_regions.sql @@ -0,0 +1,103 @@ +-- Create table for storing user-defined regions +-- Users can save frequently used custom regions for reuse in downloads and searches + +CREATE TABLE IF NOT EXISTS user_region ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + sub UUID NOT NULL, + name VARCHAR(255) NOT NULL, + description TEXT, + geojson TEXT NOT NULL, -- Store the GeoJSON as text + geometry geometry(Geometry, 4326) GENERATED ALWAYS AS (ST_GeomFromGeoJSON(geojson)) STORED, -- Auto-generate PostGIS geometry + bbox FLOAT[] GENERATED ALWAYS AS ( + ARRAY[ + ST_XMin(ST_GeomFromGeoJSON(geojson))::FLOAT, + ST_YMin(ST_GeomFromGeoJSON(geojson))::FLOAT, + ST_XMax(ST_GeomFromGeoJSON(geojson))::FLOAT, + ST_YMax(ST_GeomFromGeoJSON(geojson))::FLOAT + ] + ) STORED, -- Auto-calculate bounding box in WGS84 + area_sqkm FLOAT GENERATED ALWAYS AS ( + ST_Area(ST_Transform(ST_GeomFromGeoJSON(geojson), 5070)) / 1000000.0 + ) STORED, -- Auto-calculate area in square kilometers + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + is_public BOOLEAN DEFAULT FALSE, -- Allow sharing regions with other users + tags TEXT[], -- Optional tags for categorization + CONSTRAINT unique_user_region_name UNIQUE(sub, name) -- Each user must have unique region names +); + +-- Index for fast lookups +CREATE INDEX idx_user_region_sub ON user_region(sub); +CREATE INDEX idx_user_region_geometry ON user_region USING GIST(geometry); +CREATE INDEX idx_user_region_public ON user_region(is_public) WHERE is_public = TRUE; +CREATE INDEX idx_user_region_tags ON user_region USING GIN(tags); + +-- Function to validate GeoJSON before saving +CREATE OR REPLACE FUNCTION validate_user_region_geojson() +RETURNS TRIGGER AS $$ +BEGIN + -- Validate the GeoJSON + IF NEW.geojson IS NULL OR NEW.geojson = '' THEN + RAISE EXCEPTION 'GeoJSON cannot be empty'; + END IF; + + -- Try to parse it as geometry + BEGIN + PERFORM ST_GeomFromGeoJSON(NEW.geojson); + EXCEPTION + WHEN OTHERS THEN + RAISE EXCEPTION 'Invalid GeoJSON format: %', SQLERRM; + END; + + -- Validate the geometry + IF NOT ST_IsValid(ST_GeomFromGeoJSON(NEW.geojson)) THEN + RAISE EXCEPTION 'Invalid geometry in GeoJSON'; + END IF; + + -- Update the updated_at timestamp + NEW.updated_at = NOW(); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Add trigger for validation +CREATE TRIGGER validate_user_region_before_insert_update + BEFORE INSERT OR UPDATE ON user_region + FOR EACH ROW + EXECUTE FUNCTION validate_user_region_geojson(); + +-- View for user regions with additional computed fields +CREATE OR REPLACE VIEW v_user_region AS +SELECT + ur.id, + ur.sub, + ur.name, + ur.description, + ur.geojson, + ur.bbox, + ur.area_sqkm, + ur.created_at, + ur.updated_at, + ur.is_public, + ur.tags, + -- Count how many times this region has been used in downloads + (SELECT COUNT(*) + FROM download d + WHERE d.sub = ur.sub + AND d.clip_region_name = ur.name) AS usage_count +FROM user_region ur; + +-- Grant permissions (commented out if roles don't exist yet) +-- GRANT SELECT ON user_region, v_user_region TO cumulus_reader; +-- GRANT INSERT, UPDATE, DELETE ON user_region TO cumulus_writer; + +COMMENT ON TABLE user_region IS 'Stores user-defined geographic regions for reuse in downloads and searches'; +COMMENT ON COLUMN user_region.sub IS 'User who created the region'; +COMMENT ON COLUMN user_region.name IS 'User-friendly name for the region'; +COMMENT ON COLUMN user_region.geojson IS 'GeoJSON representation of the region'; +COMMENT ON COLUMN user_region.geometry IS 'PostGIS geometry (auto-generated from GeoJSON)'; +COMMENT ON COLUMN user_region.bbox IS 'Bounding box [minX, minY, maxX, maxY] in WGS84'; +COMMENT ON COLUMN user_region.area_sqkm IS 'Area of the region in square kilometers'; +COMMENT ON COLUMN user_region.is_public IS 'If true, region is visible to all users'; +COMMENT ON COLUMN user_region.tags IS 'Optional tags for organizing regions'; \ No newline at end of file From 1a56613d4d4f0780c5b34636f627b6cc0222cd91 Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 23 Oct 2025 08:28:05 -0500 Subject: [PATCH 05/10] add new watershed LRE Great Lakes Watershed --- sql/common/V2.53.3__add_LRE_watershed.sql | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 sql/common/V2.53.3__add_LRE_watershed.sql diff --git a/sql/common/V2.53.3__add_LRE_watershed.sql b/sql/common/V2.53.3__add_LRE_watershed.sql new file mode 100644 index 00000000..9e253ed3 --- /dev/null +++ b/sql/common/V2.53.3__add_LRE_watershed.sql @@ -0,0 +1,23 @@ +--(xmin ymax, xmax ymax, xmax ymin, xmin ymin, xmin ymax) + +-- add new watershed LRE Great Lakes Watershed +INSERT INTO watershed (id, + slug, + "name", + geometry, + office_id, + output_srid) +VALUES + ('09c53d56-a2b7-4c62-a180-2b78b6834a63', + 'great-lakes', + 'Great Lakes', + ST_GeomFromText('Polygon (( + 147342 3257822, + 1833194 3257822, + 1833194 1890482, + 147342 1890482, + 147342 3257822))', + 5070), + '586ac79a-083e-4c8c-8438-9585a88a4b3d', + 5070); + From 06ec11641d0447207143aae41da0b2f1cda48480 Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 23 Oct 2025 08:28:14 -0500 Subject: [PATCH 06/10] swap LRE for LRC (watershed was reassigned) --- sql/common/V2.53.4__swap_winni_watershed_from_LRE_to_LRC.sql | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 sql/common/V2.53.4__swap_winni_watershed_from_LRE_to_LRC.sql diff --git a/sql/common/V2.53.4__swap_winni_watershed_from_LRE_to_LRC.sql b/sql/common/V2.53.4__swap_winni_watershed_from_LRE_to_LRC.sql new file mode 100644 index 00000000..a4832814 --- /dev/null +++ b/sql/common/V2.53.4__swap_winni_watershed_from_LRE_to_LRC.sql @@ -0,0 +1,2 @@ +-- swap LRE for LRC (watershed was reassigned) +UPDATE watershed SET office_id = 'fa9b344c-911c-43e9-966c-b0e1357e385c' WHERE slug = 'lake-winnebago'; From 3129320e97d9b07c08bfc7bf0d9bde75ec907feb Mon Sep 17 00:00:00 2001 From: Oskar Hurst Date: Fri, 24 Oct 2025 23:00:06 +0000 Subject: [PATCH 07/10] add repeatables --- sql/common/R__08_functions_user_regions.sql | 40 ++++++++++++++ sql/common/R__08_views_user_regions.sql | 30 +++++++++++ sql/common/V2.62.0__user_saved_regions.sql | 60 --------------------- 3 files changed, 70 insertions(+), 60 deletions(-) create mode 100644 sql/common/R__08_functions_user_regions.sql create mode 100644 sql/common/R__08_views_user_regions.sql diff --git a/sql/common/R__08_functions_user_regions.sql b/sql/common/R__08_functions_user_regions.sql new file mode 100644 index 00000000..46c67c2d --- /dev/null +++ b/sql/common/R__08_functions_user_regions.sql @@ -0,0 +1,40 @@ + +------------------------- +-- FUNCTIONS AND TRIGGERS +-- User Regions +------------------------- + +-- Function to validate GeoJSON before saving +CREATE OR REPLACE FUNCTION validate_user_region_geojson() +RETURNS TRIGGER AS $$ +BEGIN + -- Validate the GeoJSON + IF NEW.geojson IS NULL OR NEW.geojson = '' THEN + RAISE EXCEPTION 'GeoJSON cannot be empty'; + END IF; + + -- Try to parse it as geometry + BEGIN + PERFORM ST_GeomFromGeoJSON(NEW.geojson); + EXCEPTION + WHEN OTHERS THEN + RAISE EXCEPTION 'Invalid GeoJSON format: %', SQLERRM; + END; + + -- Validate the geometry + IF NOT ST_IsValid(ST_GeomFromGeoJSON(NEW.geojson)) THEN + RAISE EXCEPTION 'Invalid geometry in GeoJSON'; + END IF; + + -- Update the updated_at timestamp + NEW.updated_at = NOW(); + + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +-- Add trigger for validation +CREATE TRIGGER validate_user_region_before_insert_update + BEFORE INSERT OR UPDATE ON user_region + FOR EACH ROW + EXECUTE FUNCTION validate_user_region_geojson(); diff --git a/sql/common/R__08_views_user_regions.sql b/sql/common/R__08_views_user_regions.sql new file mode 100644 index 00000000..f9c32fe8 --- /dev/null +++ b/sql/common/R__08_views_user_regions.sql @@ -0,0 +1,30 @@ + +------------------------- +-- VIEWS +-- User Regions +------------------------- + +-- View for user regions with additional computed fields +CREATE OR REPLACE VIEW v_user_region AS +SELECT + ur.id, + ur.sub, + ur.name, + ur.description, + ur.geojson, + ur.bbox, + ur.area_sqkm, + ur.created_at, + ur.updated_at, + ur.is_public, + ur.tags, + -- Count how many times this region has been used in downloads + (SELECT COUNT(*) + FROM download d + WHERE d.sub = ur.sub + AND d.clip_region_name = ur.name) AS usage_count +FROM user_region ur; + +-- Grant permissions (commented out if roles don't exist yet) +-- GRANT SELECT ON user_region, v_user_region TO cumulus_reader; +-- GRANT INSERT, UPDATE, DELETE ON user_region TO cumulus_writer; diff --git a/sql/common/V2.62.0__user_saved_regions.sql b/sql/common/V2.62.0__user_saved_regions.sql index 34147756..cb57f88f 100644 --- a/sql/common/V2.62.0__user_saved_regions.sql +++ b/sql/common/V2.62.0__user_saved_regions.sql @@ -32,66 +32,6 @@ CREATE INDEX idx_user_region_geometry ON user_region USING GIST(geometry); CREATE INDEX idx_user_region_public ON user_region(is_public) WHERE is_public = TRUE; CREATE INDEX idx_user_region_tags ON user_region USING GIN(tags); --- Function to validate GeoJSON before saving -CREATE OR REPLACE FUNCTION validate_user_region_geojson() -RETURNS TRIGGER AS $$ -BEGIN - -- Validate the GeoJSON - IF NEW.geojson IS NULL OR NEW.geojson = '' THEN - RAISE EXCEPTION 'GeoJSON cannot be empty'; - END IF; - - -- Try to parse it as geometry - BEGIN - PERFORM ST_GeomFromGeoJSON(NEW.geojson); - EXCEPTION - WHEN OTHERS THEN - RAISE EXCEPTION 'Invalid GeoJSON format: %', SQLERRM; - END; - - -- Validate the geometry - IF NOT ST_IsValid(ST_GeomFromGeoJSON(NEW.geojson)) THEN - RAISE EXCEPTION 'Invalid geometry in GeoJSON'; - END IF; - - -- Update the updated_at timestamp - NEW.updated_at = NOW(); - - RETURN NEW; -END; -$$ LANGUAGE plpgsql; - --- Add trigger for validation -CREATE TRIGGER validate_user_region_before_insert_update - BEFORE INSERT OR UPDATE ON user_region - FOR EACH ROW - EXECUTE FUNCTION validate_user_region_geojson(); - --- View for user regions with additional computed fields -CREATE OR REPLACE VIEW v_user_region AS -SELECT - ur.id, - ur.sub, - ur.name, - ur.description, - ur.geojson, - ur.bbox, - ur.area_sqkm, - ur.created_at, - ur.updated_at, - ur.is_public, - ur.tags, - -- Count how many times this region has been used in downloads - (SELECT COUNT(*) - FROM download d - WHERE d.sub = ur.sub - AND d.clip_region_name = ur.name) AS usage_count -FROM user_region ur; - --- Grant permissions (commented out if roles don't exist yet) --- GRANT SELECT ON user_region, v_user_region TO cumulus_reader; --- GRANT INSERT, UPDATE, DELETE ON user_region TO cumulus_writer; - COMMENT ON TABLE user_region IS 'Stores user-defined geographic regions for reuse in downloads and searches'; COMMENT ON COLUMN user_region.sub IS 'User who created the region'; COMMENT ON COLUMN user_region.name IS 'User-friendly name for the region'; From 9507cbb333e209e58d22e90e6e9d759f3bb98f69 Mon Sep 17 00:00:00 2001 From: Oskar Hurst <48417811+oskarhurst@users.noreply.github.com> Date: Mon, 27 Oct 2025 13:20:12 -0700 Subject: [PATCH 08/10] Update sql/common/R__08_functions_user_regions.sql Co-authored-by: Mike Neilson --- sql/common/R__08_functions_user_regions.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sql/common/R__08_functions_user_regions.sql b/sql/common/R__08_functions_user_regions.sql index 46c67c2d..974704c7 100644 --- a/sql/common/R__08_functions_user_regions.sql +++ b/sql/common/R__08_functions_user_regions.sql @@ -34,7 +34,7 @@ END; $$ LANGUAGE plpgsql; -- Add trigger for validation -CREATE TRIGGER validate_user_region_before_insert_update +CREATE OR REPLACE TRIGGER validate_user_region_before_insert_update BEFORE INSERT OR UPDATE ON user_region FOR EACH ROW EXECUTE FUNCTION validate_user_region_geojson(); From 01eee70044b1cdd3834c60d61dedf943276db23c Mon Sep 17 00:00:00 2001 From: Oskar Hurst Date: Mon, 27 Oct 2025 22:23:48 +0000 Subject: [PATCH 09/10] rename repeatable --- ...__07_grants_user_regions.sql => R__09_grants_user_regions.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename sql/common/{R__07_grants_user_regions.sql => R__09_grants_user_regions.sql} (100%) diff --git a/sql/common/R__07_grants_user_regions.sql b/sql/common/R__09_grants_user_regions.sql similarity index 100% rename from sql/common/R__07_grants_user_regions.sql rename to sql/common/R__09_grants_user_regions.sql From 243bca5988b78a26a1a035340bfe7bb41271202e Mon Sep 17 00:00:00 2001 From: msweier Date: Thu, 30 Oct 2025 13:17:22 -0500 Subject: [PATCH 10/10] adjust swg san diego creek watershed --- sql/common/V2.63.0__adjust_swg_san_diego.sql | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 sql/common/V2.63.0__adjust_swg_san_diego.sql diff --git a/sql/common/V2.63.0__adjust_swg_san_diego.sql b/sql/common/V2.63.0__adjust_swg_san_diego.sql new file mode 100644 index 00000000..3868cba3 --- /dev/null +++ b/sql/common/V2.63.0__adjust_swg_san_diego.sql @@ -0,0 +1,11 @@ +-- update san diego creek watershed +-- xmin,ymax (top left), xmax ymax (top right), xmax ymin (bottom right), xmin ymin (bottom left), xmin ymax (top left again) +UPDATE watershed +SET geometry = ST_GeomFromText('Polygon (( + -266955 561275, + -193507 561275, + -193507 513001, + -266955 513001, + -266955 561275))', + 5070) + WHERE slug = 'san-diego-creek'; \ No newline at end of file