From 41ca18ea9851bf1e47a80e100f877c88be02e9c9 Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 22 Aug 2025 09:59:24 -0600 Subject: [PATCH 1/4] feat: add report files support to collections functionality - Create report_files.rs helper module with permission checking - Add ReportFile asset type handling in add_assets_to_collection_handler - Add Report variant to AssetToRemove enum for removal operations - Include report files in collection queries and responses - Follow existing patterns for MetricFile and DashboardFile --- apps/api/libs/database/src/helpers/mod.rs | 1 + .../libs/database/src/helpers/report_files.rs | 155 +++++++++++++++++ .../add_assets_to_collection_handler.rs | 158 ++++++++++++++++++ .../src/collections/get_collection_handler.rs | 49 +++++- .../remove_assets_from_collection_handler.rs | 70 ++++++++ 5 files changed, 429 insertions(+), 4 deletions(-) create mode 100644 apps/api/libs/database/src/helpers/report_files.rs diff --git a/apps/api/libs/database/src/helpers/mod.rs b/apps/api/libs/database/src/helpers/mod.rs index 373a58ab2..e4edfcdcc 100644 --- a/apps/api/libs/database/src/helpers/mod.rs +++ b/apps/api/libs/database/src/helpers/mod.rs @@ -1,6 +1,7 @@ pub mod collections; pub mod dashboard_files; pub mod metric_files; +pub mod report_files; pub mod chats; pub mod organization; pub mod test_utils; diff --git a/apps/api/libs/database/src/helpers/report_files.rs b/apps/api/libs/database/src/helpers/report_files.rs new file mode 100644 index 000000000..1994c8795 --- /dev/null +++ b/apps/api/libs/database/src/helpers/report_files.rs @@ -0,0 +1,155 @@ +use crate::enums::{AssetPermissionRole, AssetType}; +use anyhow::Result; +use diesel::JoinOnDsl; +use diesel::{BoolExpressionMethods, ExpressionMethods, QueryDsl, Queryable}; +use diesel_async::RunQueryDsl; +use tokio::try_join; +use uuid::Uuid; + +use crate::models::{AssetPermission, ReportFile}; +use crate::pool::get_pg_pool; +use crate::schema::{asset_permissions, collections_to_assets, report_files}; + +/// Fetches a single report file by ID that hasn't been deleted +/// +/// # Arguments +/// * `id` - The UUID of the report file to fetch +/// +/// # Returns +/// * `Result>` - The report file if found and not deleted +pub async fn fetch_report_file(id: &Uuid) -> Result> { + let mut conn = get_pg_pool().get().await?; + + let result = match report_files::table + .filter(report_files::id.eq(id)) + .filter(report_files::deleted_at.is_null()) + .first::(&mut conn) + .await + { + Ok(result) => Some(result), + Err(diesel::NotFound) => None, + Err(e) => return Err(e.into()), + }; + + Ok(result) +} + +/// Helper function to fetch a report file by ID +async fn fetch_report(id: &Uuid) -> Result> { + let mut conn = get_pg_pool().get().await?; + + let result = match report_files::table + .filter(report_files::id.eq(id)) + .filter(report_files::deleted_at.is_null()) + .first::(&mut conn) + .await + { + Ok(result) => Some(result), + Err(diesel::NotFound) => None, + Err(e) => return Err(e.into()), + }; + + Ok(result) +} + +/// Helper function to check if a report file is publicly accessible +async fn is_publicly_accessible(report_file: &ReportFile) -> bool { + // Check if the file is publicly accessible and either has no expiry date + // or the expiry date has not passed + report_file.publicly_accessible + && report_file + .public_expiry_date + .map_or(true, |expiry| expiry > chrono::Utc::now()) +} + +/// Helper function to fetch permission for a report file +async fn fetch_permission(id: &Uuid, user_id: &Uuid) -> Result> { + let mut conn = get_pg_pool().get().await?; + + let permission = match asset_permissions::table + .filter(asset_permissions::asset_id.eq(id)) + .filter(asset_permissions::asset_type.eq(AssetType::ReportFile)) + .filter(asset_permissions::identity_id.eq(user_id)) + .filter(asset_permissions::deleted_at.is_null()) + .first::(&mut conn) + .await + { + Ok(result) => Some(result.role), + Err(diesel::NotFound) => None, + Err(e) => return Err(e.into()), + }; + + Ok(permission) +} + +/// Helper function to fetch collection-based permissions for a report file +/// +/// Checks if the user has access to the report file through collections +/// by finding collections that contain this report file and checking +/// if the user has permissions on those collections +async fn fetch_collection_permissions_for_report( + id: &Uuid, + user_id: &Uuid, +) -> Result> { + let mut conn = get_pg_pool().get().await?; + + // Find collections containing this report file + // then join with asset_permissions to find user's permissions on those collections + let permissions = asset_permissions::table + .inner_join( + collections_to_assets::table.on(asset_permissions::asset_id + .eq(collections_to_assets::collection_id) + .and(asset_permissions::asset_type.eq(AssetType::Collection)) + .and(collections_to_assets::asset_id.eq(id)) + .and(collections_to_assets::asset_type.eq(AssetType::ReportFile)) + .and(collections_to_assets::deleted_at.is_null())), + ) + .filter(asset_permissions::identity_id.eq(user_id)) + .filter(asset_permissions::deleted_at.is_null()) + .select(asset_permissions::role) + .load::(&mut conn) + .await?; + + // Return any collection-based permission (no need to determine highest) + if permissions.is_empty() { + Ok(None) + } else { + // Just take the first one since any collection permission is sufficient + Ok(permissions.into_iter().next()) + } +} + +#[derive(Queryable)] +pub struct ReportFileWithPermission { + pub report_file: ReportFile, + pub permission: Option, +} + +pub async fn fetch_report_file_with_permission( + id: &Uuid, + user_id: &Uuid, +) -> Result> { + // Run all queries concurrently + let (report_file, direct_permission, collection_permission) = try_join!( + fetch_report(id), + fetch_permission(id, user_id), + fetch_collection_permissions_for_report(id, user_id) + )?; + + // If the report file doesn't exist, return None + let report_file = match report_file { + Some(file) => file, + None => return Ok(None), + }; + + // Determine effective permission (prioritizing collection over direct) + let effective_permission = match collection_permission { + Some(collection) => Some(collection), + None => direct_permission, + }; + + Ok(Some(ReportFileWithPermission { + report_file, + permission: effective_permission, + })) +} \ No newline at end of file diff --git a/apps/api/libs/handlers/src/collections/add_assets_to_collection_handler.rs b/apps/api/libs/handlers/src/collections/add_assets_to_collection_handler.rs index 321f501c1..6995607e5 100644 --- a/apps/api/libs/handlers/src/collections/add_assets_to_collection_handler.rs +++ b/apps/api/libs/handlers/src/collections/add_assets_to_collection_handler.rs @@ -4,6 +4,7 @@ use database::{ enums::{AssetPermissionRole, AssetType}, helpers::collections::fetch_collection_with_permission, metric_files::fetch_metric_file_with_permissions, + report_files::fetch_report_file_with_permission, chats::fetch_chat_with_permission, models::CollectionToAsset, pool::get_pg_pool, @@ -116,12 +117,14 @@ pub async fn add_assets_to_collection_handler( let mut dashboard_ids = Vec::new(); let mut metric_ids = Vec::new(); let mut chat_ids = Vec::new(); + let mut report_ids = Vec::new(); for asset in &assets { match asset.asset_type { AssetType::DashboardFile => dashboard_ids.push(asset.id), AssetType::MetricFile => metric_ids.push(asset.id), AssetType::Chat => chat_ids.push(asset.id), + AssetType::ReportFile => report_ids.push(asset.id), _ => { error!( asset_id = %asset.id, @@ -567,6 +570,161 @@ pub async fn add_assets_to_collection_handler( } } + // Process reports + if !report_ids.is_empty() { + for report_id in &report_ids { + // Check if report exists + let report = fetch_report_file_with_permission(&report_id, &user.id).await?; + + let report = if let Some(report) = report { + report + } else { + error!( + report_id = %report_id, + user_id = %user.id, + "Report not found" + ); + result.failed_count += 1; + result.failed_assets.push(( + *report_id, + AssetType::ReportFile, + "Report not found".to_string(), + )); + continue; + }; + + // Check if user has access to the report + let has_report_permission = check_permission_access( + report.permission, + &[ + AssetPermissionRole::CanView, + AssetPermissionRole::CanEdit, + AssetPermissionRole::FullAccess, + AssetPermissionRole::Owner, + ], + report.report_file.organization_id, + &user.organizations, + report.report_file.workspace_sharing, + ); + + if !has_report_permission { + error!( + report_id = %report_id, + user_id = %user.id, + "User does not have permission to access this report" + ); + result.failed_count += 1; + result.failed_assets.push(( + *report_id, + AssetType::ReportFile, + "Insufficient permissions".to_string(), + )); + continue; + } + + // Check if the report is already in the collection + let existing = match collections_to_assets::table + .filter(collections_to_assets::collection_id.eq(collection_id)) + .filter(collections_to_assets::asset_id.eq(report_id)) + .filter(collections_to_assets::asset_type.eq(AssetType::ReportFile)) + .first::(&mut conn) + .await + { + Ok(record) => Some(record), + Err(diesel::NotFound) => None, + Err(e) => { + error!("Error checking if report is already in collection: {}", e); + result.failed_count += 1; + result.failed_assets.push(( + *report_id, + AssetType::ReportFile, + format!("Database error: {}", e), + )); + continue; + } + }; + + if let Some(existing_record) = existing { + if existing_record.deleted_at.is_some() { + // If it was previously deleted, update it + match diesel::update(collections_to_assets::table) + .filter(collections_to_assets::collection_id.eq(collection_id)) + .filter(collections_to_assets::asset_id.eq(report_id)) + .filter(collections_to_assets::asset_type.eq(AssetType::ReportFile)) + .set(( + collections_to_assets::deleted_at + .eq::>>(None), + collections_to_assets::updated_at.eq(chrono::Utc::now()), + collections_to_assets::updated_by.eq(user.id), + )) + .execute(&mut conn) + .await + { + Ok(_) => { + result.added_count += 1; + } + Err(e) => { + error!( + collection_id = %collection_id, + report_id = %report_id, + "Error updating report in collection: {}", e + ); + result.failed_count += 1; + result.failed_assets.push(( + *report_id, + AssetType::ReportFile, + format!("Database error: {}", e), + )); + } + } + } else { + // Already in the collection + info!( + collection_id = %collection_id, + report_id = %report_id, + "Report already in collection" + ); + result.added_count += 1; + } + } else { + // Add to collection + let new_record = CollectionToAsset { + collection_id: *collection_id, + asset_id: *report_id, + asset_type: AssetType::ReportFile, + created_at: chrono::Utc::now(), + created_by: user.id, + updated_at: chrono::Utc::now(), + updated_by: user.id, + deleted_at: None, + }; + + match diesel::insert_into(collections_to_assets::table) + .values(&new_record) + .execute(&mut conn) + .await + { + Ok(_) => { + result.added_count += 1; + } + Err(e) => { + error!( + collection_id = %collection_id, + report_id = %report_id, + "Error adding report to collection: {}", e + ); + result.failed_count += 1; + result.failed_assets.push(( + *report_id, + AssetType::ReportFile, + format!("Database error: {}", e), + )); + } + } + } + } + } + Ok(result) } diff --git a/apps/api/libs/handlers/src/collections/get_collection_handler.rs b/apps/api/libs/handlers/src/collections/get_collection_handler.rs index 8fbc1a805..593ff7e9c 100644 --- a/apps/api/libs/handlers/src/collections/get_collection_handler.rs +++ b/apps/api/libs/handlers/src/collections/get_collection_handler.rs @@ -6,7 +6,7 @@ use database::{ pool::get_pg_pool, schema::{ asset_permissions, collections_to_assets, dashboard_files, metric_files, users, - chats, + chats, report_files, }, }; use diesel::{ExpressionMethods, JoinOnDsl, NullableExpressionMethods, QueryDsl, Queryable}; @@ -272,11 +272,39 @@ pub async fn get_collection_handler( } }); + let report_assets_handle = tokio::spawn({ + let pool = pool.clone(); + let req_id = req.id; + async move { + let mut conn = pool.get().await.map_err(anyhow::Error::from)?; + collections_to_assets::table + .inner_join(report_files::table.on(report_files::id.eq(collections_to_assets::asset_id))) + .left_join(users::table.on(users::id.eq(report_files::created_by))) + .filter(collections_to_assets::collection_id.eq(req_id)) + .filter(collections_to_assets::asset_type.eq(AssetType::ReportFile)) + .filter(collections_to_assets::deleted_at.is_null()) + .filter(report_files::deleted_at.is_null()) + .select(( + report_files::id, + report_files::name, + users::name.nullable(), + users::email.nullable(), + report_files::created_at, + report_files::updated_at, + collections_to_assets::asset_type, + )) + .load::(&mut conn) + .await + .map_err(anyhow::Error::from) + } + }); + // Await all tasks and handle results - let (metric_assets_result, dashboard_assets_result, chat_assets_result) = tokio::join!( + let (metric_assets_result, dashboard_assets_result, chat_assets_result, report_assets_result) = tokio::join!( metric_assets_handle, dashboard_assets_handle, - chat_assets_handle + chat_assets_handle, + report_assets_handle ); // Process metric assets @@ -318,10 +346,23 @@ pub async fn get_collection_handler( } }; + // Process report assets + let report_assets = match report_assets_result { + Ok(Ok(assets)) => assets, + Ok(Err(e)) => { + tracing::error!("Failed to fetch report assets: {}", e); + vec![] + } + Err(e) => { + tracing::error!("Report asset task failed: {}", e); + vec![] + } + }; + // println!("dashboard_assets: {:?}", dashboard_assets); // Keep or remove debug print? // Combine all assets - let all_assets = [metric_assets, dashboard_assets, chat_assets].concat(); // Add chat_assets + let all_assets = [metric_assets, dashboard_assets, chat_assets, report_assets].concat(); let formatted_assets = format_assets(all_assets); // Get workspace sharing enabled by email if set diff --git a/apps/api/libs/handlers/src/collections/remove_assets_from_collection_handler.rs b/apps/api/libs/handlers/src/collections/remove_assets_from_collection_handler.rs index 64f6ff45e..635a8d1e5 100644 --- a/apps/api/libs/handlers/src/collections/remove_assets_from_collection_handler.rs +++ b/apps/api/libs/handlers/src/collections/remove_assets_from_collection_handler.rs @@ -20,6 +20,8 @@ pub enum AssetToRemove { Dashboard(Uuid), /// Metric ID to remove Metric(Uuid), + /// Report ID to remove + Report(Uuid), } /// Result of removing assets from a collection @@ -246,6 +248,74 @@ pub async fn remove_assets_from_collection_handler( )); } } + AssetToRemove::Report(report_id) => { + // Check if the report is in the collection + let existing = match collections_to_assets::table + .filter(collections_to_assets::collection_id.eq(collection_id)) + .filter(collections_to_assets::asset_id.eq(report_id)) + .filter(collections_to_assets::asset_type.eq(AssetType::ReportFile)) + .filter(collections_to_assets::deleted_at.is_null()) + .first::(&mut conn) + .await + { + Ok(record) => Some(record), + Err(diesel::NotFound) => None, + Err(e) => { + error!( + "Error checking if report is in collection: {}", + e + ); + result.failed_count += 1; + result.failed_assets.push(( + report_id, + AssetType::ReportFile, + format!("Database error: {}", e), + )); + continue; + } + }; + + if let Some(existing_record) = existing { + // Soft delete the record + match diesel::update(collections_to_assets::table) + .filter(collections_to_assets::collection_id.eq(existing_record.collection_id)) + .filter(collections_to_assets::asset_id.eq(existing_record.asset_id)) + .filter(collections_to_assets::asset_type.eq(existing_record.asset_type)) + .set(( + collections_to_assets::deleted_at.eq(chrono::Utc::now()), + collections_to_assets::updated_at.eq(chrono::Utc::now()), + collections_to_assets::updated_by.eq(user.id), + )) + .execute(&mut conn) + .await + { + Ok(_) => { + result.removed_count += 1; + } + Err(e) => { + error!( + collection_id = %collection_id, + report_id = %report_id, + "Error removing report from collection: {}", e + ); + result.failed_count += 1; + result.failed_assets.push(( + report_id, + AssetType::ReportFile, + format!("Database error: {}", e), + )); + } + } + } else { + // Report is not in the collection, count as failed + result.failed_count += 1; + result.failed_assets.push(( + report_id, + AssetType::ReportFile, + "Report is not in the collection".to_string(), + )); + } + } } } From 7229faea25c044e7e3a4cac52f28ce590ff90f60 Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 22 Aug 2025 10:15:12 -0600 Subject: [PATCH 2/4] fix: add report type support to collection REST endpoints - Add 'report' case to add_assets_to_collection endpoint - Add 'report' case to remove_assets_from_collection endpoint - Update error response mappings to include ReportFile type --- .../routes/rest/routes/collections/add_assets_to_collection.rs | 2 ++ .../rest/routes/collections/remove_assets_from_collection.rs | 2 ++ 2 files changed, 4 insertions(+) diff --git a/apps/api/server/src/routes/rest/routes/collections/add_assets_to_collection.rs b/apps/api/server/src/routes/rest/routes/collections/add_assets_to_collection.rs index e13a5de7c..9184cecb0 100644 --- a/apps/api/server/src/routes/rest/routes/collections/add_assets_to_collection.rs +++ b/apps/api/server/src/routes/rest/routes/collections/add_assets_to_collection.rs @@ -66,6 +66,7 @@ pub async fn add_assets_to_collection( let asset_type = match asset.type_.to_lowercase().as_str() { "dashboard" => Some(AssetType::DashboardFile), "metric" => Some(AssetType::MetricFile), + "report" => Some(AssetType::ReportFile), _ => None, }; @@ -81,6 +82,7 @@ pub async fn add_assets_to_collection( let type_str = match asset_type { AssetType::DashboardFile => "dashboard", AssetType::MetricFile => "metric", + AssetType::ReportFile => "report", _ => "unknown", }; diff --git a/apps/api/server/src/routes/rest/routes/collections/remove_assets_from_collection.rs b/apps/api/server/src/routes/rest/routes/collections/remove_assets_from_collection.rs index e257d9995..dd10a61e2 100644 --- a/apps/api/server/src/routes/rest/routes/collections/remove_assets_from_collection.rs +++ b/apps/api/server/src/routes/rest/routes/collections/remove_assets_from_collection.rs @@ -67,6 +67,7 @@ pub async fn remove_assets_from_collection( match asset.type_.to_lowercase().as_str() { "dashboard" => Some(AssetToRemove::Dashboard(asset.id)), "metric" => Some(AssetToRemove::Metric(asset.id)), + "report" => Some(AssetToRemove::Report(asset.id)), _ => None, } }).collect(); @@ -77,6 +78,7 @@ pub async fn remove_assets_from_collection( let type_str = match asset_type { AssetType::DashboardFile => "dashboard", AssetType::MetricFile => "metric", + AssetType::ReportFile => "report", _ => "unknown", }; From 49e072979c6724cfad81eea53c127bc8d96b4d3e Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 22 Aug 2025 10:20:33 -0600 Subject: [PATCH 3/4] fix: add ReportContent variant to VersionContent enum - Add ReportContent(String) variant to handle report version history - Reports store content as plain strings unlike metrics/dashboards which use YAML - Fixes deserialization error when fetching reports with version history --- apps/api/libs/database/src/types/version_history.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/api/libs/database/src/types/version_history.rs b/apps/api/libs/database/src/types/version_history.rs index d62761d2f..52f68f992 100644 --- a/apps/api/libs/database/src/types/version_history.rs +++ b/apps/api/libs/database/src/types/version_history.rs @@ -35,6 +35,8 @@ pub enum VersionContent { MetricYml(Box), #[serde(alias = "dashboard_yml")] DashboardYml(DashboardYml), + // For report files that store content as a plain string + ReportContent(String), } impl From for VersionContent { From d308b20c3e94f86e2bb453e4c50758cd670de875 Mon Sep 17 00:00:00 2001 From: dal Date: Fri, 22 Aug 2025 10:39:22 -0600 Subject: [PATCH 4/4] fix: enable report navigation and display in collections - Fix report route to use APP_REPORTS_ID instead of APP_REPORT_ID - Add report icon to CollectionIconRecord - Add report case to createAssetLink function for proper navigation --- .../CollectionIndividualContent.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx b/apps/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx index 93e569d06..aa26c5795 100644 --- a/apps/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx +++ b/apps/web/src/controllers/CollectionIndividualController/CollectionIndividualContent.tsx @@ -158,7 +158,8 @@ CollectionList.displayName = 'CollectionList'; const CollectionIconRecord: Record = { dashboard: , - metric: + metric: , + report: }; const createAssetLink = (asset: BusterCollectionItemAsset, collectionId: string) => { @@ -176,6 +177,13 @@ const createAssetLink = (asset: BusterCollectionItemAsset, collectionId: string) }); } + if (asset.asset_type === 'report') { + return createBusterRoute({ + route: BusterRoutes.APP_REPORTS_ID, + reportId: asset.id + }); + } + if (asset.asset_type === 'collection') { return createBusterRoute({ route: BusterRoutes.APP_COLLECTIONS