From 93d4cd7c139cbbfbe27eeb47caedb1e404101ec7 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 04:43:01 -0700 Subject: [PATCH 1/9] dashboard changes 1. add below fields to dashboard creation - a. tags - list of strings b. created - created datetime c. is favorite - true/false, default false 2. ensure title is unique 3. add API to get all tags - `GET /api/v1/dashboards/list_tags` --- src/handlers/http/modal/server.rs | 7 +++ src/handlers/http/users/dashboards.rs | 5 ++ src/users/dashboards.rs | 70 +++++++++++++++++++++++++-- 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index e92ae836d..af503069c 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -300,6 +300,13 @@ impl Server { .authorize(Action::ListDashboard), ), ) + .service( + web::resource("/list_tags").route( + web::get() + .to(dashboards::list_tags) + .authorize(Action::ListDashboard), + ), + ) .service( web::scope("/{dashboard_id}") .service( diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 93dc4fd7d..ceb1d0c45 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -145,6 +145,11 @@ pub async fn add_tile( Ok((web::Json(dashboard), StatusCode::OK)) } +pub async fn list_tags() -> Result { + let tags = DASHBOARDS.list_tags().await; + Ok((web::Json(tags), StatusCode::OK)) +} + #[derive(Debug, thiserror::Error)] pub enum DashboardError { #[error("Failed to connect to storage: {0}")] diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index b838a1938..3c7fb3c5e 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -52,12 +52,16 @@ pub struct Tile { pub other_fields: Option>, } #[derive(Debug, Serialize, Deserialize, Default, Clone)] +#[serde(rename_all = "camelCase")] pub struct Dashboard { pub version: Option, pub title: String, pub author: Option, pub dashboard_id: Option, + pub created: Option>, pub modified: Option>, + pub tags: Option>, + pub is_favorite: Option, // whether the dashboard is marked as favorite, default is false dashboard_type: Option, pub tiles: Option>, } @@ -77,6 +81,11 @@ impl Dashboard { if self.tiles.is_none() { self.tiles = Some(Vec::new()); } + + // if is_favorite is None, set it to false, else set it to the current value + self.is_favorite = Some( + self.is_favorite.unwrap_or(false), // default to false if not set + ); } /// create a summary of the dashboard @@ -96,6 +105,13 @@ impl Dashboard { ); } + if let Some(created) = &self.created { + map.insert( + "created".to_string(), + serde_json::Value::String(created.to_string()), + ); + } + if let Some(modified) = &self.modified { map.insert( "modified".to_string(), @@ -110,6 +126,22 @@ impl Dashboard { ); } + if let Some(tags) = &self.tags { + map.insert( + "tags".to_string(), + serde_json::Value::Array( + tags.iter() + .map(|tag| serde_json::Value::String(tag.clone())) + .collect(), + ), + ); + } + + map.insert( + "is_favorite".to_string(), + serde_json::Value::Bool(self.is_favorite.unwrap_or(false)), + ); + map } } @@ -175,6 +207,16 @@ impl Dashboards { let dashboard_id = dashboard .dashboard_id .ok_or(DashboardError::Metadata("Dashboard ID must be provided"))?; + + // ensure the dashboard has unique title + let dashboards = self.0.read().await; + let has_duplicate = dashboards + .iter() + .any(|d| d.title == dashboard.title && d.dashboard_id != dashboard.dashboard_id); + + if has_duplicate { + return Err(DashboardError::Metadata("Dashboard title must be unique")); + } let path = dashboard_path(user_id, &format!("{dashboard_id}.json")); let store = PARSEABLE.storage.get_object_store(); @@ -194,6 +236,7 @@ impl Dashboards { user_id: &str, dashboard: &mut Dashboard, ) -> Result<(), DashboardError> { + dashboard.created = Some(Utc::now()); dashboard.set_metadata(user_id, None); self.save_dashboard(user_id, dashboard).await?; @@ -211,10 +254,12 @@ impl Dashboards { dashboard_id: Ulid, dashboard: &mut Dashboard, ) -> Result<(), DashboardError> { - self.ensure_dashboard_ownership(dashboard_id, user_id) + let existing_dashboard = self + .ensure_dashboard_ownership(dashboard_id, user_id) .await?; dashboard.set_metadata(user_id, Some(dashboard_id)); + dashboard.created = existing_dashboard.created; self.save_dashboard(user_id, dashboard).await?; let mut dashboards = self.0.write().await; @@ -288,6 +333,20 @@ impl Dashboards { self.0.read().await.clone() } + /// List tags from all dashboards + /// This function returns a list of unique tags from all dashboards + pub async fn list_tags(&self) -> Vec { + let dashboards = self.0.read().await; + let mut tags = dashboards + .iter() + .filter_map(|d| d.tags.as_ref()) + .flat_map(|t| t.iter().cloned()) + .collect::>(); + tags.sort(); + tags.dedup(); + tags + } + /// Ensure the user is the owner of the dashboard /// This function is called when updating or deleting a dashboard /// check if the user is the owner of the dashboard @@ -296,10 +355,13 @@ impl Dashboards { &self, dashboard_id: Ulid, user_id: &str, - ) -> Result<(), DashboardError> { + ) -> Result { self.get_dashboard_by_user(dashboard_id, user_id) .await - .ok_or_else(|| DashboardError::Unauthorized) - .map(|_| ()) + .ok_or_else(|| { + DashboardError::Metadata( + "Dashboard does not exist or you do not have permission to access it", + ) + }) } } From 4f574c90d88060e92a087cb036f95ee2c60bf83b Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 11:43:10 -0700 Subject: [PATCH 2/9] add query params in update dashboard 1. is_favorite=true/false -- to set dashboard to favorite 2. rename_to= -- to update the title of the dashboard 3. tags= -- to update tags add validation - body and query params both cannot co-exist in PUT request --- src/handlers/http/users/dashboards.rs | 91 +++++++++++++++++++++------ src/users/dashboards.rs | 4 +- 2 files changed, 72 insertions(+), 23 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index ceb1d0c45..2da64e6ff 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -16,6 +16,8 @@ * */ +use std::collections::HashMap; + use crate::{ handlers::http::rbac::RBACError, storage::ObjectStorageError, @@ -68,37 +70,83 @@ pub async fn create_dashboard( pub async fn update_dashboard( req: HttpRequest, dashboard_id: Path, - Json(mut dashboard): Json, + Json(dashboard): Json, ) -> Result { let user_id = get_hash(&get_user_from_request(&req)?); let dashboard_id = validate_dashboard_id(dashboard_id.into_inner())?; + let mut existing_dashboard = DASHBOARDS + .get_dashboard_by_user(dashboard_id, &user_id) + .await + .ok_or(DashboardError::Metadata( + "Dashboard does not exist or user is not authorized", + ))?; - // Validate all tiles have valid IDs - if let Some(tiles) = &dashboard.tiles { - if tiles.iter().any(|tile| tile.tile_id.is_nil()) { - return Err(DashboardError::Metadata("Tile ID must be provided")); - } + let query_map = web::Query::>::from_query(req.query_string()) + .map_err(|_| DashboardError::InvalidQueryParameter)?; + + // Validate: either query params OR body, not both + let has_query_params = !query_map.is_empty(); + let has_body_update = dashboard.title != existing_dashboard.title || dashboard.tiles.is_some(); + + if has_query_params && has_body_update { + return Err(DashboardError::Metadata( + "Cannot use both query parameters and request body for updates", + )); } - // Check if tile_id are unique - if let Some(tiles) = &dashboard.tiles { - let unique_tiles: Vec<_> = tiles - .iter() - .map(|tile| tile.tile_id) - .collect::>() - .into_iter() - .collect(); - - if unique_tiles.len() != tiles.len() { - return Err(DashboardError::Metadata("Tile IDs must be unique")); + let mut final_dashboard = if has_query_params { + // Apply partial updates from query parameters + if let Some(is_favorite) = query_map.get("is_favorite") { + existing_dashboard.is_favorite = Some(is_favorite == "true"); + } + if let Some(tags) = query_map.get("tags") { + let parsed_tags: Vec = tags + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect(); + existing_dashboard.tags = if parsed_tags.is_empty() { + None + } else { + Some(parsed_tags) + }; + } + if let Some(rename_to) = query_map.get("rename_to") { + let trimmed = rename_to.trim(); + if trimmed.is_empty() { + return Err(DashboardError::Metadata("Rename to cannot be empty")); + } + existing_dashboard.title = trimmed.to_string(); + } + existing_dashboard + } else { + if let Some(tiles) = &dashboard.tiles { + if tiles.iter().any(|tile| tile.tile_id.is_nil()) { + return Err(DashboardError::Metadata("Tile ID must be provided")); + } + + // Check if tile_id are unique + let unique_tiles: Vec<_> = tiles + .iter() + .map(|tile| tile.tile_id) + .collect::>() + .into_iter() + .collect(); + + if unique_tiles.len() != tiles.len() { + return Err(DashboardError::Metadata("Tile IDs must be unique")); + } } - } + + dashboard + }; DASHBOARDS - .update(&user_id, dashboard_id, &mut dashboard) + .update(&user_id, dashboard_id, &mut final_dashboard) .await?; - Ok((web::Json(dashboard), StatusCode::OK)) + Ok((web::Json(final_dashboard), StatusCode::OK)) } pub async fn delete_dashboard( @@ -164,6 +212,8 @@ pub enum DashboardError { Custom(String), #[error("Dashboard does not exist or is not accessible")] Unauthorized, + #[error("Invalid query parameter")] + InvalidQueryParameter, } impl actix_web::ResponseError for DashboardError { @@ -175,6 +225,7 @@ impl actix_web::ResponseError for DashboardError { Self::UserDoesNotExist(_) => StatusCode::NOT_FOUND, Self::Custom(_) => StatusCode::INTERNAL_SERVER_ERROR, Self::Unauthorized => StatusCode::UNAUTHORIZED, + Self::InvalidQueryParameter => StatusCode::BAD_REQUEST, } } diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index 3c7fb3c5e..32d55cfa9 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -83,9 +83,7 @@ impl Dashboard { } // if is_favorite is None, set it to false, else set it to the current value - self.is_favorite = Some( - self.is_favorite.unwrap_or(false), // default to false if not set - ); + self.is_favorite = self.is_favorite.or(Some(false)); } /// create a summary of the dashboard From 185b07a42199a9a8bf3dbb4bac6697d71bf2ae4c Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 22:19:30 -0700 Subject: [PATCH 3/9] body optional in update dashboard --- src/handlers/http/users/dashboards.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 2da64e6ff..378af57df 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -70,7 +70,7 @@ pub async fn create_dashboard( pub async fn update_dashboard( req: HttpRequest, dashboard_id: Path, - Json(dashboard): Json, + dashboard: Option>, ) -> Result { let user_id = get_hash(&get_user_from_request(&req)?); let dashboard_id = validate_dashboard_id(dashboard_id.into_inner())?; @@ -86,7 +86,10 @@ pub async fn update_dashboard( // Validate: either query params OR body, not both let has_query_params = !query_map.is_empty(); - let has_body_update = dashboard.title != existing_dashboard.title || dashboard.tiles.is_some(); + let has_body_update = dashboard + .as_ref() + .map(|d| d.title != existing_dashboard.title || d.tiles.is_some()) + .unwrap_or(false); if has_query_params && has_body_update { return Err(DashboardError::Metadata( @@ -121,6 +124,9 @@ pub async fn update_dashboard( } existing_dashboard } else { + let dashboard = dashboard + .ok_or(DashboardError::Metadata("Request body is required"))? + .into_inner(); if let Some(tiles) = &dashboard.tiles { if tiles.iter().any(|tile| tile.tile_id.is_nil()) { return Err(DashboardError::Metadata("Tile ID must be provided")); From f74557bf0bf649b23476e4fb22536f1a9e961c56 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 22:22:13 -0700 Subject: [PATCH 4/9] camel case in listing dashboard --- src/users/dashboards.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index 32d55cfa9..eb4b760cb 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -119,7 +119,7 @@ impl Dashboard { if let Some(dashboard_id) = &self.dashboard_id { map.insert( - "dashboard_id".to_string(), + "dashboardId".to_string(), serde_json::Value::String(dashboard_id.to_string()), ); } @@ -136,7 +136,7 @@ impl Dashboard { } map.insert( - "is_favorite".to_string(), + "isFavorite".to_string(), serde_json::Value::Bool(self.is_favorite.unwrap_or(false)), ); From cb869d85dd68ebf38f57f81febab6ccf09fc1305 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Mon, 30 Jun 2025 23:25:24 -0700 Subject: [PATCH 5/9] add endpoint for list by tag --- src/handlers/http/modal/server.rs | 7 +++++++ src/handlers/http/users/dashboards.rs | 15 +++++++++++++++ src/users/dashboards.rs | 15 +++++++++++++++ 3 files changed, 37 insertions(+) diff --git a/src/handlers/http/modal/server.rs b/src/handlers/http/modal/server.rs index af503069c..575f2925d 100644 --- a/src/handlers/http/modal/server.rs +++ b/src/handlers/http/modal/server.rs @@ -307,6 +307,13 @@ impl Server { .authorize(Action::ListDashboard), ), ) + .service( + web::resource("/list_by_tag/{tag}").route( + web::get() + .to(dashboards::list_dashboards_by_tag) + .authorize(Action::ListDashboard), + ), + ) .service( web::scope("/{dashboard_id}") .service( diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 378af57df..32c256407 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -204,6 +204,21 @@ pub async fn list_tags() -> Result { Ok((web::Json(tags), StatusCode::OK)) } +pub async fn list_dashboards_by_tag(tag: Path) -> Result { + let tag = tag.into_inner(); + if tag.is_empty() { + return Err(DashboardError::Metadata("Tag cannot be empty")); + } + + let dashboards = DASHBOARDS.list_dashboards_by_tag(&tag).await; + let dashboard_summaries = dashboards + .iter() + .map(|dashboard| dashboard.to_summary()) + .collect::>(); + + Ok((web::Json(dashboard_summaries), StatusCode::OK)) +} + #[derive(Debug, thiserror::Error)] pub enum DashboardError { #[error("Failed to connect to storage: {0}")] diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index eb4b760cb..29f8f5ef9 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -345,6 +345,21 @@ impl Dashboards { tags } + /// List dashboards by tag + /// This function returns a list of dashboards that have the specified tag + pub async fn list_dashboards_by_tag(&self, tag: &str) -> Vec { + let dashboards = self.0.read().await; + dashboards + .iter() + .filter(|d| { + d.tags + .as_ref() + .map_or(false, |tags| tags.contains(&tag.to_string())) + }) + .cloned() + .collect() + } + /// Ensure the user is the owner of the dashboard /// This function is called when updating or deleting a dashboard /// check if the user is the owner of the dashboard From 910503991318ef4a3e5615b947fcd68dfc960fe6 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Tue, 1 Jul 2025 00:03:53 -0700 Subject: [PATCH 6/9] refactor --- src/handlers/http/users/dashboards.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index 32c256407..a8ad6098f 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -86,10 +86,9 @@ pub async fn update_dashboard( // Validate: either query params OR body, not both let has_query_params = !query_map.is_empty(); - let has_body_update = dashboard - .as_ref() - .map(|d| d.title != existing_dashboard.title || d.tiles.is_some()) - .unwrap_or(false); + let has_body_update = dashboard.as_ref().map_or(false, |d| { + d.title != existing_dashboard.title || d.tiles.is_some() + }); if has_query_params && has_body_update { return Err(DashboardError::Metadata( From 826b32a5b237ea35fd2d97223cd6b2e33f53fc24 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Tue, 1 Jul 2025 01:34:54 -0700 Subject: [PATCH 7/9] camel case in query param --- src/handlers/http/users/dashboards.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index a8ad6098f..ce397b2ac 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -98,7 +98,7 @@ pub async fn update_dashboard( let mut final_dashboard = if has_query_params { // Apply partial updates from query parameters - if let Some(is_favorite) = query_map.get("is_favorite") { + if let Some(is_favorite) = query_map.get("isFavorite") { existing_dashboard.is_favorite = Some(is_favorite == "true"); } if let Some(tags) = query_map.get("tags") { @@ -114,7 +114,7 @@ pub async fn update_dashboard( Some(parsed_tags) }; } - if let Some(rename_to) = query_map.get("rename_to") { + if let Some(rename_to) = query_map.get("renameTo") { let trimmed = rename_to.trim(); if trimmed.is_empty() { return Err(DashboardError::Metadata("Rename to cannot be empty")); From df47a102494d69020892b001469b97c3ff3199a7 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Tue, 1 Jul 2025 01:40:09 -0700 Subject: [PATCH 8/9] clippy suggestions --- src/handlers/http/users/dashboards.rs | 6 +++--- src/users/dashboards.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/handlers/http/users/dashboards.rs b/src/handlers/http/users/dashboards.rs index ce397b2ac..f503c3e58 100644 --- a/src/handlers/http/users/dashboards.rs +++ b/src/handlers/http/users/dashboards.rs @@ -86,9 +86,9 @@ pub async fn update_dashboard( // Validate: either query params OR body, not both let has_query_params = !query_map.is_empty(); - let has_body_update = dashboard.as_ref().map_or(false, |d| { - d.title != existing_dashboard.title || d.tiles.is_some() - }); + let has_body_update = dashboard + .as_ref() + .is_some_and(|d| d.title != existing_dashboard.title || d.tiles.is_some()); if has_query_params && has_body_update { return Err(DashboardError::Metadata( diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index 29f8f5ef9..cd53ea68f 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -354,7 +354,7 @@ impl Dashboards { .filter(|d| { d.tags .as_ref() - .map_or(false, |tags| tags.contains(&tag.to_string())) + .is_some_and(|tags| tags.contains(&tag.to_string())) }) .cloned() .collect() From 155e18eae94a9149b657aa660d04973b39747a74 Mon Sep 17 00:00:00 2001 From: Nikhil Sinha Date: Sun, 6 Jul 2025 23:24:34 -0700 Subject: [PATCH 9/9] acquire write lock for save dashboard --- src/cli.rs | 2 +- src/handlers/http/oidc.rs | 14 +++++++++-- src/users/dashboards.rs | 49 ++++++++++++++++++++++++++------------- 3 files changed, 46 insertions(+), 19 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 3ec3c4192..cfc74d81d 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -451,7 +451,7 @@ pub struct Options { help = "Object store sync threshold in seconds" )] pub object_store_sync_threshold: u64, - // the oidc scope + // the oidc scope #[arg( long = "oidc-scope", name = "oidc-scope", diff --git a/src/handlers/http/oidc.rs b/src/handlers/http/oidc.rs index 57c30e18f..13e65a4e1 100644 --- a/src/handlers/http/oidc.rs +++ b/src/handlers/http/oidc.rs @@ -77,7 +77,13 @@ pub async fn login( let session_key = extract_session_key_from_req(&req).ok(); let (session_key, oidc_client) = match (session_key, oidc_client) { (None, None) => return Ok(redirect_no_oauth_setup(query.redirect.clone())), - (None, Some(client)) => return Ok(redirect_to_oidc(query, client, PARSEABLE.options.scope.to_string().as_str())), + (None, Some(client)) => { + return Ok(redirect_to_oidc( + query, + client, + PARSEABLE.options.scope.to_string().as_str(), + )) + } (Some(session_key), client) => (session_key, client), }; // try authorize @@ -113,7 +119,11 @@ pub async fn login( } else { Users.remove_session(&key); if let Some(oidc_client) = oidc_client { - redirect_to_oidc(query, oidc_client, PARSEABLE.options.scope.to_string().as_str()) + redirect_to_oidc( + query, + oidc_client, + PARSEABLE.options.scope.to_string().as_str(), + ) } else { redirect_to_client(query.redirect.as_str(), None) } diff --git a/src/users/dashboards.rs b/src/users/dashboards.rs index cd53ea68f..24fe89f62 100644 --- a/src/users/dashboards.rs +++ b/src/users/dashboards.rs @@ -206,17 +206,7 @@ impl Dashboards { .dashboard_id .ok_or(DashboardError::Metadata("Dashboard ID must be provided"))?; - // ensure the dashboard has unique title - let dashboards = self.0.read().await; - let has_duplicate = dashboards - .iter() - .any(|d| d.title == dashboard.title && d.dashboard_id != dashboard.dashboard_id); - - if has_duplicate { - return Err(DashboardError::Metadata("Dashboard title must be unique")); - } let path = dashboard_path(user_id, &format!("{dashboard_id}.json")); - let store = PARSEABLE.storage.get_object_store(); let dashboard_bytes = serde_json::to_vec(&dashboard)?; store @@ -237,8 +227,19 @@ impl Dashboards { dashboard.created = Some(Utc::now()); dashboard.set_metadata(user_id, None); + let mut dashboards = self.0.write().await; + + let has_duplicate = dashboards + .iter() + .any(|d| d.title == dashboard.title && d.dashboard_id != dashboard.dashboard_id); + + if has_duplicate { + return Err(DashboardError::Metadata("Dashboard title must be unique")); + } + self.save_dashboard(user_id, dashboard).await?; - self.0.write().await.push(dashboard.clone()); + + dashboards.push(dashboard.clone()); Ok(()) } @@ -252,16 +253,32 @@ impl Dashboards { dashboard_id: Ulid, dashboard: &mut Dashboard, ) -> Result<(), DashboardError> { - let existing_dashboard = self - .ensure_dashboard_ownership(dashboard_id, user_id) - .await?; + let mut dashboards = self.0.write().await; + + let existing_dashboard = dashboards + .iter() + .find(|d| d.dashboard_id == Some(dashboard_id) && d.author == Some(user_id.to_string())) + .cloned() + .ok_or_else(|| { + DashboardError::Metadata( + "Dashboard does not exist or you do not have permission to access it", + ) + })?; dashboard.set_metadata(user_id, Some(dashboard_id)); dashboard.created = existing_dashboard.created; + + let has_duplicate = dashboards + .iter() + .any(|d| d.title == dashboard.title && d.dashboard_id != dashboard.dashboard_id); + + if has_duplicate { + return Err(DashboardError::Metadata("Dashboard title must be unique")); + } + self.save_dashboard(user_id, dashboard).await?; - let mut dashboards = self.0.write().await; - dashboards.retain(|d| d.dashboard_id != dashboard.dashboard_id); + dashboards.retain(|d| d.dashboard_id != Some(dashboard_id)); dashboards.push(dashboard.clone()); Ok(())