use std::collections::HashMap; use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use diesel::{BoolExpressionMethods, ExpressionMethods, JoinOnDsl, QueryDsl, Queryable, Selectable}; use diesel_async::RunQueryDsl; use futures::future::join_all; use itertools::Itertools; use middleware::AuthenticatedUser; use serde_json::Value; use serde_yaml; use tokio::task::JoinHandle; use uuid::Uuid; use crate::dashboards::types::{BusterShareIndividual, DashboardCollection}; use crate::metrics::{get_metric_for_dashboard_handler, get_metric_handler}; use crate::metrics::{BusterMetric, Dataset, Version}; use crate::utils::workspace::count_workspace_members; use database::enums::{AssetPermissionRole, AssetType, IdentityType, Verification}; use database::helpers::dashboard_files::fetch_dashboard_file_with_permission; use database::pool::get_pg_pool; use database::schema::{ asset_permissions, collections, collections_to_assets, dashboard_files, metric_files, users, }; use database::types::{MetricYml, VersionHistory}; use sharing::{check_permission_access, compute_effective_permission}; use super::{ BusterDashboard, BusterDashboardResponse, DashboardConfig, DashboardRow, DashboardRowItem, }; #[derive(Queryable, Selectable)] #[diesel(table_name = dashboard_files)] struct QueryableDashboardFile { id: Uuid, name: String, file_name: String, content: Value, #[allow(dead_code)] filter: Option, #[allow(dead_code)] organization_id: Uuid, created_by: Uuid, created_at: chrono::DateTime, updated_at: chrono::DateTime, publicly_accessible: bool, publicly_enabled_by: Option, public_expiry_date: Option>, version_history: VersionHistory, } #[derive(Queryable)] struct AssetPermissionInfo { role: AssetPermissionRole, email: String, name: Option, avatar_url: Option, } /// Fetches collections that the dashboard belongs to, filtered by user permissions async fn fetch_associated_collections_for_dashboard( dashboard_id: Uuid, user_id: &Uuid, ) -> Result> { let mut conn = get_pg_pool().get().await?; let associated_collections = collections_to_assets::table .inner_join(collections::table.on(collections::id.eq(collections_to_assets::collection_id))) .inner_join( asset_permissions::table.on(asset_permissions::asset_id .eq(collections::id) .and(asset_permissions::asset_type.eq(AssetType::Collection))), ) .filter(collections_to_assets::asset_id.eq(dashboard_id)) .filter(collections_to_assets::asset_type.eq(AssetType::DashboardFile)) .filter(collections::deleted_at.is_null()) // Ensure collection isn't deleted .filter(asset_permissions::identity_id.eq(user_id)) .filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::deleted_at.is_null()) .select((collections::id, collections::name)) .load::<(Uuid, String)>(&mut conn) .await? .into_iter() .map(|(id, name)| DashboardCollection { id: id.to_string(), name, }) .collect(); Ok(associated_collections) } pub async fn get_dashboard_handler( dashboard_id: &Uuid, user: &AuthenticatedUser, version_number: Option, password: Option, ) -> Result { // First check if the user has permission to view this dashboard let dashboard_with_permission_option = fetch_dashboard_file_with_permission(dashboard_id, &user.id).await?; // If dashboard not found, return error let dashboard_with_permission = match dashboard_with_permission_option { Some(dwp) => dwp, None => { tracing::warn!(dashboard_id = %dashboard_id, "Dashboard file not found during fetch"); return Err(anyhow!("Dashboard not found")); } }; let dashboard_file = dashboard_with_permission.dashboard_file; let direct_permission_level = dashboard_with_permission.permission; // Check if user has proper permission to view the dashboard let permission: AssetPermissionRole; tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "Checking permissions for dashboard"); // Check for direct/admin permission first tracing::debug!(dashboard_id = %dashboard_id, "Checking direct/admin permissions first."); let has_sufficient_direct_permission = check_permission_access( direct_permission_level, &[ AssetPermissionRole::CanView, AssetPermissionRole::CanEdit, AssetPermissionRole::FullAccess, AssetPermissionRole::Owner, AssetPermissionRole::CanFilter, ], dashboard_file.organization_id, &user.organizations, dashboard_file.workspace_sharing, ); tracing::debug!(dashboard_id = %dashboard_id, ?direct_permission_level, has_sufficient_direct_permission, "Direct permission check result"); if has_sufficient_direct_permission { // Compute the effective permission (highest of direct and workspace sharing) let effective_permission = compute_effective_permission( direct_permission_level, dashboard_file.workspace_sharing, dashboard_file.organization_id, &user.organizations, ); permission = effective_permission.unwrap_or(AssetPermissionRole::CanView); tracing::debug!( dashboard_id = %dashboard_id, user_id = %user.id, ?direct_permission_level, workspace_sharing = ?dashboard_file.workspace_sharing, ?permission, "Granting access with effective permission (max of direct and workspace sharing)." ); } else { // No sufficient direct/admin permission, check if user has access via a collection tracing::debug!(dashboard_id = %dashboard_id, "Insufficient direct/admin permission. Checking collection access."); let has_collection_access = sharing::check_dashboard_collection_access(dashboard_id, &user.id, &user.organizations) .await .unwrap_or(false); if has_collection_access { // User has access to a collection containing this dashboard, grant CanView tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "User has access via collection. Granting CanView."); permission = AssetPermissionRole::CanView; } else { // No collection access, check if user has access via a chat tracing::debug!(dashboard_id = %dashboard_id, "No collection access. Checking chat access."); let has_chat_access = sharing::check_dashboard_chat_access(dashboard_id, &user.id, &user.organizations) .await .unwrap_or(false); if has_chat_access { // User has access to a chat containing this dashboard, grant CanView tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "User has access via chat. Granting CanView."); permission = AssetPermissionRole::CanView; } else { // No chat access either, check public access rules tracing::debug!(dashboard_id = %dashboard_id, "No chat access. Checking public access rules."); if !dashboard_file.publicly_accessible { tracing::warn!(dashboard_id = %dashboard_id, user_id = %user.id, "Permission denied (not public, no collection/chat access, insufficient direct permission)."); return Err(anyhow!("You don't have permission to view this dashboard")); } tracing::debug!(dashboard_id = %dashboard_id, "Dashboard is publicly accessible."); // Check if the public access has expired if let Some(expiry_date) = dashboard_file.public_expiry_date { tracing::debug!(dashboard_id = %dashboard_id, ?expiry_date, "Checking expiry date"); if expiry_date < chrono::Utc::now() { tracing::warn!(dashboard_id = %dashboard_id, "Public access expired"); return Err(anyhow!("Public access to this dashboard has expired")); } } // Check if a password is required tracing::debug!(dashboard_id = %dashboard_id, has_password = dashboard_file.public_password.is_some(), "Checking password requirement"); if let Some(required_password) = &dashboard_file.public_password { tracing::debug!(dashboard_id = %dashboard_id, "Password required. Checking provided password."); match password { Some(provided_password) => { if provided_password != *required_password { // Incorrect password provided tracing::warn!(dashboard_id = %dashboard_id, user_id = %user.id, "Incorrect public password provided"); return Err(anyhow!("Incorrect password for public access")); } // Correct password provided, grant CanView via public access tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "Correct public password provided. Granting CanView."); permission = AssetPermissionRole::CanView; } None => { // Password required but none provided tracing::warn!(dashboard_id = %dashboard_id, user_id = %user.id, "Public password required but none provided"); return Err(anyhow!("public_password required for this dashboard")); } } } else { // Publicly accessible, not expired, and no password required tracing::debug!(dashboard_id = %dashboard_id, "Public access granted (no password required)."); permission = AssetPermissionRole::CanView; } } } } let mut conn = match get_pg_pool().get().await { Ok(conn) => conn, Err(e) => return Err(anyhow!("Failed to get database connection: {}", e)), }; // Declare variables for potentially versioned data let resolved_name: String; let resolved_description: Option; let resolved_content: Value; let resolved_version_num: i32; let resolved_updated_at: DateTime; // Determine which version's data to use if let Some(version) = version_number { // Get the specific version if it exists if let Some(v) = dashboard_file.version_history.get_version(version) { match &v.content { database::types::VersionContent::DashboardYml(content) => { resolved_content = content.to_value()?; resolved_version_num = v.version_number; resolved_updated_at = v.updated_at; // Extract name and description from the version's content resolved_name = resolved_content .get("name") .and_then(Value::as_str) .map(String::from) .unwrap_or_else(|| dashboard_file.name.clone()); // Fallback to main record name resolved_description = resolved_content .get("description") .and_then(Value::as_str) .map(String::from); } _ => return Err(anyhow!("Invalid version content type")), } } else { return Err(anyhow!("Version {} not found", version)); } } else { // Use current content from the main dashboard file record resolved_content = dashboard_file.content.to_value()?; resolved_version_num = dashboard_file .version_history .get_latest_version() .map(|v| v.version_number) .unwrap_or(1); resolved_updated_at = dashboard_file.updated_at; // Use main record's updated_at resolved_name = dashboard_file.name.clone(); // Use main record's name resolved_description = resolved_content .get("description") .and_then(Value::as_str) .map(String::from); }; // Parse the config from the resolved content let config = parse_dashboard_config(&resolved_content)?; // Collect all metric IDs from the rows let metric_ids: Vec = config .rows .iter() .flat_map(|row| { row.items .iter() .filter_map(|item| Uuid::parse_str(&item.id).ok()) }) .collect(); // Fetch metrics concurrently using get_metric_for_dashboard_handler with user context let mut metric_fetch_handles = Vec::new(); for metric_id in metric_ids { // Clone user for each spawned task let user_clone = user.clone(); let handle = tokio::spawn(async move { // Pass user context and None for version_number and password get_metric_for_dashboard_handler(&metric_id, &user_clone, None, None).await }); metric_fetch_handles.push((metric_id, handle)); } // Await all metric fetch tasks and collect results let metric_results = join_all( metric_fetch_handles .into_iter() .map(|(_, handle)| handle), ) .await; // Process results and build the metrics map let mut metrics = HashMap::new(); for result in metric_results { match result { Ok(Ok(metric)) => { // Successfully fetched metric metrics.insert(metric.id, metric); } Ok(Err(e)) => { // get_metric_handler returned an error // Log the error, but don't fail the entire dashboard load tracing::error!( "Failed to fetch metric for dashboard {}: {}", dashboard_id, e ); // Optionally, insert a placeholder or error metric into the map } Err(e) => { // Task join error (panic) tracing::error!( "Task join error fetching metric for dashboard {}: {}", dashboard_id, e ); } } } // Query individual permissions for this dashboard let individual_permissions_query = asset_permissions::table .inner_join(users::table.on(users::id.eq(asset_permissions::identity_id))) .filter(asset_permissions::asset_id.eq(dashboard_id)) .filter(asset_permissions::asset_type.eq(AssetType::DashboardFile)) .filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::deleted_at.is_null()) .select((asset_permissions::role, users::email, users::name, users::avatar_url)) .load::(&mut conn) .await; // Get the user info for publicly_enabled_by if it exists let public_enabled_by_user = if let Some(enabled_by_id) = dashboard_file.publicly_enabled_by { users::table .filter(users::id.eq(enabled_by_id)) .select(users::email) .first::(&mut conn) .await .ok() } else { None }; // Extract versions from version history let mut versions: Vec = dashboard_file .version_history .0 .values() .map(|v| Version { version_number: v.version_number, updated_at: v.updated_at, }) .collect(); // Sort versions by version_number in ascending order versions.sort_by(|a, b| a.version_number.cmp(&b.version_number)); // Convert AssetPermissionInfo to BusterShareIndividual let individual_permissions = match individual_permissions_query { Ok(permissions) => { if permissions.is_empty() { None } else { Some( permissions .into_iter() .map(|p| BusterShareIndividual { email: p.email, role: p.role, name: p.name, avatar_url: p.avatar_url, }) .collect::>() .into_iter() .sorted_by(|a, b| a.email.to_lowercase().cmp(&b.email.to_lowercase())) .collect(), ) } } Err(_) => None, }; // Clone dashboard_id and user_id for use in spawned task let d_id = *dashboard_id; let u_id = user.id; // Spawn task to fetch collections concurrently let collections_handle: JoinHandle>> = tokio::spawn( async move { fetch_associated_collections_for_dashboard(d_id, &u_id).await }, ); // Construct the dashboard using resolved values let dashboard = BusterDashboard { config, created_at: dashboard_file.created_at, created_by: dashboard_file.created_by, description: resolved_description, // Use resolved description id: dashboard_file.id, name: resolved_name, // Use resolved name updated_at: Some(resolved_updated_at), // Use resolved updated_at updated_by: dashboard_file.created_by, status: Verification::Verified, version_number: resolved_version_num, // Use resolved version number file: serde_yaml::to_string(&resolved_content)?, // Generate YAML from resolved content file_name: dashboard_file.file_name, }; // Await collections result let collections_result = collections_handle.await; // Handle collections result let collections = match collections_result { Ok(Ok(c)) => c, Ok(Err(e)) => { tracing::error!( "Failed to fetch associated collections for dashboard {}: {}", dashboard_id, e ); vec![] } Err(e) => { // JoinError tracing::error!( "Task join error fetching collections for dashboard {}: {}", dashboard_id, e ); vec![] } }; // Get workspace sharing enabled by email if set let workspace_sharing_enabled_by = if let Some(enabled_by_id) = dashboard_file.workspace_sharing_enabled_by { users::table .filter(users::id.eq(enabled_by_id)) .select(users::email) .first::(&mut conn) .await .ok() } else { None }; // Count workspace members let workspace_member_count = count_workspace_members(dashboard_file.organization_id) .await .unwrap_or(0); Ok(BusterDashboardResponse { access: permission, metrics, dashboard, permission, public_password: dashboard_file.public_password, collections, // Now populated with associated collections // New sharing fields individual_permissions, publicly_accessible: dashboard_file.publicly_accessible, public_expiry_date: dashboard_file.public_expiry_date, public_enabled_by: public_enabled_by_user, // Workspace sharing fields workspace_sharing: dashboard_file.workspace_sharing, workspace_sharing_enabled_by, workspace_sharing_enabled_at: dashboard_file.workspace_sharing_enabled_at, // Version information versions, // Workspace member count workspace_member_count, }) } fn parse_dashboard_config(content: &Value) -> Result { let rows = content .get("rows") .ok_or_else(|| anyhow!("Missing rows in dashboard content"))? .as_array() .ok_or_else(|| anyhow!("Rows is not an array"))? .iter() .enumerate() .map(|(index, row)| { let items = row .get("items") .ok_or_else(|| anyhow!("Missing items in row"))? .as_array() .ok_or_else(|| anyhow!("Items is not an array"))? .iter() .map(|item| { Ok(DashboardRowItem { id: item .get("id") .ok_or_else(|| anyhow!("Missing id in item"))? .as_str() .ok_or_else(|| anyhow!("Id is not a string"))? .to_string(), }) }) .collect::>>()?; // Extract column_sizes from the row if available let column_sizes = row.get("columnSizes").and_then(|sizes| { sizes.as_array().map(|arr| { arr.iter() .filter_map(|size| size.as_u64().map(|s| s as u32)) .collect::>() }) }); // Extract row_height from the row if available let row_height = row .get("rowHeight") .and_then(|height| height.as_u64().map(|h| h as u32)); Ok(DashboardRow { id: (index + 1).to_string(), items, row_height, column_sizes, }) }) .collect::>>()?; Ok(DashboardConfig { rows }) }