diff --git a/.github/workflows/cli-testing.yml b/.github/workflows/cli-testing.yml index 7608eabff..b5a603d7b 100644 --- a/.github/workflows/cli-testing.yml +++ b/.github/workflows/cli-testing.yml @@ -31,8 +31,8 @@ jobs: - name: Build and Load API Docker Image uses: useblacksmith/build-push-action@v1 with: - context: ./api - file: ./api/Dockerfile + context: ./apps/api + file: ./apps/api/Dockerfile push: false # Do not push, load locally for service container load: true # Load the image into the runner's Docker daemon tags: local-api-test:latest # Tag for the service definition diff --git a/.github/workflows/docker-release.yml b/.github/workflows/docker-release.yml index 85f346aea..315a04697 100644 --- a/.github/workflows/docker-release.yml +++ b/.github/workflows/docker-release.yml @@ -103,8 +103,8 @@ jobs: - name: Build and push API image uses: useblacksmith/build-push-action@v1 with: - context: ./api - file: ./api/Dockerfile + context: ./apps/api + file: ./apps/api/Dockerfile push: true platforms: ${{ matrix.docker_platform }} tags: | @@ -199,4 +199,4 @@ jobs: else echo "Failed to set package $ORG_NAME/${{ env.WEB_IMAGE_NAME }} visibility to public. HTTP Status: $RESPONSE_CODE" # Optionally, fail the step: exit 1 - fi \ No newline at end of file + fi diff --git a/.github/workflows/web-e2e-tests-optimized.yml b/.github/workflows/web-e2e-tests-optimized.yml index cfdeea73c..e7885d6f7 100644 --- a/.github/workflows/web-e2e-tests-optimized.yml +++ b/.github/workflows/web-e2e-tests-optimized.yml @@ -69,8 +69,8 @@ jobs: - name: Build & Load API Docker Image uses: useblacksmith/build-push-action@v1 with: - context: ./api - file: ./api/Dockerfile + context: ./apps/api + file: ./apps/api/Dockerfile push: false load: true tags: local-api-test:latest @@ -199,4 +199,4 @@ jobs: if: always() run: | docker stop local-api - docker rm local-api \ No newline at end of file + docker rm local-api diff --git a/apps/api/libs/handlers/src/chats/get_chat_handler.rs b/apps/api/libs/handlers/src/chats/get_chat_handler.rs index 38d6f5435..e53b2beef 100644 --- a/apps/api/libs/handlers/src/chats/get_chat_handler.rs +++ b/apps/api/libs/handlers/src/chats/get_chat_handler.rs @@ -53,6 +53,7 @@ struct AssetPermissionInfo { role: AssetPermissionRole, email: String, name: Option, + avatar_url: Option, } pub async fn get_chat_handler( @@ -154,7 +155,7 @@ pub async fn get_chat_handler( .filter(asset_permissions::asset_type.eq(AssetType::Chat)) .filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::deleted_at.is_null()) - .select((asset_permissions::role, users::email, users::name)) + .select((asset_permissions::role, users::email, users::name, users::avatar_url)) .load::(&mut conn) .await; @@ -302,6 +303,7 @@ pub async fn get_chat_handler( email: p.email, role: p.role, name: p.name, + avatar_url: p.avatar_url, }) .collect::>(), ) diff --git a/apps/api/libs/handlers/src/chats/list_chats_handler.rs b/apps/api/libs/handlers/src/chats/list_chats_handler.rs index 5701d6192..a0fdd824d 100644 --- a/apps/api/libs/handlers/src/chats/list_chats_handler.rs +++ b/apps/api/libs/handlers/src/chats/list_chats_handler.rs @@ -78,7 +78,7 @@ pub async fn list_chats_handler( request: ListChatsRequest, user: &AuthenticatedUser, ) -> Result> { - use database::schema::{asset_permissions, chats, users}; + use database::schema::{asset_permissions, chats, messages, users}; let mut conn = get_pg_pool().get().await?; @@ -106,6 +106,18 @@ pub async fn list_chats_handler( .inner_join(users::table.on(chats::created_by.eq(users::id))) .filter(chats::deleted_at.is_null()) .filter(chats::title.ne("")) // Filter out empty titles + .filter( + diesel::dsl::exists( + messages::table + .filter(messages::chat_id.eq(chats::id)) + .filter(messages::request_message.is_not_null()) + .filter(messages::deleted_at.is_null()) + ).or( + diesel::dsl::sql::( + "(SELECT COUNT(*) FROM messages WHERE messages.chat_id = chats.id AND messages.deleted_at IS NULL) > 1" + ) + ) + ) .into_boxed(); // Add user filter if not admin view @@ -173,4 +185,4 @@ pub async fn list_chats_handler( }; Ok(items) -} \ No newline at end of file +} diff --git a/apps/api/libs/handlers/src/chats/types.rs b/apps/api/libs/handlers/src/chats/types.rs index 0e64bc5d5..2af48d536 100644 --- a/apps/api/libs/handlers/src/chats/types.rs +++ b/apps/api/libs/handlers/src/chats/types.rs @@ -12,6 +12,7 @@ pub struct BusterShareIndividual { pub email: String, pub role: AssetPermissionRole, pub name: Option, + pub avatar_url: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] 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 251b38927..4d31a0b57 100644 --- a/apps/api/libs/handlers/src/collections/get_collection_handler.rs +++ b/apps/api/libs/handlers/src/collections/get_collection_handler.rs @@ -25,6 +25,7 @@ struct AssetPermissionInfo { role: AssetPermissionRole, email: String, name: Option, + avatar_url: Option, } /// Type for querying asset data from database @@ -128,6 +129,7 @@ pub async fn get_collection_handler( asset_permissions::role, users::email, users::name, + users::avatar_url, )) .load::(&mut conn) .await; @@ -150,6 +152,7 @@ pub async fn get_collection_handler( email: p.email, role: p.role, name: p.name, + avatar_url: p.avatar_url, }) .collect::>(), ) diff --git a/apps/api/libs/handlers/src/collections/types.rs b/apps/api/libs/handlers/src/collections/types.rs index 84a18844a..5c57276fd 100644 --- a/apps/api/libs/handlers/src/collections/types.rs +++ b/apps/api/libs/handlers/src/collections/types.rs @@ -12,6 +12,7 @@ pub struct BusterShareIndividual { pub email: String, pub role: AssetPermissionRole, pub name: Option, + pub avatar_url: Option, } // List collections types diff --git a/apps/api/libs/handlers/src/dashboards/get_dashboard_handler.rs b/apps/api/libs/handlers/src/dashboards/get_dashboard_handler.rs index 074b85802..cfdf090ad 100644 --- a/apps/api/libs/handlers/src/dashboards/get_dashboard_handler.rs +++ b/apps/api/libs/handlers/src/dashboards/get_dashboard_handler.rs @@ -52,6 +52,7 @@ struct AssetPermissionInfo { role: AssetPermissionRole, email: String, name: Option, + avatar_url: Option, } /// Fetches collections that the dashboard belongs to, filtered by user permissions @@ -147,48 +148,61 @@ pub async fn get_dashboard_handler( tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, ?permission, "Granting access via direct permission."); } } else { - // No sufficient direct/admin permission, check public access rules - tracing::debug!(dashboard_id = %dashboard_id, "Insufficient direct/admin permission. Checking public access rules."); - if !dashboard_file.publicly_accessible { - tracing::warn!(dashboard_id = %dashboard_id, user_id = %user.id, "Permission denied (not public, 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)."); + // No sufficient direct/admin permission, check if user has access via a chat + tracing::debug!(dashboard_id = %dashboard_id, "Insufficient direct/admin permission. Checking chat access."); + + let has_chat_access = sharing::check_dashboard_chat_access(dashboard_id, &user.id) + .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 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; + } } } @@ -315,7 +329,7 @@ pub async fn get_dashboard_handler( .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)) + .select((asset_permissions::role, users::email, users::name, users::avatar_url)) .load::(&mut conn) .await; @@ -358,6 +372,7 @@ pub async fn get_dashboard_handler( email: p.email, role: p.role, name: p.name, + avatar_url: p.avatar_url, }) .collect::>(), ) diff --git a/apps/api/libs/handlers/src/dashboards/types.rs b/apps/api/libs/handlers/src/dashboards/types.rs index 2cc6be98c..9908ae959 100644 --- a/apps/api/libs/handlers/src/dashboards/types.rs +++ b/apps/api/libs/handlers/src/dashboards/types.rs @@ -31,6 +31,7 @@ pub struct BusterShareIndividual { pub email: String, pub role: AssetPermissionRole, pub name: Option, + pub avatar_url: Option, } // Note: This extends BusterShare which needs to be defined diff --git a/apps/api/libs/handlers/src/logs/list_logs_handler.rs b/apps/api/libs/handlers/src/logs/list_logs_handler.rs index 4ce45bf8e..09d65fabe 100644 --- a/apps/api/libs/handlers/src/logs/list_logs_handler.rs +++ b/apps/api/libs/handlers/src/logs/list_logs_handler.rs @@ -69,7 +69,7 @@ pub async fn list_logs_handler( request: ListLogsRequest, organization_id: Uuid, ) -> Result { - use database::schema::{chats, users}; + use database::schema::{chats, messages, users}; let mut conn = get_pg_pool().get().await?; @@ -80,6 +80,18 @@ pub async fn list_logs_handler( .filter(chats::organization_id.eq(organization_id)) .filter(chats::title.ne("")) // Filter out empty titles .filter(chats::title.ne(" ")) // Filter out single space + .filter( + diesel::dsl::exists( + messages::table + .filter(messages::chat_id.eq(chats::id)) + .filter(messages::request_message.is_not_null()) + .filter(messages::deleted_at.is_null()) + ).or( + diesel::dsl::sql::( + "(SELECT COUNT(*) FROM messages WHERE messages.chat_id = chats.id AND messages.deleted_at IS NULL) > 1" + ) + ) + ) .into_boxed(); // Calculate offset based on page number diff --git a/apps/api/libs/handlers/src/metrics/get_metric_data_handler.rs b/apps/api/libs/handlers/src/metrics/get_metric_data_handler.rs index 47f5268e4..f7bb0ecdc 100644 --- a/apps/api/libs/handlers/src/metrics/get_metric_data_handler.rs +++ b/apps/api/libs/handlers/src/metrics/get_metric_data_handler.rs @@ -100,9 +100,40 @@ pub async fn get_metric_data_handler( } } } else { - // No dashboard access, return the original permission error - tracing::warn!("No dashboard association found for metric. Returning original error."); - return Err(e); + // No dashboard access, check if user has access via a chat + tracing::info!("No dashboard association found. Checking chat access."); + let has_chat_access = sharing::check_metric_chat_access(&request.metric_id, &user.id) + .await + .unwrap_or(false); + + if has_chat_access { + // User has access to a chat containing this metric + tracing::info!("Found associated chat with user access. Fetching metric with chat context."); + match get_metric_for_dashboard_handler( + &request.metric_id, + &user, + request.version_number, + request.password.clone(), + ) + .await + { + Ok(metric_via_chat) => { + tracing::debug!( + "Successfully retrieved metric via chat association." + ); + metric_via_chat // Use this metric definition + } + Err(fetch_err) => { + // If fetching via chat fails unexpectedly, return that error + tracing::error!("Failed to fetch metric via chat context: {}", fetch_err); + return Err(fetch_err); + } + } + } else { + // No dashboard or chat access, return the original permission error + tracing::warn!("No dashboard or chat association found for metric. Returning original error."); + return Err(e); + } } } else { // Error was not permission-related, return original error diff --git a/apps/api/libs/handlers/src/metrics/get_metric_for_dashboard_handler.rs b/apps/api/libs/handlers/src/metrics/get_metric_for_dashboard_handler.rs index 27df1131b..7907958cf 100644 --- a/apps/api/libs/handlers/src/metrics/get_metric_for_dashboard_handler.rs +++ b/apps/api/libs/handlers/src/metrics/get_metric_for_dashboard_handler.rs @@ -32,6 +32,7 @@ struct AssetPermissionInfo { role: AssetPermissionRole, email: String, name: Option, + avatar_url: Option, } /// Fetch the dashboards associated with the given metric id, filtered by user permissions @@ -169,48 +170,61 @@ pub async fn get_metric_for_dashboard_handler( tracing::debug!(metric_id = %metric_id, user_id = %user.id, "User has access via dashboard. Granting CanView."); permission = AssetPermissionRole::CanView; } else { - // No dashboard access, check public access rules - tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking public access rules."); - if !metric_file.publicly_accessible { - tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Permission denied (not public, no dashboard access, insufficient direct permission)."); - return Err(anyhow!("You don't have permission to view this metric")); - } - tracing::debug!(metric_id = %metric_id, "Metric is publicly accessible."); - - // Check if the public access has expired - if let Some(expiry_date) = metric_file.public_expiry_date { - tracing::debug!(metric_id = %metric_id, ?expiry_date, "Checking expiry date"); - if expiry_date < chrono::Utc::now() { - tracing::warn!(metric_id = %metric_id, "Public access expired"); - return Err(anyhow!("Public access to this metric has expired")); - } - } - - // Check if a password is required - tracing::debug!(metric_id = %metric_id, has_password = metric_file.public_password.is_some(), "Checking password requirement"); - if let Some(required_password) = &metric_file.public_password { - tracing::debug!(metric_id = %metric_id, "Password required. Checking provided password."); - match password { - Some(provided_password) => { - if provided_password != *required_password { - // Incorrect password provided - tracing::warn!(metric_id = %metric_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!(metric_id = %metric_id, user_id = %user.id, "Correct public password provided. Granting CanView."); - permission = AssetPermissionRole::CanView; - } - None => { - // Password required but none provided - tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Public password required but none provided"); - return Err(anyhow!("public_password required for this metric")); - } - } - } else { - // Publicly accessible, not expired, and no password required - tracing::debug!(metric_id = %metric_id, "Public access granted (no password required)."); + // No dashboard access, check if user has access via a chat + tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking chat access."); + + let has_chat_access = sharing::check_metric_chat_access(metric_id, &user.id) + .await + .unwrap_or(false); + + if has_chat_access { + // User has access to a chat containing this metric, grant CanView + tracing::debug!(metric_id = %metric_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!(metric_id = %metric_id, "No chat access. Checking public access rules."); + if !metric_file.publicly_accessible { + tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Permission denied (not public, no dashboard/chat access, insufficient direct permission)."); + return Err(anyhow!("You don't have permission to view this metric")); + } + tracing::debug!(metric_id = %metric_id, "Metric is publicly accessible."); + + // Check if the public access has expired + if let Some(expiry_date) = metric_file.public_expiry_date { + tracing::debug!(metric_id = %metric_id, ?expiry_date, "Checking expiry date"); + if expiry_date < chrono::Utc::now() { + tracing::warn!(metric_id = %metric_id, "Public access expired"); + return Err(anyhow!("Public access to this metric has expired")); + } + } + + // Check if a password is required + tracing::debug!(metric_id = %metric_id, has_password = metric_file.public_password.is_some(), "Checking password requirement"); + if let Some(required_password) = &metric_file.public_password { + tracing::debug!(metric_id = %metric_id, "Password required. Checking provided password."); + match password { + Some(provided_password) => { + if provided_password != *required_password { + // Incorrect password provided + tracing::warn!(metric_id = %metric_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!(metric_id = %metric_id, user_id = %user.id, "Correct public password provided. Granting CanView."); + permission = AssetPermissionRole::CanView; + } + None => { + // Password required but none provided + tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Public password required but none provided"); + return Err(anyhow!("public_password required for this metric")); + } + } + } else { + // Publicly accessible, not expired, and no password required + tracing::debug!(metric_id = %metric_id, "Public access granted (no password required)."); + permission = AssetPermissionRole::CanView; + } } } } @@ -335,7 +349,7 @@ pub async fn get_metric_for_dashboard_handler( .filter(asset_permissions::asset_type.eq(AssetType::MetricFile)) .filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::deleted_at.is_null()) - .select((asset_permissions::role, users::email, users::name)) + .select((asset_permissions::role, users::email, users::name, users::avatar_url)) .load::(&mut conn) .await; @@ -399,6 +413,7 @@ pub async fn get_metric_for_dashboard_handler( email: p.email, role: p.role, name: p.name, + avatar_url: p.avatar_url, }) .collect::>(), ) diff --git a/apps/api/libs/handlers/src/metrics/get_metric_handler.rs b/apps/api/libs/handlers/src/metrics/get_metric_handler.rs index 855a4dd3a..cece06458 100644 --- a/apps/api/libs/handlers/src/metrics/get_metric_handler.rs +++ b/apps/api/libs/handlers/src/metrics/get_metric_handler.rs @@ -30,6 +30,7 @@ struct AssetPermissionInfo { role: AssetPermissionRole, email: String, name: Option, + avatar_url: Option, } /// Fetch the dashboards associated with the given metric id, filtered by user permissions @@ -167,48 +168,61 @@ pub async fn get_metric_handler( tracing::debug!(metric_id = %metric_id, user_id = %user.id, "User has access via dashboard. Granting CanView."); permission = AssetPermissionRole::CanView; } else { - // No dashboard access, check public access rules - tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking public access rules."); - if !metric_file.publicly_accessible { - tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Permission denied (not public, no dashboard access, insufficient direct permission)."); - return Err(anyhow!("You don't have permission to view this metric")); - } - tracing::debug!(metric_id = %metric_id, "Metric is publicly accessible."); - - // Check if the public access has expired - if let Some(expiry_date) = metric_file.public_expiry_date { - tracing::debug!(metric_id = %metric_id, ?expiry_date, "Checking expiry date"); - if expiry_date < chrono::Utc::now() { - tracing::warn!(metric_id = %metric_id, "Public access expired"); - return Err(anyhow!("Public access to this metric has expired")); - } - } - - // Check if a password is required - tracing::debug!(metric_id = %metric_id, has_password = metric_file.public_password.is_some(), "Checking password requirement"); - if let Some(required_password) = &metric_file.public_password { - tracing::debug!(metric_id = %metric_id, "Password required. Checking provided password."); - match password { - Some(provided_password) => { - if provided_password != *required_password { - // Incorrect password provided - tracing::warn!(metric_id = %metric_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!(metric_id = %metric_id, user_id = %user.id, "Correct public password provided. Granting CanView."); - permission = AssetPermissionRole::CanView; - } - None => { - // Password required but none provided - tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Public password required but none provided"); - return Err(anyhow!("public_password required for this metric")); - } - } - } else { - // Publicly accessible, not expired, and no password required - tracing::debug!(metric_id = %metric_id, "Public access granted (no password required)."); + // No dashboard access, check if user has access via a chat + tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking chat access."); + + let has_chat_access = sharing::check_metric_chat_access(metric_id, &user.id) + .await + .unwrap_or(false); + + if has_chat_access { + // User has access to a chat containing this metric, grant CanView + tracing::debug!(metric_id = %metric_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!(metric_id = %metric_id, "No chat access. Checking public access rules."); + if !metric_file.publicly_accessible { + tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Permission denied (not public, no dashboard/chat access, insufficient direct permission)."); + return Err(anyhow!("You don't have permission to view this metric")); + } + tracing::debug!(metric_id = %metric_id, "Metric is publicly accessible."); + + // Check if the public access has expired + if let Some(expiry_date) = metric_file.public_expiry_date { + tracing::debug!(metric_id = %metric_id, ?expiry_date, "Checking expiry date"); + if expiry_date < chrono::Utc::now() { + tracing::warn!(metric_id = %metric_id, "Public access expired"); + return Err(anyhow!("Public access to this metric has expired")); + } + } + + // Check if a password is required + tracing::debug!(metric_id = %metric_id, has_password = metric_file.public_password.is_some(), "Checking password requirement"); + if let Some(required_password) = &metric_file.public_password { + tracing::debug!(metric_id = %metric_id, "Password required. Checking provided password."); + match password { + Some(provided_password) => { + if provided_password != *required_password { + // Incorrect password provided + tracing::warn!(metric_id = %metric_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!(metric_id = %metric_id, user_id = %user.id, "Correct public password provided. Granting CanView."); + permission = AssetPermissionRole::CanView; + } + None => { + // Password required but none provided + tracing::warn!(metric_id = %metric_id, user_id = %user.id, "Public password required but none provided"); + return Err(anyhow!("public_password required for this metric")); + } + } + } else { + // Publicly accessible, not expired, and no password required + tracing::debug!(metric_id = %metric_id, "Public access granted (no password required)."); + permission = AssetPermissionRole::CanView; + } } } } @@ -361,7 +375,7 @@ pub async fn get_metric_handler( .filter(asset_permissions::asset_type.eq(AssetType::MetricFile)) .filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::deleted_at.is_null()) - .select((asset_permissions::role, users::email, users::name)) + .select((asset_permissions::role, users::email, users::name, users::avatar_url)) .load::(&mut conn) .await; @@ -425,6 +439,7 @@ pub async fn get_metric_handler( email: p.email, role: p.role, name: p.name, + avatar_url: p.avatar_url, }) .collect::>(), ) diff --git a/apps/api/libs/handlers/src/metrics/types.rs b/apps/api/libs/handlers/src/metrics/types.rs index 7c6b2ec60..ae313c527 100644 --- a/apps/api/libs/handlers/src/metrics/types.rs +++ b/apps/api/libs/handlers/src/metrics/types.rs @@ -23,6 +23,7 @@ pub struct BusterShareIndividual { pub email: String, pub role: AssetPermissionRole, pub name: Option, + pub avatar_url: Option, } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/apps/api/libs/sharing/src/asset_access_checks.rs b/apps/api/libs/sharing/src/asset_access_checks.rs index a5a662a4c..2d34ab0e2 100644 --- a/apps/api/libs/sharing/src/asset_access_checks.rs +++ b/apps/api/libs/sharing/src/asset_access_checks.rs @@ -117,6 +117,172 @@ pub async fn check_metric_dashboard_access( Ok(has_public_access.is_some()) } +/// Checks if a user has access to a metric through any associated chat. +/// +/// This function is used to implement permission cascading from chats to metrics. +/// If a user has access to any chat containing the metric, they get at least CanView permission. +/// +/// # Arguments +/// * `metric_id` - UUID of the metric to check +/// * `user_id` - UUID of the user to check permissions for +/// +/// # Returns +/// * `Result` - True if the user has access to any chat containing the metric, false otherwise +pub async fn check_metric_chat_access( + metric_id: &Uuid, + user_id: &Uuid, +) -> Result { + let mut conn = get_pg_pool().get().await.map_err(|e| { + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UnableToSendCommand, + Box::new(e.to_string()), + ) + })?; + + // Check if user has access to any chat containing this metric + let has_chat_access = database::schema::messages_to_files::table + .inner_join( + database::schema::messages::table + .on(database::schema::messages::id.eq(database::schema::messages_to_files::message_id)), + ) + .inner_join( + database::schema::chats::table + .on(database::schema::chats::id.eq(database::schema::messages::chat_id)), + ) + .inner_join( + asset_permissions::table.on( + asset_permissions::asset_id.eq(database::schema::chats::id) + .and(asset_permissions::asset_type.eq(AssetType::Chat)) + .and(asset_permissions::identity_id.eq(user_id)) + .and(asset_permissions::identity_type.eq(IdentityType::User)) + .and(asset_permissions::deleted_at.is_null()) + ), + ) + .filter(database::schema::messages_to_files::file_id.eq(metric_id)) + .filter(database::schema::messages_to_files::deleted_at.is_null()) + .filter(database::schema::messages::deleted_at.is_null()) + .filter(database::schema::chats::deleted_at.is_null()) + .select(database::schema::chats::id) + .first::(&mut conn) + .await + .optional()?; + + if has_chat_access.is_some() { + return Ok(true); + } + + // Now check if metric belongs to any PUBLIC chat + let now = chrono::Utc::now(); + let has_public_chat_access = database::schema::messages_to_files::table + .inner_join( + database::schema::messages::table + .on(database::schema::messages::id.eq(database::schema::messages_to_files::message_id)), + ) + .inner_join( + database::schema::chats::table + .on(database::schema::chats::id.eq(database::schema::messages::chat_id)), + ) + .filter(database::schema::messages_to_files::file_id.eq(metric_id)) + .filter(database::schema::messages_to_files::deleted_at.is_null()) + .filter(database::schema::messages::deleted_at.is_null()) + .filter(database::schema::chats::deleted_at.is_null()) + .filter(database::schema::chats::publicly_accessible.eq(true)) + .filter( + database::schema::chats::public_expiry_date + .is_null() + .or(database::schema::chats::public_expiry_date.gt(now)), + ) + .select(database::schema::chats::id) + .first::(&mut conn) + .await + .optional()?; + + Ok(has_public_chat_access.is_some()) +} + +/// Checks if a user has access to a dashboard through any associated chat. +/// +/// This function is used to implement permission cascading from chats to dashboards. +/// If a user has access to any chat containing the dashboard, they get at least CanView permission. +/// +/// # Arguments +/// * `dashboard_id` - UUID of the dashboard to check +/// * `user_id` - UUID of the user to check permissions for +/// +/// # Returns +/// * `Result` - True if the user has access to any chat containing the dashboard, false otherwise +pub async fn check_dashboard_chat_access( + dashboard_id: &Uuid, + user_id: &Uuid, +) -> Result { + let mut conn = get_pg_pool().get().await.map_err(|e| { + diesel::result::Error::DatabaseError( + diesel::result::DatabaseErrorKind::UnableToSendCommand, + Box::new(e.to_string()), + ) + })?; + + // Check if user has access to any chat containing this dashboard + let has_chat_access = database::schema::messages_to_files::table + .inner_join( + database::schema::messages::table + .on(database::schema::messages::id.eq(database::schema::messages_to_files::message_id)), + ) + .inner_join( + database::schema::chats::table + .on(database::schema::chats::id.eq(database::schema::messages::chat_id)), + ) + .inner_join( + asset_permissions::table.on( + asset_permissions::asset_id.eq(database::schema::chats::id) + .and(asset_permissions::asset_type.eq(AssetType::Chat)) + .and(asset_permissions::identity_id.eq(user_id)) + .and(asset_permissions::identity_type.eq(IdentityType::User)) + .and(asset_permissions::deleted_at.is_null()) + ), + ) + .filter(database::schema::messages_to_files::file_id.eq(dashboard_id)) + .filter(database::schema::messages_to_files::deleted_at.is_null()) + .filter(database::schema::messages::deleted_at.is_null()) + .filter(database::schema::chats::deleted_at.is_null()) + .select(database::schema::chats::id) + .first::(&mut conn) + .await + .optional()?; + + if has_chat_access.is_some() { + return Ok(true); + } + + // Now check if dashboard belongs to any PUBLIC chat + let now = chrono::Utc::now(); + let has_public_chat_access = database::schema::messages_to_files::table + .inner_join( + database::schema::messages::table + .on(database::schema::messages::id.eq(database::schema::messages_to_files::message_id)), + ) + .inner_join( + database::schema::chats::table + .on(database::schema::chats::id.eq(database::schema::messages::chat_id)), + ) + .filter(database::schema::messages_to_files::file_id.eq(dashboard_id)) + .filter(database::schema::messages_to_files::deleted_at.is_null()) + .filter(database::schema::messages::deleted_at.is_null()) + .filter(database::schema::chats::deleted_at.is_null()) + .filter(database::schema::chats::publicly_accessible.eq(true)) + .filter( + database::schema::chats::public_expiry_date + .is_null() + .or(database::schema::chats::public_expiry_date.gt(now)), + ) + .select(database::schema::chats::id) + .first::(&mut conn) + .await + .optional()?; + + Ok(has_public_chat_access.is_some()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/apps/api/libs/sharing/src/lib.rs b/apps/api/libs/sharing/src/lib.rs index 6658b9ba4..ec086d512 100644 --- a/apps/api/libs/sharing/src/lib.rs +++ b/apps/api/libs/sharing/src/lib.rs @@ -19,4 +19,4 @@ pub use types::{ SerializableAssetPermission, UserInfo, }; pub use user_lookup::find_user_by_email; -pub use asset_access_checks::{check_permission_access, check_metric_dashboard_access}; +pub use asset_access_checks::{check_permission_access, check_metric_dashboard_access, check_metric_chat_access, check_dashboard_chat_access}; diff --git a/apps/web/src/api/asset_interfaces/shared_interfaces/shareInterfaces.ts b/apps/web/src/api/asset_interfaces/shared_interfaces/shareInterfaces.ts index e0c2c1764..cb3b492d0 100644 --- a/apps/web/src/api/asset_interfaces/shared_interfaces/shareInterfaces.ts +++ b/apps/web/src/api/asset_interfaces/shared_interfaces/shareInterfaces.ts @@ -1,17 +1,5 @@ import type { ShareRole } from '@buster/server-shared/share'; -/** - * Type defining the sharing permissions and settings for a dashboard - * - * @interface ShareRequest - */ -export type SharePostRequest = { - email: string; - role: ShareRole; - avatar_url?: string | null; - name?: string | undefined; -}[]; - export type ShareDeleteRequest = string[]; export type ShareUpdateRequest = { diff --git a/apps/web/src/api/buster_rest/collections/requests.ts b/apps/web/src/api/buster_rest/collections/requests.ts index f727cd95c..83dcf65d0 100644 --- a/apps/web/src/api/buster_rest/collections/requests.ts +++ b/apps/web/src/api/buster_rest/collections/requests.ts @@ -2,10 +2,10 @@ import type { ShareAssetType } from '@buster/server-shared/share'; import type { BusterCollection, BusterCollectionListItem } from '@/api/asset_interfaces/collection'; import type { ShareDeleteRequest, - SharePostRequest, ShareUpdateRequest } from '@/api/asset_interfaces/shared_interfaces'; import mainApi from '@/api/buster_rest/instances'; +import { SharePostRequest } from '@buster/server-shared/share'; export const collectionsGetList = async (params: { /** Current page number (1-based indexing) */ diff --git a/apps/web/src/api/buster_rest/dashboards/queryRequests.ts b/apps/web/src/api/buster_rest/dashboards/queryRequests.ts index 0e3e1c63d..5ef411592 100644 --- a/apps/web/src/api/buster_rest/dashboards/queryRequests.ts +++ b/apps/web/src/api/buster_rest/dashboards/queryRequests.ts @@ -355,17 +355,14 @@ export const useShareDashboard = () => { const { latestVersionNumber } = useGetDashboardVersionNumber(); return useMutation({ mutationFn: shareDashboard, - onMutate: (variables) => { - const queryKey = dashboardQueryKeys.dashboardGetDashboard( - variables.id, - latestVersionNumber - ).queryKey; + onMutate: ({ id, params }) => { + const queryKey = dashboardQueryKeys.dashboardGetDashboard(id, latestVersionNumber).queryKey; queryClient.setQueryData(queryKey, (previousData) => { if (!previousData) return previousData; return create(previousData, (draft) => { draft.individual_permissions = [ - ...variables.params.map((p) => ({ + ...params.map((p) => ({ ...p, name: p.name, avatar_url: p.avatar_url || null diff --git a/apps/web/src/api/buster_rest/dashboards/requests.ts b/apps/web/src/api/buster_rest/dashboards/requests.ts index 72464fa8d..7be43330f 100644 --- a/apps/web/src/api/buster_rest/dashboards/requests.ts +++ b/apps/web/src/api/buster_rest/dashboards/requests.ts @@ -5,11 +5,11 @@ import type { } from '@/api/asset_interfaces/dashboard'; import type { ShareDeleteRequest, - SharePostRequest, ShareUpdateRequest } from '@/api/asset_interfaces/shared_interfaces'; import mainApi from '@/api/buster_rest/instances'; import { serverFetch } from '@/api/createServerInstance'; +import { SharePostRequest } from '@buster/server-shared/share'; export const dashboardsGetList = async (params: { /** The page number to fetch */ diff --git a/apps/web/src/api/buster_rest/metrics/requests.ts b/apps/web/src/api/buster_rest/metrics/requests.ts index 4f49eb11e..bbd64cc7e 100644 --- a/apps/web/src/api/buster_rest/metrics/requests.ts +++ b/apps/web/src/api/buster_rest/metrics/requests.ts @@ -9,8 +9,6 @@ import type { DuplicateMetricResponse, GetMetricDataRequest, ListMetricsResponse, - ShareMetricRequest, - ShareMetricResponse, UpdateMetricRequest, GetMetricRequest, GetMetricListRequest, @@ -22,6 +20,7 @@ import type { } from '@buster/server-shared/metrics'; import { serverFetch } from '@/api/createServerInstance'; import { mainApi } from '../instances'; +import { SharePostRequest } from '@buster/server-shared/share'; export const getMetric = async (params: GetMetricRequest): Promise => { return mainApi @@ -85,7 +84,7 @@ export const bulkUpdateMetricVerificationStatus = async ( // share metrics -export const shareMetric = async ({ id, params }: { id: string; params: ShareMetricRequest }) => { +export const shareMetric = async ({ id, params }: { id: string; params: SharePostRequest }) => { return mainApi.post(`/metric_files/${id}/sharing`, params).then((res) => res.data); }; diff --git a/apps/web/src/api/buster_rest/metrics/updateMetricQueryRequests.ts b/apps/web/src/api/buster_rest/metrics/updateMetricQueryRequests.ts index fc0893d22..7af7903df 100644 --- a/apps/web/src/api/buster_rest/metrics/updateMetricQueryRequests.ts +++ b/apps/web/src/api/buster_rest/metrics/updateMetricQueryRequests.ts @@ -221,17 +221,14 @@ export const useShareMetric = () => { const { selectedVersionNumber } = useGetMetricVersionNumber(); return useMutation({ mutationFn: shareMetric, - onMutate: (variables) => { - const queryKey = metricsQueryKeys.metricsGetMetric( - variables.id, - selectedVersionNumber - ).queryKey; + onMutate: ({ id, params }) => { + const queryKey = metricsQueryKeys.metricsGetMetric(id, selectedVersionNumber).queryKey; queryClient.setQueryData(queryKey, (previousData: BusterMetric | undefined) => { if (!previousData) return previousData; return create(previousData, (draft: BusterMetric) => { draft.individual_permissions = [ - ...variables.params.map((p) => ({ + ...params.map((p) => ({ ...p, name: p.name, avatar_url: p.avatar_url || null diff --git a/packages/server-shared/src/chats/chat.types.ts b/packages/server-shared/src/chats/chat.types.ts index 05b3ac5c4..ac72c602c 100644 --- a/packages/server-shared/src/chats/chat.types.ts +++ b/packages/server-shared/src/chats/chat.types.ts @@ -1,4 +1,5 @@ import { z } from 'zod'; +import { ShareIndividualSchema } from '../share'; import { ChatMessageSchema } from './chat-message.types'; const AssetType = z.enum(['metric_file', 'dashboard_file']); @@ -6,13 +7,6 @@ const AssetType = z.enum(['metric_file', 'dashboard_file']); // Asset Permission Role enum (matching database enum) export const AssetPermissionRoleSchema = z.enum(['viewer', 'editor', 'owner']); -// Individual permission schema -export const BusterShareIndividualSchema = z.object({ - email: z.string().email(), - role: AssetPermissionRoleSchema, - name: z.string().optional(), -}); - // Main ChatWithMessages schema export const ChatWithMessagesSchema = z.object({ id: z.string(), @@ -27,7 +21,7 @@ export const ChatWithMessagesSchema = z.object({ created_by_name: z.string(), created_by_avatar: z.string().nullable(), // Sharing fields - individual_permissions: z.array(BusterShareIndividualSchema).optional(), + individual_permissions: z.array(ShareIndividualSchema).optional(), publicly_accessible: z.boolean(), public_expiry_date: z.string().datetime().optional(), public_enabled_by: z.string().optional(), @@ -67,7 +61,6 @@ export const CancelChatParamsSchema = z.object({ // Infer types from schemas export type AssetPermissionRole = z.infer; -export type BusterShareIndividual = z.infer; export type ChatWithMessages = z.infer; export type ChatCreateRequest = z.infer; export type ChatCreateHandlerRequest = z.infer; diff --git a/packages/server-shared/src/metrics/requests.types.ts b/packages/server-shared/src/metrics/requests.types.ts index ccf658bdb..e2b088006 100644 --- a/packages/server-shared/src/metrics/requests.types.ts +++ b/packages/server-shared/src/metrics/requests.types.ts @@ -53,15 +53,6 @@ export const BulkUpdateMetricVerificationStatusRequestSchema = z.array( }) ); -export const ShareMetricRequestSchema = z.array( - z.object({ - email: z.string(), - name: z.string().optional(), - role: ShareRoleSchema, - avatar_url: z.string().nullable().optional(), - }) -); - export const ShareDeleteRequestSchema = z.array(z.string()); export const ShareUpdateRequestSchema = z.object({ @@ -87,6 +78,5 @@ export type DuplicateMetricRequest = z.infer; -export type ShareMetricRequest = z.infer; export type ShareDeleteRequest = z.infer; export type ShareUpdateRequest = z.infer; diff --git a/packages/server-shared/src/share/index.ts b/packages/server-shared/src/share/index.ts index 51be04808..6332cb85b 100644 --- a/packages/server-shared/src/share/index.ts +++ b/packages/server-shared/src/share/index.ts @@ -1,2 +1,3 @@ export * from './share-interfaces.types'; export * from './verification.types'; +export * from './requests'; diff --git a/packages/server-shared/src/share/requests.ts b/packages/server-shared/src/share/requests.ts new file mode 100644 index 000000000..76fea2f67 --- /dev/null +++ b/packages/server-shared/src/share/requests.ts @@ -0,0 +1,13 @@ +import { z } from 'zod'; +import { ShareRoleSchema } from './share-interfaces.types'; + +export const SharePostRequestSchema = z.array( + z.object({ + email: z.string().email(), + role: ShareRoleSchema, + avatar_url: z.string().nullable().optional(), + name: z.string().optional(), + }) +); + +export type SharePostRequest = z.infer; diff --git a/packages/slack/src/services/messaging.ts b/packages/slack/src/services/messaging.ts index 67f69260e..5008a21f6 100644 --- a/packages/slack/src/services/messaging.ts +++ b/packages/slack/src/services/messaging.ts @@ -2,6 +2,7 @@ import { WebClient } from '@slack/web-api'; import { z } from 'zod'; import { type SendMessageResult, type SlackMessage, SlackMessageSchema } from '../types'; import { SlackIntegrationError } from '../types/errors'; +import { convertMarkdownToSlack } from '../utils/markdownToSlack'; import { validateWithSchema } from '../utils/validation-helpers'; export class SlackMessagingService { @@ -33,10 +34,15 @@ export class SlackMessagingService { throw new SlackIntegrationError('CHANNEL_NOT_FOUND', 'Channel ID is required'); } + const convertedMessage = + typeof message.text === 'string' + ? { ...message, ...convertMarkdownToSlack(message.text) } + : message; + // Validate message const validatedMessage = validateWithSchema( SlackMessageSchema, - message, + convertedMessage, 'Invalid message format' ); @@ -151,10 +157,15 @@ export class SlackMessagingService { ); } + const convertedReplyMessage = + typeof replyMessage.text === 'string' + ? { ...replyMessage, ...convertMarkdownToSlack(replyMessage.text) } + : replyMessage; + // Validate message const validatedMessage = validateWithSchema( SlackMessageSchema, - replyMessage, + convertedReplyMessage, 'Invalid reply message format' ); @@ -320,10 +331,15 @@ export class SlackMessagingService { ); } + const convertedUpdatedMessage = + typeof updatedMessage.text === 'string' + ? { ...updatedMessage, ...convertMarkdownToSlack(updatedMessage.text) } + : updatedMessage; + // Validate message const validatedMessage = validateWithSchema( SlackMessageSchema, - updatedMessage, + convertedUpdatedMessage, 'Invalid message format' ); diff --git a/packages/slack/src/utils/markdownToSlack.test.ts b/packages/slack/src/utils/markdownToSlack.test.ts new file mode 100644 index 000000000..3c03c2caa --- /dev/null +++ b/packages/slack/src/utils/markdownToSlack.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { convertMarkdownToSlack } from './markdownToSlack'; + +describe('convertMarkdownToSlack', () => { + it('should handle empty or invalid input', () => { + expect(convertMarkdownToSlack('')).toEqual({ text: '' }); + expect(convertMarkdownToSlack(null as any)).toEqual({ text: '' }); + expect(convertMarkdownToSlack(undefined as any)).toEqual({ text: '' }); + }); + + it('should convert headers to section blocks', () => { + const markdown = '# Main Title\n## Subtitle\nSome content'; + const result = convertMarkdownToSlack(markdown); + + expect(result.blocks).toHaveLength(3); + expect(result.blocks?.[0]).toEqual({ + type: 'section', + text: { + type: 'mrkdwn', + text: '*Main Title*', + }, + }); + expect(result.blocks?.[1]).toEqual({ + type: 'section', + text: { + type: 'mrkdwn', + text: '*Subtitle*', + }, + }); + expect(result.blocks?.[2]).toEqual({ + type: 'section', + text: { + type: 'mrkdwn', + text: 'Some content', + }, + }); + }); + + it('should convert bold text', () => { + const markdown = 'This is **bold** and this is __also bold__'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('This is *bold* and this is *also bold*'); + }); + + it('should convert italic text', () => { + const markdown = 'This is *italic* and this is _also italic_'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('This is _italic_ and this is _also italic_'); + }); + + it('should handle inline code', () => { + const markdown = 'Use `console.log()` to debug'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('Use `console.log()` to debug'); + }); + + it('should handle code blocks', () => { + const markdown = '```javascript\nconsole.log("hello");\n```'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('```javascript\nconsole.log("hello");\n```'); + }); + + it('should handle code blocks without language', () => { + const markdown = '```\nsome code\n```'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('```\nsome code\n```'); + }); + + it('should convert unordered lists', () => { + const markdown = '- Item 1\n* Item 2\n+ Item 3'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('• Item 1\n• Item 2\n• Item 3'); + }); + + it('should convert ordered lists', () => { + const markdown = '1. First item\n2. Second item\n3. Third item'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('1. First item\n2. Second item\n3. Third item'); + }); + + it('should handle mixed formatting', () => { + const markdown = '# Title\nThis is **bold** and *italic* with `code`\n- List item'; + const result = convertMarkdownToSlack(markdown); + + expect(result.blocks).toHaveLength(2); + expect(result.blocks?.[0]).toEqual({ + type: 'section', + text: { + type: 'mrkdwn', + text: '*Title*', + }, + }); + expect(result.blocks?.[1]).toEqual({ + type: 'section', + text: { + type: 'mrkdwn', + text: 'This is *bold* and _italic_ with `code`\n• List item', + }, + }); + }); + + it('should handle text without complex formatting', () => { + const markdown = 'Simple text with **bold** and *italic*'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('Simple text with *bold* and _italic_'); + expect(result.blocks).toBeUndefined(); + }); + + it('should clean up extra whitespace', () => { + const markdown = 'Text with\n\n\n\nmultiple newlines'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('Text with\n\nmultiple newlines'); + }); + + it('should handle nested formatting correctly', () => { + const markdown = '**This is bold with *italic* inside**'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('*This is bold with _italic_ inside*'); + }); + + it('should preserve unsupported markdown unchanged', () => { + const markdown = 'Text with [link](http://example.com) and ![image](image.png)'; + const result = convertMarkdownToSlack(markdown); + + expect(result.text).toBe('Text with [link](http://example.com) and ![image](image.png)'); + }); +}); diff --git a/packages/slack/src/utils/markdownToSlack.ts b/packages/slack/src/utils/markdownToSlack.ts new file mode 100644 index 000000000..208395a20 --- /dev/null +++ b/packages/slack/src/utils/markdownToSlack.ts @@ -0,0 +1,100 @@ +import type { SlackBlock } from '../types/blocks'; + +export interface MarkdownConversionResult { + text: string; + blocks?: SlackBlock[]; +} + +export function convertMarkdownToSlack(markdown: string): MarkdownConversionResult { + if (!markdown || typeof markdown !== 'string') { + return { text: markdown || '' }; + } + + let text = markdown; + const blocks: SlackBlock[] = []; + let hasComplexFormatting = false; + + const headerRegex = /^(#{1,6})\s+(.+)$/gm; + const headerMatches = [...text.matchAll(headerRegex)]; + + if (headerMatches.length > 0) { + hasComplexFormatting = true; + for (const match of headerMatches) { + const level = match[1]?.length || 1; + const headerText = match[2] || ''; + + const slackHeader = level <= 2 ? `*${headerText}*` : `*${headerText}*`; + + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: slackHeader, + }, + }); + } + + text = text.replace(headerRegex, ''); + } + + const codeBlockRegex = /```(\w+)?\n?([\s\S]*?)```/g; + text = text.replace(codeBlockRegex, (_match, language, code) => { + return `\`\`\`${language || ''}\n${code.trim()}\n\`\`\``; + }); + + const inlineCodeRegex = /`([^`]+)`/g; + text = text.replace(inlineCodeRegex, '`$1`'); + + const boldPlaceholder = '___BOLD_PLACEHOLDER___'; + const boldMatches: Array<{ placeholder: string; replacement: string }> = []; + let boldCounter = 0; + + const boldRegex = /(\*\*|__)(.*?)\1/g; + text = text.replace(boldRegex, (_match, _delimiter, content) => { + const placeholder = `${boldPlaceholder}${boldCounter}${boldPlaceholder}`; + const processedContent = content.replace(/\*([^*]+)\*/g, '_$1_').replace(/_([^_]+)_/g, '_$1_'); + boldMatches.push({ placeholder, replacement: `*${processedContent}*` }); + boldCounter++; + return placeholder; + }); + + const italicRegex = /(? { + const content = group1 || group2; + return `_${content}_`; + }); + + for (const { placeholder, replacement } of boldMatches) { + text = text.replace(placeholder, replacement); + } + + const unorderedListRegex = /^[\s]*[-*+]\s+(.+)$/gm; + text = text.replace(unorderedListRegex, '• $1'); + + const orderedListRegex = /^[\s]*\d+\.\s+(.+)$/gm; + let listCounter = 1; + text = text.replace(orderedListRegex, (_match, content) => { + return `${listCounter++}. ${content}`; + }); + + text = text.replace(/\n{3,}/g, '\n\n').trim(); + + if (hasComplexFormatting && blocks.length > 0) { + if (text.trim()) { + blocks.push({ + type: 'section', + text: { + type: 'mrkdwn', + text: text.trim(), + }, + }); + } + + return { + text: markdown, // Fallback text for notifications + blocks, + }; + } + + return { text }; +}