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__08_functions_user_regions.sql b/sql/common/R__08_functions_user_regions.sql new file mode 100644 index 00000000..974704c7 --- /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 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(); 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/R__09_grants_user_regions.sql b/sql/common/R__09_grants_user_regions.sql new file mode 100644 index 00000000..03d98480 --- /dev/null +++ b/sql/common/R__09_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.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 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); + 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'; 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..cb57f88f --- /dev/null +++ b/sql/common/V2.62.0__user_saved_regions.sql @@ -0,0 +1,43 @@ +-- 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); + +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 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