Merge branch 'staging' into big-nate/bus-1424-default-color-palette-in-workspace-settings

This commit is contained in:
Nate Kelley 2025-07-17 10:51:17 -06:00
commit be6b510685
No known key found for this signature in database
GPG Key ID: FD90372AB8D98B4F
30 changed files with 699 additions and 193 deletions

View File

@ -31,8 +31,8 @@ jobs:
- name: Build and Load API Docker Image - name: Build and Load API Docker Image
uses: useblacksmith/build-push-action@v1 uses: useblacksmith/build-push-action@v1
with: with:
context: ./api context: ./apps/api
file: ./api/Dockerfile file: ./apps/api/Dockerfile
push: false # Do not push, load locally for service container push: false # Do not push, load locally for service container
load: true # Load the image into the runner's Docker daemon load: true # Load the image into the runner's Docker daemon
tags: local-api-test:latest # Tag for the service definition tags: local-api-test:latest # Tag for the service definition

View File

@ -103,8 +103,8 @@ jobs:
- name: Build and push API image - name: Build and push API image
uses: useblacksmith/build-push-action@v1 uses: useblacksmith/build-push-action@v1
with: with:
context: ./api context: ./apps/api
file: ./api/Dockerfile file: ./apps/api/Dockerfile
push: true push: true
platforms: ${{ matrix.docker_platform }} platforms: ${{ matrix.docker_platform }}
tags: | tags: |

View File

@ -69,8 +69,8 @@ jobs:
- name: Build & Load API Docker Image - name: Build & Load API Docker Image
uses: useblacksmith/build-push-action@v1 uses: useblacksmith/build-push-action@v1
with: with:
context: ./api context: ./apps/api
file: ./api/Dockerfile file: ./apps/api/Dockerfile
push: false push: false
load: true load: true
tags: local-api-test:latest tags: local-api-test:latest

View File

@ -53,6 +53,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole, role: AssetPermissionRole,
email: String, email: String,
name: Option<String>, name: Option<String>,
avatar_url: Option<String>,
} }
pub async fn get_chat_handler( 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::asset_type.eq(AssetType::Chat))
.filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::identity_type.eq(IdentityType::User))
.filter(asset_permissions::deleted_at.is_null()) .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::<AssetPermissionInfo>(&mut conn) .load::<AssetPermissionInfo>(&mut conn)
.await; .await;
@ -302,6 +303,7 @@ pub async fn get_chat_handler(
email: p.email, email: p.email,
role: p.role, role: p.role,
name: p.name, name: p.name,
avatar_url: p.avatar_url,
}) })
.collect::<Vec<BusterShareIndividual>>(), .collect::<Vec<BusterShareIndividual>>(),
) )

View File

@ -78,7 +78,7 @@ pub async fn list_chats_handler(
request: ListChatsRequest, request: ListChatsRequest,
user: &AuthenticatedUser, user: &AuthenticatedUser,
) -> Result<Vec<ChatListItem>> { ) -> Result<Vec<ChatListItem>> {
use database::schema::{asset_permissions, chats, users}; use database::schema::{asset_permissions, chats, messages, users};
let mut conn = get_pg_pool().get().await?; 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))) .inner_join(users::table.on(chats::created_by.eq(users::id)))
.filter(chats::deleted_at.is_null()) .filter(chats::deleted_at.is_null())
.filter(chats::title.ne("")) // Filter out empty titles .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::<diesel::sql_types::Bool>(
"(SELECT COUNT(*) FROM messages WHERE messages.chat_id = chats.id AND messages.deleted_at IS NULL) > 1"
)
)
)
.into_boxed(); .into_boxed();
// Add user filter if not admin view // Add user filter if not admin view

View File

@ -12,6 +12,7 @@ pub struct BusterShareIndividual {
pub email: String, pub email: String,
pub role: AssetPermissionRole, pub role: AssetPermissionRole,
pub name: Option<String>, pub name: Option<String>,
pub avatar_url: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]

View File

@ -25,6 +25,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole, role: AssetPermissionRole,
email: String, email: String,
name: Option<String>, name: Option<String>,
avatar_url: Option<String>,
} }
/// Type for querying asset data from database /// Type for querying asset data from database
@ -128,6 +129,7 @@ pub async fn get_collection_handler(
asset_permissions::role, asset_permissions::role,
users::email, users::email,
users::name, users::name,
users::avatar_url,
)) ))
.load::<AssetPermissionInfo>(&mut conn) .load::<AssetPermissionInfo>(&mut conn)
.await; .await;
@ -150,6 +152,7 @@ pub async fn get_collection_handler(
email: p.email, email: p.email,
role: p.role, role: p.role,
name: p.name, name: p.name,
avatar_url: p.avatar_url,
}) })
.collect::<Vec<BusterShareIndividual>>(), .collect::<Vec<BusterShareIndividual>>(),
) )

View File

@ -12,6 +12,7 @@ pub struct BusterShareIndividual {
pub email: String, pub email: String,
pub role: AssetPermissionRole, pub role: AssetPermissionRole,
pub name: Option<String>, pub name: Option<String>,
pub avatar_url: Option<String>,
} }
// List collections types // List collections types

View File

@ -52,6 +52,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole, role: AssetPermissionRole,
email: String, email: String,
name: Option<String>, name: Option<String>,
avatar_url: Option<String>,
} }
/// Fetches collections that the dashboard belongs to, filtered by user permissions /// 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."); tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, ?permission, "Granting access via direct permission.");
} }
} else { } else {
// No sufficient direct/admin permission, check public access rules // No sufficient direct/admin permission, check if user has access via a chat
tracing::debug!(dashboard_id = %dashboard_id, "Insufficient direct/admin permission. Checking public access rules."); tracing::debug!(dashboard_id = %dashboard_id, "Insufficient direct/admin permission. Checking chat access.");
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 let has_chat_access = sharing::check_dashboard_chat_access(dashboard_id, &user.id)
if let Some(expiry_date) = dashboard_file.public_expiry_date { .await
tracing::debug!(dashboard_id = %dashboard_id, ?expiry_date, "Checking expiry date"); .unwrap_or(false);
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 if has_chat_access {
tracing::debug!(dashboard_id = %dashboard_id, has_password = dashboard_file.public_password.is_some(), "Checking password requirement"); // User has access to a chat containing this dashboard, grant CanView
if let Some(required_password) = &dashboard_file.public_password { tracing::debug!(dashboard_id = %dashboard_id, user_id = %user.id, "User has access via chat. Granting CanView.");
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; 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::asset_type.eq(AssetType::DashboardFile))
.filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::identity_type.eq(IdentityType::User))
.filter(asset_permissions::deleted_at.is_null()) .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::<AssetPermissionInfo>(&mut conn) .load::<AssetPermissionInfo>(&mut conn)
.await; .await;
@ -358,6 +372,7 @@ pub async fn get_dashboard_handler(
email: p.email, email: p.email,
role: p.role, role: p.role,
name: p.name, name: p.name,
avatar_url: p.avatar_url,
}) })
.collect::<Vec<BusterShareIndividual>>(), .collect::<Vec<BusterShareIndividual>>(),
) )

View File

@ -31,6 +31,7 @@ pub struct BusterShareIndividual {
pub email: String, pub email: String,
pub role: AssetPermissionRole, pub role: AssetPermissionRole,
pub name: Option<String>, pub name: Option<String>,
pub avatar_url: Option<String>,
} }
// Note: This extends BusterShare which needs to be defined // Note: This extends BusterShare which needs to be defined

View File

@ -69,7 +69,7 @@ pub async fn list_logs_handler(
request: ListLogsRequest, request: ListLogsRequest,
organization_id: Uuid, organization_id: Uuid,
) -> Result<ListLogsResponse, anyhow::Error> { ) -> Result<ListLogsResponse, anyhow::Error> {
use database::schema::{chats, users}; use database::schema::{chats, messages, users};
let mut conn = get_pg_pool().get().await?; 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::organization_id.eq(organization_id))
.filter(chats::title.ne("")) // Filter out empty titles .filter(chats::title.ne("")) // Filter out empty titles
.filter(chats::title.ne(" ")) // Filter out single space .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::<diesel::sql_types::Bool>(
"(SELECT COUNT(*) FROM messages WHERE messages.chat_id = chats.id AND messages.deleted_at IS NULL) > 1"
)
)
)
.into_boxed(); .into_boxed();
// Calculate offset based on page number // Calculate offset based on page number

View File

@ -100,9 +100,40 @@ pub async fn get_metric_data_handler(
} }
} }
} else { } else {
// No dashboard access, return the original permission error // No dashboard access, check if user has access via a chat
tracing::warn!("No dashboard association found for metric. Returning original error."); tracing::info!("No dashboard association found. Checking chat access.");
return Err(e); 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 { } else {
// Error was not permission-related, return original error // Error was not permission-related, return original error

View File

@ -32,6 +32,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole, role: AssetPermissionRole,
email: String, email: String,
name: Option<String>, name: Option<String>,
avatar_url: Option<String>,
} }
/// Fetch the dashboards associated with the given metric id, filtered by user permissions /// 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."); tracing::debug!(metric_id = %metric_id, user_id = %user.id, "User has access via dashboard. Granting CanView.");
permission = AssetPermissionRole::CanView; permission = AssetPermissionRole::CanView;
} else { } else {
// No dashboard access, check public access rules // No dashboard access, check if user has access via a chat
tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking public access rules."); tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking chat access.");
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 let has_chat_access = sharing::check_metric_chat_access(metric_id, &user.id)
if let Some(expiry_date) = metric_file.public_expiry_date { .await
tracing::debug!(metric_id = %metric_id, ?expiry_date, "Checking expiry date"); .unwrap_or(false);
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 if has_chat_access {
tracing::debug!(metric_id = %metric_id, has_password = metric_file.public_password.is_some(), "Checking password requirement"); // User has access to a chat containing this metric, grant CanView
if let Some(required_password) = &metric_file.public_password { tracing::debug!(metric_id = %metric_id, user_id = %user.id, "User has access via chat. Granting CanView.");
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; 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::asset_type.eq(AssetType::MetricFile))
.filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::identity_type.eq(IdentityType::User))
.filter(asset_permissions::deleted_at.is_null()) .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::<AssetPermissionInfo>(&mut conn) .load::<AssetPermissionInfo>(&mut conn)
.await; .await;
@ -399,6 +413,7 @@ pub async fn get_metric_for_dashboard_handler(
email: p.email, email: p.email,
role: p.role, role: p.role,
name: p.name, name: p.name,
avatar_url: p.avatar_url,
}) })
.collect::<Vec<BusterShareIndividual>>(), .collect::<Vec<BusterShareIndividual>>(),
) )

View File

@ -30,6 +30,7 @@ struct AssetPermissionInfo {
role: AssetPermissionRole, role: AssetPermissionRole,
email: String, email: String,
name: Option<String>, name: Option<String>,
avatar_url: Option<String>,
} }
/// Fetch the dashboards associated with the given metric id, filtered by user permissions /// 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."); tracing::debug!(metric_id = %metric_id, user_id = %user.id, "User has access via dashboard. Granting CanView.");
permission = AssetPermissionRole::CanView; permission = AssetPermissionRole::CanView;
} else { } else {
// No dashboard access, check public access rules // No dashboard access, check if user has access via a chat
tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking public access rules."); tracing::debug!(metric_id = %metric_id, "No dashboard access. Checking chat access.");
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 let has_chat_access = sharing::check_metric_chat_access(metric_id, &user.id)
if let Some(expiry_date) = metric_file.public_expiry_date { .await
tracing::debug!(metric_id = %metric_id, ?expiry_date, "Checking expiry date"); .unwrap_or(false);
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 if has_chat_access {
tracing::debug!(metric_id = %metric_id, has_password = metric_file.public_password.is_some(), "Checking password requirement"); // User has access to a chat containing this metric, grant CanView
if let Some(required_password) = &metric_file.public_password { tracing::debug!(metric_id = %metric_id, user_id = %user.id, "User has access via chat. Granting CanView.");
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; 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::asset_type.eq(AssetType::MetricFile))
.filter(asset_permissions::identity_type.eq(IdentityType::User)) .filter(asset_permissions::identity_type.eq(IdentityType::User))
.filter(asset_permissions::deleted_at.is_null()) .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::<AssetPermissionInfo>(&mut conn) .load::<AssetPermissionInfo>(&mut conn)
.await; .await;
@ -425,6 +439,7 @@ pub async fn get_metric_handler(
email: p.email, email: p.email,
role: p.role, role: p.role,
name: p.name, name: p.name,
avatar_url: p.avatar_url,
}) })
.collect::<Vec<crate::metrics::types::BusterShareIndividual>>(), .collect::<Vec<crate::metrics::types::BusterShareIndividual>>(),
) )

View File

@ -23,6 +23,7 @@ pub struct BusterShareIndividual {
pub email: String, pub email: String,
pub role: AssetPermissionRole, pub role: AssetPermissionRole,
pub name: Option<String>, pub name: Option<String>,
pub avatar_url: Option<String>,
} }
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]

View File

@ -117,6 +117,172 @@ pub async fn check_metric_dashboard_access(
Ok(has_public_access.is_some()) 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<bool>` - 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<bool, diesel::result::Error> {
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::<Uuid>(&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::<Uuid>(&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<bool>` - 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<bool, diesel::result::Error> {
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::<Uuid>(&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::<Uuid>(&mut conn)
.await
.optional()?;
Ok(has_public_chat_access.is_some())
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@ -19,4 +19,4 @@ pub use types::{
SerializableAssetPermission, UserInfo, SerializableAssetPermission, UserInfo,
}; };
pub use user_lookup::find_user_by_email; 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};

View File

@ -1,17 +1,5 @@
import type { ShareRole } from '@buster/server-shared/share'; 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 ShareDeleteRequest = string[];
export type ShareUpdateRequest = { export type ShareUpdateRequest = {

View File

@ -2,10 +2,10 @@ import type { ShareAssetType } from '@buster/server-shared/share';
import type { BusterCollection, BusterCollectionListItem } from '@/api/asset_interfaces/collection'; import type { BusterCollection, BusterCollectionListItem } from '@/api/asset_interfaces/collection';
import type { import type {
ShareDeleteRequest, ShareDeleteRequest,
SharePostRequest,
ShareUpdateRequest ShareUpdateRequest
} from '@/api/asset_interfaces/shared_interfaces'; } from '@/api/asset_interfaces/shared_interfaces';
import mainApi from '@/api/buster_rest/instances'; import mainApi from '@/api/buster_rest/instances';
import { SharePostRequest } from '@buster/server-shared/share';
export const collectionsGetList = async (params: { export const collectionsGetList = async (params: {
/** Current page number (1-based indexing) */ /** Current page number (1-based indexing) */

View File

@ -355,17 +355,14 @@ export const useShareDashboard = () => {
const { latestVersionNumber } = useGetDashboardVersionNumber(); const { latestVersionNumber } = useGetDashboardVersionNumber();
return useMutation({ return useMutation({
mutationFn: shareDashboard, mutationFn: shareDashboard,
onMutate: (variables) => { onMutate: ({ id, params }) => {
const queryKey = dashboardQueryKeys.dashboardGetDashboard( const queryKey = dashboardQueryKeys.dashboardGetDashboard(id, latestVersionNumber).queryKey;
variables.id,
latestVersionNumber
).queryKey;
queryClient.setQueryData(queryKey, (previousData) => { queryClient.setQueryData(queryKey, (previousData) => {
if (!previousData) return previousData; if (!previousData) return previousData;
return create(previousData, (draft) => { return create(previousData, (draft) => {
draft.individual_permissions = [ draft.individual_permissions = [
...variables.params.map((p) => ({ ...params.map((p) => ({
...p, ...p,
name: p.name, name: p.name,
avatar_url: p.avatar_url || null avatar_url: p.avatar_url || null

View File

@ -5,11 +5,11 @@ import type {
} from '@/api/asset_interfaces/dashboard'; } from '@/api/asset_interfaces/dashboard';
import type { import type {
ShareDeleteRequest, ShareDeleteRequest,
SharePostRequest,
ShareUpdateRequest ShareUpdateRequest
} from '@/api/asset_interfaces/shared_interfaces'; } from '@/api/asset_interfaces/shared_interfaces';
import mainApi from '@/api/buster_rest/instances'; import mainApi from '@/api/buster_rest/instances';
import { serverFetch } from '@/api/createServerInstance'; import { serverFetch } from '@/api/createServerInstance';
import { SharePostRequest } from '@buster/server-shared/share';
export const dashboardsGetList = async (params: { export const dashboardsGetList = async (params: {
/** The page number to fetch */ /** The page number to fetch */

View File

@ -9,8 +9,6 @@ import type {
DuplicateMetricResponse, DuplicateMetricResponse,
GetMetricDataRequest, GetMetricDataRequest,
ListMetricsResponse, ListMetricsResponse,
ShareMetricRequest,
ShareMetricResponse,
UpdateMetricRequest, UpdateMetricRequest,
GetMetricRequest, GetMetricRequest,
GetMetricListRequest, GetMetricListRequest,
@ -22,6 +20,7 @@ import type {
} from '@buster/server-shared/metrics'; } from '@buster/server-shared/metrics';
import { serverFetch } from '@/api/createServerInstance'; import { serverFetch } from '@/api/createServerInstance';
import { mainApi } from '../instances'; import { mainApi } from '../instances';
import { SharePostRequest } from '@buster/server-shared/share';
export const getMetric = async (params: GetMetricRequest): Promise<GetMetricResponse> => { export const getMetric = async (params: GetMetricRequest): Promise<GetMetricResponse> => {
return mainApi return mainApi
@ -85,7 +84,7 @@ export const bulkUpdateMetricVerificationStatus = async (
// share metrics // 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<string>(`/metric_files/${id}/sharing`, params).then((res) => res.data); return mainApi.post<string>(`/metric_files/${id}/sharing`, params).then((res) => res.data);
}; };

View File

@ -221,17 +221,14 @@ export const useShareMetric = () => {
const { selectedVersionNumber } = useGetMetricVersionNumber(); const { selectedVersionNumber } = useGetMetricVersionNumber();
return useMutation({ return useMutation({
mutationFn: shareMetric, mutationFn: shareMetric,
onMutate: (variables) => { onMutate: ({ id, params }) => {
const queryKey = metricsQueryKeys.metricsGetMetric( const queryKey = metricsQueryKeys.metricsGetMetric(id, selectedVersionNumber).queryKey;
variables.id,
selectedVersionNumber
).queryKey;
queryClient.setQueryData(queryKey, (previousData: BusterMetric | undefined) => { queryClient.setQueryData(queryKey, (previousData: BusterMetric | undefined) => {
if (!previousData) return previousData; if (!previousData) return previousData;
return create(previousData, (draft: BusterMetric) => { return create(previousData, (draft: BusterMetric) => {
draft.individual_permissions = [ draft.individual_permissions = [
...variables.params.map((p) => ({ ...params.map((p) => ({
...p, ...p,
name: p.name, name: p.name,
avatar_url: p.avatar_url || null avatar_url: p.avatar_url || null

View File

@ -1,4 +1,5 @@
import { z } from 'zod'; import { z } from 'zod';
import { ShareIndividualSchema } from '../share';
import { ChatMessageSchema } from './chat-message.types'; import { ChatMessageSchema } from './chat-message.types';
const AssetType = z.enum(['metric_file', 'dashboard_file']); 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) // Asset Permission Role enum (matching database enum)
export const AssetPermissionRoleSchema = z.enum(['viewer', 'editor', 'owner']); 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 // Main ChatWithMessages schema
export const ChatWithMessagesSchema = z.object({ export const ChatWithMessagesSchema = z.object({
id: z.string(), id: z.string(),
@ -27,7 +21,7 @@ export const ChatWithMessagesSchema = z.object({
created_by_name: z.string(), created_by_name: z.string(),
created_by_avatar: z.string().nullable(), created_by_avatar: z.string().nullable(),
// Sharing fields // Sharing fields
individual_permissions: z.array(BusterShareIndividualSchema).optional(), individual_permissions: z.array(ShareIndividualSchema).optional(),
publicly_accessible: z.boolean(), publicly_accessible: z.boolean(),
public_expiry_date: z.string().datetime().optional(), public_expiry_date: z.string().datetime().optional(),
public_enabled_by: z.string().optional(), public_enabled_by: z.string().optional(),
@ -67,7 +61,6 @@ export const CancelChatParamsSchema = z.object({
// Infer types from schemas // Infer types from schemas
export type AssetPermissionRole = z.infer<typeof AssetPermissionRoleSchema>; export type AssetPermissionRole = z.infer<typeof AssetPermissionRoleSchema>;
export type BusterShareIndividual = z.infer<typeof BusterShareIndividualSchema>;
export type ChatWithMessages = z.infer<typeof ChatWithMessagesSchema>; export type ChatWithMessages = z.infer<typeof ChatWithMessagesSchema>;
export type ChatCreateRequest = z.infer<typeof ChatCreateRequestSchema>; export type ChatCreateRequest = z.infer<typeof ChatCreateRequestSchema>;
export type ChatCreateHandlerRequest = z.infer<typeof ChatCreateHandlerRequestSchema>; export type ChatCreateHandlerRequest = z.infer<typeof ChatCreateHandlerRequestSchema>;

View File

@ -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 ShareDeleteRequestSchema = z.array(z.string());
export const ShareUpdateRequestSchema = z.object({ export const ShareUpdateRequestSchema = z.object({
@ -87,6 +78,5 @@ export type DuplicateMetricRequest = z.infer<typeof DuplicateMetricRequestSchema
export type BulkUpdateMetricVerificationStatusRequest = z.infer< export type BulkUpdateMetricVerificationStatusRequest = z.infer<
typeof BulkUpdateMetricVerificationStatusRequestSchema typeof BulkUpdateMetricVerificationStatusRequestSchema
>; >;
export type ShareMetricRequest = z.infer<typeof ShareMetricRequestSchema>;
export type ShareDeleteRequest = z.infer<typeof ShareDeleteRequestSchema>; export type ShareDeleteRequest = z.infer<typeof ShareDeleteRequestSchema>;
export type ShareUpdateRequest = z.infer<typeof ShareUpdateRequestSchema>; export type ShareUpdateRequest = z.infer<typeof ShareUpdateRequestSchema>;

View File

@ -1,2 +1,3 @@
export * from './share-interfaces.types'; export * from './share-interfaces.types';
export * from './verification.types'; export * from './verification.types';
export * from './requests';

View File

@ -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<typeof SharePostRequestSchema>;

View File

@ -2,6 +2,7 @@ import { WebClient } from '@slack/web-api';
import { z } from 'zod'; import { z } from 'zod';
import { type SendMessageResult, type SlackMessage, SlackMessageSchema } from '../types'; import { type SendMessageResult, type SlackMessage, SlackMessageSchema } from '../types';
import { SlackIntegrationError } from '../types/errors'; import { SlackIntegrationError } from '../types/errors';
import { convertMarkdownToSlack } from '../utils/markdownToSlack';
import { validateWithSchema } from '../utils/validation-helpers'; import { validateWithSchema } from '../utils/validation-helpers';
export class SlackMessagingService { export class SlackMessagingService {
@ -33,10 +34,15 @@ export class SlackMessagingService {
throw new SlackIntegrationError('CHANNEL_NOT_FOUND', 'Channel ID is required'); throw new SlackIntegrationError('CHANNEL_NOT_FOUND', 'Channel ID is required');
} }
const convertedMessage =
typeof message.text === 'string'
? { ...message, ...convertMarkdownToSlack(message.text) }
: message;
// Validate message // Validate message
const validatedMessage = validateWithSchema( const validatedMessage = validateWithSchema(
SlackMessageSchema, SlackMessageSchema,
message, convertedMessage,
'Invalid message format' 'Invalid message format'
); );
@ -151,10 +157,15 @@ export class SlackMessagingService {
); );
} }
const convertedReplyMessage =
typeof replyMessage.text === 'string'
? { ...replyMessage, ...convertMarkdownToSlack(replyMessage.text) }
: replyMessage;
// Validate message // Validate message
const validatedMessage = validateWithSchema( const validatedMessage = validateWithSchema(
SlackMessageSchema, SlackMessageSchema,
replyMessage, convertedReplyMessage,
'Invalid reply message format' 'Invalid reply message format'
); );
@ -320,10 +331,15 @@ export class SlackMessagingService {
); );
} }
const convertedUpdatedMessage =
typeof updatedMessage.text === 'string'
? { ...updatedMessage, ...convertMarkdownToSlack(updatedMessage.text) }
: updatedMessage;
// Validate message // Validate message
const validatedMessage = validateWithSchema( const validatedMessage = validateWithSchema(
SlackMessageSchema, SlackMessageSchema,
updatedMessage, convertedUpdatedMessage,
'Invalid message format' 'Invalid message format'
); );

View File

@ -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)');
});
});

View File

@ -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 = /(?<!\*)\*([^*]+)\*(?!\*)|(?<!_)_([^_]+)_(?!_)/g;
text = text.replace(italicRegex, (_match, group1, group2) => {
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 };
}